about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-02-22 14:23:57 -0600
committerGitHub <noreply@github.com>2023-02-22 14:23:57 -0600
commitf28334739b107f3e9f7b6ca2670778dba280600d (patch)
tree4e1563242e1a041c5d5483ab018123170dcb3fc8 /src
parent7916b26aadb7e003728d9dc653ab8b8deabf4076 (diff)
downloadvoidsky-f28334739b107f3e9f7b6ca2670778dba280600d.tar.zst
Merge main into the Web PR (#230)
* Update to RN 71.1.0 (#100)

* Update to RN 71

* Adds missing lint plugin

* Add missing native changes

* Bump @atproto/api@0.0.7 (#112)

* Image not loading on swipe (#114)

* Adds prefetching to images

* Adds image prefetch

* bugfix for images not showing on swipe

* Fixes prefetch bug

* Update src/view/com/util/PostEmbeds.tsx

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior

* Increase max image resolutions and sizes (#118)

* Slightly increase the hitslop for post controls

* Fix character counter color in dark mode

* Update login to use new session.create api, which enables email login (close #93) (#119)

* Replaces the alert with dropdown for profile image and banner (#123)

* replaces the alert with dropdown for profile image and banner

* lint

* Fix to ordering of images in the embed grid (#121)

* Add explicit link-embed controls to the composer (#120)

* Add explicit link-embed controls

* Update the target rez/size of link embed thumbs

* Remove the alert before publishing without a link card

* [Draft] Fixes image failing on reupload issue (#128)

* Fixes image failing on reupload issue

* Use tmp folder instead of documents

* lint

* Image performance improvements (#126)

* Switch out most images for FastImage

* Add image loading placeholders

* Fix tests

* Collection of fixes to list rendering (#127)

* Fix bug that caused endless spinners in profile feeds

* Bundle fetches of suggested actors into one update

* Fixes to suggested follow rendering

* Fix missing replacement of flex:1 to height:100

* Fixes to navigation swipes (#129)

* Nav swipe: increase the distance traveled in response to gesture movement.

This causes swipes to feel faster and more responsive.

* Fix: fully clamp the swipe against the edge

* Improve the performance of swipes by skipping the interaction manager

* Adds dark mode to the edit screen (#130)

* Adds dark mode to edit screen

* lint

* lint

* lint

* Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log

* Adds dark mode to the dropdown (#131)

* Adds dark mode to the bottom sheet

* Make background button lighter (like before)

* lint

* Fix bug in lightbox rendering (#133)

* Fix layout in onboarding to not overflow the footer

* Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136)

* Disable like/repost animations to see if theyre causing #135 (#137)

* Composer: mention tagging now works in middle of text (close #105) (#139)

* Implement account deletion (#141)

* Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened

* Adds more tracking all around the app (#142)

* Adds more tracking all around the app

* more events

* lint

* using better analytics naming

* missed file

* more fixes

* Calculate image aspect ratio on load (#146)

* Calculate image aspect ratio on load

* Move aspect ratio bounds to constants

* Adds detox testing and instructions (#147)

* Adds detox testing and instructions

* lint

* lint

* Error cleanup (close #79) (#148)

* Avoid surfacing errors to the user when it's not critical

* Remove now-unused GetAssertionsView

* Apply cleanError() consistently

* Give a better error message for Upstream Failures (http status 502)

* Hide errors in notifications because they're not useful

* More e2e tests (create account) (#150)

* Adds respots under the 'post' tab under profile (#158)

* Adds dark mode to delete account screen (#159)

* 87 dark mode edit profile (#162)

* Adds dark mode to delete account screen

* Adds one more missed darkmode

* more fixes

* Remove fallback gradient on external links without thumbs (#164)

* Remove fallback gradient on external links without thumbs

* Remove fallback gradient on external links without thumbs in the composer preview

* Fix refresh behavior around a series of models (repost, graph, vote) (#163)

* Fix refresh behavior around a series of models (repost, graph, vote)

* Fix cursor behavior in reposted-by view

* Fixes issue where retrying on image upload fails (#166)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* 154 cached image profile (#167)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Fixes image cache error on second try for profile screen

* lint

* lint

* lint

* Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0

* Minor aesthetic cleanup in SessionModel

* Wire up ReportAccount and ReportPost (#168)

* Fixes embeds for youtube channels (#169)

* Bump app ios version to 1.1 (needed after app store submission)

* Fix potential issues with promise guards when an error occurs (#170)

* Refactor models to use bundleAsync and lock regions (#171)

* Fix to an edge case with feed re-ordering for threads (#172)

* 151 fix youtube channel embed (#173)

* Fixes embeds for youtube channels

* Tests for youtube extract meta

* lint

* Add 'doesnt use non-exempt encryption' to ios config

* Rework the search UI and add  (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests

* 151 feat youtube embed iframe (#176)

* youtube embed iframe temp commit

* Fixes styling and code cleanup

* lint

* Now clicking between the pause and settings button doesn't trigger the parent

* use modest branding (less yt logos)

* Stop playing the video once there's a navigation event

* Make sure the iframe is unmounted on any navigation event

* fixes tests

* lint

* Add scroll-to-top for all screens (#177)

* Adds hardcoded suggested list (#178)

* Adds hardcoded suggested list

* Update suggested-actors-view to support page sizes smaller than the hardcoded list

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* more robust centering of the play button (#181)

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>

* Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed

* Fix e2e tests

* Fix home feed positioning (closes #189) (#195)

* Fix home feed positioning for floating header

* Fix positioning of errors in home feed

* Fix lint

* Don't show new-content notification for reposts (close #179) (#197)

* Show the splash screen during session resumption (close #186) (#199)

* Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198)

* UI updates to the floating action button (#201)

* Update FAB to use a plus icon and not drop shadow

* Update FAB positioning to be more consistent in different shell modes

* Animate the FAB's repositioning

* Remove the 'loading' placeholder from images as it degraded feed perf (#202)

* Remove the 'loading' placeholder from images as it degraded feed perf

* Remove references

* Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208)

RN has a bug where rendering a flatlist with an empty array appears to break its
virtual list windowing behaviors. See https://stackoverflow.com/a/67873596

* Only give the loading spinner on the home feed during PTR (#207)

(cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e)

* Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211)

* Push notification fixes (#210)

* Fix to when screen analytics events are firing

* Fix: dont trigger update state when backgrounded

* Small fix to notifee API usage

* Fix: properly load notification info for push card

* Add feedback link to main menu (close #191) (#212)

* Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue

* Fixes to notifications (#216)

* Improve push-notification for follows

* Refresh notifications on screen open (close #214)

* Avoid showing loader more than needed in post threads

* Refactor notification polling to handle view-state more effectively

* Delete a bunch of tests taht werent adding value

* Remove the accounts integration test; we'll use the e2e test instead

* Load latest in notifications when the screen is open rather than full refresh

* Randomize hard-coded suggested follows (#226)

* Ensure follows are loaded before filtering hardcoded suggestions

* Randomize hard-coded suggested profiles (close #219)

* Sanitizes posts on publish and render (#217)

* Sanatizes posts on publish and render

* lint

* lint and added sanitize to thread view as well

* adjusts indices based on replaced text

* Woops, fixes a bug

* bugfix + cleanup

* comment

* lint

* move sanitize text to later in the flow

* undo changes to compose post

* Add RichText library building upon the sanitizePost library method

* Add lodash.clonedeep dep

* Switch to RichText processing on record load & render

* Fix lint

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* A group of notifications fixes (#227)

* Fix: don't group together notifications that can't visually be grouped (close #221)

* Mark all notifications read on PTR

* Small optimization: useCallback and useMemo in posts feed

* Add loading spinner to footer of notifications (close #222)

* Fix to scrolling to posts within a thread (#228)

* Fix: render the entire thread at start so that scrollToIndex works always (close #270)

* Visual fixes to thread 'load more'

* A few small perf improvements to thread rendering

* Fix lint

* 1.2

* Remove unused logger lib

* Remove state-mock

* Type fixes

* Reorganize the folder structure for lib and switch to typescript path aliases

* Move build-flags into lib

* Move to the state path alias

* Add view path alias

* Fix lint

* iOS build fixes

* Wrap analytics in native/web splitter and re-enable in all view code

* Add web version of react-native-webview

* Add web split for version number

* Fix BlurView import for web

* Add web split for fastimage

* Create web split for permissions lib

* Fix for web high priority images

---------

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx47
-rw-r--r--src/lib/ThemeContext.tsx (renamed from src/view/lib/ThemeContext.tsx)2
-rw-r--r--src/lib/analytics.tsx74
-rw-r--r--src/lib/analytics.web.tsx16
-rw-r--r--src/lib/api/api-polyfill.ts (renamed from src/state/lib/api-polyfill.ts)4
-rw-r--r--src/lib/api/api-polyfill.web.ts (renamed from src/state/lib/api-polyfill.web.ts)0
-rw-r--r--src/lib/api/index.ts (renamed from src/state/lib/api.ts)35
-rw-r--r--src/lib/app-info.ts4
-rw-r--r--src/lib/app-info.web.ts3
-rw-r--r--src/lib/assets.native.ts5
-rw-r--r--src/lib/assets.ts10
-rw-r--r--src/lib/async/bundle.ts24
-rw-r--r--src/lib/bg-scheduler.ts (renamed from src/state/lib/bg-scheduler.ts)2
-rw-r--r--src/lib/bg-scheduler.web.ts (renamed from src/state/lib/bg-scheduler.web.ts)0
-rw-r--r--src/lib/build-flags.ts (renamed from src/build-flags.ts)0
-rw-r--r--src/lib/constants.ts65
-rw-r--r--src/lib/errors.ts4
-rw-r--r--src/lib/hooks/useAnimatedValue.ts (renamed from src/view/lib/hooks/useAnimatedValue.ts)0
-rw-r--r--src/lib/hooks/useOnMainScroll.ts (renamed from src/view/lib/hooks/useOnMainScroll.ts)2
-rw-r--r--src/lib/hooks/usePalette.ts (renamed from src/view/lib/hooks/usePalette.ts)0
-rw-r--r--src/lib/icons.tsx (renamed from src/view/lib/icons.tsx)0
-rw-r--r--src/lib/images.ts32
-rw-r--r--src/lib/images.web.ts5
-rw-r--r--src/lib/link-meta/bsky.ts (renamed from src/lib/extractBskyMeta.ts)26
-rw-r--r--src/lib/link-meta/html.ts (renamed from src/lib/extractHtmlMeta.ts)4
-rw-r--r--src/lib/link-meta/link-meta.ts (renamed from src/lib/link-meta.ts)8
-rw-r--r--src/lib/link-meta/twitter.ts (renamed from src/lib/extractTwitterMeta.ts)0
-rw-r--r--src/lib/link-meta/youtube.ts (renamed from src/lib/extractYoutubeMeta.ts)7
-rw-r--r--src/lib/notifee.ts (renamed from src/view/lib/notifee.ts)28
-rw-r--r--src/lib/permissions.ts61
-rw-r--r--src/lib/permissions.web.ts22
-rw-r--r--src/lib/storage.ts (renamed from src/state/lib/storage.ts)0
-rw-r--r--src/lib/strings.ts267
-rw-r--r--src/lib/strings/errors.ts23
-rw-r--r--src/lib/strings/handles.ts13
-rw-r--r--src/lib/strings/helpers.ts17
-rw-r--r--src/lib/strings/mention-manip.ts37
-rw-r--r--src/lib/strings/rich-text-detection.ts107
-rw-r--r--src/lib/strings/rich-text-sanitize.ts32
-rw-r--r--src/lib/strings/rich-text.ts216
-rw-r--r--src/lib/strings/time.ts29
-rw-r--r--src/lib/strings/url-helpers.ts108
-rw-r--r--src/lib/styles.ts (renamed from src/view/lib/styles.ts)12
-rw-r--r--src/lib/themes.ts (renamed from src/view/lib/themes.ts)16
-rw-r--r--src/lib/type-guards.ts (renamed from src/state/lib/type-guards.ts)0
-rw-r--r--src/state/index.ts40
-rw-r--r--src/state/models/feed-view.ts343
-rw-r--r--src/state/models/get-assertions-view.ts123
-rw-r--r--src/state/models/link-metas-view.ts2
-rw-r--r--src/state/models/log.ts2
-rw-r--r--src/state/models/me.ts68
-rw-r--r--src/state/models/my-follows.ts109
-rw-r--r--src/state/models/navigation.ts55
-rw-r--r--src/state/models/notifications-view.ts334
-rw-r--r--src/state/models/onboard.ts2
-rw-r--r--src/state/models/post-thread-view.ts39
-rw-r--r--src/state/models/post.ts3
-rw-r--r--src/state/models/profile-view.ts62
-rw-r--r--src/state/models/profiles-view.ts4
-rw-r--r--src/state/models/reposted-by-view.ts69
-rw-r--r--src/state/models/root-store.ts198
-rw-r--r--src/state/models/session.ts414
-rw-r--r--src/state/models/shell-ui.ts30
-rw-r--r--src/state/models/suggested-actors-view.ts173
-rw-r--r--src/state/models/suggested-posts-view.ts148
-rw-r--r--src/state/models/user-autocomplete-view.ts34
-rw-r--r--src/state/models/user-followers-view.ts61
-rw-r--r--src/state/models/user-follows-view.ts61
-rw-r--r--src/state/models/votes-view.ts62
-rw-r--r--src/view/com/composer/Autocomplete.tsx4
-rw-r--r--src/view/com/composer/ComposePost.tsx141
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx42
-rw-r--r--src/view/com/composer/Prompt.tsx2
-rw-r--r--src/view/com/composer/SelectedPhoto.tsx5
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx2
-rw-r--r--src/view/com/composer/char-progress/CharProgress.web.tsx2
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.tsx112
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.web.tsx6
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx14
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx11
-rw-r--r--src/view/com/discover/LiteSuggestedFollows.tsx11
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx208
-rw-r--r--src/view/com/discover/SuggestedPosts.tsx66
-rw-r--r--src/view/com/discover/WhoToFollow.tsx89
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx12
-rw-r--r--src/view/com/lightbox/Lightbox.tsx6
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx6
-rw-r--r--src/view/com/login/CreateAccount.tsx30
-rw-r--r--src/view/com/login/Logo.tsx2
-rw-r--r--src/view/com/login/Signin.tsx97
-rw-r--r--src/view/com/modals/Confirm.tsx7
-rw-r--r--src/view/com/modals/DeleteAccount.tsx210
-rw-r--r--src/view/com/modals/EditProfile.tsx51
-rw-r--r--src/view/com/modals/Modal.tsx33
-rw-r--r--src/view/com/modals/Modal.web.tsx18
-rw-r--r--src/view/com/modals/ReportAccount.tsx30
-rw-r--r--src/view/com/modals/ReportPost.tsx37
-rw-r--r--src/view/com/modals/ServerInput.tsx13
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx8
-rw-r--r--src/view/com/notifications/Feed.tsx82
-rw-r--r--src/view/com/notifications/FeedItem.tsx29
-rw-r--r--src/view/com/onboard/FeatureExplainer.tsx16
-rw-r--r--src/view/com/onboard/FeatureExplainer.web.tsx9
-rw-r--r--src/view/com/onboard/Follows.tsx12
-rw-r--r--src/view/com/onboard/Follows.web.tsx6
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx69
-rw-r--r--src/view/com/post-thread/PostThread.tsx61
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx76
-rw-r--r--src/view/com/post-thread/PostVotedBy.tsx77
-rw-r--r--src/view/com/post/Post.tsx21
-rw-r--r--src/view/com/post/PostText.tsx4
-rw-r--r--src/view/com/posts/ComposerPrompt.tsx46
-rw-r--r--src/view/com/posts/ComposerPrompt.web.tsx4
-rw-r--r--src/view/com/posts/Feed.tsx157
-rw-r--r--src/view/com/posts/FeedItem.tsx27
-rw-r--r--src/view/com/profile/ProfileCard.tsx138
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx68
-rw-r--r--src/view/com/profile/ProfileFollows.tsx75
-rw-r--r--src/view/com/profile/ProfileHeader.tsx57
-rw-r--r--src/view/com/util/BlurView.web.tsx2
-rw-r--r--src/view/com/util/EmptyState.tsx4
-rw-r--r--src/view/com/util/FAB.tsx55
-rw-r--r--src/view/com/util/Link.tsx16
-rw-r--r--src/view/com/util/LoadLatestBtn.tsx4
-rw-r--r--src/view/com/util/LoadLatestBtn.web.tsx2
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx8
-rw-r--r--src/view/com/util/Picker.tsx2
-rw-r--r--src/view/com/util/PostCtrls.tsx193
-rw-r--r--src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx69
-rw-r--r--src/view/com/util/PostEmbeds/YoutubeEmbed.tsx119
-rw-r--r--src/view/com/util/PostEmbeds/index.tsx (renamed from src/view/com/util/PostEmbeds.tsx)99
-rw-r--r--src/view/com/util/PostMeta.tsx4
-rw-r--r--src/view/com/util/Selector.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx115
-rw-r--r--src/view/com/util/UserBanner.tsx97
-rw-r--r--src/view/com/util/UserInfoText.tsx4
-rw-r--r--src/view/com/util/ViewHeader.tsx152
-rw-r--r--src/view/com/util/ViewHeader.web.tsx46
-rw-r--r--src/view/com/util/ViewSelector.tsx9
-rw-r--r--src/view/com/util/Views.web.tsx5
-rw-r--r--src/view/com/util/anim/TriggerableAnimated.tsx73
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx4
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx6
-rw-r--r--src/view/com/util/forms/Button.tsx4
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx113
-rw-r--r--src/view/com/util/forms/RadioButton.tsx4
-rw-r--r--src/view/com/util/forms/RadioGroup.tsx2
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx6
-rw-r--r--src/view/com/util/gestures/HorzSwipe.tsx5
-rw-r--r--src/view/com/util/gestures/SwipeAndZoom.tsx2
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx138
-rw-r--r--src/view/com/util/images/Image.tsx12
-rw-r--r--src/view/com/util/images/Image.web.tsx11
-rw-r--r--src/view/com/util/images/ImageHorzList.tsx2
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx36
-rw-r--r--src/view/com/util/images/constants.ts1
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx2
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx12
-rw-r--r--src/view/com/util/text/RichText.tsx28
-rw-r--r--src/view/com/util/text/Text.tsx4
-rw-r--r--src/view/index.ts4
-rw-r--r--src/view/lib/addStyle.ts11
-rw-r--r--src/view/lib/assets.native.ts5
-rw-r--r--src/view/lib/assets.ts7
-rw-r--r--src/view/routes.ts4
-rw-r--r--src/view/screens/Contacts.tsx6
-rw-r--r--src/view/screens/Debug.tsx15
-rw-r--r--src/view/screens/Home.tsx58
-rw-r--r--src/view/screens/Log.tsx8
-rw-r--r--src/view/screens/Login.tsx47
-rw-r--r--src/view/screens/Login.web.tsx14
-rw-r--r--src/view/screens/NotFound.tsx2
-rw-r--r--src/view/screens/Notifications.tsx81
-rw-r--r--src/view/screens/Onboard.tsx4
-rw-r--r--src/view/screens/PostDownvotedBy.tsx4
-rw-r--r--src/view/screens/PostRepostedBy.tsx4
-rw-r--r--src/view/screens/PostThread.tsx14
-rw-r--r--src/view/screens/PostUpvotedBy.tsx4
-rw-r--r--src/view/screens/Profile.tsx19
-rw-r--r--src/view/screens/ProfileFollowers.tsx4
-rw-r--r--src/view/screens/ProfileFollows.tsx4
-rw-r--r--src/view/screens/Search.tsx232
-rw-r--r--src/view/screens/Settings.tsx40
-rw-r--r--src/view/shell/mobile/Composer.tsx6
-rw-r--r--src/view/shell/mobile/Menu.tsx77
-rw-r--r--src/view/shell/mobile/TabsSelector.tsx8
-rw-r--r--src/view/shell/mobile/index.tsx123
-rw-r--r--src/view/shell/web/Composer.tsx4
-rw-r--r--src/view/shell/web/DesktopLeftColumn.tsx10
-rw-r--r--src/view/shell/web/DesktopRightColumn.tsx6
-rw-r--r--src/view/shell/web/index.tsx8
191 files changed, 5223 insertions, 3194 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index f00e3cad1..17b8b4552 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -6,35 +6,27 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
 import SplashScreen from 'react-native-splash-screen'
 import {SafeAreaProvider} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
-import {
-  createClient,
-  SegmentClient,
-  AnalyticsProvider,
-} from '@segment/analytics-react-native'
-import {ThemeProvider} from './view/lib/ThemeContext'
+import {ThemeProvider} from 'lib/ThemeContext'
 import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
 import {MobileShell} from './view/shell/mobile'
-import {s} from './view/lib/styles'
-import notifee, {EventType} from '@notifee/react-native'
+import {s} from 'lib/styles'
+import * as notifee from 'lib/notifee'
+import * as analytics from 'lib/analytics'
+import * as Toast from './view/com/util/Toast'
 
 const App = observer(() => {
   const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
     undefined,
   )
-  const [segment, setSegment] = useState<SegmentClient | undefined>(undefined)
 
   // init
   useEffect(() => {
     view.setup()
-    setSegment(
-      createClient({
-        writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
-        trackAppLifecycleEvents: true,
-      }),
-    )
     setupState().then(store => {
       setRootStore(store)
+      analytics.init(store)
+      notifee.init(store)
       SplashScreen.hide()
       Linking.getInitialURL().then((url: string | null) => {
         if (url) {
@@ -44,12 +36,8 @@ const App = observer(() => {
       Linking.addEventListener('url', ({url}) => {
         store.nav.handleLink(url)
       })
-      notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
-        store.log.debug('Notifee foreground event', {type})
-        if (type === EventType.PRESS) {
-          store.log.debug('User pressed a notifee, opening notifications')
-          store.nav.switchTo(1, true)
-        }
+      store.onSessionDropped(() => {
+        Toast.show('Sorry! Your session expired. Please log in again.')
       })
     })
   }, [])
@@ -58,20 +46,19 @@ const App = observer(() => {
   if (!rootStore) {
     return null
   }
-
   return (
     <GestureHandlerRootView style={s.h100pct}>
-      <RootSiblingParent>
-        <AnalyticsProvider client={segment}>
-          <RootStoreProvider value={rootStore}>
-            <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
+      <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
+        <RootSiblingParent>
+          <analytics.Provider>
+            <RootStoreProvider value={rootStore}>
               <SafeAreaProvider>
                 <MobileShell />
               </SafeAreaProvider>
-            </ThemeProvider>
-          </RootStoreProvider>
-        </AnalyticsProvider>
-      </RootSiblingParent>
+            </RootStoreProvider>
+          </analytics.Provider>
+        </RootSiblingParent>
+      </ThemeProvider>
     </GestureHandlerRootView>
   )
 })
diff --git a/src/view/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx
index 16a7d9cb3..bcfc076f4 100644
--- a/src/view/lib/ThemeContext.tsx
+++ b/src/lib/ThemeContext.tsx
@@ -53,6 +53,7 @@ export type TypographyVariant =
   | 'xs-medium'
   | 'xs-bold'
   | 'xs-heavy'
+  | 'title-2xl'
   | 'title-xl'
   | 'title-lg'
   | 'title'
@@ -60,6 +61,7 @@ export type TypographyVariant =
   | 'post-text-lg'
   | 'post-text'
   | 'button'
+  | 'button-lg'
   | 'mono'
 export type Typography = Record<TypographyVariant, TextStyle>
 
diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx
new file mode 100644
index 000000000..441cdc454
--- /dev/null
+++ b/src/lib/analytics.tsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import {AppState, AppStateStatus} from 'react-native'
+import {createClient, AnalyticsProvider} from '@segment/analytics-react-native'
+import {RootStoreModel, AppInfo} from 'state/models/root-store'
+
+const segmentClient = createClient({
+  writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
+  trackAppLifecycleEvents: false,
+})
+
+export {useAnalytics} from '@segment/analytics-react-native'
+
+export function init(store: RootStoreModel) {
+  // NOTE
+  // this method is a copy of segment's own lifecycle event tracking
+  // we handle it manually to ensure that it never fires while the app is backgrounded
+  // -prf
+  segmentClient.onContextLoaded(() => {
+    if (AppState.currentState !== 'active') {
+      store.log.debug('Prevented a metrics ping while the app was backgrounded')
+      return
+    }
+    const context = segmentClient.context.get()
+    if (typeof context?.app === 'undefined') {
+      store.log.debug('Aborted metrics ping due to unavailable context')
+      return
+    }
+
+    const oldAppInfo = store.appInfo
+    const newAppInfo = context.app as AppInfo
+    store.setAppInfo(newAppInfo)
+    store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
+
+    if (typeof oldAppInfo === 'undefined') {
+      segmentClient.track('Application Installed', {
+        version: newAppInfo.version,
+        build: newAppInfo.build,
+      })
+    } else if (newAppInfo.version !== oldAppInfo.version) {
+      segmentClient.track('Application Updated', {
+        version: newAppInfo.version,
+        build: newAppInfo.build,
+        previous_version: oldAppInfo.version,
+        previous_build: oldAppInfo.build,
+      })
+    }
+    segmentClient.track('Application Opened', {
+      from_background: false,
+      version: newAppInfo.version,
+      build: newAppInfo.build,
+    })
+  })
+
+  let lastState: AppStateStatus = AppState.currentState
+  AppState.addEventListener('change', (state: AppStateStatus) => {
+    if (state === 'active' && lastState !== 'active') {
+      const context = segmentClient.context.get()
+      segmentClient.track('Application Opened', {
+        from_background: true,
+        version: context?.app?.version,
+        build: context?.app?.build,
+      })
+    } else if (state !== 'active' && lastState === 'active') {
+      segmentClient.track('Application Backgrounded')
+    }
+    lastState = state
+  })
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  return (
+    <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
+  )
+}
diff --git a/src/lib/analytics.web.tsx b/src/lib/analytics.web.tsx
new file mode 100644
index 000000000..d7b9b2646
--- /dev/null
+++ b/src/lib/analytics.web.tsx
@@ -0,0 +1,16 @@
+// TODO
+import React from 'react'
+import {RootStoreModel} from 'state/models/root-store'
+
+export function useAnalytics() {
+  return {
+    screen(_name: string) {},
+    track(_name: string, _opts: any) {},
+  }
+}
+
+export function init(_store: RootStoreModel) {}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  return children
+}
diff --git a/src/state/lib/api-polyfill.ts b/src/lib/api/api-polyfill.ts
index df397cbd9..3b5ba7518 100644
--- a/src/state/lib/api-polyfill.ts
+++ b/src/lib/api/api-polyfill.ts
@@ -1,10 +1,10 @@
-import {sessionClient as AtpApi} from '@atproto/api'
+import AtpAgent from '@atproto/api'
 import RNFS from 'react-native-fs'
 
 const TIMEOUT = 10e3 // 10s
 
 export function doPolyfill() {
-  AtpApi.xrpc.fetch = fetchHandler
+  AtpAgent.configure({fetch: fetchHandler})
 }
 
 interface FetchHandlerResponse {
diff --git a/src/state/lib/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts
index 1469cf905..1469cf905 100644
--- a/src/state/lib/api-polyfill.web.ts
+++ b/src/lib/api/api-polyfill.web.ts
diff --git a/src/state/lib/api.ts b/src/lib/api/index.ts
index 7ae04f52d..d800c376c 100644
--- a/src/state/lib/api.ts
+++ b/src/lib/api/index.ts
@@ -1,16 +1,11 @@
-/**
- * The environment is a place where services and shared dependencies between
- * models live. They are made available to every model via dependency injection.
- */
-
-// import {ReactNativeStore} from './auth'
 import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
-import {RootStoreModel} from '../models/root-store'
-import {extractEntities} from '../../lib/strings'
-import {isNetworkError} from '../../lib/errors'
-import {LinkMeta} from '../../lib/link-meta'
-import {Image} from '../../lib/images'
+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 '../images'
+import {RichText} from '../strings/rich-text'
 
 export interface ExternalEmbedDraft {
   uri: string
@@ -19,9 +14,22 @@ export interface ExternalEmbedDraft {
   localThumb?: Image
 }
 
+export async function resolveName(store: RootStoreModel, didOrHandle: string) {
+  if (!didOrHandle) {
+    throw new Error('Invalid handle: ""')
+  }
+  if (didOrHandle.startsWith('did:')) {
+    return didOrHandle
+  }
+  const res = await store.api.com.atproto.handle.resolve({
+    handle: didOrHandle,
+  })
+  return res.data.did
+}
+
 export async function post(
   store: RootStoreModel,
-  text: string,
+  rawText: string,
   replyTo?: string,
   extLink?: ExternalEmbedDraft,
   images?: string[],
@@ -30,6 +38,9 @@ export async function post(
 ) {
   let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
   let reply
+  const text = new RichText(rawText, undefined, {
+    cleanNewlines: true,
+  }).text.trim()
 
   onStateChange?.('Processing...')
   const entities = extractEntities(text, knownHandles)
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
new file mode 100644
index 000000000..1ced274e7
--- /dev/null
+++ b/src/lib/app-info.ts
@@ -0,0 +1,4 @@
+import VersionNumber from 'react-native-version-number'
+
+export const appVersion = VersionNumber.appVersion
+export const buildVersion = VersionNumber.buildVersion
diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts
new file mode 100644
index 000000000..a2b6858da
--- /dev/null
+++ b/src/lib/app-info.web.ts
@@ -0,0 +1,3 @@
+// TODO
+export const appVersion = 'TODO'
+export const buildVersion = 'TODO'
diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts
new file mode 100644
index 000000000..d7f4a7287
--- /dev/null
+++ b/src/lib/assets.native.ts
@@ -0,0 +1,5 @@
+import {ImageRequireSource} from 'react-native'
+
+export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg')
+export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg')
+export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png')
diff --git a/src/lib/assets.ts b/src/lib/assets.ts
new file mode 100644
index 000000000..216478762
--- /dev/null
+++ b/src/lib/assets.ts
@@ -0,0 +1,10 @@
+import {ImageRequireSource} from 'react-native'
+
+// @ts-ignore we need to pretend -prf
+export const DEF_AVATAR: ImageRequireSource = {uri: '/img/default-avatar.jpg'}
+// @ts-ignore we need to pretend -prf
+export const TABS_EXPLAINER: ImageRequireSource = {
+  uri: '/img/tabs-explainer.jpg',
+}
+// @ts-ignore we need to pretend -prf
+export const CLOUD_SPLASH: ImageRequireSource = {uri: '/img/cloud-splash.png'}
diff --git a/src/lib/async/bundle.ts b/src/lib/async/bundle.ts
new file mode 100644
index 000000000..e307cd437
--- /dev/null
+++ b/src/lib/async/bundle.ts
@@ -0,0 +1,24 @@
+type BundledFn<Args extends readonly unknown[], Res> = (
+  ...args: Args
+) => Promise<Res>
+
+/**
+ * A helper which ensures that multiple calls to an async function
+ * only produces one in-flight request at a time.
+ */
+export function bundleAsync<Args extends readonly unknown[], Res>(
+  fn: BundledFn<Args, Res>,
+): BundledFn<Args, Res> {
+  let promise: Promise<Res> | undefined
+  return async (...args) => {
+    if (promise) {
+      return promise
+    }
+    promise = fn(...args)
+    try {
+      return await promise
+    } finally {
+      promise = undefined
+    }
+  }
+}
diff --git a/src/state/lib/bg-scheduler.ts b/src/lib/bg-scheduler.ts
index 97ccb78b2..db3f2d7fd 100644
--- a/src/state/lib/bg-scheduler.ts
+++ b/src/lib/bg-scheduler.ts
@@ -4,7 +4,7 @@ import BackgroundFetch, {
 
 export function configure(
   handler: (taskId: string) => Promise<void>,
-  timeoutHandler: (taskId: string) => Promise<void>,
+  timeoutHandler: (taskId: string) => void,
 ): Promise<BackgroundFetchStatus> {
   return BackgroundFetch.configure(
     {minimumFetchInterval: 15},
diff --git a/src/state/lib/bg-scheduler.web.ts b/src/lib/bg-scheduler.web.ts
index 91ec9428f..91ec9428f 100644
--- a/src/state/lib/bg-scheduler.web.ts
+++ b/src/lib/bg-scheduler.web.ts
diff --git a/src/build-flags.ts b/src/lib/build-flags.ts
index 155230e5d..155230e5d 100644
--- a/src/build-flags.ts
+++ b/src/lib/build-flags.ts
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 000000000..2a3043c06
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,65 @@
+export const FEEDBACK_FORM_URL =
+  'https://docs.google.com/forms/d/e/1FAIpQLSdavFRXTdB6tRobaFrRR2A1gv3b-IBHwQkBmNZTRpoqmcrPrQ/viewform?usp=sf_link'
+
+export const MAX_DISPLAY_NAME = 64
+export const MAX_DESCRIPTION = 256
+
+export const PROD_SUGGESTED_FOLLOWS = [
+  'john',
+  'visakanv',
+  'saz',
+  'steph',
+  'ratzlaff',
+  'beth',
+  'weisser',
+  'katherine',
+  'annagat',
+  'josh',
+  'lurkshark',
+  'amir',
+  'amyxzh',
+  'danielle',
+  'jack-frazee',
+  'vibes',
+  'cat',
+  'yuriy',
+  'alvinreyes',
+  'skoot',
+  'patricia',
+  'ara4n',
+  'case',
+  'armand',
+  'ivan',
+  'nicholas',
+  'kelsey',
+  'ericlee',
+  'emily',
+  'jake',
+  'jennijuju',
+  'ian5v',
+  'bnewbold',
+  'chris',
+  'mtclai',
+  'willscott',
+  'michael',
+  'kwkroeger',
+  'broox',
+  'iamrosewang',
+  'jack-morrison',
+  'pwang',
+  'martin',
+  'jack',
+  'dan',
+  'why',
+  'divy',
+  'jay',
+  'paul',
+].map(handle => `${handle}.bsky.social`)
+
+export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
+  handle => `${handle}.staging.bsky.dev`,
+)
+
+export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
+  handle => `${handle}.test`,
+)
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
deleted file mode 100644
index 216d5927b..000000000
--- a/src/lib/errors.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export function isNetworkError(e: unknown) {
-  const str = String(e)
-  return str.includes('Abort') || str.includes('Network request failed')
-}
diff --git a/src/view/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts
index 1307ef952..1307ef952 100644
--- a/src/view/lib/hooks/useAnimatedValue.ts
+++ b/src/lib/hooks/useAnimatedValue.ts
diff --git a/src/view/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts
index c3c16ff83..41b35dd4f 100644
--- a/src/view/lib/hooks/useOnMainScroll.ts
+++ b/src/lib/hooks/useOnMainScroll.ts
@@ -1,6 +1,6 @@
 import {useState} from 'react'
 import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
-import {RootStoreModel} from '../../../state'
+import {RootStoreModel} from 'state/index'
 
 export type OnScrollCb = (
   event: NativeSyntheticEvent<NativeScrollEvent>,
diff --git a/src/view/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts
index 5b9929c7d..5b9929c7d 100644
--- a/src/view/lib/hooks/usePalette.ts
+++ b/src/lib/hooks/usePalette.ts
diff --git a/src/view/lib/icons.tsx b/src/lib/icons.tsx
index f400c3f72..f400c3f72 100644
--- a/src/view/lib/icons.tsx
+++ b/src/lib/icons.tsx
diff --git a/src/lib/images.ts b/src/lib/images.ts
index 8d5eaded0..609e03bda 100644
--- a/src/lib/images.ts
+++ b/src/lib/images.ts
@@ -2,8 +2,8 @@ import RNFetchBlob from 'rn-fetch-blob'
 import ImageResizer from '@bam.tech/react-native-image-resizer'
 import {Share} from 'react-native'
 import RNFS from 'react-native-fs'
-
-import * as Toast from '../view/com/util/Toast'
+import uuid from 'react-native-uuid'
+import * as Toast from 'view/com/util/Toast'
 
 export interface DownloadAndResizeOpts {
   uri: string
@@ -23,16 +23,12 @@ export interface Image {
 }
 
 export async function downloadAndResize(opts: DownloadAndResizeOpts) {
-  let appendExt
+  let appendExt = 'jpeg'
   try {
     const urip = new URL(opts.uri)
     const ext = urip.pathname.split('.').pop()
-    if (ext === 'jpg' || ext === 'jpeg') {
-      appendExt = 'jpeg'
-    } else if (ext === 'png') {
+    if (ext === 'png') {
       appendExt = 'png'
-    } else {
-      return
     }
   } catch (e: any) {
     console.error('Invalid URI', opts.uri, e)
@@ -109,12 +105,18 @@ export async function compressIfNeeded(
   if (img.size < maxSize) {
     return img
   }
-  return await resize(origUri, {
+  const resizedImage = await resize(origUri, {
     width: img.width,
     height: img.height,
     mode: 'stretch',
     maxSize,
   })
+  const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
+  const finalImg = {
+    ...resizedImage,
+    path: finalImageMovedPath,
+  }
+  return finalImg
 }
 
 export interface Dim {
@@ -150,3 +152,15 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
   }
   RNFS.unlink(imagePath)
 }
+
+export const moveToPremanantPath = async (path: string) => {
+  /*
+  Since this package stores images in a temp directory, we need to move the file to a permanent location.
+  Relevant: IOS bug when trying to open a second time:
+  https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
+  */
+  const filename = uuid.v4()
+  const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
+  RNFS.moveFile(path, destinationPath)
+  return destinationPath
+}
diff --git a/src/lib/images.web.ts b/src/lib/images.web.ts
index 5158e005f..4b6d93af2 100644
--- a/src/lib/images.web.ts
+++ b/src/lib/images.web.ts
@@ -1,6 +1,5 @@
-import {Share} from 'react-native'
-
-import * as Toast from '../view/com/util/Toast'
+// import {Share} from 'react-native'
+// import * as Toast from 'view/com/util/Toast'
 
 export interface DownloadAndResizeOpts {
   uri: string
diff --git a/src/lib/extractBskyMeta.ts b/src/lib/link-meta/bsky.ts
index e53036aec..fba41260d 100644
--- a/src/lib/extractBskyMeta.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,18 +1,18 @@
 import {LikelyType, LinkMeta} from './link-meta'
-import {match as matchRoute} from '../view/routes'
-import {convertBskyAppUrlIfNeeded, makeRecordUri} from './strings'
-import {RootStoreModel} from '../state'
-import {PostThreadViewModel} from '../state/models/post-thread-view'
+import {match as matchRoute} from 'view/routes'
+import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
+import {RootStoreModel} from 'state/index'
+import {PostThreadViewModel} from 'state/models/post-thread-view'
 
-import {Home} from '../view/screens/Home'
-import {Search} from '../view/screens/Search'
-import {Notifications} from '../view/screens/Notifications'
-import {PostThread} from '../view/screens/PostThread'
-import {PostUpvotedBy} from '../view/screens/PostUpvotedBy'
-import {PostRepostedBy} from '../view/screens/PostRepostedBy'
-import {Profile} from '../view/screens/Profile'
-import {ProfileFollowers} from '../view/screens/ProfileFollowers'
-import {ProfileFollows} from '../view/screens/ProfileFollows'
+import {Home} from 'view/screens/Home'
+import {Search} from 'view/screens/Search'
+import {Notifications} from 'view/screens/Notifications'
+import {PostThread} from 'view/screens/PostThread'
+import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
+import {PostRepostedBy} from 'view/screens/PostRepostedBy'
+import {Profile} from 'view/screens/Profile'
+import {ProfileFollowers} from 'view/screens/ProfileFollowers'
+import {ProfileFollows} from 'view/screens/ProfileFollows'
 
 // NOTE
 // this is a hack around the lack of hosted social metadata
diff --git a/src/lib/extractHtmlMeta.ts b/src/lib/link-meta/html.ts
index 70387f71d..220f8431d 100644
--- a/src/lib/extractHtmlMeta.ts
+++ b/src/lib/link-meta/html.ts
@@ -1,5 +1,5 @@
-import {extractTwitterMeta} from './extractTwitterMeta'
-import {extractYoutubeMeta} from './extractYoutubeMeta'
+import {extractTwitterMeta} from './twitter'
+import {extractYoutubeMeta} from './youtube'
 
 interface ExtractHtmlMetaInput {
   html: string
diff --git a/src/lib/link-meta.ts b/src/lib/link-meta/link-meta.ts
index 2826e969a..6c4ad5384 100644
--- a/src/lib/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -1,8 +1,8 @@
 import he from 'he'
-import {isBskyAppUrl} from './strings'
-import {RootStoreModel} from '../state'
-import {extractBskyMeta} from './extractBskyMeta'
-import {extractHtmlMeta} from './extractHtmlMeta'
+import {isBskyAppUrl} from '../strings/url-helpers'
+import {RootStoreModel} from 'state/index'
+import {extractBskyMeta} from './bsky'
+import {extractHtmlMeta} from './html'
 
 export enum LikelyType {
   HTML,
diff --git a/src/lib/extractTwitterMeta.ts b/src/lib/link-meta/twitter.ts
index d785903c0..d785903c0 100644
--- a/src/lib/extractTwitterMeta.ts
+++ b/src/lib/link-meta/twitter.ts
diff --git a/src/lib/extractYoutubeMeta.ts b/src/lib/link-meta/youtube.ts
index 566e3be46..42eed51e8 100644
--- a/src/lib/extractYoutubeMeta.ts
+++ b/src/lib/link-meta/youtube.ts
@@ -4,10 +4,12 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
   const youtubeDescriptionRegex =
     /"videoDetails":.*"shortDescription":"([^"]*)"/i
   const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
-
+  const youtubeAvatarRegex =
+    /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i
   const youtubeTitleMatch = youtubeTitleRegex.exec(html)
   const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
   const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
+  const youtubeAvatarMatch = youtubeAvatarRegex.exec(html)
 
   if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
     res.title = decodeURI(youtubeTitleMatch[1])
@@ -21,6 +23,9 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
   if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
     res.image = youtubeThumbnailMatch[1] + 'default.jpg'
   }
+  if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) {
+    res.image = youtubeAvatarMatch[1]
+  }
 
   return res
 }
diff --git a/src/view/lib/notifee.ts b/src/lib/notifee.ts
index 5e1917381..fb0afdd60 100644
--- a/src/view/lib/notifee.ts
+++ b/src/lib/notifee.ts
@@ -1,7 +1,26 @@
-import notifee from '@notifee/react-native'
+import notifee, {EventType} from '@notifee/react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
-import {NotificationsViewItemModel} from '../../state/models/notifications-view'
-import {enforceLen} from '../../lib/strings'
+import {RootStoreModel} from 'state/models/root-store'
+import {TabPurpose} from 'state/models/navigation'
+import {NotificationsViewItemModel} from 'state/models/notifications-view'
+import {enforceLen} from 'lib/strings/helpers'
+
+export function init(store: RootStoreModel) {
+  store.onUnreadNotifications(count => notifee.setBadgeCount(count))
+  store.onPushNotification(displayNotificationFromModel)
+  store.onSessionLoaded(() => {
+    // request notifications permission once the user has logged in
+    notifee.requestPermission()
+  })
+  notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
+    store.log.debug('Notifee foreground event', {type})
+    if (type === EventType.PRESS) {
+      store.log.debug('User pressed a notifee, opening notifications')
+      store.nav.switchTo(TabPurpose.Notifs, true)
+    }
+  })
+  notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
+}
 
 export function displayNotification(
   title: string,
@@ -39,7 +58,8 @@ export function displayNotificationFromModel(
     title = `${author} replied to your post`
     body = notif.additionalPost?.thread?.postRecord?.text || ''
   } else if (notif.isFollow) {
-    title = `${author} followed you`
+    title = 'New follower!'
+    body = `${author} has followed you`
   } else {
     return
   }
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
new file mode 100644
index 000000000..ab2c73ca6
--- /dev/null
+++ b/src/lib/permissions.ts
@@ -0,0 +1,61 @@
+import {Alert} from 'react-native'
+import {
+  check,
+  openSettings,
+  Permission,
+  PermissionStatus,
+  PERMISSIONS,
+  RESULTS,
+} from 'react-native-permissions'
+
+export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY
+export const CAMERA = PERMISSIONS.IOS.CAMERA
+
+/**
+ * Returns `true` if the user has granted permission or hasn't made
+ * a decision yet. Returns `false` if unavailable or not granted.
+ */
+export async function hasAccess(perm: Permission): Promise<boolean> {
+  const status = await check(perm)
+  return isntANo(status)
+}
+
+export async function requestAccessIfNeeded(
+  perm: Permission,
+): Promise<boolean> {
+  if (await hasAccess(perm)) {
+    return true
+  }
+  let permDescription
+  if (perm === PHOTO_LIBRARY) {
+    permDescription = 'photo library'
+  } else if (perm === CAMERA) {
+    permDescription = 'camera'
+  } else {
+    return false
+  }
+  Alert.alert(
+    'Permission needed',
+    `Bluesky does not have permission to access your ${permDescription}.`,
+    [
+      {
+        text: 'Cancel',
+        style: 'cancel',
+      },
+      {text: 'Open Settings', onPress: () => openSettings()},
+    ],
+  )
+  return false
+}
+
+export async function requestPhotoAccessIfNeeded() {
+  return requestAccessIfNeeded(PHOTO_LIBRARY)
+}
+
+export async function requestCameraAccessIfNeeded() {
+  return requestAccessIfNeeded(CAMERA)
+}
+
+function isntANo(status: PermissionStatus): boolean {
+  return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED
+}
diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts
new file mode 100644
index 000000000..5b69637ed
--- /dev/null
+++ b/src/lib/permissions.web.ts
@@ -0,0 +1,22 @@
+/*
+At the moment, Web doesn't have any equivalence for these.
+*/
+
+export const PHOTO_LIBRARY = ''
+export const CAMERA = ''
+
+export async function hasAccess(_perm: any): Promise<boolean> {
+  return true
+}
+
+export async function requestAccessIfNeeded(_perm: any): Promise<boolean> {
+  return true
+}
+
+export async function requestPhotoAccessIfNeeded() {
+  return requestAccessIfNeeded(PHOTO_LIBRARY)
+}
+
+export async function requestCameraAccessIfNeeded() {
+  return requestAccessIfNeeded(CAMERA)
+}
diff --git a/src/state/lib/storage.ts b/src/lib/storage.ts
index dc5fb620f..dc5fb620f 100644
--- a/src/state/lib/storage.ts
+++ b/src/lib/storage.ts
diff --git a/src/lib/strings.ts b/src/lib/strings.ts
deleted file mode 100644
index 8b93fa933..000000000
--- a/src/lib/strings.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import {AtUri} from '../third-party/uri'
-import {AppBskyFeedPost} from '@atproto/api'
-type Entity = AppBskyFeedPost.Entity
-import {PROD_SERVICE} from '../state'
-import {isNetworkError} from './errors'
-import TLDs from 'tlds'
-
-export const MAX_DISPLAY_NAME = 64
-export const MAX_DESCRIPTION = 256
-
-export function pluralize(n: number, base: string, plural?: string): string {
-  if (n === 1) {
-    return base
-  }
-  if (plural) {
-    return plural
-  }
-  return base + 's'
-}
-
-export function makeRecordUri(
-  didOrName: string,
-  collection: string,
-  rkey: string,
-) {
-  const urip = new AtUri('at://host/')
-  urip.host = didOrName
-  urip.collection = collection
-  urip.rkey = rkey
-  return urip.toString()
-}
-
-const MINUTE = 60
-const HOUR = MINUTE * 60
-const DAY = HOUR * 24
-const MONTH = DAY * 30
-const YEAR = DAY * 365
-export function ago(date: number | string | Date): string {
-  let ts: number
-  if (typeof date === 'string') {
-    ts = Number(new Date(date))
-  } else if (date instanceof Date) {
-    ts = Number(date)
-  } else {
-    ts = date
-  }
-  const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
-  if (diffSeconds < MINUTE) {
-    return `${diffSeconds}s`
-  } else if (diffSeconds < HOUR) {
-    return `${Math.floor(diffSeconds / MINUTE)}m`
-  } else if (diffSeconds < DAY) {
-    return `${Math.floor(diffSeconds / HOUR)}h`
-  } else if (diffSeconds < MONTH) {
-    return `${Math.floor(diffSeconds / DAY)}d`
-  } else if (diffSeconds < YEAR) {
-    return `${Math.floor(diffSeconds / MONTH)}mo`
-  } else {
-    return new Date(ts).toLocaleDateString()
-  }
-}
-
-export function isValidDomain(str: string): boolean {
-  return !!TLDs.find(tld => {
-    let i = str.lastIndexOf(tld)
-    if (i === -1) {
-      return false
-    }
-    return str.charAt(i - 1) === '.' && i === str.length - tld.length
-  })
-}
-
-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
-}
-type DetectedLinkable = string | DetectedLink
-export function detectLinkables(text: string): DetectedLinkable[] {
-  const re =
-    /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
-  const segments = []
-  let match
-  let start = 0
-  while ((match = re.exec(text))) {
-    let matchIndex = match.index
-    let matchValue = match[0]
-
-    if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
-      continue
-    }
-
-    if (/\s|\(/.test(matchValue)) {
-      // HACK
-      // skip the starting space
-      // we have to do this because RN doesnt support negative lookaheads
-      // -prf
-      matchIndex++
-      matchValue = matchValue.slice(1)
-    }
-
-    // strip ending puncuation
-    if (/[.,;!?]$/.test(matchValue)) {
-      matchValue = matchValue.slice(0, -1)
-    }
-    if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
-      matchValue = matchValue.slice(0, -1)
-    }
-
-    if (start !== matchIndex) {
-      segments.push(text.slice(start, matchIndex))
-    }
-    segments.push({link: matchValue})
-    start = matchIndex + matchValue.length
-  }
-  if (start < text.length) {
-    segments.push(text.slice(start))
-  }
-  return segments
-}
-
-export function makeValidHandle(str: string): string {
-  if (str.length > 20) {
-    str = str.slice(0, 20)
-  }
-  str = str.toLowerCase()
-  return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
-}
-
-export function createFullHandle(name: string, domain: string): string {
-  name = (name || '').replace(/[.]+$/, '')
-  domain = (domain || '').replace(/^[.]+/, '')
-  return `${name}.${domain}`
-}
-
-export function enforceLen(str: string, len: number, ellipsis = false): string {
-  str = str || ''
-  if (str.length > len) {
-    return str.slice(0, len) + (ellipsis ? '...' : '')
-  }
-  return str
-}
-
-export function cleanError(str: any): string {
-  if (!str) {
-    return str
-  }
-  if (typeof str !== 'string') {
-    str = str.toString()
-  }
-  if (isNetworkError(str)) {
-    return 'Unable to connect. Please check your internet connection and try again.'
-  }
-  if (str.startsWith('Error: ')) {
-    return str.slice('Error: '.length)
-  }
-  return str
-}
-
-export function toNiceDomain(url: string): string {
-  try {
-    const urlp = new URL(url)
-    if (`https://${urlp.host}` === PROD_SERVICE) {
-      return 'Bluesky Social'
-    }
-    return urlp.host
-  } catch (e) {
-    return url
-  }
-}
-
-export function toShortUrl(url: string): string {
-  try {
-    const urlp = new URL(url)
-    const shortened =
-      urlp.host +
-      (urlp.pathname === '/' ? '' : urlp.pathname) +
-      urlp.search +
-      urlp.hash
-    if (shortened.length > 30) {
-      return shortened.slice(0, 27) + '...'
-    }
-    return shortened
-  } catch (e) {
-    return url
-  }
-}
-
-export function toShareUrl(url: string): string {
-  if (!url.startsWith('https')) {
-    const urlp = new URL('https://bsky.app')
-    urlp.pathname = url
-    url = urlp.toString()
-  }
-  return url
-}
-
-export function isBskyAppUrl(url: string): boolean {
-  return url.startsWith('https://bsky.app/')
-}
-
-export function convertBskyAppUrlIfNeeded(url: string): string {
-  if (isBskyAppUrl(url)) {
-    try {
-      const urlp = new URL(url)
-      return urlp.pathname
-    } catch (e) {
-      console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
-    }
-  }
-  return url
-}
diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts
new file mode 100644
index 000000000..0efcad335
--- /dev/null
+++ b/src/lib/strings/errors.ts
@@ -0,0 +1,23 @@
+export function cleanError(str: any): string {
+  if (!str) {
+    return ''
+  }
+  if (typeof str !== 'string') {
+    str = str.toString()
+  }
+  if (isNetworkError(str)) {
+    return 'Unable to connect. Please check your internet connection and try again.'
+  }
+  if (str.includes('Upstream Failure')) {
+    return 'The server appears to be experiencing issues. Please try again in a few moments.'
+  }
+  if (str.startsWith('Error: ')) {
+    return str.slice('Error: '.length)
+  }
+  return str
+}
+
+export function isNetworkError(e: unknown) {
+  const str = String(e)
+  return str.includes('Abort') || str.includes('Network request failed')
+}
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
new file mode 100644
index 000000000..3409a0312
--- /dev/null
+++ b/src/lib/strings/handles.ts
@@ -0,0 +1,13 @@
+export function makeValidHandle(str: string): string {
+  if (str.length > 20) {
+    str = str.slice(0, 20)
+  }
+  str = str.toLowerCase()
+  return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
+}
+
+export function createFullHandle(name: string, domain: string): string {
+  name = (name || '').replace(/[.]+$/, '')
+  domain = (domain || '').replace(/^[.]+/, '')
+  return `${name}.${domain}`
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
new file mode 100644
index 000000000..183d53e31
--- /dev/null
+++ b/src/lib/strings/helpers.ts
@@ -0,0 +1,17 @@
+export function pluralize(n: number, base: string, plural?: string): string {
+  if (n === 1) {
+    return base
+  }
+  if (plural) {
+    return plural
+  }
+  return base + 's'
+}
+
+export function enforceLen(str: string, len: number, ellipsis = false): string {
+  str = str || ''
+  if (str.length > len) {
+    return str.slice(0, len) + (ellipsis ? '...' : '')
+  }
+  return str
+}
diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts
new file mode 100644
index 000000000..1f7cbe434
--- /dev/null
+++ b/src/lib/strings/mention-manip.ts
@@ -0,0 +1,37 @@
+interface FoundMention {
+  value: string
+  index: number
+}
+
+export function getMentionAt(
+  text: string,
+  cursorPos: number,
+): FoundMention | undefined {
+  let re = /(^|\s)@([a-z0-9.]*)/gi
+  let match
+  while ((match = re.exec(text))) {
+    const spaceOffset = match[1].length
+    const index = match.index + spaceOffset
+    if (
+      cursorPos >= index &&
+      cursorPos <= index + match[0].length - spaceOffset
+    ) {
+      return {value: match[2], index}
+    }
+  }
+  return undefined
+}
+
+export function insertMentionAt(
+  text: string,
+  cursorPos: number,
+  mention: string,
+) {
+  const target = getMentionAt(text, cursorPos)
+  if (target) {
+    return `${text.slice(0, target.index)}@${mention} ${text.slice(
+      target.index + target.value.length + 1, // add 1 to include the "@"
+    )}`
+  }
+  return text
+}
diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts
new file mode 100644
index 000000000..386ed48e1
--- /dev/null
+++ b/src/lib/strings/rich-text-detection.ts
@@ -0,0 +1,107 @@
+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
+}
+type DetectedLinkable = string | DetectedLink
+export function detectLinkables(text: string): DetectedLinkable[] {
+  const re =
+    /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
+  const segments = []
+  let match
+  let start = 0
+  while ((match = re.exec(text))) {
+    let matchIndex = match.index
+    let matchValue = match[0]
+
+    if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
+      continue
+    }
+
+    if (/\s|\(/.test(matchValue)) {
+      // HACK
+      // skip the starting space
+      // we have to do this because RN doesnt support negative lookaheads
+      // -prf
+      matchIndex++
+      matchValue = matchValue.slice(1)
+    }
+
+    // strip ending puncuation
+    if (/[.,;!?]$/.test(matchValue)) {
+      matchValue = matchValue.slice(0, -1)
+    }
+    if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
+      matchValue = matchValue.slice(0, -1)
+    }
+
+    if (start !== matchIndex) {
+      segments.push(text.slice(start, matchIndex))
+    }
+    segments.push({link: matchValue})
+    start = matchIndex + matchValue.length
+  }
+  if (start < text.length) {
+    segments.push(text.slice(start))
+  }
+  return segments
+}
diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts
new file mode 100644
index 000000000..0b5895707
--- /dev/null
+++ b/src/lib/strings/rich-text-sanitize.ts
@@ -0,0 +1,32 @@
+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
new file mode 100644
index 000000000..1df2144e0
--- /dev/null
+++ b/src/lib/strings/rich-text.ts
@@ -0,0 +1,216 @@
+/*
+= 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/strings/time.ts b/src/lib/strings/time.ts
new file mode 100644
index 000000000..4f62eeba9
--- /dev/null
+++ b/src/lib/strings/time.ts
@@ -0,0 +1,29 @@
+const MINUTE = 60
+const HOUR = MINUTE * 60
+const DAY = HOUR * 24
+const MONTH = DAY * 30
+const YEAR = DAY * 365
+export function ago(date: number | string | Date): string {
+  let ts: number
+  if (typeof date === 'string') {
+    ts = Number(new Date(date))
+  } else if (date instanceof Date) {
+    ts = Number(date)
+  } else {
+    ts = date
+  }
+  const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
+  if (diffSeconds < MINUTE) {
+    return `${diffSeconds}s`
+  } else if (diffSeconds < HOUR) {
+    return `${Math.floor(diffSeconds / MINUTE)}m`
+  } else if (diffSeconds < DAY) {
+    return `${Math.floor(diffSeconds / HOUR)}h`
+  } else if (diffSeconds < MONTH) {
+    return `${Math.floor(diffSeconds / DAY)}d`
+  } else if (diffSeconds < YEAR) {
+    return `${Math.floor(diffSeconds / MONTH)}mo`
+  } else {
+    return new Date(ts).toLocaleDateString()
+  }
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
new file mode 100644
index 000000000..a149f49c3
--- /dev/null
+++ b/src/lib/strings/url-helpers.ts
@@ -0,0 +1,108 @@
+import {AtUri} from '../../third-party/uri'
+import {PROD_SERVICE} from 'state/index'
+import TLDs from 'tlds'
+
+export function isValidDomain(str: string): boolean {
+  return !!TLDs.find(tld => {
+    let i = str.lastIndexOf(tld)
+    if (i === -1) {
+      return false
+    }
+    return str.charAt(i - 1) === '.' && i === str.length - tld.length
+  })
+}
+
+export function makeRecordUri(
+  didOrName: string,
+  collection: string,
+  rkey: string,
+) {
+  const urip = new AtUri('at://host/')
+  urip.host = didOrName
+  urip.collection = collection
+  urip.rkey = rkey
+  return urip.toString()
+}
+
+export function toNiceDomain(url: string): string {
+  try {
+    const urlp = new URL(url)
+    if (`https://${urlp.host}` === PROD_SERVICE) {
+      return 'Bluesky Social'
+    }
+    return urlp.host
+  } catch (e) {
+    return url
+  }
+}
+
+export function toShortUrl(url: string): string {
+  try {
+    const urlp = new URL(url)
+    const shortened =
+      urlp.host +
+      (urlp.pathname === '/' ? '' : urlp.pathname) +
+      urlp.search +
+      urlp.hash
+    if (shortened.length > 30) {
+      return shortened.slice(0, 27) + '...'
+    }
+    return shortened
+  } catch (e) {
+    return url
+  }
+}
+
+export function toShareUrl(url: string): string {
+  if (!url.startsWith('https')) {
+    const urlp = new URL('https://bsky.app')
+    urlp.pathname = url
+    url = urlp.toString()
+  }
+  return url
+}
+
+export function isBskyAppUrl(url: string): boolean {
+  return url.startsWith('https://bsky.app/')
+}
+
+export function convertBskyAppUrlIfNeeded(url: string): string {
+  if (isBskyAppUrl(url)) {
+    try {
+      const urlp = new URL(url)
+      return urlp.pathname
+    } catch (e) {
+      console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
+    }
+  }
+  return url
+}
+
+export function getYoutubeVideoId(link: string): string | undefined {
+  let url
+  try {
+    url = new URL(link)
+  } catch (e) {
+    return undefined
+  }
+
+  if (
+    url.hostname !== 'www.youtube.com' &&
+    url.hostname !== 'youtube.com' &&
+    url.hostname !== 'youtu.be'
+  ) {
+    return undefined
+  }
+  if (url.hostname === 'youtu.be') {
+    const videoId = url.pathname.split('/')[1]
+    if (!videoId) {
+      return undefined
+    }
+    return videoId
+  }
+  const videoId = url.searchParams.get('v') as string
+  if (!videoId) {
+    return undefined
+  }
+  return videoId
+}
diff --git a/src/view/lib/styles.ts b/src/lib/styles.ts
index 7129867e9..db6c03606 100644
--- a/src/view/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,4 +1,4 @@
-import {StyleSheet, TextStyle} from 'react-native'
+import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
 
 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
@@ -206,3 +206,13 @@ export function lh(
     lineHeight: (theme.typography[type].fontSize || 16) * height,
   }
 }
+
+export function addStyle<T>(
+  base: StyleProp<T>,
+  addedStyle: StyleProp<T>,
+): StyleProp<T> {
+  if (Array.isArray(base)) {
+    return base.concat([addedStyle])
+  }
+  return [base, addedStyle]
+}
diff --git a/src/view/lib/themes.ts b/src/lib/themes.ts
index 84e2b7883..c544eebf2 100644
--- a/src/view/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -14,7 +14,7 @@ export const defaultTheme: Theme = {
       link: colors.blue3,
       border: '#f0e9e9',
       borderDark: '#e0d9d9',
-      icon: colors.gray3,
+      icon: colors.gray4,
 
       // non-standard
       textVeryLight: colors.gray4,
@@ -208,11 +208,16 @@ export const defaultTheme: Theme = {
       fontWeight: '800',
     },
 
-    'title-xl': {
+    'title-2xl': {
       fontSize: 34,
       letterSpacing: 0.25,
       fontWeight: '500',
     },
+    'title-xl': {
+      fontSize: 28,
+      letterSpacing: 0.25,
+      fontWeight: '500',
+    },
     'title-lg': {
       fontSize: 22,
       fontWeight: '500',
@@ -237,6 +242,11 @@ export const defaultTheme: Theme = {
       letterSpacing: 0.4,
       fontWeight: '400',
     },
+    'button-lg': {
+      fontWeight: '500',
+      fontSize: 18,
+      letterSpacing: 0.5,
+    },
     button: {
       fontWeight: '500',
       fontSize: 14,
@@ -263,7 +273,7 @@ export const darkTheme: Theme = {
       link: colors.blue3,
       border: colors.gray6,
       borderDark: colors.gray5,
-      icon: colors.gray5,
+      icon: colors.gray4,
 
       // non-standard
       textVeryLight: colors.gray4,
diff --git a/src/state/lib/type-guards.ts b/src/lib/type-guards.ts
index 8fe651ffb..8fe651ffb 100644
--- a/src/state/lib/type-guards.ts
+++ b/src/lib/type-guards.ts
diff --git a/src/state/index.ts b/src/state/index.ts
index 654c15af7..61b85e51d 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -1,9 +1,9 @@
 import {autorun} from 'mobx'
-import {Platform} from 'react-native'
-import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
+import {AppState, Platform} from 'react-native'
+import {AtpAgent} from '@atproto/api'
 import {RootStoreModel} from './models/root-store'
-import * as apiPolyfill from './lib/api-polyfill'
-import * as storage from './lib/storage'
+import * as apiPolyfill from 'lib/api/api-polyfill'
+import * as storage from 'lib/storage'
 
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583'
@@ -19,8 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
 
   apiPolyfill.doPolyfill()
 
-  const api = AtpApi.service(serviceUri) as SessionServiceClient
-  rootStore = new RootStoreModel(api)
+  rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
   try {
     data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
     rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
@@ -28,25 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
   } catch (e: any) {
     rootStore.log.error('Failed to load state from storage', e)
   }
-
-  rootStore.session
-    .connect()
-    .then(() => {
-      rootStore.log.debug('Session connected')
-      return rootStore.fetchStateUpdate()
-    })
-    .catch((e: any) => {
-      rootStore.log.warn('Failed initial connect', e)
-    })
-  // @ts-ignore .on() is correct -prf
-  api.sessionManager.on('session', () => {
-    if (!api.sessionManager.session && rootStore.session.hasSession) {
-      // reset session
-      rootStore.session.clear()
-    } else if (api.sessionManager.session) {
-      rootStore.session.updateAuthTokens(api.sessionManager.session)
-    }
-  })
+  rootStore.attemptSessionResumption()
 
   // track changes & save to storage
   autorun(() => {
@@ -56,7 +37,14 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
 
   // periodic state fetch
   setInterval(() => {
-    rootStore.fetchStateUpdate()
+    // NOTE
+    // this must ONLY occur when the app is active, as the bg-fetch handler
+    // will wake up the thread and cause this interval to fire, which in
+    // turn schedules a bunch of work at a poor time
+    // -prf
+    if (AppState.currentState === 'active') {
+      rootStore.updateSessionState()
+    }
   }, STATE_FETCH_INTERVAL)
 
   return rootStore
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 621059822..f80c5f2c0 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -5,13 +5,16 @@ import {
   AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
 } from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
 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'
-import {cleanError} from '../../lib/strings'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {RichText} from 'lib/strings/rich-text'
 
 const PAGE_SIZE = 30
 
@@ -37,6 +40,7 @@ export class FeedItemModel {
   reply?: FeedViewPost['reply']
   replyParent?: FeedItemModel
   reason?: FeedViewPost['reason']
+  richText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -49,6 +53,11 @@ 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},
+        )
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -187,10 +196,9 @@ export class FeedModel {
   hasMore = true
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
-  _loadPromise: Promise<void> | undefined
-  _loadMorePromise: Promise<void> | undefined
-  _loadLatestPromise: Promise<void> | undefined
-  _updatePromise: Promise<void> | undefined
+
+  // used to linearize async modifications to state
+  private lock = new AwaitLock()
 
   // data
   feed: FeedItemModel[] = []
@@ -206,10 +214,6 @@ export class FeedModel {
         rootStore: false,
         params: false,
         loadMoreCursor: false,
-        _loadPromise: false,
-        _loadMorePromise: false,
-        _loadLatestPromise: false,
-        _updatePromise: false,
       },
       {autoBind: true},
     )
@@ -229,13 +233,22 @@ export class FeedModel {
   }
 
   get nonReplyFeed() {
-    return this.feed.filter(
-      item =>
+    const nonReplyFeed = this.feed.filter(item => {
+      const params = this.params as GetAuthorFeed.QueryParams
+      const isRepost =
+        item.reply &&
+        (item?.reasonRepost?.by?.handle === params.author ||
+          item?.reasonRepost?.by?.did === params.author)
+
+      return (
         !item.reply || // not a reply
+        isRepost ||
         ((item._isThreadParent || // but allow if it's a thread by the user
           item._isThreadChild) &&
-          item.reply?.root.author.did === item.post.author.did),
-    )
+          item.reply?.root.author.did === item.post.author.did)
+      )
+    })
+    return nonReplyFeed
   }
 
   setHasNewLatest(v: boolean) {
@@ -246,21 +259,44 @@ export class FeedModel {
   // =
 
   /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('FeedModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasNewLatest = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.pollCursor = undefined
+    this.feed = []
+  }
+
+  /**
    * Load for first render
    */
-  async setup(isRefreshing = false) {
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
     if (isRefreshing) {
       this.isRefreshing = true // set optimistically for UI
     }
-    if (this._loadPromise) {
-      return this._loadPromise
+    await this.lock.acquireAsync()
+    try {
+      this.setHasNewLatest(false)
+      this._xLoading(isRefreshing)
+      try {
+        const res = await this._getFeed({limit: PAGE_SIZE})
+        await this._replaceAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle(e)
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this.setHasNewLatest(false)
-    this._loadPromise = this._initialLoad(isRefreshing)
-    await this._loadPromise
-    this._loadPromise = undefined
-  }
+  })
 
   /**
    * Register any event listeners. Returns a cleanup function.
@@ -280,42 +316,93 @@ export class FeedModel {
   /**
    * Load more posts to the end of the feed
    */
-  async loadMore() {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.hasMore || this.hasError) {
+        return
+      }
+      this._xLoading()
+      try {
+        const res = await this._getFeed({
+          before: this.loadMoreCursor,
+          limit: PAGE_SIZE,
+        })
+        await this._appendAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('FeedView: Failed to load more', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._loadMorePromise = this._loadMore()
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   /**
    * Load more posts to the start of the feed
    */
-  async loadLatest() {
-    if (this._loadLatestPromise) {
-      return this._loadLatestPromise
+  loadLatest = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      this.setHasNewLatest(false)
+      this._xLoading()
+      try {
+        const res = await this._getFeed({limit: PAGE_SIZE})
+        await this._prependAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('FeedView: Failed to load latest', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this.setHasNewLatest(false)
-    this._loadLatestPromise = this._loadLatest()
-    await this._loadLatestPromise
-    this._loadLatestPromise = undefined
-  }
+  })
 
   /**
    * Update content in-place
    */
-  async update() {
-    if (this._updatePromise) {
-      return this._updatePromise
+  update = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.feed.length) {
+        return
+      }
+      this._xLoading()
+      let numToFetch = this.feed.length
+      let cursor
+      try {
+        do {
+          const res: GetTimeline.Response = await this._getFeed({
+            before: cursor,
+            limit: Math.min(numToFetch, 100),
+          })
+          if (res.data.feed.length === 0) {
+            break // sanity check
+          }
+          this._updateAll(res)
+          numToFetch -= res.data.feed.length
+          cursor = res.data.cursor
+        } while (cursor && numToFetch > 0)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('FeedView: Failed to update', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._updatePromise = this._update()
-    await this._updatePromise
-    this._updatePromise = undefined
-  }
+  })
 
   /**
    * Check if new posts are available
@@ -324,17 +411,18 @@ export class FeedModel {
     if (this.hasNewLatest) {
       return
     }
-    await this._pendingWork()
     const res = await this._getFeed({limit: 1})
     const currentLatestUri = this.pollCursor
-    const receivedLatestUri = res.data.feed[0]
-      ? res.data.feed[0].post.uri
-      : undefined
-    const hasNewLatest = Boolean(
-      receivedLatestUri &&
-        (this.feed.length === 0 || receivedLatestUri !== currentLatestUri),
-    )
-    this.setHasNewLatest(hasNewLatest)
+    const item = res.data.feed[0]
+    if (!item) {
+      return
+    }
+    if (AppBskyFeedFeedViewPost.isReasonRepost(item.reason)) {
+      if (item.reason.by.did === this.rootStore.me.did) {
+        return // ignore reposts by the user
+      }
+    }
+    this.setHasNewLatest(item.post.uri !== currentLatestUri)
   }
 
   /**
@@ -363,95 +451,15 @@ export class FeedModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? cleanError(err.toString()) : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Posts feed request failed', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _pendingWork() {
-    if (this._loadPromise) {
-      await this._loadPromise
-    }
-    if (this._loadMorePromise) {
-      await this._loadMorePromise
-    }
-    if (this._loadLatestPromise) {
-      await this._loadLatestPromise
-    }
-    if (this._updatePromise) {
-      await this._updatePromise
-    }
-  }
-
-  private async _initialLoad(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this._getFeed({limit: PAGE_SIZE})
-      await this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadLatest() {
-    this._xLoading()
-    try {
-      const res = await this._getFeed({limit: PAGE_SIZE})
-      await this._prependAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadMore() {
-    if (!this.hasMore || this.hasError) {
-      return
-    }
-    this._xLoading()
-    try {
-      const res = await this._getFeed({
-        before: this.loadMoreCursor,
-        limit: PAGE_SIZE,
-      })
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _update() {
-    if (!this.feed.length) {
-      return
-    }
-    this._xLoading()
-    let numToFetch = this.feed.length
-    let cursor
-    try {
-      do {
-        const res: GetTimeline.Response = await this._getFeed({
-          before: cursor,
-          limit: Math.min(numToFetch, 100),
-        })
-        if (res.data.feed.length === 0) {
-          break // sanity check
-        }
-        this._updateAll(res)
-        numToFetch -= res.data.feed.length
-        cursor = res.data.cursor
-      } while (cursor && numToFetch > 0)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
   private async _replaceAll(
     res: GetTimeline.Response | GetAuthorFeed.Response,
   ) {
@@ -570,32 +578,9 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
     reorg.unshift(item)
   }
 
-  // phase two: identify the positions of the threads
-  let activeSlice = -1
-  let threadSlices: Slice[] = []
-  for (let i = 0; i < reorg.length; i++) {
-    const item = reorg[i] as FeedViewPostWithThreadMeta
-    if (activeSlice === -1) {
-      if (item._isThreadParent) {
-        activeSlice = i
-      }
-    } else {
-      if (!item._isThreadChild) {
-        threadSlices.push({index: activeSlice, length: i - activeSlice})
-        if (item._isThreadParent) {
-          activeSlice = i
-        } else {
-          activeSlice = -1
-        }
-      }
-    }
-  }
-  if (activeSlice !== -1) {
-    threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
-  }
-
-  // phase three: reorder the feed so that the timestamp of the
+  // phase two: reorder the feed so that the timestamp of the
   // last post in a thread establishes its ordering
+  let threadSlices: Slice[] = identifyThreadSlices(reorg)
   for (const slice of threadSlices) {
     const removed: FeedViewPostWithThreadMeta[] = reorg.splice(
       slice.index,
@@ -610,8 +595,10 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
     slice.index = newIndex
   }
 
-  // phase four: compress any threads that are longer than 3 posts
+  // phase three: compress any threads that are longer than 3 posts
   let removedCount = 0
+  // phase 2 moved posts around, so we need to re-identify the slice indices
+  threadSlices = identifyThreadSlices(reorg)
   for (const slice of threadSlices) {
     if (slice.length > 3) {
       reorg.splice(slice.index - removedCount + 1, slice.length - 3)
@@ -626,6 +613,32 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
   return reorg
 }
 
+function identifyThreadSlices(feed: FeedViewPost[]): Slice[] {
+  let activeSlice = -1
+  let threadSlices: Slice[] = []
+  for (let i = 0; i < feed.length; i++) {
+    const item = feed[i] as FeedViewPostWithThreadMeta
+    if (activeSlice === -1) {
+      if (item._isThreadParent) {
+        activeSlice = i
+      }
+    } else {
+      if (!item._isThreadChild) {
+        threadSlices.push({index: activeSlice, length: i - activeSlice})
+        if (item._isThreadParent) {
+          activeSlice = i
+        } else {
+          activeSlice = -1
+        }
+      }
+    }
+  }
+  if (activeSlice !== -1) {
+    threadSlices.push({index: activeSlice, length: feed.length - activeSlice})
+  }
+  return threadSlices
+}
+
 // WARNING: mutates `feed`
 function dedupReposts(feed: FeedItemModel[]) {
   // remove duplicates caused by reposts
diff --git a/src/state/models/get-assertions-view.ts b/src/state/models/get-assertions-view.ts
deleted file mode 100644
index bdb2c0894..000000000
--- a/src/state/models/get-assertions-view.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyGraphGetAssertions as GetAssertions} from '@atproto/api'
-import {RootStoreModel} from './root-store'
-
-export type Assertion = GetAssertions.Assertion & {
-  _reactKey: string
-}
-
-export class GetAssertionsView {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetAssertions.QueryParams
-
-  // data
-  assertions: Assertion[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetAssertions.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.assertions.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  getBySubject(did: string) {
-    return this.assertions.find(assertion => assertion.subject.did === did)
-  }
-
-  get confirmed() {
-    return this.assertions.filter(assertion => !!assertion.confirmation)
-  }
-
-  get unconfirmed() {
-    return this.assertions.filter(assertion => !assertion.confirmation)
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    await this._fetch()
-  }
-
-  async refresh() {
-    await this._fetch(true)
-  }
-
-  async loadMore() {
-    // TODO
-  }
-
-  // state transitions
-  // =
-
-  private _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  private _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = err ? err.toString() : ''
-    if (err) {
-      this.rootStore.log.error('Failed to fetch assertions', err)
-    }
-  }
-
-  // loader functions
-  // =
-
-  private async _fetch(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this.rootStore.api.app.bsky.graph.getAssertions(
-        this.params,
-      )
-      this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private _replaceAll(res: GetAssertions.Response) {
-    this.assertions.length = 0
-    let counter = 0
-    for (const item of res.data.assertions) {
-      this._append({
-        _reactKey: `item-${counter++}`,
-        ...item,
-      })
-    }
-  }
-
-  private _append(item: Assertion) {
-    this.assertions.push(item)
-  }
-}
diff --git a/src/state/models/link-metas-view.ts b/src/state/models/link-metas-view.ts
index 6b787987d..59447008a 100644
--- a/src/state/models/link-metas-view.ts
+++ b/src/state/models/link-metas-view.ts
@@ -1,7 +1,7 @@
 import {makeAutoObservable} from 'mobx'
 import {LRUMap} from 'lru_map'
 import {RootStoreModel} from './root-store'
-import {LinkMeta, getLinkMeta} from '../../lib/link-meta'
+import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
 
 type CacheValue = Promise<LinkMeta> | LinkMeta
 export class LinkMetasViewModel {
diff --git a/src/state/models/log.ts b/src/state/models/log.ts
index 67f4a210c..f2709f2f1 100644
--- a/src/state/models/log.ts
+++ b/src/state/models/log.ts
@@ -1,6 +1,6 @@
 import {makeAutoObservable} from 'mobx'
 import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
-import {isObj, hasProp} from '../lib/type-guards'
+import {isObj, hasProp} from 'lib/type-guards'
 
 interface LogEntry {
   id: string
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 0d0c1d1de..0cb84c9fc 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -1,10 +1,9 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import notifee from '@notifee/react-native'
 import {RootStoreModel} from './root-store'
 import {FeedModel} from './feed-view'
 import {NotificationsViewModel} from './notifications-view'
-import {isObj, hasProp} from '../lib/type-guards'
-import {displayNotificationFromModel} from '../../view/lib/notifee'
+import {MyFollowsModel} from './my-follows'
+import {isObj, hasProp} from 'lib/type-guards'
 
 export class MeModel {
   did: string = ''
@@ -12,9 +11,9 @@ export class MeModel {
   displayName: string = ''
   description: string = ''
   avatar: string = ''
-  notificationCount: number = 0
   mainFeed: FeedModel
   notifications: NotificationsViewModel
+  follows: MyFollowsModel
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -26,15 +25,17 @@ export class MeModel {
       algorithm: 'reverse-chronological',
     })
     this.notifications = new NotificationsViewModel(this.rootStore, {})
+    this.follows = new MyFollowsModel(this.rootStore)
   }
 
   clear() {
+    this.mainFeed.clear()
+    this.notifications.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
     this.description = ''
     this.avatar = ''
-    this.notificationCount = 0
   }
 
   serialize(): unknown {
@@ -77,9 +78,10 @@ export class MeModel {
 
   async load() {
     const sess = this.rootStore.session
-    if (sess.hasSession && sess.data) {
-      this.did = sess.data.did || ''
-      this.handle = sess.data.handle
+    this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
+    if (sess.hasSession) {
+      this.did = sess.currentSession?.did || ''
+      this.handle = sess.currentSession?.handle || ''
       const profile = await this.rootStore.api.app.bsky.actor.getProfile({
         actor: this.did,
       })
@@ -94,10 +96,6 @@ export class MeModel {
           this.avatar = ''
         }
       })
-      this.mainFeed = new FeedModel(this.rootStore, 'home', {
-        algorithm: 'reverse-chronological',
-      })
-      this.notifications = new NotificationsViewModel(this.rootStore, {})
       await Promise.all([
         this.mainFeed.setup().catch(e => {
           this.rootStore.log.error('Failed to setup main feed model', e)
@@ -105,51 +103,13 @@ export class MeModel {
         this.notifications.setup().catch(e => {
           this.rootStore.log.error('Failed to setup notifications model', e)
         }),
+        this.follows.fetch().catch(e => {
+          this.rootStore.log.error('Failed to load my follows', e)
+        }),
       ])
-
-      // request notifications permission once the user has logged in
-      notifee.requestPermission()
+      this.rootStore.emitSessionLoaded()
     } else {
       this.clear()
     }
   }
-
-  clearNotificationCount() {
-    this.notificationCount = 0
-    notifee.setBadgeCount(0)
-  }
-
-  async fetchNotifications() {
-    const res = await this.rootStore.api.app.bsky.notification.getCount()
-    runInAction(() => {
-      const newNotifications = this.notificationCount !== res.data.count
-      this.notificationCount = res.data.count
-      notifee.setBadgeCount(this.notificationCount)
-      if (newNotifications) {
-        this.notifications.refresh()
-      }
-    })
-  }
-
-  async bgFetchNotifications() {
-    const res = await this.rootStore.api.app.bsky.notification.getCount()
-    // NOTE we don't update this.notificationCount to avoid repaints during bg
-    //      this means `newNotifications` may not be accurate, so we rely on
-    //      `mostRecent` to determine if there really is a new notif to show -prf
-    const newNotifications = this.notificationCount !== res.data.count
-    notifee.setBadgeCount(res.data.count)
-    this.rootStore.log.debug(
-      `Background fetch received unread count = ${res.data.count}`,
-    )
-    if (newNotifications) {
-      this.rootStore.log.debug(
-        'Background fetch detected potentially a new notification',
-      )
-      const mostRecent = await this.notifications.getNewMostRecent()
-      if (mostRecent) {
-        this.rootStore.log.debug('Got the notification, triggering a push')
-        displayNotificationFromModel(mostRecent)
-      }
-    }
-  }
 }
diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts
new file mode 100644
index 000000000..252e8a3d3
--- /dev/null
+++ b/src/state/models/my-follows.ts
@@ -0,0 +1,109 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} 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
+
+/**
+ * This model is used to maintain a synced local cache of the user's
+ * follows. It should be periodically refreshed and updated any time
+ * the user makes a change to their follows.
+ */
+export class MyFollowsModel {
+  // data
+  followDidToRecordMap: Record<string, string> = {}
+  lastSync = 0
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  // public api
+  // =
+
+  fetchIfNeeded = bundleAsync(async () => {
+    if (
+      Object.keys(this.followDidToRecordMap).length === 0 ||
+      Date.now() - this.lastSync > CACHE_TTL
+    ) {
+      return await this.fetch()
+    }
+  })
+
+  fetch = bundleAsync(async () => {
+    this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
+    let before
+    let records: FollowsListResponseRecord[] = []
+    do {
+      const res: FollowsListResponse =
+        await this.rootStore.api.app.bsky.graph.follow.list({
+          user: this.rootStore.me.did,
+          before,
+        })
+      records = records.concat(res.records)
+      before = res.cursor
+    } while (typeof before !== 'undefined')
+    runInAction(() => {
+      this.followDidToRecordMap = {}
+      for (const record of records) {
+        this.followDidToRecordMap[record.value.subject.did] = record.uri
+      }
+      this.lastSync = Date.now()
+    })
+  })
+
+  isFollowing(did: string) {
+    return !!this.followDidToRecordMap[did]
+  }
+
+  getFollowUri(did: string): string {
+    const v = this.followDidToRecordMap[did]
+    if (!v) {
+      throw new Error('Not a followed user')
+    }
+    return v
+  }
+
+  addFollow(did: string, recordUri: string) {
+    this.followDidToRecordMap[did] = recordUri
+  }
+
+  removeFollow(did: string) {
+    delete this.followDidToRecordMap[did]
+  }
+
+  /**
+   * Use this to incrementally update the cache as views provide information
+   */
+  hydrate(did: string, recordUri: string | undefined) {
+    if (recordUri) {
+      this.followDidToRecordMap[did] = recordUri
+    } else {
+      delete this.followDidToRecordMap[did]
+    }
+  }
+
+  /**
+   * Use this to incrementally update the cache as views provide information
+   */
+  hydrateProfiles(profiles: Profile[]) {
+    for (const profile of profiles) {
+      if (profile.viewer) {
+        this.hydrate(profile.did, profile.viewer.following)
+      }
+    }
+  }
+}
diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts
index 224ffef0d..feb03b367 100644
--- a/src/state/models/navigation.ts
+++ b/src/state/models/navigation.ts
@@ -1,5 +1,7 @@
+import {RootStoreModel} from './root-store'
 import {makeAutoObservable} from 'mobx'
-import {TABS_ENABLED} from '../../build-flags'
+import {TABS_ENABLED} from 'lib/build-flags'
+import {segmentClient} from 'lib/analytics'
 
 let __id = 0
 function genId() {
@@ -11,13 +13,20 @@ function genId() {
 // we've since decided to pause that idea and do something more traditional
 // until we're fully sure what that is, the tabs are being repurposed into a fixed topology
 // - Tab 0: The "Default" tab
-// - Tab 1: The "Notifications" tab
+// - Tab 1: The "Search" tab
+// - Tab 2: The "Notifications" tab
 // These tabs always retain the first item in their history.
-// The default tab is used for basically everything except notifications.
 // -prf
 export enum TabPurpose {
   Default = 0,
-  Notifs = 1,
+  Search = 1,
+  Notifs = 2,
+}
+
+export const TabPurposeMainPath: Record<TabPurpose, string> = {
+  [TabPurpose.Default]: '/',
+  [TabPurpose.Search]: '/search',
+  [TabPurpose.Notifs]: '/notifications',
 }
 
 interface HistoryItem {
@@ -36,11 +45,9 @@ export class NavigationTabModel {
   isNewTab = false
 
   constructor(public fixedTabPurpose: TabPurpose) {
-    if (fixedTabPurpose === TabPurpose.Notifs) {
-      this.history = [{url: '/notifications', ts: Date.now(), id: genId()}]
-    } else {
-      this.history = [{url: '/', ts: Date.now(), id: genId()}]
-    }
+    this.history = [
+      {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
+    ]
     makeAutoObservable(this, {
       serialize: false,
       hydrate: false,
@@ -96,6 +103,13 @@ export class NavigationTabModel {
   // =
 
   navigate(url: string, title?: string) {
+    try {
+      const path = url.split('/')[1]
+      segmentClient.track('Navigation', {
+        path,
+      })
+    } catch (error) {}
+
     if (this.current?.url === url) {
       this.refresh()
     } else {
@@ -104,8 +118,7 @@ export class NavigationTabModel {
       }
       // TEMP ensure the tab has its purpose's main view -prf
       if (this.history.length < 1) {
-        const fixedUrl =
-          this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
+        const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
         this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
       }
       this.history.push({url, title, ts: Date.now(), id: genId()})
@@ -211,12 +224,14 @@ export class NavigationTabModel {
 export class NavigationModel {
   tabs: NavigationTabModel[] = [
     new NavigationTabModel(TabPurpose.Default),
+    new NavigationTabModel(TabPurpose.Search),
     new NavigationTabModel(TabPurpose.Notifs),
   ]
   tabIndex = 0
 
-  constructor() {
+  constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
+      rootStore: false,
       serialize: false,
       hydrate: false,
     })
@@ -225,6 +240,7 @@ export class NavigationModel {
   clear() {
     this.tabs = [
       new NavigationTabModel(TabPurpose.Default),
+      new NavigationTabModel(TabPurpose.Search),
       new NavigationTabModel(TabPurpose.Notifs),
     ]
     this.tabIndex = 0
@@ -249,6 +265,7 @@ export class NavigationModel {
   // =
 
   navigate(url: string, title?: string) {
+    this.rootStore.emitNavigation()
     this.tab.navigate(url, title)
   }
 
@@ -286,10 +303,16 @@ export class NavigationModel {
   // fixed tab helper function
   // -prf
   switchTo(purpose: TabPurpose, reset: boolean) {
-    if (purpose === TabPurpose.Notifs) {
-      this.tabIndex = 1
-    } else {
-      this.tabIndex = 0
+    this.rootStore.emitNavigation()
+    switch (purpose) {
+      case TabPurpose.Notifs:
+        this.tabIndex = 2
+        break
+      case TabPurpose.Search:
+        this.tabIndex = 1
+        break
+      default:
+        this.tabIndex = 0
     }
     if (reset) {
       this.tab.fixedTabReset()
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
index 93b6a398f..048de968d 100644
--- a/src/state/models/notifications-view.ts
+++ b/src/state/models/notifications-view.ts
@@ -8,11 +8,13 @@ import {
   AppBskyGraphAssertion,
   AppBskyGraphFollow,
 } from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from './root-store'
 import {PostThreadViewModel} from './post-thread-view'
-import {cleanError} from '../../lib/strings'
+import {cleanError} from 'lib/strings/errors'
 
-const UNGROUPABLE_REASONS = ['assertion']
+const GROUPABLE_REASONS = ['vote', 'repost', 'follow']
 const PAGE_SIZE = 30
 const MS_1HR = 1e3 * 60 * 60
 const MS_2DAY = MS_1HR * 48
@@ -190,15 +192,16 @@ export class NotificationsViewModel {
   params: ListNotifications.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  _loadPromise: Promise<void> | undefined
-  _loadMorePromise: Promise<void> | undefined
-  _updatePromise: Promise<void> | undefined
+
+  // used to linearize async modifications to state
+  private lock = new AwaitLock()
 
   // data
   notifications: NotificationsViewItemModel[] = []
+  unreadCount = 0
 
   // this is used to help trigger push notifications
-  mostRecentNotification: NotificationsViewItemModel | undefined
+  mostRecentNotificationUri: string | undefined
 
   constructor(
     public rootStore: RootStoreModel,
@@ -209,10 +212,7 @@ export class NotificationsViewModel {
       {
         rootStore: false,
         params: false,
-        mostRecentNotification: false,
-        _loadPromise: false,
-        _loadMorePromise: false,
-        _updatePromise: false,
+        mostRecentNotificationUri: false,
       },
       {autoBind: true},
     )
@@ -235,20 +235,47 @@ export class NotificationsViewModel {
   // =
 
   /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('NotificationsModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.notifications = []
+    this.unreadCount = 0
+    this.rootStore.emitUnreadNotifications(0)
+    this.mostRecentNotificationUri = undefined
+  }
+
+  /**
    * Load for first render
    */
-  async setup(isRefreshing = false) {
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
     if (isRefreshing) {
       this.isRefreshing = true // set optimistically for UI
     }
-    if (this._loadPromise) {
-      return this._loadPromise
+    await this.lock.acquireAsync()
+    try {
+      this._xLoading(isRefreshing)
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+        })
+        const res = await this.rootStore.api.app.bsky.notification.list(params)
+        await this._replaceAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle(e)
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._loadPromise = this._initialLoad(isRefreshing)
-    await this._loadPromise
-    this._loadPromise = undefined
-  }
+  })
 
   /**
    * Reset and load
@@ -260,59 +287,148 @@ export class NotificationsViewModel {
   /**
    * Load more posts to the end of the notifications
    */
-  async loadMore() {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async () => {
+    if (!this.hasMore) {
+      return
     }
-    await this._pendingWork()
-    this._loadMorePromise = this._loadMore()
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this.lock.acquireAsync()
+    try {
+      this._xLoading()
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+          before: this.loadMoreCursor,
+        })
+        const res = await this.rootStore.api.app.bsky.notification.list(params)
+        await this._appendAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to load more', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Load more posts at the start of the notifications
+   */
+  loadLatest = bundleAsync(async () => {
+    if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) {
+      return this.refresh()
+    }
+    this.lock.acquireAsync()
+    try {
+      this._xLoading()
+      try {
+        const res = await this.rootStore.api.app.bsky.notification.list({
+          limit: PAGE_SIZE,
+        })
+        await this._prependAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to load latest', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
 
   /**
    * Update content in-place
    */
-  async update() {
-    if (this._updatePromise) {
-      return this._updatePromise
+  update = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.notifications.length) {
+        return
+      }
+      this._xLoading()
+      let numToFetch = this.notifications.length
+      let cursor
+      try {
+        do {
+          const res: ListNotifications.Response =
+            await this.rootStore.api.app.bsky.notification.list({
+              before: cursor,
+              limit: Math.min(numToFetch, 100),
+            })
+          if (res.data.notifications.length === 0) {
+            break // sanity check
+          }
+          this._updateAll(res)
+          numToFetch -= res.data.notifications.length
+          cursor = res.data.cursor
+        } while (cursor && numToFetch > 0)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to update', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._updatePromise = this._update()
-    await this._updatePromise
-    this._updatePromise = undefined
-  }
+  })
+
+  // unread notification apis
+  // =
+
+  /**
+   * Get the current number of unread notifications
+   * returns true if the number changed
+   */
+  loadUnreadCount = bundleAsync(async () => {
+    const old = this.unreadCount
+    const res = await this.rootStore.api.app.bsky.notification.getCount()
+    runInAction(() => {
+      this.unreadCount = res.data.count
+    })
+    this.rootStore.emitUnreadNotifications(this.unreadCount)
+    return this.unreadCount !== old
+  })
 
   /**
    * Update read/unread state
    */
-  async updateReadState() {
+  async markAllRead() {
     try {
+      this.unreadCount = 0
+      this.rootStore.emitUnreadNotifications(0)
       await this.rootStore.api.app.bsky.notification.updateSeen({
         seenAt: new Date().toISOString(),
       })
-      this.rootStore.me.clearNotificationCount()
     } catch (e: any) {
       this.rootStore.log.warn('Failed to update notifications read state', e)
     }
   }
 
   async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
-    let old = this.mostRecentNotification
-    const res = await this.rootStore.api.app.bsky.notification.list({limit: 1})
-    if (
-      !res.data.notifications[0] ||
-      old?.uri === res.data.notifications[0].uri
-    ) {
+    let old = this.mostRecentNotificationUri
+    const res = await this.rootStore.api.app.bsky.notification.list({
+      limit: 1,
+    })
+    if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
       return
     }
-    this.mostRecentNotification = new NotificationsViewItemModel(
+    this.mostRecentNotificationUri = res.data.notifications[0].uri
+    const notif = new NotificationsViewItemModel(
       this.rootStore,
       'mostRecent',
       res.data.notifications[0],
     )
-    await this.mostRecentNotification.fetchAdditionalData()
-    return this.mostRecentNotification
+    await notif.fetchAdditionalData()
+    return notif
   }
 
   // state transitions
@@ -329,93 +445,17 @@ export class NotificationsViewModel {
     this.isRefreshing = false
     this.hasLoaded = true
     this.error = cleanError(err)
-    this.error = err ? cleanError(err) : ''
     if (err) {
       this.rootStore.log.error('Failed to fetch notifications', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _pendingWork() {
-    if (this._loadPromise) {
-      await this._loadPromise
-    }
-    if (this._loadMorePromise) {
-      await this._loadMorePromise
-    }
-    if (this._updatePromise) {
-      await this._updatePromise
-    }
-  }
-
-  private async _initialLoad(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-      })
-      const res = await this.rootStore.api.app.bsky.notification.list(params)
-      await this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadMore() {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading()
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      const res = await this.rootStore.api.app.bsky.notification.list(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _update() {
-    if (!this.notifications.length) {
-      return
-    }
-    this._xLoading()
-    let numToFetch = this.notifications.length
-    let cursor
-    try {
-      do {
-        const res: ListNotifications.Response =
-          await this.rootStore.api.app.bsky.notification.list({
-            before: cursor,
-            limit: Math.min(numToFetch, 100),
-          })
-        if (res.data.notifications.length === 0) {
-          break // sanity check
-        }
-        this._updateAll(res)
-        numToFetch -= res.data.notifications.length
-        cursor = res.data.cursor
-      } while (cursor && numToFetch > 0)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
   private async _replaceAll(res: ListNotifications.Response) {
     if (res.data.notifications[0]) {
-      this.mostRecentNotification = new NotificationsViewItemModel(
-        this.rootStore,
-        'mostRecent',
-        res.data.notifications[0],
-      )
+      this.mostRecentNotificationUri = res.data.notifications[0].uri
     }
     return this._appendAll(res, true)
   }
@@ -451,14 +491,40 @@ export class NotificationsViewModel {
     })
   }
 
+  private async _prependAll(res: ListNotifications.Response) {
+    const promises = []
+    const itemModels: NotificationsViewItemModel[] = []
+    const dedupedNotifs = res.data.notifications.filter(
+      n1 =>
+        !this.notifications.find(
+          n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)),
+        ),
+    )
+    for (const item of groupNotifications(dedupedNotifs)) {
+      const itemModel = new NotificationsViewItemModel(
+        this.rootStore,
+        `item-${_idCounter++}`,
+        item,
+      )
+      if (itemModel.needsAdditionalData) {
+        promises.push(itemModel.fetchAdditionalData())
+      }
+      itemModels.push(itemModel)
+    }
+    await Promise.all(promises).catch(e => {
+      this.rootStore.log.error(
+        'Uncaught failure during notifications-view _prependAll()',
+        e,
+      )
+    })
+    runInAction(() => {
+      this.notifications = itemModels.concat(this.notifications)
+    })
+  }
+
   private _updateAll(res: ListNotifications.Response) {
     for (const item of res.data.notifications) {
-      const existingItem = this.notifications.find(
-        // this find function has a key subtlety- the indexedAt comparison
-        // the reason for this is reposts: they set the URI of the original post, not of the repost record
-        // the indexedAt time will be for the repost however, so we use that to help us
-        item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt,
-      )
+      const existingItem = this.notifications.find(item2 => isEq(item, item2))
       if (existingItem) {
         existingItem.copy(item, true)
       }
@@ -473,7 +539,7 @@ function groupNotifications(
   for (const item of items) {
     const ts = +new Date(item.indexedAt)
     let grouped = false
-    if (!UNGROUPABLE_REASONS.includes(item.reason)) {
+    if (GROUPABLE_REASONS.includes(item.reason)) {
       for (const item2 of items2) {
         const ts2 = +new Date(item2.indexedAt)
         if (
@@ -495,3 +561,11 @@ function groupNotifications(
   }
   return items2
 }
+
+type N = ListNotifications.Notification | NotificationsViewItemModel
+function isEq(a: N, b: N) {
+  // this function has a key subtlety- the indexedAt comparison
+  // the reason for this is reposts: they set the URI of the original post, not of the repost record
+  // the indexedAt time will be for the repost however, so we use that to help us
+  return a.uri === b.uri && a.indexedAt === b.indexedAt
+}
diff --git a/src/state/models/onboard.ts b/src/state/models/onboard.ts
index 5ab5ecb62..aa275c6b7 100644
--- a/src/state/models/onboard.ts
+++ b/src/state/models/onboard.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable} from 'mobx'
-import {isObj, hasProp} from '../lib/type-guards'
+import {isObj, hasProp} from 'lib/type-guards'
 
 export const OnboardStage = {
   Explainers: 'explainers',
diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts
index 584658e14..ad989cc53 100644
--- a/src/state/models/post-thread-view.ts
+++ b/src/state/models/post-thread-view.ts
@@ -5,7 +5,9 @@ import {
 } from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import * as apilib from '../lib/api'
+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,6 +28,7 @@ export class PostThreadViewPostModel {
   postRecord?: FeedPost.Record
   parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
   replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
+  richText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -38,6 +41,11 @@ 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},
+        )
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -276,7 +284,7 @@ export class PostThreadViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch post thread', err)
     }
@@ -290,7 +298,7 @@ export class PostThreadViewModel {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -314,7 +322,7 @@ export class PostThreadViewModel {
   }
 
   private _replaceAll(res: GetPostThread.Response) {
-    // sortThread(res.data.thread) TODO needed?
+    sortThread(res.data.thread)
     const keyGen = reactKeyGenerator()
     const thread = new PostThreadViewPostModel(
       this.rootStore,
@@ -330,36 +338,37 @@ export class PostThreadViewModel {
   }
 }
 
-/*
-TODO needed?
+type MaybePost =
+  | GetPostThread.ThreadViewPost
+  | GetPostThread.NotFoundPost
+  | {[k: string]: unknown; $type: string}
 function sortThread(post: MaybePost) {
   if (post.notFound) {
     return
   }
-  post = post as GetPostThread.Post
+  post = post as GetPostThread.ThreadViewPost
   if (post.replies) {
     post.replies.sort((a: MaybePost, b: MaybePost) => {
-      post = post as GetPostThread.Post
+      post = post as GetPostThread.ThreadViewPost
       if (a.notFound) {
         return 1
       }
       if (b.notFound) {
         return -1
       }
-      a = a as GetPostThread.Post
-      b = b as GetPostThread.Post
-      const aIsByOp = a.author.did === post.author.did
-      const bIsByOp = b.author.did === post.author.did
+      a = a as GetPostThread.ThreadViewPost
+      b = b as GetPostThread.ThreadViewPost
+      const aIsByOp = a.post.author.did === post.post.author.did
+      const bIsByOp = b.post.author.did === post.post.author.did
       if (aIsByOp && bIsByOp) {
-        return a.indexedAt.localeCompare(b.indexedAt) // oldest
+        return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
       } else if (aIsByOp) {
         return -1 // op's own reply
       } else if (bIsByOp) {
         return 1 // op's own reply
       }
-      return b.indexedAt.localeCompare(a.indexedAt) // newest
+      return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
     })
     post.replies.forEach(reply => sortThread(reply))
   }
 }
-*/
diff --git a/src/state/models/post.ts b/src/state/models/post.ts
index 497c8e4c9..749e98bb0 100644
--- a/src/state/models/post.ts
+++ b/src/state/models/post.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
 import {AppBskyFeedPost as Post} from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import {cleanError} from '../../lib/strings'
+import {cleanError} from 'lib/strings/errors'
 
 type RemoveIndex<T> = {
   [P in keyof T as string extends P
@@ -67,7 +67,6 @@ export class PostModel implements RemoveIndex<Post.Record> {
     this.isLoading = false
     this.hasLoaded = true
     this.error = cleanError(err)
-    this.error = err ? cleanError(err) : ''
     if (err) {
       this.rootStore.log.error('Failed to fetch post', err)
     }
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index 79882a562..8630eae52 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -1,22 +1,23 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
+import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker'
 import {
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile as Profile,
   AppBskySystemDeclRef,
-  AppBskyFeedPost,
 } from '@atproto/api'
 type DeclRef = AppBskySystemDeclRef.Main
-type Entity = AppBskyFeedPost.Entity
-import {extractEntities} from '../../lib/strings'
+import {extractEntities} from 'lib/strings/rich-text-detection'
 import {RootStoreModel} from './root-store'
-import * as apilib from '../lib/api'
+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'
 
-export class ProfileViewMyStateModel {
-  follow?: string
+export class ProfileViewViewerModel {
   muted?: boolean
+  following?: string
+  followedBy?: string
 
   constructor() {
     makeAutoObservable(this)
@@ -46,10 +47,10 @@ export class ProfileViewModel {
   followersCount: number = 0
   followsCount: number = 0
   postsCount: number = 0
-  myState = new ProfileViewMyStateModel()
+  viewer = new ProfileViewViewerModel()
 
   // added data
-  descriptionEntities?: Entity[]
+  descriptionRichText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -97,11 +98,24 @@ export class ProfileViewModel {
     if (!this.rootStore.me.did) {
       throw new Error('Not logged in')
     }
-    if (this.myState.follow) {
-      await apilib.unfollow(this.rootStore, this.myState.follow)
+
+    const follows = this.rootStore.me.follows
+    const followUri = follows.isFollowing(this.did)
+      ? follows.getFollowUri(this.did)
+      : undefined
+
+    // guard against this view getting out of sync with the follows cache
+    if (followUri !== this.viewer.following) {
+      this.viewer.following = followUri
+      return
+    }
+
+    if (followUri) {
+      await apilib.unfollow(this.rootStore, followUri)
       runInAction(() => {
         this.followersCount--
-        this.myState.follow = undefined
+        this.viewer.following = undefined
+        this.rootStore.me.follows.removeFollow(this.did)
       })
     } else {
       const res = await apilib.follow(
@@ -111,15 +125,16 @@ export class ProfileViewModel {
       )
       runInAction(() => {
         this.followersCount++
-        this.myState.follow = res.uri
+        this.viewer.following = res.uri
+        this.rootStore.me.follows.addFollow(this.did, res.uri)
       })
     }
   }
 
   async updateProfile(
     updates: Profile.Record,
-    newUserAvatar: PickedImage | undefined,
-    newUserBanner: PickedImage | undefined,
+    newUserAvatar: PickedMedia | undefined,
+    newUserBanner: PickedMedia | undefined,
   ) {
     if (newUserAvatar) {
       const res = await this.rootStore.api.com.atproto.blob.upload(
@@ -152,13 +167,13 @@ export class ProfileViewModel {
 
   async muteAccount() {
     await this.rootStore.api.app.bsky.graph.mute({user: this.did})
-    this.myState.muted = true
+    this.viewer.muted = true
     await this.refresh()
   }
 
   async unmuteAccount() {
     await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
-    this.myState.muted = false
+    this.viewer.muted = false
     await this.refresh()
   }
 
@@ -175,7 +190,7 @@ export class ProfileViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch profile', err)
     }
@@ -210,9 +225,14 @@ export class ProfileViewModel {
     this.followersCount = res.data.followersCount
     this.followsCount = res.data.followsCount
     this.postsCount = res.data.postsCount
-    if (res.data.myState) {
-      Object.assign(this.myState, res.data.myState)
+    if (res.data.viewer) {
+      Object.assign(this.viewer, res.data.viewer)
+      this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
     }
-    this.descriptionEntities = extractEntities(this.description || '')
+    this.descriptionRichText = new RichText(
+      this.description || '',
+      extractEntities(this.description || ''),
+      {cleanNewlines: true},
+    )
   }
 }
diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts
index 804491c8b..4241e50e1 100644
--- a/src/state/models/profiles-view.ts
+++ b/src/state/models/profiles-view.ts
@@ -31,7 +31,9 @@ export class ProfilesViewModel {
       }
     }
     try {
-      const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did})
+      const promise = this.rootStore.api.app.bsky.actor.getProfile({
+        actor: did,
+      })
       this.cache.set(did, promise)
       const res = await promise
       this.cache.set(did, res)
diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts
index 1de6b7c58..69a728d6f 100644
--- a/src/state/models/reposted-by-view.ts
+++ b/src/state/models/reposted-by-view.ts
@@ -1,11 +1,17 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
-import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api'
+import {
+  AppBskyFeedGetRepostedBy as GetRepostedBy,
+  AppBskyActorRef as ActorRef,
+} from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
-export type RepostedByItem = GetRepostedBy.RepostedBy
+export type RepostedByItem = ActorRef.WithInfo
 
 export class RepostedByViewModel {
   // state
@@ -17,7 +23,6 @@ export class RepostedByViewModel {
   params: GetRepostedBy.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   uri: string = ''
@@ -57,17 +62,28 @@ export class RepostedByViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
-    }
-    if (!this.resolvedUri) {
-      await this._resolveUri()
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    this._xLoading(replace)
+    try {
+      if (!this.resolvedUri) {
+        await this._resolveUri()
+      }
+      const params = Object.assign({}, this.params, {
+        uri: this.resolvedUri,
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   // state transitions
   // =
@@ -82,20 +98,20 @@ export class RepostedByViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch reposted by view', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
   private async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -105,28 +121,15 @@ export class RepostedByViewModel {
     })
   }
 
-  private async _loadMore(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        uri: this.resolvedUri,
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.repostedBy = []
-      }
-      const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetRepostedBy.Response) {
+    this.repostedBy = []
+    this._appendAll(res)
   }
 
   private _appendAll(res: GetRepostedBy.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
+    this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
   }
 }
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 2f6931cdc..11d496351 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -3,71 +3,63 @@
  */
 
 import {makeAutoObservable} from 'mobx'
-import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
+import {AtpAgent} from '@atproto/api'
 import {createContext, useContext} from 'react'
 import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
-import * as BgScheduler from '../lib/bg-scheduler'
-import {isObj, hasProp} from '../lib/type-guards'
+import * as BgScheduler from 'lib/bg-scheduler'
+import {z} from 'zod'
+import {isObj, hasProp} from 'lib/type-guards'
 import {LogModel} from './log'
 import {SessionModel} from './session'
 import {NavigationModel} from './navigation'
 import {ShellUiModel} from './shell-ui'
 import {ProfilesViewModel} from './profiles-view'
 import {LinkMetasViewModel} from './link-metas-view'
+import {NotificationsViewItemModel} from './notifications-view'
 import {MeModel} from './me'
 import {OnboardModel} from './onboard'
-import {isNetworkError} from '../../lib/errors'
+
+export const appInfo = z.object({
+  build: z.string(),
+  name: z.string(),
+  namespace: z.string(),
+  version: z.string(),
+})
+export type AppInfo = z.infer<typeof appInfo>
 
 export class RootStoreModel {
+  agent: AtpAgent
+  appInfo?: AppInfo
   log = new LogModel()
   session = new SessionModel(this)
-  nav = new NavigationModel()
-  shell = new ShellUiModel()
+  nav = new NavigationModel(this)
+  shell = new ShellUiModel(this)
   me = new MeModel(this)
   onboard = new OnboardModel()
   profiles = new ProfilesViewModel(this)
   linkMetas = new LinkMetasViewModel(this)
 
-  constructor(public api: SessionServiceClient) {
+  constructor(agent: AtpAgent) {
+    this.agent = agent
     makeAutoObservable(this, {
       api: false,
-      resolveName: false,
       serialize: false,
       hydrate: false,
     })
     this.initBgFetch()
   }
 
-  async resolveName(didOrHandle: string) {
-    if (!didOrHandle) {
-      throw new Error('Invalid handle: ""')
-    }
-    if (didOrHandle.startsWith('did:')) {
-      return didOrHandle
-    }
-    const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle})
-    return res.data.did
+  get api() {
+    return this.agent.api
   }
 
-  async fetchStateUpdate() {
-    if (!this.session.hasSession) {
-      return
-    }
-    try {
-      if (!this.session.online) {
-        await this.session.connect()
-      }
-      await this.me.fetchNotifications()
-    } catch (e: any) {
-      if (isNetworkError(e)) {
-        this.session.setOnline(false) // connection lost
-      }
-      this.log.error('Failed to fetch latest state', e)
-    }
+  setAppInfo(info: AppInfo) {
+    this.appInfo = info
   }
 
   serialize(): unknown {
     return {
+      appInfo: this.appInfo,
       log: this.log.serialize(),
       session: this.session.serialize(),
       me: this.me.serialize(),
@@ -79,6 +71,12 @@ export class RootStoreModel {
 
   hydrate(v: unknown) {
     if (isObj(v)) {
+      if (hasProp(v, 'appInfo')) {
+        const appInfoParsed = appInfo.safeParse(v.appInfo)
+        if (appInfoParsed.success) {
+          this.setAppInfo(appInfoParsed.data)
+        }
+      }
       if (hasProp(v, 'log')) {
         this.log.hydrate(v.log)
       }
@@ -100,20 +98,131 @@ export class RootStoreModel {
     }
   }
 
-  clearAll() {
+  /**
+   * Called during init to resume any stored session.
+   */
+  async attemptSessionResumption() {
+    this.log.debug('RootStoreModel:attemptSessionResumption')
+    try {
+      await this.session.attemptSessionResumption()
+      this.log.debug('Session initialized', {
+        hasSession: this.session.hasSession,
+      })
+      this.updateSessionState()
+    } catch (e: any) {
+      this.log.warn('Failed to initialize session', e)
+    }
+  }
+
+  /**
+   * Called by the session model. Refreshes session-oriented state.
+   */
+  async handleSessionChange(agent: AtpAgent) {
+    this.log.debug('RootStoreModel:handleSessionChange')
+    this.agent = agent
+    this.nav.clear()
+    this.me.clear()
+    await this.me.load()
+  }
+
+  /**
+   * Called by the session model. Handles session drops by informing the user.
+   */
+  async handleSessionDrop() {
+    this.log.debug('RootStoreModel:handleSessionDrop')
+    this.nav.clear()
+    this.me.clear()
+    this.emitSessionDropped()
+  }
+
+  /**
+   * Clears all session-oriented state.
+   */
+  clearAllSessionState() {
+    this.log.debug('RootStoreModel:clearAllSessionState')
     this.session.clear()
     this.nav.clear()
     this.me.clear()
   }
 
+  /**
+   * Periodic poll for new session state.
+   */
+  async updateSessionState() {
+    if (!this.session.hasSession) {
+      return
+    }
+    try {
+      await this.me.follows.fetchIfNeeded()
+    } catch (e: any) {
+      this.log.error('Failed to fetch latest state', e)
+    }
+  }
+
+  // global event bus
+  // =
+  // - some events need to be passed around between views and models
+  //   in order to keep state in sync; these methods are for that
+
+  // a post was deleted by the local user
   onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
     return DeviceEventEmitter.addListener('post-deleted', handler)
   }
-
   emitPostDeleted(uri: string) {
     DeviceEventEmitter.emit('post-deleted', uri)
   }
 
+  // the session has started and been fully hydrated
+  onSessionLoaded(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('session-loaded', handler)
+  }
+  emitSessionLoaded() {
+    DeviceEventEmitter.emit('session-loaded')
+  }
+
+  // the session was dropped due to bad/expired refresh tokens
+  onSessionDropped(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('session-dropped', handler)
+  }
+  emitSessionDropped() {
+    DeviceEventEmitter.emit('session-dropped')
+  }
+
+  // the current screen has changed
+  onNavigation(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('navigation', handler)
+  }
+  emitNavigation() {
+    DeviceEventEmitter.emit('navigation')
+  }
+
+  // a "soft reset" typically means scrolling to top and loading latest
+  // but it can depend on the screen
+  onScreenSoftReset(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('screen-soft-reset', handler)
+  }
+  emitScreenSoftReset() {
+    DeviceEventEmitter.emit('screen-soft-reset')
+  }
+
+  // the unread notifications count has changed
+  onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('unread-notifications', handler)
+  }
+  emitUnreadNotifications(count: number) {
+    DeviceEventEmitter.emit('unread-notifications', count)
+  }
+
+  // a notification has been queued for push
+  onPushNotification(
+    handler: (notif: NotificationsViewItemModel) => void,
+  ): EmitterSubscription {
+    return DeviceEventEmitter.addListener('push-notification', handler)
+  }
+  emitPushNotification(notif: NotificationsViewItemModel) {
+    DeviceEventEmitter.emit('push-notification', notif)
+  }
+
   // background fetch
   // =
   // - we use this to poll for unread notifications, which is not "ideal" behavior but
@@ -135,7 +244,22 @@ export class RootStoreModel {
   async onBgFetch(taskId: string) {
     this.log.debug(`Background fetch fired for task ${taskId}`)
     if (this.session.hasSession) {
-      await this.me.bgFetchNotifications()
+      const res = await this.api.app.bsky.notification.getCount()
+      const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
+      this.emitUnreadNotifications(res.data.count)
+      this.log.debug(
+        `Background fetch received unread count = ${res.data.count}`,
+      )
+      if (hasNewNotifs) {
+        this.log.debug(
+          'Background fetch detected potentially a new notification',
+        )
+        const mostRecent = await this.me.notifications.getNewMostRecent()
+        if (mostRecent) {
+          this.log.debug('Got the notification, triggering a push')
+          this.emitPushNotification(mostRecent)
+        }
+      }
     }
     BgScheduler.finish(taskId)
   }
@@ -146,7 +270,9 @@ export class RootStoreModel {
   }
 }
 
-const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init
+const throwawayInst = new RootStoreModel(
+  new AtpAgent({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
 export const useStores = () => useContext(RootStoreContext)
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index bc0a9123f..6e816120d 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -1,25 +1,22 @@
-import {makeAutoObservable, runInAction} from 'mobx'
+import {makeAutoObservable} from 'mobx'
 import {
-  sessionClient as AtpApi,
-  Session,
-  SessionServiceClient,
+  AtpAgent,
+  AtpSessionEvent,
+  AtpSessionData,
   ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
 } from '@atproto/api'
-import {isObj, hasProp} from '../lib/type-guards'
+import normalizeUrl from 'normalize-url'
+import {isObj, hasProp} from 'lib/type-guards'
 import {z} from 'zod'
 import {RootStoreModel} from './root-store'
-import {isNetworkError} from '../../lib/errors'
 
 export type ServiceDescription = GetAccountsConfig.OutputSchema
 
-export const sessionData = z.object({
+export const activeSession = z.object({
   service: z.string(),
-  refreshJwt: z.string(),
-  accessJwt: z.string(),
-  handle: z.string(),
   did: z.string(),
 })
-export type SessionData = z.infer<typeof sessionData>
+export type ActiveSession = z.infer<typeof activeSession>
 
 export const accountData = z.object({
   service: z.string(),
@@ -32,18 +29,24 @@ export const accountData = z.object({
 })
 export type AccountData = z.infer<typeof accountData>
 
+interface AdditionalAccountData {
+  displayName?: string
+  aviUrl?: string
+}
+
 export class SessionModel {
   /**
-   * Current session data
+   * Currently-active session
    */
-  data: SessionData | null = null
+  data: ActiveSession | null = null
   /**
-   * A listing of the currently & previous sessions, used for account switching
+   * A listing of the currently & previous sessions
    */
   accounts: AccountData[] = []
-  online = false
-  attemptingConnect = false
-  private _connectPromise: Promise<boolean> | undefined
+  /**
+   * Flag to indicate if we're doing our initial-load session resumption
+   */
+  isResumingSession = false
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
@@ -53,8 +56,22 @@ export class SessionModel {
     })
   }
 
+  get currentSession() {
+    if (!this.data) {
+      return undefined
+    }
+    const {did, service} = this.data
+    return this.accounts.find(
+      account =>
+        normalizeUrl(account.service) === normalizeUrl(service) &&
+        account.did === did &&
+        !!account.accessJwt &&
+        !!account.refreshJwt,
+    )
+  }
+
   get hasSession() {
-    return this.data !== null
+    return !!this.currentSession && !!this.rootStore.agent.session
   }
 
   get hasAccounts() {
@@ -75,8 +92,8 @@ export class SessionModel {
   hydrate(v: unknown) {
     this.accounts = []
     if (isObj(v)) {
-      if (hasProp(v, 'data') && sessionData.safeParse(v.data)) {
-        this.data = v.data as SessionData
+      if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
+        this.data = v.data as ActiveSession
       }
       if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
         for (const account of v.accounts) {
@@ -90,92 +107,96 @@ export class SessionModel {
 
   clear() {
     this.data = null
-    this.setOnline(false)
   }
 
-  setState(data: SessionData) {
-    this.data = data
-  }
-
-  setOnline(online: boolean, attemptingConnect?: boolean) {
-    this.online = online
-    if (typeof attemptingConnect === 'boolean') {
-      this.attemptingConnect = attemptingConnect
-    }
-  }
-
-  updateAuthTokens(session: Session) {
-    if (this.data) {
-      this.setState({
-        ...this.data,
-        accessJwt: session.accessJwt,
-        refreshJwt: session.refreshJwt,
-      })
+  /**
+   * Attempts to resume the previous session loaded from storage
+   */
+  async attemptSessionResumption() {
+    const sess = this.currentSession
+    if (sess) {
+      this.rootStore.log.debug(
+        'SessionModel:attemptSessionResumption found stored session',
+      )
+      this.isResumingSession = true
+      try {
+        return await this.resumeSession(sess)
+      } finally {
+        this.isResumingSession = false
+      }
+    } else {
+      this.rootStore.log.debug(
+        'SessionModel:attemptSessionResumption has no session to resume',
+      )
     }
   }
 
   /**
-   * Sets up the XRPC API, must be called before connecting to a service
+   * Sets the active session
    */
-  private configureApi(): boolean {
-    if (!this.data) {
-      return false
+  setActiveSession(agent: AtpAgent, did: string) {
+    this.rootStore.log.debug('SessionModel:setActiveSession')
+    this.data = {
+      service: agent.service.toString(),
+      did,
     }
-
-    try {
-      const serviceUri = new URL(this.data.service)
-      this.rootStore.api.xrpc.uri = serviceUri
-    } catch (e: any) {
-      this.rootStore.log.error(
-        `Invalid service URL: ${this.data.service}. Resetting session.`,
-        e,
-      )
-      this.clear()
-      return false
-    }
-
-    this.rootStore.api.sessionManager.set({
-      refreshJwt: this.data.refreshJwt,
-      accessJwt: this.data.accessJwt,
-    })
-    return true
+    this.rootStore.handleSessionChange(agent)
   }
 
   /**
-   * Upserts the current session into the accounts
+   * Upserts a session into the accounts
    */
-  private addSessionToAccounts() {
-    if (!this.data) {
-      return
-    }
+  private persistSession(
+    service: string,
+    did: string,
+    event: AtpSessionEvent,
+    session?: AtpSessionData,
+    addedInfo?: AdditionalAccountData,
+  ) {
+    this.rootStore.log.debug('SessionModel:persistSession', {
+      service,
+      did,
+      event,
+      hasSession: !!session,
+    })
+
+    // upsert the account in our listing
     const existingAccount = this.accounts.find(
-      acc => acc.service === this.data?.service && acc.did === this.data.did,
+      account => account.service === service && account.did === did,
     )
     const newAccount = {
-      service: this.data.service,
-      refreshJwt: this.data.refreshJwt,
-      accessJwt: this.data.accessJwt,
-      handle: this.data.handle,
-      did: this.data.did,
-      displayName: this.rootStore.me.displayName,
-      aviUrl: this.rootStore.me.avatar,
+      service,
+      did,
+      refreshJwt: session?.refreshJwt,
+      accessJwt: session?.accessJwt,
+      handle: session?.handle || existingAccount?.handle || '',
+      displayName: addedInfo
+        ? addedInfo.displayName
+        : existingAccount?.displayName || '',
+      aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
     }
     if (!existingAccount) {
       this.accounts.push(newAccount)
     } else {
-      this.accounts = this.accounts
-        .filter(
-          acc =>
-            !(acc.service === this.data?.service && acc.did === this.data.did),
-        )
-        .concat([newAccount])
+      this.accounts = [
+        newAccount,
+        ...this.accounts.filter(
+          account => !(account.service === service && account.did === did),
+        ),
+      ]
+    }
+
+    // if the session expired, fire an event to let the user know
+    if (event === 'expired') {
+      this.rootStore.handleSessionDrop()
     }
   }
 
   /**
    * Clears any session tokens from the accounts; used on logout.
    */
-  private clearSessionTokensFromAccounts() {
+  private clearSessionTokens() {
+    this.rootStore.log.debug('SessionModel:clearSessionTokens')
     this.accounts = this.accounts.map(acct => ({
       service: acct.service,
       handle: acct.handle,
@@ -186,65 +207,73 @@ export class SessionModel {
   }
 
   /**
-   * Fetches the current session from the service, if possible.
-   * Requires an existing session (.data) to be populated with access tokens.
+   * Fetches additional information about an account on load.
    */
-  async connect(): Promise<boolean> {
-    if (this._connectPromise) {
-      return this._connectPromise
+  private async loadAccountInfo(agent: AtpAgent, did: string) {
+    const res = await agent.api.app.bsky.actor
+      .getProfile({actor: did})
+      .catch(_e => undefined)
+    if (res) {
+      return {
+        dispayName: res.data.displayName,
+        aviUrl: res.data.avatar,
+      }
     }
-    this._connectPromise = this._connect()
-    const res = await this._connectPromise
-    this._connectPromise = undefined
-    return res
   }
 
-  private async _connect(): Promise<boolean> {
-    this.attemptingConnect = true
-    if (!this.configureApi()) {
+  /**
+   * 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({})
+    return res.data
+  }
+
+  /**
+   * Attempt to resume a session that we still have access tokens for.
+   */
+  async resumeSession(account: AccountData): Promise<boolean> {
+    this.rootStore.log.debug('SessionModel:resumeSession')
+    if (!(account.accessJwt && account.refreshJwt && account.service)) {
+      this.rootStore.log.debug(
+        'SessionModel:resumeSession aborted due to lack of access tokens',
+      )
       return false
     }
 
+    const agent = new AtpAgent({
+      service: account.service,
+      persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(account.service, account.did, evt, sess)
+      },
+    })
+
     try {
-      const sess = await this.rootStore.api.com.atproto.session.get()
-      if (sess.success && this.data && this.data.did === sess.data.did) {
-        this.setOnline(true, false)
-        if (this.rootStore.me.did !== sess.data.did) {
-          this.rootStore.me.clear()
-        }
-        this.rootStore.me
-          .load()
-          .catch(e => {
-            this.rootStore.log.error(
-              'Failed to fetch local user information',
-              e,
-            )
-          })
-          .then(() => {
-            this.addSessionToAccounts()
-          })
-        return true // success
-      }
+      await agent.resumeSession({
+        accessJwt: account.accessJwt,
+        refreshJwt: account.refreshJwt,
+        did: account.did,
+        handle: account.handle,
+      })
+      const addedInfo = await this.loadAccountInfo(agent, account.did)
+      this.persistSession(
+        account.service,
+        account.did,
+        'create',
+        agent.session,
+        addedInfo,
+      )
+      this.rootStore.log.debug('SessionModel:resumeSession succeeded')
     } catch (e: any) {
-      if (isNetworkError(e)) {
-        this.setOnline(false, false) // connection issue
-        return false
-      } else {
-        this.clear() // invalid session cached
-      }
+      this.rootStore.log.debug('SessionModel:resumeSession failed', {
+        error: e.toString(),
+      })
+      return false
     }
 
-    this.setOnline(false, false)
-    return false
-  }
-
-  /**
-   * Helper to fetch the accounts config settings from an account.
-   */
-  async describeService(service: string): Promise<ServiceDescription> {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.server.getAccountsConfig({})
-    return res.data
+    this.setActiveSession(agent, account.did)
+    return true
   }
 
   /**
@@ -252,78 +281,32 @@ export class SessionModel {
    */
   async login({
     service,
-    handle,
+    identifier,
     password,
   }: {
     service: string
-    handle: string
+    identifier: string
     password: string
   }) {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.session.create({handle, password})
-    if (res.data.accessJwt && res.data.refreshJwt) {
-      this.setState({
-        service: service,
-        accessJwt: res.data.accessJwt,
-        refreshJwt: res.data.refreshJwt,
-        handle: res.data.handle,
-        did: res.data.did,
-      })
-      this.configureApi()
-      this.setOnline(true, false)
-      this.rootStore.me
-        .load()
-        .catch(e => {
-          this.rootStore.log.error('Failed to fetch local user information', e)
-        })
-        .then(() => {
-          this.addSessionToAccounts()
-        })
-    }
-  }
-
-  /**
-   * Attempt to resume a session that we still have access tokens for.
-   */
-  async resumeSession(account: AccountData): Promise<boolean> {
-    if (!(account.accessJwt && account.refreshJwt && account.service)) {
-      return false
+    this.rootStore.log.debug('SessionModel:login')
+    const agent = new AtpAgent({service})
+    await agent.login({identifier, password})
+    if (!agent.session) {
+      throw new Error('Failed to establish session')
     }
 
-    // test that the session is good
-    const api = AtpApi.service(account.service)
-    api.sessionManager.set({
-      refreshJwt: account.refreshJwt,
-      accessJwt: account.accessJwt,
-    })
-    try {
-      const sess = await api.com.atproto.session.get()
-      if (
-        !sess.success ||
-        sess.data.did !== account.did ||
-        !api.sessionManager.session
-      ) {
-        return false
-      }
+    const did = agent.session.did
+    const addedInfo = await this.loadAccountInfo(agent, did)
 
-      // copy over the access tokens, as they may have refreshed during the .get() above
-      runInAction(() => {
-        account.refreshJwt = api.sessionManager.session?.refreshJwt
-        account.accessJwt = api.sessionManager.session?.accessJwt
-      })
-    } catch (_e) {
-      return false
-    }
+    this.persistSession(service, did, 'create', agent.session, addedInfo)
+    agent.setPersistSessionHandler(
+      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(service, did, evt, sess)
+      },
+    )
 
-    // session is good, connect
-    this.setState({
-      service: account.service,
-      accessJwt: account.accessJwt,
-      refreshJwt: account.refreshJwt,
-      handle: account.handle,
-      did: account.did,
-    })
-    return this.connect()
+    this.setActiveSession(agent, did)
+    this.rootStore.log.debug('SessionModel:login succeeded')
   }
 
   async createAccount({
@@ -339,38 +322,41 @@ export class SessionModel {
     handle: string
     inviteCode?: string
   }) {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.account.create({
+    this.rootStore.log.debug('SessionModel:createAccount')
+    const agent = new AtpAgent({service})
+    await agent.createAccount({
       handle,
       password,
       email,
       inviteCode,
     })
-    if (res.data.accessJwt && res.data.refreshJwt) {
-      this.setState({
-        service: service,
-        accessJwt: res.data.accessJwt,
-        refreshJwt: res.data.refreshJwt,
-        handle: res.data.handle,
-        did: res.data.did,
-      })
-      this.rootStore.onboard.start()
-      this.configureApi()
-      this.rootStore.me
-        .load()
-        .catch(e => {
-          this.rootStore.log.error('Failed to fetch local user information', e)
-        })
-        .then(() => {
-          this.addSessionToAccounts()
-        })
+    if (!agent.session) {
+      throw new Error('Failed to establish session')
     }
+
+    const did = agent.session.did
+    const addedInfo = await this.loadAccountInfo(agent, did)
+
+    this.persistSession(service, did, 'create', agent.session, addedInfo)
+    agent.setPersistSessionHandler(
+      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(service, did, evt, sess)
+      },
+    )
+
+    this.setActiveSession(agent, did)
+    this.rootStore.onboard.start()
+    this.rootStore.log.debug('SessionModel:createAccount succeeded')
   }
 
   /**
    * Close all sessions across all accounts.
    */
   async logout() {
+    this.rootStore.log.debug('SessionModel:logout')
+    // TODO
+    // 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.log.warn(
@@ -379,7 +365,7 @@ export class SessionModel {
         )
       })
     }*/
-    this.clearSessionTokensFromAccounts()
-    this.rootStore.clearAll()
+    this.clearSessionTokens()
+    this.rootStore.clearAllSessionState()
   }
 }
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index 09ffd265a..b9f480ecd 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -1,7 +1,8 @@
+import {RootStoreModel} from './root-store'
 import {makeAutoObservable} from 'mobx'
 import {ProfileViewModel} from './profile-view'
-import {isObj, hasProp} from '../lib/type-guards'
-import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
+import {isObj, hasProp} from 'lib/type-guards'
+import {PickedMedia} from 'view/com/util/images/image-crop-picker/types'
 
 export class ConfirmModal {
   name = 'confirm'
@@ -40,7 +41,7 @@ export class ServerInputModal {
 export class ReportPostModal {
   name = 'report-post'
 
-  constructor(public postUrl: string) {
+  constructor(public postUri: string, public postCid: string) {
     makeAutoObservable(this)
   }
 }
@@ -59,7 +60,13 @@ export class CropImageModal {
   constructor(
     public uri: string,
     public onSelect: (img?: PickedMedia) => void,
-  ) {
+  ) {}
+}
+
+export class DeleteAccountModal {
+  name = 'delete-account'
+
+  constructor() {
     makeAutoObservable(this)
   }
 }
@@ -111,14 +118,19 @@ export class ShellUiModel {
     | ReportPostModal
     | ReportAccountModal
     | CropImageModal
+    | DeleteAccountModal
     | undefined
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
   isComposerActive = false
   composerOpts: ComposerOpts | undefined
 
-  constructor() {
-    makeAutoObservable(this, {serialize: false, hydrate: false})
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {
+      serialize: false,
+      rootStore: false,
+      hydrate: false,
+    })
   }
 
   serialize(): unknown {
@@ -154,8 +166,10 @@ export class ShellUiModel {
       | ServerInputModal
       | ReportPostModal
       | ReportAccountModal
-      | CropImageModal,
+      | CropImageModal
+      | DeleteAccountModal,
   ) {
+    this.rootStore.emitNavigation()
     this.isModalActive = true
     this.activeModal = modal
   }
@@ -166,6 +180,7 @@ export class ShellUiModel {
   }
 
   openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
+    this.rootStore.emitNavigation()
     this.isLightboxActive = true
     this.activeLightbox = lightbox
   }
@@ -176,6 +191,7 @@ export class ShellUiModel {
   }
 
   openComposer(opts: ComposerOpts) {
+    this.rootStore.emitNavigation()
     this.isComposerActive = true
     this.composerOpts = opts
   }
diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts
index 0c9e0c3e1..4764f581e 100644
--- a/src/state/models/suggested-actors-view.ts
+++ b/src/state/models/suggested-actors-view.ts
@@ -1,25 +1,48 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AppBskyActorProfile as Profile} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+import {
+  DEV_SUGGESTED_FOLLOWS,
+  PROD_SUGGESTED_FOLLOWS,
+  STAGING_SUGGESTED_FOLLOWS,
+} from 'lib/constants'
 
 const PAGE_SIZE = 30
 
-export type SuggestedActor = GetSuggestions.Actor
+export type SuggestedActor = Profile.ViewBasic | Profile.View
+
+const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
+  if (serviceUrl.includes('localhost')) {
+    return DEV_SUGGESTED_FOLLOWS
+  } else if (serviceUrl.includes('staging')) {
+    return STAGING_SUGGESTED_FOLLOWS
+  } else {
+    return PROD_SUGGESTED_FOLLOWS
+  }
+}
 
 export class SuggestedActorsViewModel {
   // state
+  pageSize = PAGE_SIZE
   isLoading = false
   isRefreshing = false
   hasLoaded = false
   error = ''
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
+
+  private hardCodedSuggestions: SuggestedActor[] | undefined
 
   // data
   suggestions: SuggestedActor[] = []
 
-  constructor(public rootStore: RootStoreModel) {
+  constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
+    if (opts?.pageSize) {
+      this.pageSize = opts.pageSize
+    }
     makeAutoObservable(
       this,
       {
@@ -48,13 +71,96 @@ export class SuggestedActorsViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    if (replace) {
+      this.hardCodedSuggestions = undefined
+    }
+    this._xLoading(replace)
+    try {
+      let items: SuggestedActor[] = this.suggestions
+      if (replace) {
+        items = []
+        this.loadMoreCursor = undefined
+      }
+      let res
+      do {
+        await this.fetchHardcodedSuggestions()
+        if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) {
+          // pull from the hard-coded suggestions
+          const newItems = this.hardCodedSuggestions.splice(0, this.pageSize)
+          items = items.concat(newItems)
+          this.hasMore = true
+          this.loadMoreCursor = undefined
+        } else {
+          // pull from the PDS' algo
+          res = await this.rootStore.api.app.bsky.actor.getSuggestions({
+            limit: this.pageSize,
+            cursor: this.loadMoreCursor,
+          })
+          this.loadMoreCursor = res.data.cursor
+          this.hasMore = !!this.loadMoreCursor
+          items = items.concat(
+            res.data.actors.filter(
+              actor => !items.find(i => i.did === actor.did),
+            ),
+          )
+        }
+      } while (items.length < this.pageSize && this.hasMore)
+      runInAction(() => {
+        this.suggestions = items
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  private async fetchHardcodedSuggestions() {
+    if (this.hardCodedSuggestions) {
+      return
+    }
+    await this.rootStore.me.follows.fetchIfNeeded()
+    try {
+      // clone the array so we can mutate it
+      const actors = [
+        ...getSuggestionList({
+          serviceUrl: this.rootStore.session.currentSession?.service || '',
+        }),
+      ]
+
+      // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
+      let profiles: Profile.View[] = []
+      do {
+        const res = await this.rootStore.api.app.bsky.actor.getProfiles({
+          actors: actors.splice(0, 25),
+        })
+        profiles = profiles.concat(res.data.profiles)
+      } while (actors.length)
+
+      runInAction(() => {
+        profiles = profiles.filter(profile => {
+          if (this.rootStore.me.follows.isFollowing(profile.did)) {
+            return false
+          }
+          if (profile.did === this.rootStore.me.did) {
+            return false
+          }
+          return true
+        })
+        this.hardCodedSuggestions = shuffle(profiles)
+      })
+    } catch (e) {
+      this.rootStore.log.error(
+        'Failed to getProfiles() for suggested follows',
+        {e},
+      )
+      runInAction(() => {
+        this.hardCodedSuggestions = []
+      })
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
   }
 
   // state transitions
@@ -70,52 +176,9 @@ export class SuggestedActorsViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch suggested actors', err)
     }
   }
-
-  // loader functions
-  // =
-
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      if (this.isRefreshing) {
-        this.suggestions = []
-      }
-      let res
-      let totalAdded = 0
-      do {
-        res = await this.rootStore.api.app.bsky.actor.getSuggestions({
-          limit: PAGE_SIZE,
-          cursor: this.loadMoreCursor,
-        })
-        totalAdded += await this._appendAll(res)
-      } while (totalAdded < PAGE_SIZE && this.hasMore)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _appendAll(res: GetSuggestions.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    const newSuggestions = res.data.actors.filter(actor => {
-      if (actor.did === this.rootStore.me.did) {
-        return false // skip self
-      }
-      if (actor.myState?.follow) {
-        return false // skip already-followed users
-      }
-      return true
-    })
-    this.suggestions = this.suggestions.concat(newSuggestions)
-    return newSuggestions.length
-  }
 }
diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts
new file mode 100644
index 000000000..7b44370de
--- /dev/null
+++ b/src/state/models/suggested-posts-view.ts
@@ -0,0 +1,148 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {
+  AppBskyFeedFeedViewPost,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
+import {RootStoreModel} from './root-store'
+import {FeedItemModel} from './feed-view'
+import {cleanError} from 'lib/strings/errors'
+
+const TEAM_HANDLES = [
+  'jay.bsky.social',
+  'paul.bsky.social',
+  'dan.bsky.social',
+  'divy.bsky.social',
+  'why.bsky.social',
+  'iamrosewang.bsky.social',
+]
+
+export class SuggestedPostsView {
+  // state
+  isLoading = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  posts: FeedItemModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.posts.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    this._xLoading()
+    try {
+      const responses = await Promise.all(
+        TEAM_HANDLES.map(handle =>
+          this.rootStore.api.app.bsky.feed
+            .getAuthorFeed({author: handle, limit: 10})
+            .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
+        ),
+      )
+      runInAction(() => {
+        this.posts = mergeAndFilterResponses(this.rootStore, responses)
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
+        e,
+      })
+      this._xIdle() // dont bubble to the user
+    }
+  }
+
+  // state transitions
+  // =
+
+  private _xLoading() {
+    this.isLoading = true
+    this.error = ''
+  }
+
+  private _xIdle(err?: any) {
+    this.isLoading = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch suggested posts', err)
+    }
+  }
+}
+
+function mergeAndFilterResponses(
+  store: RootStoreModel,
+  responses: GetAuthorFeed.Response[],
+): FeedItemModel[] {
+  let posts: AppBskyFeedFeedViewPost.Main[] = []
+
+  // merge into one array
+  for (const res of responses) {
+    if (res.success) {
+      posts = posts.concat(res.data.feed)
+    }
+  }
+
+  // filter down to reposts of other users
+  const now = Date.now()
+  const uris = new Set()
+  posts = posts.filter(p => {
+    if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
+      if (uris.has(p.post.uri)) {
+        return false
+      }
+      uris.add(p.post.uri)
+      return true
+    }
+    return false
+  })
+
+  // sort by index time
+  posts.sort((a, b) => {
+    return (
+      Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
+    )
+  })
+
+  // hydrate into models and strip the reasons to hide that these are reposts
+  return posts.map((post, i) => {
+    delete post.reason
+    return new FeedItemModel(store, `post-${i}`, post)
+  })
+}
+
+function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
+  return (
+    post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
+    post.post.author.did !== (post.reason as ReasonRepost).by.did
+  )
+}
+
+const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
+function isRecentEnough(
+  now: number,
+  post: AppBskyFeedFeedViewPost.Main,
+): boolean {
+  return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
+}
diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts
index 8f467da69..8e4211c27 100644
--- a/src/state/models/user-autocomplete-view.ts
+++ b/src/state/models/user-autocomplete-view.ts
@@ -1,8 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyGraphGetFollows as GetFollows,
-  AppBskyActorSearchTypeahead as SearchTypeahead,
-} from '@atproto/api'
+import {AppBskyActorRef} from '@atproto/api'
+import AwaitLock from 'await-lock'
 import {RootStoreModel} from './root-store'
 
 export class UserAutocompleteViewModel {
@@ -10,11 +8,11 @@ export class UserAutocompleteViewModel {
   isLoading = false
   isActive = false
   prefix = ''
-  _searchPromise: Promise<any> | undefined
+  lock = new AwaitLock()
 
   // data
-  follows: GetFollows.Follow[] = []
-  searchRes: SearchTypeahead.User[] = []
+  follows: AppBskyActorRef.WithInfo[] = []
+  searchRes: AppBskyActorRef.WithInfo[] = []
   knownHandles: Set<string> = new Set()
 
   constructor(public rootStore: RootStoreModel) {
@@ -58,16 +56,20 @@ export class UserAutocompleteViewModel {
   }
 
   async setPrefix(prefix: string) {
-    const origPrefix = prefix
-    this.prefix = prefix.trim()
-    if (this.prefix) {
-      await this._searchPromise
-      if (this.prefix !== origPrefix) {
-        return // another prefix was set before we got our chance
+    const origPrefix = prefix.trim()
+    this.prefix = origPrefix
+    await this.lock.acquireAsync()
+    try {
+      if (this.prefix) {
+        if (this.prefix !== origPrefix) {
+          return // another prefix was set before we got our chance
+        }
+        await this._search()
+      } else {
+        this.searchRes = []
       }
-      this._searchPromise = this._search()
-    } else {
-      this.searchRes = []
+    } finally {
+      this.lock.release()
     }
   }
 
diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts
index 9daaf35a4..7400262a4 100644
--- a/src/state/models/user-followers-view.ts
+++ b/src/state/models/user-followers-view.ts
@@ -4,10 +4,12 @@ import {
   AppBskyActorRef as ActorRef,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowerItem = GetFollowers.Follower
+export type FollowerItem = ActorRef.WithInfo
 
 export class UserFollowersViewModel {
   // state
@@ -18,7 +20,6 @@ export class UserFollowersViewModel {
   params: GetFollowers.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   subject: ActorRef.WithInfo = {
@@ -62,14 +63,27 @@ export class UserFollowersViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this._xLoading(replace)
+    try {
+      const params = Object.assign({}, this.params, {
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
 
   // state transitions
   // =
@@ -84,39 +98,24 @@ export class UserFollowersViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch user followers', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.followers = []
-      }
-      const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetFollowers.Response) {
+    this.followers = []
+    this._appendAll(res)
   }
 
-  private async _appendAll(res: GetFollowers.Response) {
+  private _appendAll(res: GetFollowers.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.followers = this.followers.concat(res.data.followers)
+    this.rootStore.me.follows.hydrateProfiles(res.data.followers)
   }
 }
diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts
index d43a10c75..7d28d7ebd 100644
--- a/src/state/models/user-follows-view.ts
+++ b/src/state/models/user-follows-view.ts
@@ -4,10 +4,12 @@ import {
   AppBskyActorRef as ActorRef,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowItem = GetFollows.Follow
+export type FollowItem = ActorRef.WithInfo
 
 export class UserFollowsViewModel {
   // state
@@ -18,7 +20,6 @@ export class UserFollowsViewModel {
   params: GetFollows.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   subject: ActorRef.WithInfo = {
@@ -62,14 +63,27 @@ export class UserFollowsViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this._xLoading(replace)
+    try {
+      const params = Object.assign({}, this.params, {
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
 
   // state transitions
   // =
@@ -84,39 +98,24 @@ export class UserFollowsViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch user follows', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.follows = []
-      }
-      const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetFollows.Response) {
+    this.follows = []
+    this._appendAll(res)
   }
 
-  private async _appendAll(res: GetFollows.Response) {
+  private _appendAll(res: GetFollows.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.follows = this.follows.concat(res.data.follows)
+    this.rootStore.me.follows.hydrateProfiles(res.data.follows)
   }
 }
diff --git a/src/state/models/votes-view.ts b/src/state/models/votes-view.ts
index df939226f..ad8698d21 100644
--- a/src/state/models/votes-view.ts
+++ b/src/state/models/votes-view.ts
@@ -2,6 +2,9 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
 import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
@@ -17,7 +20,6 @@ export class VotesViewModel {
   params: GetVotes.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   uri: string = ''
@@ -54,17 +56,31 @@ export class VotesViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    if (!this.resolvedUri) {
-      await this._resolveUri()
+    this._xLoading(replace)
+    try {
+      if (!this.resolvedUri) {
+        await this._resolveUri()
+      }
+      const params = Object.assign({}, this.params, {
+        uri: this.resolvedUri,
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   // state transitions
   // =
@@ -79,20 +95,20 @@ export class VotesViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch votes', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
   private async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -102,23 +118,9 @@ export class VotesViewModel {
     })
   }
 
-  private async _loadMore(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        uri: this.resolvedUri,
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.votes = []
-      }
-      const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
-      this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetVotes.Response) {
+    this.votes = []
+    this._appendAll(res)
   }
 
   private _appendAll(res: GetVotes.Response) {
diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx
index 94381e644..c17e41b37 100644
--- a/src/view/com/composer/Autocomplete.tsx
+++ b/src/view/com/composer/Autocomplete.tsx
@@ -5,9 +5,9 @@ import {
   StyleSheet,
   useWindowDimensions,
 } from 'react-native'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from '../util/text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
 
 interface AutocompleteItem {
   handle: string
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index 1144b5e48..6431a11aa 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
+  NativeSyntheticEvent,
   Platform,
   SafeAreaView,
   ScrollView,
   StyleSheet,
+  TextInputSelectionChangeEventData,
   TouchableOpacity,
   TouchableWithoutFeedback,
   View,
@@ -16,8 +18,9 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-// import {useAnalytics} from '@segment/analytics-react-native' TODO
-import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
+import {useAnalytics} from 'lib/analytics'
+import _isEqual from 'lodash.isequal'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
 import {Autocomplete} from './Autocomplete'
 import {ExternalEmbed} from './ExternalEmbed'
 import {Text} from '../util/text/Text'
@@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
 import {CharProgress} from './char-progress/CharProgress'
 import {TextLink} from '../util/Link'
 import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from '../../../state'
-import * as apilib from '../../../state/lib/api'
-import {ComposerOpts} from '../../../state/models/shell-ui'
-import {s, colors, gradients} from '../../lib/styles'
-import {
-  detectLinkables,
-  extractEntities,
-  cleanError,
-} from '../../../lib/strings'
-import {getLinkMeta} from '../../../lib/link-meta'
-import {downloadAndResize} from '../../../lib/images'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
+import {ComposerOpts} from 'state/models/shell-ui'
+import {s, colors, gradients} from 'lib/styles'
+import {cleanError} from 'lib/strings/errors'
+import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
+import {getLinkMeta} from 'lib/link-meta/link-meta'
+import {downloadAndResize} from 'lib/images'
 import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
+import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
 import {SelectedPhoto} from './SelectedPhoto'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 const MAX_TEXT_LENGTH = 256
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
+interface Selection {
+  start: number
+  end: number
+}
+
 export const ComposePost = observer(function ComposePost({
   replyTo,
   imagesOpen,
@@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({
   onPost?: ComposerOpts['onPost']
   onClose: () => void
 }) {
-  // const {track} = useAnalytics() TODO
+  const {track, screen} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
   const textInput = useRef<TextInputRef>(null)
+  const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const [isProcessing, setIsProcessing] = useState(false)
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
@@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({
   const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
     undefined,
   )
-  const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([])
+  const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>(
+    new Set(),
+  )
   const [isSelectingPhotos, setIsSelectingPhotos] = useState(
     imagesOpen || false,
   )
@@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({
     if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
       downloadAndResize({
         uri: extLink.meta.image,
-        width: 250,
-        height: 250,
+        width: 2000,
+        height: 2000,
         mode: 'contain',
-        maxSize: 100000,
+        maxSize: 1000000,
         timeout: 15e3,
       })
         .catch(() => undefined)
@@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({
     textInput.current?.focus()
   }
   const onPressSelectPhotos = () => {
+    track('ComposePost:SelectPhotos')
     if (isSelectingPhotos) {
       setIsSelectingPhotos(false)
     } else if (selectedPhotos.length < 4) {
@@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({
     }
   }
   const onSelectPhotos = (photos: string[]) => {
+    track('ComposePost:SelectPhotos:Done')
     setSelectedPhotos(photos)
     if (photos.length >= 4) {
       setIsSelectingPhotos(false)
     }
   }
+  const onPressAddLinkCard = (uri: string) => {
+    setExtLink({uri, isLoading: true})
+  }
   const onChangeText = (newText: string) => {
     setText(newText)
 
-    const prefix = extractTextAutocompletePrefix(newText)
-    if (typeof prefix === 'string') {
+    const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
+    if (prefix) {
       autocompleteView.setActive(true)
-      autocompleteView.setPrefix(prefix)
+      autocompleteView.setPrefix(prefix.value)
     } else {
       autocompleteView.setActive(false)
     }
 
-    if (!extLink && /\s$/.test(newText)) {
-      const ents = extractEntities(newText)
-      const entLink = ents
-        ?.filter(
-          ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value),
-        )
-        .pop() // use last
-      if (entLink) {
-        setExtLink({
-          uri: entLink.value,
-          isLoading: true,
-        })
-        setAttemptedExtLinks([...attemptedExtLinks, entLink.value])
+    if (!extLink) {
+      const ents = extractEntities(newText)?.filter(ent => ent.type === 'link')
+      const set = new Set(ents ? ents.map(e => e.value) : [])
+      if (!_isEqual(set, suggestedExtLinks)) {
+        setSuggestedExtLinks(set)
       }
     }
   }
@@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({
       onSelectPhotos([...selectedPhotos, finalImgPath])
     }
   }
+  const onSelectionChange = (
+    evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
+  ) => {
+    // NOTE we track the input selection using a ref to avoid excessive renders -prf
+    textInputSelection.current = evt.nativeEvent.selection
+  }
+  const onSelectAutocompleteItem = (item: string) => {
+    setText(insertMentionAt(text, textInputSelection.current?.start || 0, item))
+    autocompleteView.setActive(false)
+  }
   const onPressCancel = () => hackfixOnClose()
   const onPressPublish = async () => {
     if (isProcessing) {
@@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({
         autocompleteView.knownHandles,
         setProcessingState,
       )
-      // TODO
-      // track('Create Post', {
-      //   imageCount: selectedPhotos.length,
-      // })
+      track('Create Post', {
+        imageCount: selectedPhotos.length,
+      })
     } catch (e: any) {
+      setExtLink({
+        ...extLink,
+        isLoading: true,
+        localThumb: undefined,
+      } as apilib.ExternalEmbedDraft)
       setError(cleanError(e.message))
       setIsProcessing(false)
       return
@@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({
     hackfixOnClose()
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
   }
-  const onSelectAutocompleteItem = (item: string) => {
-    setText(replaceTextAutocompletePrefix(text, item))
-    autocompleteView.setActive(false)
-  }
 
   const canPost = text.length <= MAX_TEXT_LENGTH
 
@@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({
                 innerRef={textInput}
                 onChangeText={(str: string) => onChangeText(str)}
                 onPaste={onPaste}
+                onSelectionChange={onSelectionChange}
                 placeholder={selectTextInputPlaceholder}
                 style={[
                   pal.text,
@@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({
               />
             )}
           </ScrollView>
-          {isSelectingPhotos && selectedPhotos.length < 4 && (
+          {isSelectingPhotos && selectedPhotos.length < 4 ? (
             <PhotoCarouselPicker
               selectedPhotos={selectedPhotos}
               onSelectPhotos={onSelectPhotos}
             />
-          )}
+          ) : !extLink &&
+            selectedPhotos.length === 0 &&
+            suggestedExtLinks.size > 0 ? (
+            <View style={s.mb5}>
+              {Array.from(suggestedExtLinks).map(url => (
+                <TouchableOpacity
+                  key={`suggested-${url}`}
+                  style={[pal.borderDark, styles.addExtLinkBtn]}
+                  onPress={() => onPressAddLinkCard(url)}>
+                  <Text>
+                    Add link card: <Text style={pal.link}>{url}</Text>
+                  </Text>
+                </TouchableOpacity>
+              ))}
+            </View>
+          ) : null}
           <View style={[pal.border, styles.bottomBar]}>
             <TouchableOpacity
               testID="composerSelectPhotosButton"
@@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({
   )
 })
 
-const atPrefixRegex = /@([a-z0-9.]*)$/i
-function extractTextAutocompletePrefix(text: string) {
-  const match = atPrefixRegex.exec(text)
-  if (match) {
-    return match[1]
-  }
-  return undefined
-}
-function replaceTextAutocompletePrefix(text: string, item: string) {
-  return text.replace(atPrefixRegex, `@${item} `)
-}
-
 const styles = StyleSheet.create({
   outer: {
     flexDirection: 'column',
@@ -532,6 +552,13 @@ const styles = StyleSheet.create({
     paddingLeft: 13,
     paddingRight: 8,
   },
+  addExtLinkBtn: {
+    borderWidth: 1,
+    borderRadius: 24,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    marginBottom: 4,
+  },
   bottomBar: {
     flexDirection: 'row',
     paddingVertical: 10,
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index ed4bfb5ed..23dcaffd5 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -7,12 +7,11 @@ import {
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {BlurView} from '../util/BlurView'
-import LinearGradient from 'react-native-linear-gradient'
 import {AutoSizedImage} from '../util/images/AutoSizedImage'
 import {Text} from '../util/text/Text'
-import {s, gradients} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {ExternalEmbedDraft} from '../../../state/lib/api'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ExternalEmbedDraft} from 'lib/api/index'
 
 export const ExternalEmbed = ({
   link,
@@ -30,31 +29,12 @@ export const ExternalEmbed = ({
     <View style={[styles.outer, pal.view, pal.border]}>
       {link.isLoading ? (
         <View
-          style={[
-            styles.image,
-            styles.imageFallback,
-            {backgroundColor: pal.colors.backgroundLight},
-          ]}>
+          style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}>
           <ActivityIndicator size="large" style={styles.spinner} />
         </View>
       ) : link.localThumb ? (
-        <AutoSizedImage
-          uri={link.localThumb.path}
-          containerStyle={styles.image}
-        />
-      ) : (
-        <LinearGradient
-          colors={[gradients.blueDark.start, gradients.blueDark.end]}
-          start={{x: 0, y: 0}}
-          end={{x: 1, y: 1}}
-          style={[styles.image, styles.imageFallback]}
-        />
-      )}
-      <TouchableWithoutFeedback onPress={onRemove}>
-        <BlurView style={styles.removeBtn} blurType="dark">
-          <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
-        </BlurView>
-      </TouchableWithoutFeedback>
+        <AutoSizedImage uri={link.localThumb.path} style={styles.image} />
+      ) : undefined}
       <View style={styles.inner}>
         {!!link.meta?.title && (
           <Text type="sm-bold" numberOfLines={2} style={[pal.text]}>
@@ -81,6 +61,11 @@ export const ExternalEmbed = ({
           </Text>
         )}
       </View>
+      <TouchableWithoutFeedback onPress={onRemove}>
+        <BlurView style={styles.removeBtn} blurType="dark">
+          <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
+        </BlurView>
+      </TouchableWithoutFeedback>
     </View>
   )
 }
@@ -98,10 +83,7 @@ const styles = StyleSheet.create({
     borderTopLeftRadius: 6,
     borderTopRightRadius: 6,
     width: '100%',
-    height: 200,
-  },
-  imageFallback: {
-    height: 160,
+    maxHeight: 200,
   },
   removeBtn: {
     position: 'absolute',
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index d0c023e25..46a0cec62 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function ComposePrompt({
   text = "What's up?",
diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx
index dd508fe1f..6aeda33cd 100644
--- a/src/view/com/composer/SelectedPhoto.tsx
+++ b/src/view/com/composer/SelectedPhoto.tsx
@@ -1,7 +1,8 @@
 import React, {useCallback} from 'react'
-import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {colors} from '../../lib/styles'
+import Image from 'view/com/util/images/Image'
+import {colors} from 'lib/styles'
 
 export const SelectedPhoto = ({
   selectedPhotos,
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index d4093064b..cd7cb2c4e 100644
--- a/src/view/com/composer/char-progress/CharProgress.tsx
+++ b/src/view/com/composer/char-progress/CharProgress.tsx
@@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text'
 import ProgressCircle from 'react-native-progress/Circle'
 // @ts-ignore no type definition -prf
 import ProgressPie from 'react-native-progress/Pie'
-import {s, colors} from '../../../lib/styles'
+import {s, colors} from 'lib/styles'
 
 const MAX_TEXT_LENGTH = 256
 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
diff --git a/src/view/com/composer/char-progress/CharProgress.web.tsx b/src/view/com/composer/char-progress/CharProgress.web.tsx
index dfb2fad58..d32d7a72c 100644
--- a/src/view/com/composer/char-progress/CharProgress.web.tsx
+++ b/src/view/com/composer/char-progress/CharProgress.web.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {View} from 'react-native'
 import {Text} from '../../util/text/Text'
-import {s} from '../../../lib/styles'
+import {s} from 'lib/styles'
 
 const MAX_TEXT_LENGTH = 256
 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx
index 7a5c9f65d..406f8b04c 100644
--- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx
+++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx
@@ -4,6 +4,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {useAnalytics} from 'lib/analytics'
 import {
   openPicker,
   openCamera,
@@ -12,18 +13,26 @@ import {
 import {
   UserLocalPhotosModel,
   PhotoIdentifier,
-} from '../../../../state/models/user-local-photos'
-import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
-import {usePalette} from '../../../lib/hooks/usePalette'
-import {useStores, RootStoreModel} from '../../../../state'
+} from 'state/models/user-local-photos'
+import {
+  compressIfNeeded,
+  moveToPremanantPath,
+  scaleDownDimensions,
+} from 'lib/images'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores, RootStoreModel} from 'state/index'
+import {
+  requestPhotoAccessIfNeeded,
+  requestCameraAccessIfNeeded,
+} from 'lib/permissions'
 
-const MAX_WIDTH = 1000
-const MAX_HEIGHT = 1000
-const MAX_SIZE = 300000
+const MAX_WIDTH = 2000
+const MAX_HEIGHT = 2000
+const MAX_SIZE = 1000000
 
 const IMAGE_PARAMS = {
-  width: 1000,
-  height: 1000,
+  width: 2000,
+  height: 2000,
   freeStyleCropEnabled: true,
 }
 
@@ -46,8 +55,10 @@ export async function cropPhoto(
     width,
     height,
   })
+
   const img = await compressIfNeeded(cropperRes, MAX_SIZE)
-  return img.path
+  const permanentPath = await moveToPremanantPath(img.path)
+  return permanentPath
 }
 
 export const PhotoCarouselPicker = ({
@@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({
   selectedPhotos: string[]
   onSelectPhotos: (v: string[]) => void
 }) => {
+  const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const [localPhotos, setLocalPhotos] = React.useState<
-    UserLocalPhotosModel | undefined
-  >(undefined)
+  const [isSetup, setIsSetup] = React.useState<boolean>(false)
+
+  const localPhotos = React.useMemo<UserLocalPhotosModel>(
+    () => new UserLocalPhotosModel(store),
+    [store],
+  )
 
-  // initial setup
   React.useEffect(() => {
-    const photos = new UserLocalPhotosModel(store)
-    photos.setup().then(() => {
-      if (photos.photos) {
-        setLocalPhotos(photos)
-      }
+    // initial setup
+    localPhotos.setup().then(() => {
+      setIsSetup(true)
     })
-  }, [store])
+  }, [localPhotos])
 
   const handleOpenCamera = useCallback(async () => {
     try {
+      if (!(await requestCameraAccessIfNeeded())) {
+        return
+      }
       const cameraRes = await openCamera(store, {
         mediaType: 'photo',
         ...IMAGE_PARAMS,
@@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({
 
   const handleSelectPhoto = useCallback(
     async (item: PhotoIdentifier) => {
+      track('PhotoCarouselPicker:PhotoSelected')
       try {
         const imgPath = await cropPhoto(
           store,
@@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({
         store.log.warn('Error selecting photo', err)
       }
     },
-    [store, selectedPhotos, onSelectPhotos],
+    [track, store, onSelectPhotos, selectedPhotos],
   )
 
-  const handleOpenGallery = useCallback(() => {
-    openPicker(store, {
+  const handleOpenGallery = useCallback(async () => {
+    track('PhotoCarouselPicker:GalleryOpened')
+    if (!(await requestPhotoAccessIfNeeded())) {
+      return
+    }
+    const items = await openPicker(store, {
       multiple: true,
       maxFiles: 4 - selectedPhotos.length,
       mediaType: 'photo',
-    }).then(async items => {
-      const result = []
-
-      for (const image of items) {
-        // choose target dimensions based on the original
-        // this causes the photo cropper to start with the full image "selected"
-        const {width, height} = scaleDownDimensions(
-          {width: image.width, height: image.height},
-          {width: MAX_WIDTH, height: MAX_HEIGHT},
-        )
-        const cropperRes = await openCropper(store, {
-          mediaType: 'photo',
-          path: image.path,
-          freeStyleCropEnabled: true,
-          width,
-          height,
-        })
-        const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
-        result.push(finalImg.path)
-      }
-      onSelectPhotos([...selectedPhotos, ...result])
     })
-  }, [store, selectedPhotos, onSelectPhotos])
+    const result = []
+
+    for (const image of items) {
+      // choose target dimensions based on the original
+      // this causes the photo cropper to start with the full image "selected"
+      const {width, height} = scaleDownDimensions(
+        {width: image.width, height: image.height},
+        {width: MAX_WIDTH, height: MAX_HEIGHT},
+      )
+      const cropperRes = await openCropper(store, {
+        mediaType: 'photo',
+        path: image.path,
+        ...IMAGE_PARAMS,
+        width,
+        height,
+      })
+      const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
+      const permanentPath = await moveToPremanantPath(finalImg.path)
+      result.push(permanentPath)
+    }
+    onSelectPhotos([...selectedPhotos, ...result])
+  }, [track, store, selectedPhotos, onSelectPhotos])
 
   return (
     <ScrollView
@@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({
           size={24}
         />
       </TouchableOpacity>
-      {localPhotos != null &&
+      {isSetup &&
         localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
           <TouchableOpacity
             testID="openSelectPhotoButton"
diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
index bb2800026..607f8e724 100644
--- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
+++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
@@ -9,9 +9,9 @@ import {
   openCamera,
   openCropper,
 } from '../../util/images/image-crop-picker/ImageCropPicker'
-import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
-import {usePalette} from '../../../lib/hooks/usePalette'
-import {useStores, RootStoreModel} from '../../../../state'
+import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores, RootStoreModel} from 'state/index'
 
 const MAX_WIDTH = 1000
 const MAX_HEIGHT = 1000
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 3c5dacf80..be6150e11 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,10 +1,15 @@
 import React from 'react'
-import {StyleProp, TextStyle} from 'react-native'
+import {
+  NativeSyntheticEvent,
+  StyleProp,
+  TextInputSelectionChangeEventData,
+  TextStyle,
+} from 'react-native'
 import PasteInput, {
   PastedFile,
   PasteInputRef,
 } from '@mattermost/react-native-paste-input'
-import {usePalette} from '../../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export type TextInputRef = PasteInputRef
 
@@ -14,6 +19,9 @@ interface TextInputProps {
   placeholder: string
   style: StyleProp<TextStyle>
   onChangeText: (str: string) => void
+  onSelectionChange?:
+    | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
+    | undefined
   onPaste: (err: string | undefined, uris: string[]) => void
 }
 
@@ -23,6 +31,7 @@ export function TextInput({
   placeholder,
   style,
   onChangeText,
+  onSelectionChange,
   onPaste,
   children,
 }: React.PropsWithChildren<TextInputProps>) {
@@ -44,6 +53,7 @@ export function TextInput({
       multiline
       scrollEnabled
       onChangeText={(str: string) => onChangeText(str)}
+      onSelectionChange={onSelectionChange}
       onPaste={onPasteInner}
       placeholder={placeholder}
       placeholderTextColor={pal.colors.textLight}
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 395f8e5a2..2b610850c 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,12 +1,14 @@
 import React from 'react'
 import {
+  NativeSyntheticEvent,
   StyleProp,
   StyleSheet,
   TextInput as RNTextInput,
+  TextInputSelectionChangeEventData,
   TextStyle,
 } from 'react-native'
-import {usePalette} from '../../../lib/hooks/usePalette'
-import {addStyle} from '../../../lib/addStyle'
+import {usePalette} from 'lib/hooks/usePalette'
+import {addStyle} from 'lib/styles'
 
 export type TextInputRef = RNTextInput
 
@@ -16,6 +18,9 @@ interface TextInputProps {
   placeholder: string
   style: StyleProp<TextStyle>
   onChangeText: (str: string) => void
+  onSelectionChange?:
+    | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
+    | undefined
   onPaste: (err: string | undefined, uris: string[]) => void
 }
 
@@ -25,6 +30,7 @@ export function TextInput({
   placeholder,
   style,
   onChangeText,
+  onSelectionChange,
   children,
 }: React.PropsWithChildren<TextInputProps>) {
   const pal = usePalette('default')
@@ -36,6 +42,7 @@ export function TextInput({
       multiline
       scrollEnabled
       onChangeText={(str: string) => onChangeText(str)}
+      onSelectionChange={onSelectionChange}
       placeholder={placeholder}
       placeholderTextColor={pal.colors.textLight}
       style={style}>
diff --git a/src/view/com/discover/LiteSuggestedFollows.tsx b/src/view/com/discover/LiteSuggestedFollows.tsx
index ce01af7c7..5314e691c 100644
--- a/src/view/com/discover/LiteSuggestedFollows.tsx
+++ b/src/view/com/discover/LiteSuggestedFollows.tsx
@@ -8,19 +8,18 @@ import {
 import LinearGradient from 'react-native-linear-gradient'
 import {observer} from 'mobx-react-lite'
 import _omit from 'lodash.omit'
-import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import * as Toast from '../util/Toast'
-import {useStores} from '../../../state'
-import * as apilib from '../../../state/lib/api'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
 import {
   SuggestedActorsViewModel,
   SuggestedActor,
-} from '../../../state/models/suggested-actors-view'
-import {s, gradients} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+} from 'state/models/suggested-actors-view'
+import {s, gradients} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export const LiteSuggestedFollows = observer(() => {
   const store = useStores()
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx
index 9ed893148..1e40956ce 100644
--- a/src/view/com/discover/SuggestedFollows.tsx
+++ b/src/view/com/discover/SuggestedFollows.tsx
@@ -1,51 +1,28 @@
-import React, {useEffect, useState} from 'react'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {observer} from 'mobx-react-lite'
-import _omit from 'lodash.omit'
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
+import {observer} from 'mobx-react-lite'
 import {ErrorScreen} from '../util/error/ErrorScreen'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {UserAvatar} from '../util/UserAvatar'
-import * as Toast from '../util/Toast'
-import {useStores} from '../../../state'
-import * as apilib from '../../../state/lib/api'
+import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
+import {useStores} from 'state/index'
 import {
   SuggestedActorsViewModel,
   SuggestedActor,
-} from '../../../state/models/suggested-actors-view'
-import {s, gradients} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+} from 'state/models/suggested-actors-view'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export const SuggestedFollows = observer(
-  ({
-    onNoSuggestions,
-    asLinks,
-  }: {
-    onNoSuggestions?: () => void
-    asLinks?: boolean
-  }) => {
+  ({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
     const pal = usePalette('default')
     const store = useStores()
-    const [follows, setFollows] = useState<Record<string, string>>({})
 
-    // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
     const view = React.useMemo<SuggestedActorsViewModel>(
       () => new SuggestedActorsViewModel(store),
       [store],
     )
 
-    useEffect(() => {
+    React.useEffect(() => {
       view
         .loadMore()
         .catch((err: any) =>
@@ -53,7 +30,7 @@ export const SuggestedFollows = observer(
         )
     }, [view, store.log])
 
-    useEffect(() => {
+    React.useEffect(() => {
       if (!view.isLoading && !view.hasError && !view.hasContent) {
         onNoSuggestions?.()
       }
@@ -74,46 +51,16 @@ export const SuggestedFollows = observer(
         )
     }
 
-    const onPressFollow = async (item: SuggestedActor) => {
-      try {
-        const res = await apilib.follow(store, item.did, item.declaration.cid)
-        setFollows({[item.did]: res.uri, ...follows})
-      } catch (e: any) {
-        store.log.error('Failed fo create follow', e)
-        Toast.show('An issue occurred, please try again.')
-      }
-    }
-    const onPressUnfollow = async (item: SuggestedActor) => {
-      try {
-        await apilib.unfollow(store, follows[item.did])
-        setFollows(_omit(follows, [item.did]))
-      } catch (e: any) {
-        store.log.error('Failed fo delete follow', e)
-        Toast.show('An issue occurred, please try again.')
-      }
-    }
-
     const renderItem = ({item}: {item: SuggestedActor}) => {
-      if (asLinks) {
-        return (
-          <Link
-            href={`/profile/${item.handle}`}
-            title={item.displayName || item.handle}>
-            <User
-              item={item}
-              follow={follows[item.did]}
-              onPressFollow={onPressFollow}
-              onPressUnfollow={onPressUnfollow}
-            />
-          </Link>
-        )
-      }
       return (
-        <User
-          item={item}
-          follow={follows[item.did]}
-          onPressFollow={onPressFollow}
-          onPressUnfollow={onPressUnfollow}
+        <ProfileCardWithFollowBtn
+          key={item.did}
+          did={item.did}
+          declarationCid={item.declaration.cid}
+          handle={item.handle}
+          displayName={item.displayName}
+          avatar={item.avatar}
+          description={item.description}
         />
       )
     }
@@ -146,7 +93,6 @@ export const SuggestedFollows = observer(
                 </View>
               )}
               contentContainerStyle={s.contentContainer}
-              style={s.flex1}
             />
           </View>
         )}
@@ -155,128 +101,16 @@ export const SuggestedFollows = observer(
   },
 )
 
-const User = ({
-  item,
-  follow,
-  onPressFollow,
-  onPressUnfollow,
-}: {
-  item: SuggestedActor
-  follow: string | undefined
-  onPressFollow: (item: SuggestedActor) => void
-  onPressUnfollow: (item: SuggestedActor) => void
-}) => {
-  const pal = usePalette('default')
-  return (
-    <View style={[styles.actor, pal.view, pal.border]}>
-      <View style={styles.actorMeta}>
-        <View style={styles.actorAvi}>
-          <UserAvatar
-            size={40}
-            displayName={item.displayName}
-            handle={item.handle}
-            avatar={item.avatar}
-          />
-        </View>
-        <View style={styles.actorContent}>
-          <Text type="title-sm" style={pal.text} numberOfLines={1}>
-            {item.displayName || item.handle}
-          </Text>
-          <Text style={pal.textLight} numberOfLines={1}>
-            @{item.handle}
-          </Text>
-        </View>
-        <View style={styles.actorBtn}>
-          {follow ? (
-            <TouchableOpacity onPress={() => onPressUnfollow(item)}>
-              <View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
-                <Text type="button" style={pal.text}>
-                  Unfollow
-                </Text>
-              </View>
-            </TouchableOpacity>
-          ) : (
-            <TouchableOpacity onPress={() => onPressFollow(item)}>
-              <LinearGradient
-                colors={[gradients.blueLight.start, gradients.blueLight.end]}
-                start={{x: 0, y: 0}}
-                end={{x: 1, y: 1}}
-                style={[styles.btn, styles.gradientBtn]}>
-                <FontAwesomeIcon
-                  icon="plus"
-                  style={[s.white as FontAwesomeIconStyle, s.mr5]}
-                  size={15}
-                />
-                <Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
-              </LinearGradient>
-            </TouchableOpacity>
-          )}
-        </View>
-      </View>
-      {item.description ? (
-        <View style={styles.actorDetails}>
-          <Text style={pal.text} numberOfLines={4}>
-            {item.description}
-          </Text>
-        </View>
-      ) : undefined}
-    </View>
-  )
-}
-
 const styles = StyleSheet.create({
   container: {
-    flex: 1,
+    height: '100%',
   },
 
   suggestionsContainer: {
-    flex: 1,
+    height: '100%',
   },
   footer: {
     height: 200,
     paddingTop: 20,
   },
-
-  actor: {
-    borderTopWidth: 1,
-  },
-  actorMeta: {
-    flexDirection: 'row',
-  },
-  actorAvi: {
-    width: 60,
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  actorContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-  },
-  actorBtn: {
-    paddingRight: 10,
-    paddingTop: 10,
-  },
-  actorDetails: {
-    paddingLeft: 60,
-    paddingRight: 10,
-    paddingBottom: 10,
-  },
-
-  gradientBtn: {
-    paddingHorizontal: 24,
-    paddingVertical: 6,
-  },
-  secondaryBtn: {
-    paddingHorizontal: 14,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingVertical: 7,
-    borderRadius: 50,
-    marginLeft: 6,
-  },
 })
diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx
new file mode 100644
index 000000000..86a6bd394
--- /dev/null
+++ b/src/view/com/discover/SuggestedPosts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {SuggestedPostsView} from 'state/models/suggested-posts-view'
+import {s} from 'lib/styles'
+import {FeedItem as Post} from '../posts/FeedItem'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export const SuggestedPosts = observer(() => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const suggestedPostsView = React.useMemo<SuggestedPostsView>(
+    () => new SuggestedPostsView(store),
+    [store],
+  )
+
+  React.useEffect(() => {
+    if (!suggestedPostsView.hasLoaded) {
+      suggestedPostsView.setup()
+    }
+  }, [store, suggestedPostsView])
+
+  return (
+    <>
+      {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
+        <Text type="title" style={[styles.heading, pal.text]}>
+          Recently, on Bluesky...
+        </Text>
+      )}
+      {suggestedPostsView.hasContent && (
+        <>
+          <View style={[pal.border, styles.bottomBorder]}>
+            {suggestedPostsView.posts.map(item => (
+              <Post item={item} key={item._reactKey} />
+            ))}
+          </View>
+        </>
+      )}
+      {suggestedPostsView.isLoading && (
+        <View style={s.mt10}>
+          <ActivityIndicator />
+        </View>
+      )}
+    </>
+  )
+})
+
+const styles = StyleSheet.create({
+  heading: {
+    fontWeight: 'bold',
+    paddingHorizontal: 12,
+    paddingTop: 16,
+    paddingBottom: 8,
+  },
+
+  bottomBorder: {
+    borderBottomWidth: 1,
+  },
+
+  loadMore: {
+    paddingLeft: 12,
+    paddingVertical: 10,
+  },
+})
diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx
new file mode 100644
index 000000000..17c10ca7e
--- /dev/null
+++ b/src/view/com/discover/WhoToFollow.tsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view'
+import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
+import {Text} from '../util/text/Text'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export const WhoToFollow = observer(() => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
+    () => new SuggestedActorsViewModel(store, {pageSize: 5}),
+    [store],
+  )
+
+  React.useEffect(() => {
+    suggestedActorsView.loadMore(true)
+  }, [store, suggestedActorsView])
+
+  const onPressLoadMoreSuggestedActors = () => {
+    suggestedActorsView.loadMore()
+  }
+  return (
+    <>
+      {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
+        <Text type="title" style={[styles.heading, pal.text]}>
+          Who to follow
+        </Text>
+      )}
+      {suggestedActorsView.hasContent && (
+        <>
+          <View style={[pal.border, styles.bottomBorder]}>
+            {suggestedActorsView.suggestions.map(item => (
+              <ProfileCardWithFollowBtn
+                key={item.did}
+                did={item.did}
+                declarationCid={item.declaration.cid}
+                handle={item.handle}
+                displayName={item.displayName}
+                avatar={item.avatar}
+                description={item.description}
+              />
+            ))}
+          </View>
+          {!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
+            <TouchableOpacity
+              onPress={onPressLoadMoreSuggestedActors}
+              style={styles.loadMore}>
+              <Text type="lg" style={pal.link}>
+                Show more
+              </Text>
+            </TouchableOpacity>
+          )}
+        </>
+      )}
+      {suggestedActorsView.isLoading && (
+        <View style={s.mt10}>
+          <ActivityIndicator />
+        </View>
+      )}
+    </>
+  )
+})
+
+const styles = StyleSheet.create({
+  heading: {
+    fontWeight: 'bold',
+    paddingHorizontal: 12,
+    paddingTop: 16,
+    paddingBottom: 8,
+  },
+
+  bottomBorder: {
+    borderBottomWidth: 1,
+  },
+
+  loadMore: {
+    paddingLeft: 16,
+    paddingVertical: 12,
+  },
+})
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index fdaafe737..83259330f 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -86,12 +86,18 @@ function ImageViewing({
     [toggleBarsVisible],
   )
 
+  const onLayout = useCallback(() => {
+    if (imageIndex) {
+      imageList.current?.scrollToIndex({index: imageIndex, animated: false})
+    }
+  }, [imageList, imageIndex])
+
   if (!visible) {
     return null
   }
 
   return (
-    <View style={styles.screen}>
+    <View style={styles.screen} onLayout={onLayout}>
       <Modal />
       <View style={[styles.container, {opacity, backgroundColor}]}>
         <Animated.View style={[styles.header, {transform: headerTransform}]}>
@@ -108,12 +114,8 @@ function ImageViewing({
           data={images}
           horizontal
           pagingEnabled
-          windowSize={2}
-          initialNumToRender={1}
-          maxToRenderPerBatch={1}
           showsHorizontalScrollIndicator={false}
           showsVerticalScrollIndicator={false}
-          initialScrollIndex={imageIndex}
           getItem={(_, index) => images[index]}
           getItemCount={() => images.length}
           getItemLayout={(_, index) => ({
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index df5839783..894c6b118 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -2,9 +2,9 @@ import React from 'react'
 import {View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import ImageView from './ImageViewing'
-import {useStores} from '../../../state'
-import * as models from '../../../state/models/shell-ui'
-import {saveImageModal} from '../../../lib/images'
+import {useStores} from 'state/index'
+import * as models from 'state/models/shell-ui'
+import {saveImageModal} from 'lib/images'
 import {ImageSource} from './ImageViewing/@types'
 
 export const Lightbox = observer(function Lightbox() {
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index 4062ddef7..dfe4f5603 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -8,9 +8,9 @@ import {
 } from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useStores} from '../../../state'
-import * as models from '../../../state/models/shell-ui'
-import {colors} from '../../lib/styles'
+import {useStores} from 'state/index'
+import * as models from 'state/models/shell-ui'
+import {colors} from 'lib/styles'
 
 interface Img {
   uri: string
diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx
index dbb3ab52f..6dc93f5e3 100644
--- a/src/view/com/login/CreateAccount.tsx
+++ b/src/view/com/login/CreateAccount.tsx
@@ -15,24 +15,22 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {ComAtprotoAccountCreate} from '@atproto/api'
 import * as EmailValidator from 'email-validator'
-// import {useAnalytics} from '@segment/analytics-react-native' TODO
+import {useAnalytics} from 'lib/analytics'
 import {LogoTextHero} from './Logo'
 import {Picker} from '../util/Picker'
 import {TextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
-import {s, colors} from '../../lib/styles'
-import {
-  makeValidHandle,
-  createFullHandle,
-  toNiceDomain,
-} from '../../../lib/strings'
-import {useStores, DEFAULT_SERVICE} from '../../../state'
-import {ServiceDescription} from '../../../state/models/session'
-import {ServerInputModal} from '../../../state/models/shell-ui'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {s, colors} from 'lib/styles'
+import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {useStores, DEFAULT_SERVICE} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+import {ServerInputModal} from 'state/models/shell-ui'
+import {usePalette} from 'lib/hooks/usePalette'
+import {cleanError} from 'lib/strings/errors'
 
 export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
-  // const {track} = useAnalytics() TODO
+  const {track, screen} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -50,6 +48,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
   const [is13, setIs13] = useState<boolean>(false)
 
   useEffect(() => {
+    screen('CreateAccount')
+  }, [screen])
+
+  useEffect(() => {
     let aborted = false
     setError('')
     setServiceDescription(undefined)
@@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
         password,
         inviteCode,
       })
-      // track('Create Account') TODO
+      track('Create Account')
     } catch (e: any) {
       let errMsg = e.toString()
       if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
@@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
       }
       store.log.error('Failed to create account', e)
       setIsProcessing(false)
-      setError(errMsg.replace(/^Error:/, ''))
+      setError(cleanError(errMsg))
     }
   }
 
diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx
index ec53549b1..7601ea31f 100644
--- a/src/view/com/login/Logo.tsx
+++ b/src/view/com/login/Logo.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {StyleSheet} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
-import {s, gradients} from '../../lib/styles'
+import {s, gradients} from 'lib/styles'
 import {Text} from '../util/text/Text'
 
 export const LogoTextHero = () => {
diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx
index 8a260a9ba..eed173119 100644
--- a/src/view/com/login/Signin.tsx
+++ b/src/view/com/login/Signin.tsx
@@ -13,19 +13,21 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import * as EmailValidator from 'email-validator'
-import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
-// import {useAnalytics} from '@segment/analytics-react-native' TODO
+import AtpAgent from '@atproto/api'
+import {useAnalytics} from 'lib/analytics'
 import {LogoTextHero} from './Logo'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
-import {s, colors} from '../../lib/styles'
-import {createFullHandle, toNiceDomain} from '../../../lib/strings'
-import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
-import {ServiceDescription} from '../../../state/models/session'
-import {ServerInputModal} from '../../../state/models/shell-ui'
-import {AccountData} from '../../../state/models/session'
-import {isNetworkError} from '../../../lib/errors'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {s, colors} from 'lib/styles'
+import {createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+import {ServerInputModal} from 'state/models/shell-ui'
+import {AccountData} from 'state/models/session'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {cleanError} from 'lib/strings/errors'
 
 enum Forms {
   Login,
@@ -38,6 +40,7 @@ enum Forms {
 export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
   const pal = usePalette('default')
   const store = useStores()
+  const {track} = useAnalytics()
   const [error, setError] = useState<string>('')
   const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
   const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
@@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
   }, [store.session, store.log, serviceUrl, retryDescribeTrigger])
 
   const onPressRetryConnect = () => setRetryDescribeTrigger({})
+  const onPressForgotPassword = () => {
+    track('Signin:PressedForgotPassword')
+    setCurrentForm(Forms.ForgotPassword)
+  }
 
   return (
     <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
@@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
           setError={setError}
           setServiceUrl={setServiceUrl}
           onPressBack={onPressBack}
-          onPressForgotPassword={gotoForm(Forms.ForgotPassword)}
+          onPressForgotPassword={onPressForgotPassword}
           onPressRetryConnect={onPressRetryConnect}
         />
       ) : undefined}
@@ -153,15 +160,19 @@ const ChooseAccountForm = ({
   onSelectAccount: (account?: AccountData) => void
   onPressBack: () => void
 }) => {
-  // const {track} = useAnalytics() TODO
+  const {track, screen} = useAnalytics()
   const pal = usePalette('default')
   const [isProcessing, setIsProcessing] = React.useState(false)
 
+  // React.useEffect(() => {
+  screen('Choose Account')
+  // }, [screen])
+
   const onTryAccount = async (account: AccountData) => {
     if (account.accessJwt && account.refreshJwt) {
       setIsProcessing(true)
       if (await store.session.resumeSession(account)) {
-        // track('Sign In', {resumedSession: true}) TODO
+        track('Sign In', {resumedSession: true})
         setIsProcessing(false)
         return
       }
@@ -261,15 +272,16 @@ const LoginForm = ({
   onPressBack: () => void
   onPressForgotPassword: () => void
 }) => {
-  // const {track} = useAnalytics() TODO
+  const {track} = useAnalytics()
   const pal = usePalette('default')
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [handle, setHandle] = useState<string>(initialHandle)
+  const [identifier, setIdentifier] = useState<string>(initialHandle)
   const [password, setPassword] = useState<string>('')
 
   const onPressSelectService = () => {
     store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
     Keyboard.dismiss()
+    track('Signin:PressedSelectService')
   }
 
   const onPressNext = async () => {
@@ -278,20 +290,21 @@ const LoginForm = ({
 
     try {
       // try to guess the handle if the user just gave their own username
-      let fullHandle = handle
+      let fullIdent = identifier
       if (
+        !identifier.includes('@') && // not an email
         serviceDescription &&
         serviceDescription.availableUserDomains.length > 0
       ) {
         let matched = false
         for (const domain of serviceDescription.availableUserDomains) {
-          if (fullHandle.endsWith(domain)) {
+          if (fullIdent.endsWith(domain)) {
             matched = true
           }
         }
         if (!matched) {
-          fullHandle = createFullHandle(
-            handle,
+          fullIdent = createFullHandle(
+            identifier,
             serviceDescription.availableUserDomains[0],
           )
         }
@@ -299,10 +312,10 @@ const LoginForm = ({
 
       await store.session.login({
         service: serviceUrl,
-        handle: fullHandle,
+        identifier: fullIdent,
         password,
       })
-      // track('Sign In', {resumedSession: false}) TODO
+      track('Sign In', {resumedSession: false})
     } catch (e: any) {
       const errMsg = e.toString()
       store.log.warn('Failed to login', e)
@@ -314,12 +327,12 @@ const LoginForm = ({
           'Unable to contact your service. Please check your Internet connection.',
         )
       } else {
-        setError(errMsg.replace(/^Error:/, ''))
+        setError(cleanError(errMsg))
       }
     }
   }
 
-  const isReady = !!serviceDescription && !!handle && !!password
+  const isReady = !!serviceDescription && !!identifier && !!password
   return (
     <View testID="loginForm">
       <LogoTextHero />
@@ -361,13 +374,13 @@ const LoginForm = ({
           <TextInput
             testID="loginUsernameInput"
             style={[pal.text, styles.textInput]}
-            placeholder="Username"
+            placeholder="Username or email address"
             placeholderTextColor={pal.colors.textLight}
             autoCapitalize="none"
             autoFocus
             autoCorrect={false}
-            value={handle}
-            onChangeText={str => setHandle((str || '').toLowerCase())}
+            value={identifier}
+            onChangeText={str => setIdentifier((str || '').toLowerCase())}
             editable={!isProcessing}
           />
         </View>
@@ -464,6 +477,11 @@ const ForgotPasswordForm = ({
   const pal = usePalette('default')
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [email, setEmail] = useState<string>('')
+  const {screen} = useAnalytics()
+
+  // useEffect(() => {
+  screen('Signin:ForgotPassword')
+  // }, [screen])
 
   const onPressSelectService = () => {
     store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
@@ -478,8 +496,8 @@ const ForgotPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const api = AtpApi.service(serviceUrl) as SessionServiceClient
-      await api.com.atproto.account.requestPasswordReset({email})
+      const agent = new AtpAgent({service: serviceUrl})
+      await agent.api.com.atproto.account.requestPasswordReset({email})
       onEmailSent()
     } catch (e: any) {
       const errMsg = e.toString()
@@ -490,7 +508,7 @@ const ForgotPasswordForm = ({
           'Unable to contact your service. Please check your Internet connection.',
         )
       } else {
-        setError(errMsg.replace(/^Error:/, ''))
+        setError(cleanError(errMsg))
       }
     }
   }
@@ -604,6 +622,12 @@ const SetNewPasswordForm = ({
   onPasswordSet: () => void
 }) => {
   const pal = usePalette('default')
+  const {screen} = useAnalytics()
+
+  // useEffect(() => {
+  screen('Signin:SetNewPasswordForm')
+  // }, [screen])
+
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [resetCode, setResetCode] = useState<string>('')
   const [password, setPassword] = useState<string>('')
@@ -613,8 +637,11 @@ const SetNewPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const api = AtpApi.service(serviceUrl) as SessionServiceClient
-      await api.com.atproto.account.resetPassword({token: resetCode, password})
+      const agent = new AtpAgent({service: serviceUrl})
+      await agent.api.com.atproto.account.resetPassword({
+        token: resetCode,
+        password,
+      })
       onPasswordSet()
     } catch (e: any) {
       const errMsg = e.toString()
@@ -625,7 +652,7 @@ const SetNewPasswordForm = ({
           'Unable to contact your service. Please check your Internet connection.',
         )
       } else {
-        setError(errMsg.replace(/^Error:/, ''))
+        setError(cleanError(errMsg))
       }
     }
   }
@@ -726,6 +753,12 @@ const SetNewPasswordForm = ({
 }
 
 const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
+  const {screen} = useAnalytics()
+
+  // useEffect(() => {
+  screen('Signin:PasswordUpdatedForm')
+  // }, [screen])
+
   const pal = usePalette('default')
   return (
     <>
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 3e2ad6eea..60c104f99 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -7,9 +7,10 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s, colors, gradients} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {s, colors, gradients} from 'lib/styles'
 import {ErrorMessage} from '../util/error/ErrorMessage'
+import {cleanError} from 'lib/strings/errors'
 
 export const snapPoints = ['50%']
 
@@ -33,7 +34,7 @@ export function Component({
       store.shell.closeModal()
       return
     } catch (e: any) {
-      setError(e.toString())
+      setError(cleanError(e))
       setIsProcessing(false)
     }
   }
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
new file mode 100644
index 000000000..de29e728d
--- /dev/null
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -0,0 +1,210 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
+import LinearGradient from 'react-native-linear-gradient'
+import * as Toast from '../util/Toast'
+import {Text} from '../util/text/Text'
+import {useStores} from 'state/index'
+import {s, colors, gradients} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {cleanError} from 'lib/strings/errors'
+
+export const snapPoints = ['60%']
+
+export function Component({}: {}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
+  const [confirmCode, setConfirmCode] = React.useState<string>('')
+  const [password, setPassword] = React.useState<string>('')
+  const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
+  const [error, setError] = React.useState<string>('')
+  const onPressSendEmail = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.api.com.atproto.account.requestDelete()
+      setIsEmailSent(true)
+    } catch (e: any) {
+      setError(cleanError(e))
+    }
+    setIsProcessing(false)
+  }
+  const onPressConfirmDelete = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.api.com.atproto.account.delete({
+        did: store.me.did,
+        password,
+        token: confirmCode,
+      })
+      Toast.show('Your account has been deleted')
+      store.nav.tab.fixedTabReset()
+      store.session.clear()
+      store.shell.closeModal()
+    } catch (e: any) {
+      setError(cleanError(e))
+    }
+    setIsProcessing(false)
+  }
+  const onCancel = () => {
+    store.shell.closeModal()
+  }
+  return (
+    <View
+      style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}>
+      <View style={[styles.innerContainer, pal.view]}>
+        <Text type="title-xl" style={[styles.title, pal.text]}>
+          Delete account
+        </Text>
+        {!isEmailSent ? (
+          <>
+            <Text type="lg" style={[styles.description, pal.text]}>
+              For security reasons, we'll need to send a confirmation code to
+              your email.
+            </Text>
+            {error ? (
+              <View style={s.mt10}>
+                <ErrorMessage message={error} />
+              </View>
+            ) : undefined}
+            {isProcessing ? (
+              <View style={[styles.btn, s.mt10]}>
+                <ActivityIndicator />
+              </View>
+            ) : (
+              <>
+                <TouchableOpacity
+                  style={styles.mt20}
+                  onPress={onPressSendEmail}>
+                  <LinearGradient
+                    colors={[
+                      gradients.blueLight.start,
+                      gradients.blueLight.end,
+                    ]}
+                    start={{x: 0, y: 0}}
+                    end={{x: 1, y: 1}}
+                    style={[styles.btn]}>
+                    <Text type="button-lg" style={[s.white, s.bold]}>
+                      Send email
+                    </Text>
+                  </LinearGradient>
+                </TouchableOpacity>
+                <TouchableOpacity
+                  style={[styles.btn, s.mt10]}
+                  onPress={onCancel}>
+                  <Text type="button-lg" style={pal.textLight}>
+                    Cancel
+                  </Text>
+                </TouchableOpacity>
+              </>
+            )}
+          </>
+        ) : (
+          <>
+            <Text type="lg" style={styles.description}>
+              Check your inbox for an email with the confirmation code to enter
+              below:
+            </Text>
+            <BottomSheetTextInput
+              style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
+              placeholder="Confirmation code"
+              placeholderTextColor={pal.textLight.color}
+              value={confirmCode}
+              onChangeText={setConfirmCode}
+            />
+            <Text type="lg" style={styles.description}>
+              Please enter your password as well:
+            </Text>
+            <BottomSheetTextInput
+              style={[styles.textInput, pal.borderDark, pal.text]}
+              placeholder="Password"
+              placeholderTextColor={pal.textLight.color}
+              secureTextEntry
+              value={password}
+              onChangeText={setPassword}
+            />
+            {error ? (
+              <View style={styles.mt20}>
+                <ErrorMessage message={error} />
+              </View>
+            ) : undefined}
+            {isProcessing ? (
+              <View style={[styles.btn, s.mt10]}>
+                <ActivityIndicator />
+              </View>
+            ) : (
+              <>
+                <TouchableOpacity
+                  style={[styles.btn, styles.evilBtn, styles.mt20]}
+                  onPress={onPressConfirmDelete}>
+                  <Text type="button-lg" style={[s.white, s.bold]}>
+                    Delete my account
+                  </Text>
+                </TouchableOpacity>
+                <TouchableOpacity
+                  style={[styles.btn, s.mt10]}
+                  onPress={onCancel}>
+                  <Text type="button-lg" style={pal.textLight}>
+                    Cancel
+                  </Text>
+                </TouchableOpacity>
+              </>
+            )}
+          </>
+        )}
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+  innerContainer: {
+    paddingBottom: 20,
+  },
+  title: {
+    textAlign: 'center',
+    marginTop: 12,
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 22,
+    marginBottom: 10,
+  },
+  mt20: {
+    marginTop: 20,
+  },
+  mb20: {
+    marginBottom: 20,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    fontSize: 20,
+    marginHorizontal: 20,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    marginHorizontal: 20,
+  },
+  evilBtn: {
+    backgroundColor: colors.red4,
+  },
+})
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 12b72a399..add75e89e 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util'
 import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from '../../../state'
-import {ProfileViewModel} from '../../../state/models/profile-view'
-import {s, colors, gradients} from '../../lib/styles'
-import {
-  enforceLen,
-  MAX_DISPLAY_NAME,
-  MAX_DESCRIPTION,
-} from '../../../lib/strings'
-import {isNetworkError} from '../../../lib/errors'
-import {compressIfNeeded} from '../../../lib/images'
+import {useStores} from 'state/index'
+import {ProfileViewModel} from 'state/models/profile-view'
+import {s, colors, gradients} from 'lib/styles'
+import {enforceLen} from 'lib/strings/helpers'
+import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
+import {compressIfNeeded} from 'lib/images'
 import {UserBanner} from '../util/UserBanner'
 import {UserAvatar} from '../util/UserAvatar'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {cleanError, isNetworkError} from 'lib/strings/errors'
 
 export const snapPoints = ['80%']
 
@@ -35,6 +34,9 @@ export function Component({
 }) {
   const store = useStores()
   const [error, setError] = useState<string>('')
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+
   const [isProcessing, setProcessing] = useState<boolean>(false)
   const [displayName, setDisplayName] = useState<string>(
     profileView.displayName || '',
@@ -54,24 +56,27 @@ export function Component({
     store.shell.closeModal()
   }
   const onSelectNewAvatar = async (img: PickedMedia) => {
+    track('EditProfile:AvatarSelected')
     try {
-      const finalImg = await compressIfNeeded(img, 300000)
-      setNewUserAvatar(finalImg)
+      const finalImg = await compressIfNeeded(img, 1000000)
+      setNewUserAvatar({mediaType: 'photo', ...finalImg})
       setUserAvatar(finalImg.path)
     } catch (e: any) {
-      setError(e.message || e.toString())
+      setError(cleanError(e))
     }
   }
   const onSelectNewBanner = async (img: PickedMedia) => {
+    track('EditProfile:BannerSelected')
     try {
-      const finalImg = await compressIfNeeded(img, 500000)
-      setNewUserBanner(finalImg)
+      const finalImg = await compressIfNeeded(img, 1000000)
+      setNewUserBanner({mediaType: 'photo', ...finalImg})
       setUserBanner(finalImg.path)
     } catch (e: any) {
-      setError(e.message || e.toString())
+      setError(cleanError(e))
     }
   }
   const onPressSave = async () => {
+    track('EditProfile:Save')
     setProcessing(true)
     if (error) {
       setError('')
@@ -94,7 +99,7 @@ export function Component({
           'Failed to save your profile. Check your internet connection and try again.',
         )
       } else {
-        setError(e.message)
+        setError(cleanError(e))
       }
     }
     setProcessing(false)
@@ -103,13 +108,13 @@ export function Component({
   return (
     <View style={s.flex1}>
       <ScrollView style={styles.inner}>
-        <Text style={styles.title}>Edit my profile</Text>
+        <Text style={[styles.title, pal.text]}>Edit my profile</Text>
         <View style={styles.photos}>
           <UserBanner
             banner={userBanner}
             onSelectNewBanner={onSelectNewBanner}
           />
-          <View style={styles.avi}>
+          <View style={[styles.avi, {borderColor: pal.colors.background}]}>
             <UserAvatar
               size={80}
               avatar={userAvatar}
@@ -127,7 +132,7 @@ export function Component({
         <View>
           <Text style={styles.label}>Display Name</Text>
           <TextInput
-            style={styles.textInput}
+            style={[styles.textInput, pal.text]}
             placeholder="e.g. Alice Roberts"
             placeholderTextColor={colors.gray4}
             value={displayName}
@@ -135,9 +140,9 @@ export function Component({
           />
         </View>
         <View style={s.pb10}>
-          <Text style={styles.label}>Description</Text>
+          <Text style={[styles.label, pal.text]}>Description</Text>
           <TextInput
-            style={[styles.textArea]}
+            style={[styles.textArea, pal.text]}
             placeholder="e.g. Artist, dog-lover, and memelord."
             placeholderTextColor={colors.gray4}
             multiline
@@ -162,7 +167,7 @@ export function Component({
         )}
         <TouchableOpacity style={s.mt5} onPress={onPressCancel}>
           <View style={[styles.btn]}>
-            <Text style={[s.black, s.bold]}>Cancel</Text>
+            <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
           </View>
         </TouchableOpacity>
       </ScrollView>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index d0b40d56d..2529d0d5b 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react'
 import {View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
-import {useStores} from '../../../state'
+import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 
-import * as models from '../../../state/models/shell-ui'
+import * as models from 'state/models/shell-ui'
 
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as ReportAccountModal from './ReportAccount'
+import * as DeleteAccountModal from './DeleteAccount'
+import {usePalette} from 'lib/hooks/usePalette'
+import {StyleSheet} from 'react-native'
 
 const CLOSED_SNAPPOINTS = ['10%']
 
 export const Modal = observer(function Modal() {
   const store = useStores()
   const bottomSheetRef = useRef<BottomSheet>(null)
-
+  const pal = usePalette('default')
   const onBottomSheetChange = (snapPoint: number) => {
     if (snapPoint === -1) {
       store.shell.closeModal()
@@ -62,10 +65,21 @@ export const Modal = observer(function Modal() {
     )
   } else if (store.shell.activeModal?.name === 'report-post') {
     snapPoints = ReportPostModal.snapPoints
-    element = <ReportPostModal.Component />
+    element = (
+      <ReportPostModal.Component
+        {...(store.shell.activeModal as models.ReportPostModal)}
+      />
+    )
   } else if (store.shell.activeModal?.name === 'report-account') {
     snapPoints = ReportAccountModal.snapPoints
-    element = <ReportAccountModal.Component />
+    element = (
+      <ReportAccountModal.Component
+        {...(store.shell.activeModal as models.ReportAccountModal)}
+      />
+    )
+  } else if (store.shell.activeModal?.name === 'delete-account') {
+    snapPoints = DeleteAccountModal.snapPoints
+    element = <DeleteAccountModal.Component />
   } else {
     element = <View />
   }
@@ -80,8 +94,17 @@ export const Modal = observer(function Modal() {
       backdropComponent={
         store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
       }
+      handleIndicatorStyle={{backgroundColor: pal.text.color}}
+      handleStyle={[styles.handle, pal.view]}
       onChange={onBottomSheetChange}>
       {element}
     </BottomSheet>
   )
 })
+
+const styles = StyleSheet.create({
+  handle: {
+    borderTopLeftRadius: 10,
+    borderTopRightRadius: 10,
+  },
+})
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 44ea95f07..3c6551093 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
 
-import * as models from '../../../state/models/shell-ui'
+import * as models from 'state/models/shell-ui'
 
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
@@ -48,9 +48,17 @@ export const Modal = observer(function Modal() {
       />
     )
   } else if (store.shell.activeModal?.name === 'report-post') {
-    element = <ReportPostModal.Component />
+    element = (
+      <ReportPostModal.Component
+        {...(store.shell.activeModal as models.ReportPostModal)}
+      />
+    )
   } else if (store.shell.activeModal?.name === 'report-account') {
-    element = <ReportAccountModal.Component />
+    element = (
+      <ReportAccountModal.Component
+        {...(store.shell.activeModal as models.ReportAccountModal)}
+      />
+    )
   } else if (store.shell.activeModal?.name === 'crop-image') {
     element = (
       <CropImageModal.Component
diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx
index 1385d5711..377a32838 100644
--- a/src/view/com/modals/ReportAccount.tsx
+++ b/src/view/com/modals/ReportAccount.tsx
@@ -5,12 +5,15 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {ComAtprotoReportReasonType} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from '../../../state'
-import {s, colors, gradients} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {s, colors, gradients} from 'lib/styles'
 import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
 import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
 import {ErrorMessage} from '../util/error/ErrorMessage'
+import {cleanError} from 'lib/strings/errors'
 
 const ITEMS: RadioGroupItem[] = [
   {key: 'spam', label: 'Spam or excessive repeat posts'},
@@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [
 
 export const snapPoints = ['50%']
 
-export function Component() {
+export function Component({did}: {did: string}) {
   const store = useStores()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
@@ -28,13 +31,30 @@ export function Component() {
   const onSelectIssue = (v: string) => setIssue(v)
   const onPress = async () => {
     setError('')
+    if (!issue) {
+      return
+    }
     setIsProcessing(true)
     try {
-      // TODO
+      // NOTE: we should update the lexicon of reasontype to include more options -prf
+      let reasonType = ComAtprotoReportReasonType.OTHER
+      if (issue === 'spam') {
+        reasonType = ComAtprotoReportReasonType.SPAM
+      }
+      const reason = ITEMS.find(item => item.key === issue)?.label || ''
+      await store.api.com.atproto.report.create({
+        reasonType,
+        reason,
+        subject: {
+          $type: 'com.atproto.repo.repoRef',
+          did,
+        },
+      })
+      Toast.show("Thank you for your report! We'll look into it promptly.")
       store.shell.closeModal()
       return
     } catch (e: any) {
-      setError(e.toString())
+      setError(cleanError(e))
       setIsProcessing(false)
     }
   }
diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx
index 8a3a1f758..3d47e7ef0 100644
--- a/src/view/com/modals/ReportPost.tsx
+++ b/src/view/com/modals/ReportPost.tsx
@@ -5,12 +5,15 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {ComAtprotoReportReasonType} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from '../../../state'
-import {s, colors, gradients} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {s, colors, gradients} from 'lib/styles'
 import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
 import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
 import {ErrorMessage} from '../util/error/ErrorMessage'
+import {cleanError} from 'lib/strings/errors'
 
 const ITEMS: RadioGroupItem[] = [
   {key: 'spam', label: 'Spam or excessive repeat posts'},
@@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [
 
 export const snapPoints = ['50%']
 
-export function Component() {
+export function Component({
+  postUri,
+  postCid,
+}: {
+  postUri: string
+  postCid: string
+}) {
   const store = useStores()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
@@ -29,13 +38,31 @@ export function Component() {
   const onSelectIssue = (v: string) => setIssue(v)
   const onPress = async () => {
     setError('')
+    if (!issue) {
+      return
+    }
     setIsProcessing(true)
     try {
-      // TODO
+      // NOTE: we should update the lexicon of reasontype to include more options -prf
+      let reasonType = ComAtprotoReportReasonType.OTHER
+      if (issue === 'spam') {
+        reasonType = ComAtprotoReportReasonType.SPAM
+      }
+      const reason = ITEMS.find(item => item.key === issue)?.label || ''
+      await store.api.com.atproto.report.create({
+        reasonType,
+        reason,
+        subject: {
+          $type: 'com.atproto.repo.recordRef',
+          uri: postUri,
+          cid: postCid,
+        },
+      })
+      Toast.show("Thank you for your report! We'll look into it promptly.")
       store.shell.closeModal()
       return
     } catch (e: any) {
-      setError(e.toString())
+      setError(cleanError(e))
       setIsProcessing(false)
     }
   }
diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx
index dde836719..5a9a4cfed 100644
--- a/src/view/com/modals/ServerInput.tsx
+++ b/src/view/com/modals/ServerInput.tsx
@@ -6,14 +6,10 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {ScrollView, TextInput} from './util'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
-import {
-  LOCAL_DEV_SERVICE,
-  STAGING_SERVICE,
-  PROD_SERVICE,
-} from '../../../state/index'
-import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
+import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
 
 export const snapPoints = ['80%']
 
@@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
           {LOGIN_INCLUDE_DEV_SERVERS ? (
             <>
               <TouchableOpacity
+                testID="localDevServerButton"
                 style={styles.btn}
                 onPress={() => doSelect(LOCAL_DEV_SERVICE)}>
                 <Text style={styles.btnText}>Local dev server</Text>
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 1f234c4a6..e43f37397 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from '../../util/text/Text'
 import {PickedMedia} from '../../util/images/image-crop-picker/types'
-import {s, gradients} from '../../../lib/styles'
-import {useStores} from '../../../../state'
-import {usePalette} from '../../../lib/hooks/usePalette'
-import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
+import {s, gradients} from 'lib/styles'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
 
 enum AspectRatio {
   Square = 'square',
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 55e958c2b..5c1ee935b 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,31 +1,61 @@
-import React from 'react'
+import React, {MutableRefObject} from 'react'
 import {observer} from 'mobx-react-lite'
-import {StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
-import {NotificationsViewModel} from '../../../state/models/notifications-view'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {NotificationsViewModel} from 'state/models/notifications-view'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {EmptyState} from '../util/EmptyState'
-import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
-import {s} from '../../lib/styles'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {s} from 'lib/styles'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 
 export const Feed = observer(function Feed({
   view,
+  scrollElRef,
   onPressTryAgain,
   onScroll,
 }: {
   view: NotificationsViewModel
+  scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
 }) {
+  const data = React.useMemo(() => {
+    let feedItems
+    if (view.hasLoaded) {
+      if (view.isEmpty) {
+        feedItems = [EMPTY_FEED_ITEM]
+      } else {
+        feedItems = view.notifications
+      }
+    }
+    return feedItems
+  }, [view.hasLoaded, view.isEmpty, view.notifications])
+
+  const onRefresh = React.useCallback(async () => {
+    try {
+      await view.refresh()
+      await view.markAllRead()
+    } catch (err) {
+      view.rootStore.log.error('Failed to refresh notifications feed', err)
+    }
+  }, [view])
+  const onEndReached = React.useCallback(async () => {
+    try {
+      await view.loadMore()
+    } catch (err) {
+      view.rootStore.log.error('Failed to load more notifications', err)
+    }
+  }, [view])
+
   // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
   //   VirtualizedList: You have a large list that is slow to update - make sure your
   //   renderItem function renders components that follow React performance best practices
   //   like PureComponent, shouldComponentUpdate, etc
-  const renderItem = ({item}: {item: any}) => {
+  const renderItem = React.useCallback(({item}: {item: any}) => {
     if (item === EMPTY_FEED_ITEM) {
       return (
         <EmptyState
@@ -36,29 +66,20 @@ export const Feed = observer(function Feed({
       )
     }
     return <FeedItem item={item} />
-  }
-  const onRefresh = () => {
-    view
-      .refresh()
-      .catch(err =>
-        view.rootStore.log.error('Failed to refresh notifications feed', err),
-      )
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err =>
-        view.rootStore.log.error('Failed to load more notifications', err),
-      )
-  }
-  let data
-  if (view.hasLoaded) {
-    if (view.isEmpty) {
-      data = [EMPTY_FEED_ITEM]
-    } else {
-      data = view.notifications
-    }
-  }
+  }, [])
+
+  const FeedFooter = React.useCallback(
+    () =>
+      view.isLoading ? (
+        <View style={styles.feedFooter}>
+          <ActivityIndicator />
+        </View>
+      ) : (
+        <View />
+      ),
+    [view],
+  )
+
   return (
     <View style={s.h100pct}>
       <CenteredView>
@@ -72,9 +93,11 @@ export const Feed = observer(function Feed({
       </CenteredView>
       {data && (
         <FlatList
+          ref={scrollElRef}
           data={data}
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
+          ListFooterComponent={FeedFooter}
           refreshing={view.isRefreshing}
           onRefresh={onRefresh}
           onEndReached={onEndReached}
@@ -87,5 +110,6 @@ export const Feed = observer(function Feed({
 })
 
 const styles = StyleSheet.create({
+  feedFooter: {paddingTop: 20},
   emptyState: {paddingVertical: 40},
 })
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index da6fc7d35..94bedc46f 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -14,19 +14,19 @@ import {
   FontAwesomeIconStyle,
   Props,
 } from '@fortawesome/react-native-fontawesome'
-import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
-import {PostThreadViewModel} from '../../../state/models/post-thread-view'
-import {s, colors} from '../../lib/styles'
-import {ago, pluralize} from '../../../lib/strings'
-import {HeartIconSolid} from '../../lib/icons'
+import {NotificationsViewItemModel} from 'state/models/notifications-view'
+import {PostThreadViewModel} from 'state/models/post-thread-view'
+import {s, colors} from 'lib/styles'
+import {ago} from 'lib/strings/time'
+import {pluralize} from 'lib/strings/helpers'
+import {HeartIconSolid} from 'lib/icons'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import {ImageHorzList} from '../util/images/ImageHorzList'
-import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Post} from '../post/Post'
 import {Link} from '../util/Link'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
 const MAX_AUTHORS = 5
 
@@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({
   }
 
   if (item.isReply || item.isMention) {
+    if (item.additionalPost?.error) {
+      // hide errors - it doesnt help the user to show them
+      return <View />
+    }
     return (
       <Link href={itemHref} title={itemTitle} noFeedback>
         <Post
@@ -347,12 +351,13 @@ function AdditionalPostText({
   additionalPost?: PostThreadViewModel
 }) {
   const pal = usePalette('default')
-  if (!additionalPost || !additionalPost.thread?.postRecord) {
+  if (
+    !additionalPost ||
+    !additionalPost.thread?.postRecord ||
+    additionalPost.error
+  ) {
     return <View />
   }
-  if (additionalPost.error) {
-    return <ErrorMessage message={additionalPost.error} />
-  }
   const text = additionalPost.thread?.postRecord.text
   const images = (
     additionalPost.thread.post.embed as AppBskyEmbedImages.Presented
diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx
index d8d502cfb..323b1ba14 100644
--- a/src/view/com/onboard/FeatureExplainer.tsx
+++ b/src/view/com/onboard/FeatureExplainer.tsx
@@ -14,10 +14,10 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s} from '../../lib/styles'
-import {TABS_EXPLAINER} from '../../lib/assets'
-import {TABS_ENABLED} from '../../../build-flags'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {TABS_EXPLAINER} from 'lib/assets'
+import {TABS_ENABLED} from 'lib/build-flags'
 
 const ROUTES = TABS_ENABLED
   ? [
@@ -127,11 +127,15 @@ export const FeatureExplainer = () => {
         <View />
       )}
       <View style={styles.footer}>
-        <TouchableOpacity onPress={onPressSkip}>
+        <TouchableOpacity
+          onPress={onPressSkip}
+          testID="onboardFeatureExplainerSkipBtn">
           <Text style={[s.blue3, s.f18]}>Skip</Text>
         </TouchableOpacity>
         <View style={s.flex1} />
-        <TouchableOpacity onPress={onPressNext}>
+        <TouchableOpacity
+          onPress={onPressNext}
+          testID="onboardFeatureExplainerNextBtn">
           <Text style={[s.blue3, s.f18]}>Next</Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/onboard/FeatureExplainer.web.tsx b/src/view/com/onboard/FeatureExplainer.web.tsx
index 83d460808..177ac58dd 100644
--- a/src/view/com/onboard/FeatureExplainer.web.tsx
+++ b/src/view/com/onboard/FeatureExplainer.web.tsx
@@ -2,7 +2,6 @@ import React, {useState} from 'react'
 import {
   Animated,
   Image,
-  SafeAreaView,
   StyleSheet,
   TouchableOpacity,
   useWindowDimensions,
@@ -15,10 +14,10 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {CenteredView} from '../util/Views.web'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
-import {TABS_EXPLAINER} from '../../lib/assets'
-import {TABS_ENABLED} from '../../../build-flags'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {TABS_EXPLAINER} from 'lib/assets'
+import {TABS_ENABLED} from 'lib/build-flags'
 
 const ROUTES = TABS_ENABLED
   ? [
diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx
index 76eff3f4b..e7de82b39 100644
--- a/src/view/com/onboard/Follows.tsx
+++ b/src/view/com/onboard/Follows.tsx
@@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {SuggestedFollows} from '../discover/SuggestedFollows'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
 
 export const Follows = observer(() => {
   const store = useStores()
@@ -18,13 +18,15 @@ export const Follows = observer(() => {
   return (
     <SafeAreaView style={styles.container}>
       <Text style={styles.title}>Suggested follows</Text>
-      <SuggestedFollows onNoSuggestions={onNoSuggestions} />
+      <View style={s.flex1}>
+        <SuggestedFollows onNoSuggestions={onNoSuggestions} />
+      </View>
       <View style={styles.footer}>
-        <TouchableOpacity onPress={onPressNext}>
+        <TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn">
           <Text style={[s.blue3, s.f18]}>Skip</Text>
         </TouchableOpacity>
         <View style={s.flex1} />
-        <TouchableOpacity onPress={onPressNext}>
+        <TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn">
           <Text style={[s.blue3, s.f18]}>Next</Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/onboard/Follows.web.tsx b/src/view/com/onboard/Follows.web.tsx
index 50e119e49..6b015bb09 100644
--- a/src/view/com/onboard/Follows.web.tsx
+++ b/src/view/com/onboard/Follows.web.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
-import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {SuggestedFollows} from '../discover/SuggestedFollows'
 import {CenteredView} from '../util/Views.web'
 import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
 
 export const Follows = observer(() => {
   const store = useStores()
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index dacdfa50f..a9fabac3d 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views'
 import {
   RepostedByViewModel,
   RepostedByItem,
-} from '../../../state/models/reposted-by-view'
-import {UserAvatar} from '../util/UserAvatar'
+} from 'state/models/reposted-by-view'
+import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
+import {useStores} from 'state/index'
 
 export const PostRepostedBy = observer(function PostRepostedBy({
   uri,
@@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({
   // loaded
   // =
   const renderItem = ({item}: {item: RepostedByItem}) => (
-    <RepostedByItemCom item={item} />
+    <ProfileCardWithFollowBtn
+      key={item.did}
+      did={item.did}
+      declarationCid={item.declaration.cid}
+      handle={item.handle}
+      displayName={item.displayName}
+      avatar={item.avatar}
+      isFollowedBy={!!item.viewer?.followedBy}
+    />
   )
   return (
     <FlatList
@@ -83,57 +88,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
   )
 })
 
-const RepostedByItemCom = ({item}: {item: RepostedByItem}) => {
-  return (
-    <Link
-      style={styles.outer}
-      href={`/profile/${item.handle}`}
-      title={item.handle}
-      noFeedback>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar
-            size={40}
-            displayName={item.displayName}
-            handle={item.handle}
-            avatar={item.avatar}
-          />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text>
-          <Text style={[s.f14, s.gray5]}>@{item.handle}</Text>
-        </View>
-      </View>
-    </Link>
-  )
-}
-
 const styles = StyleSheet.create({
-  outer: {
-    marginTop: 1,
-    backgroundColor: colors.white,
-  },
-  layout: {
-    flexDirection: 'row',
-  },
-  layoutAvi: {
-    width: 60,
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  avi: {
-    width: 40,
-    height: 40,
-    borderRadius: 20,
-    resizeMode: 'cover',
-  },
-  layoutContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
   footer: {
     height: 200,
     paddingTop: 20,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 59dbf1e16..0a092c46b 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,14 +1,14 @@
 import React, {useRef} from 'react'
 import {observer} from 'mobx-react-lite'
-import {ActivityIndicator, View} from 'react-native'
+import {ActivityIndicator} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
 import {
   PostThreadViewModel,
   PostThreadViewPostModel,
-} from '../../../state/models/post-thread-view'
+} from 'state/models/post-thread-view'
 import {PostThreadItem} from './PostThreadItem'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {s} from '../../lib/styles'
+import {s} from 'lib/styles'
 
 export const PostThread = observer(function PostThread({
   uri,
@@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({
   view: PostThreadViewModel
 }) {
   const ref = useRef<FlatList>(null)
-  const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
-  const onRefresh = () => {
-    view
-      ?.refresh()
-      .catch(err =>
-        view.rootStore.log.error('Failed to refresh posts thread', err),
-      )
-  }
-  const onLayout = () => {
+  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const posts = React.useMemo(
+    () => (view.thread ? Array.from(flattenThread(view.thread)) : []),
+    [view.thread],
+  )
+
+  // events
+  // =
+  const onRefresh = React.useCallback(async () => {
+    setIsRefreshing(true)
+    try {
+      view?.refresh()
+    } catch (err) {
+      view.rootStore.log.error('Failed to refresh posts thread', err)
+    }
+    setIsRefreshing(false)
+  }, [view, setIsRefreshing])
+  const onLayout = React.useCallback(() => {
     const index = posts.findIndex(post => post._isHighlightedPost)
     if (index !== -1) {
       ref.current?.scrollToIndex({
@@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({
         viewOffset: 40,
       })
     }
-  }
-  const onScrollToIndexFailed = (info: {
-    index: number
-    highestMeasuredFrameIndex: number
-    averageItemLength: number
-  }) => {
-    ref.current?.scrollToOffset({
-      animated: false,
-      offset: info.averageItemLength * info.index,
-    })
-  }
+  }, [posts, ref])
+  const onScrollToIndexFailed = React.useCallback(
+    (info: {
+      index: number
+      highestMeasuredFrameIndex: number
+      averageItemLength: number
+    }) => {
+      ref.current?.scrollToOffset({
+        animated: false,
+        offset: info.averageItemLength * info.index,
+      })
+    },
+    [ref],
+  )
 
   // loading
   // =
@@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({
     <FlatList
       ref={ref}
       data={posts}
+      initialNumToRender={posts.length}
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
-      refreshing={view.isRefreshing}
+      refreshing={isRefreshing}
       onRefresh={onRefresh}
       onLayout={onLayout}
       onScrollToIndexFailed={onScrollToIndexFailed}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index d39296285..cd3a49d64 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -1,4 +1,4 @@
-import React, {useMemo, useState} from 'react'
+import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {StyleSheet, View} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
@@ -7,22 +7,23 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
+import {PostThreadViewPostModel} from 'state/models/post-thread-view'
 import {Link} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {Text} from '../util/text/Text'
 import {PostDropdownBtn} from '../util/forms/DropdownButton'
 import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
-import {s} from '../../lib/styles'
-import {ago, pluralize} from '../../../lib/strings'
-import {useStores} from '../../../state'
+import {s} from 'lib/styles'
+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 {PostCtrls} from '../util/PostCtrls'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ComposePrompt} from '../composer/Prompt'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 const PARENT_REPLY_LINE_LENGTH = 8
 
@@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({
 }) {
   const pal = usePalette('default')
   const store = useStores()
-  const [deleted, setDeleted] = useState(false)
+  const [deleted, setDeleted] = React.useState(false)
   const record = item.postRecord
   const hasEngagement = item.post.upvoteCount || item.post.repostCount
 
-  const itemHref = useMemo(() => {
+  const itemUri = item.post.uri
+  const itemCid = item.post.cid
+  const itemHref = React.useMemo(() => {
     const urip = new AtUri(item.post.uri)
     return `/profile/${item.post.author.handle}/post/${urip.rkey}`
   }, [item.post.uri, item.post.author.handle])
   const itemTitle = `Post by ${item.post.author.handle}`
   const authorHref = `/profile/${item.post.author.handle}`
   const authorTitle = item.post.author.handle
-  const upvotesHref = useMemo(() => {
+  const upvotesHref = React.useMemo(() => {
     const urip = new AtUri(item.post.uri)
     return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
   }, [item.post.uri, item.post.author.handle])
   const upvotesTitle = 'Likes on this post'
-  const repostsHref = useMemo(() => {
+  const repostsHref = React.useMemo(() => {
     const urip = new AtUri(item.post.uri)
     return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
   }, [item.post.uri, item.post.author.handle])
   const repostsTitle = 'Reposts of this post'
 
-  const onPressReply = () => {
+  const onPressReply = React.useCallback(() => {
     store.shell.openComposer({
       replyTo: {
         uri: item.post.uri,
@@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({
       },
       onPost: onPostReply,
     })
-  }
-  const onPressToggleRepost = () => {
-    item
+  }, [store, item, record, onPostReply])
+  const onPressToggleRepost = React.useCallback(() => {
+    return item
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
-  }
-  const onPressToggleUpvote = () => {
-    item
+  }, [item, store])
+  const onPressToggleUpvote = React.useCallback(() => {
+    return item
       .toggleUpvote()
       .catch(e => store.log.error('Failed to toggle upvote', e))
-  }
-  const onCopyPostText = () => {
+  }, [item, store])
+  const onCopyPostText = React.useCallback(() => {
     Clipboard.setString(record?.text || '')
     Toast.show('Copied to clipboard')
-  }
-  const onDeletePost = () => {
+  }, [record])
+  const onDeletePost = React.useCallback(() => {
     item.delete().then(
       () => {
         setDeleted(true)
@@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({
         Toast.show('Failed to delete post, please try again')
       },
     )
-  }
+  }, [item, store])
 
   if (!record) {
     return <ErrorMessage message="Invalid or unsupported post record" />
@@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({
                 <View style={s.flex1} />
                 <PostDropdownBtn
                   style={styles.metaItem}
+                  itemUri={itemUri}
+                  itemCid={itemCid}
                   itemHref={itemHref}
                   itemTitle={itemTitle}
                   isAuthor={item.post.author.did === store.me.did}
@@ -179,7 +184,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             </View>
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
-            {record.text ? (
+            {item.richText?.text ? (
               <View
                 style={[
                   styles.postTextContainer,
@@ -187,8 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 ]}>
                 <RichText
                   type="post-text-lg"
-                  text={record.text}
-                  entities={record.entities}
+                  richText={item.richText}
                   lineHeight={1.3}
                 />
               </View>
@@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({
             <View style={[s.pl10, s.pb5]}>
               <PostCtrls
                 big
+                itemUri={itemUri}
+                itemCid={itemCid}
                 itemHref={itemHref}
                 itemTitle={itemTitle}
                 isAuthor={item.post.author.did === store.me.did}
@@ -301,12 +307,11 @@ export const PostThreadItem = observer(function PostThreadItem({
                   <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
                   <Text type="sm">This post is by a muted account.</Text>
                 </View>
-              ) : record.text ? (
+              ) : item.richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
                     type="post-text"
-                    text={record.text}
-                    entities={record.entities}
+                    richText={item.richText}
                     style={pal.text}
                     lineHeight={1.3}
                   />
@@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({
               ) : undefined}
               <PostEmbeds embed={item.post.embed} style={s.mb10} />
               <PostCtrls
+                itemUri={itemUri}
+                itemCid={itemCid}
                 itemHref={itemHref}
                 itemTitle={itemTitle}
                 isAuthor={item.post.author.did === store.me.did}
@@ -341,7 +348,12 @@ export const PostThreadItem = observer(function PostThreadItem({
             href={itemHref}
             title={itemTitle}
             noFeedback>
-            <Text style={pal.link}>Load more</Text>
+            <Text style={pal.link}>Continue thread...</Text>
+            <FontAwesomeIcon
+              icon="angle-right"
+              style={pal.link as FontAwesomeIconStyle}
+              size={18}
+            />
           </Link>
         ) : undefined}
       </>
@@ -433,8 +445,12 @@ const styles = StyleSheet.create({
     marginRight: 10,
   },
   loadMore: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
     borderTopWidth: 1,
-    paddingLeft: 28,
+    paddingLeft: 80,
+    paddingRight: 20,
     paddingVertical: 10,
+    marginBottom: 8,
   },
 })
diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostVotedBy.tsx
index 680bbadf4..2734aaea9 100644
--- a/src/view/com/post-thread/PostVotedBy.tsx
+++ b/src/view/com/post-thread/PostVotedBy.tsx
@@ -2,14 +2,10 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
-import {VotesViewModel, VoteItem} from '../../../state/models/votes-view'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
+import {VotesViewModel, VoteItem} from 'state/models/votes-view'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from '../../../state'
-import {s} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
+import {useStores} from 'state/index'
 
 export const PostVotedBy = observer(function PostVotedBy({
   uri,
@@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({
 
   // loaded
   // =
-  const renderItem = ({item}: {item: VoteItem}) => <LikedByItem item={item} />
+  const renderItem = ({item}: {item: VoteItem}) => (
+    <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}
+      isFollowedBy={!!item.actor.viewer?.followedBy}
+    />
+  )
   return (
     <FlatList
       data={view.votes}
@@ -77,62 +83,7 @@ export const PostVotedBy = observer(function PostVotedBy({
   )
 })
 
-const LikedByItem = ({item}: {item: VoteItem}) => {
-  const pal = usePalette('default')
-
-  return (
-    <Link
-      style={[styles.outer, pal.view]}
-      href={`/profile/${item.actor.handle}`}
-      title={item.actor.handle}
-      noFeedback>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar
-            size={40}
-            displayName={item.actor.displayName}
-            handle={item.actor.handle}
-            avatar={item.actor.avatar}
-          />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text style={[s.f15, s.bold, pal.text]}>
-            {item.actor.displayName || item.actor.handle}
-          </Text>
-          <Text style={[s.f14, s.gray5, pal.textLight]}>
-            @{item.actor.handle}
-          </Text>
-        </View>
-      </View>
-    </Link>
-  )
-}
-
 const styles = StyleSheet.create({
-  outer: {
-    marginTop: 1,
-  },
-  layout: {
-    flexDirection: 'row',
-  },
-  layoutAvi: {
-    width: 60,
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  avi: {
-    width: 40,
-    height: 40,
-    borderRadius: 20,
-    resizeMode: 'cover',
-  },
-  layoutContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
   footer: {
     height: 200,
     paddingTop: 20,
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index d00cc83c2..8e793ecc0 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {AtUri} from '../../../third-party/uri'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {PostThreadViewModel} from '../../../state/models/post-thread-view'
+import {PostThreadViewModel} from 'state/models/post-thread-view'
 import {Link} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
@@ -20,9 +20,9 @@ import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export const Post = observer(function Post({
   uri,
@@ -80,6 +80,8 @@ export const Post = observer(function Post({
   const item = view.thread
   const record = view.thread.postRecord
 
+  const itemUri = item.post.uri
+  const itemCid = item.post.cid
   const itemUrip = new AtUri(item.post.uri)
   const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
   const itemTitle = `Post by ${item.post.author.handle}`
@@ -105,12 +107,12 @@ export const Post = observer(function Post({
     })
   }
   const onPressToggleRepost = () => {
-    item
+    return item
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }
   const onPressToggleUpvote = () => {
-    item
+    return item
       .toggleUpvote()
       .catch(e => store.log.error('Failed to toggle upvote', e))
   }
@@ -178,18 +180,19 @@ export const Post = observer(function Post({
               <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
               <Text type="sm">This post is by a muted account.</Text>
             </View>
-          ) : record.text ? (
+          ) : item.richText?.text ? (
             <View style={styles.postTextContainer}>
               <RichText
                 type="post-text"
-                text={record.text}
-                entities={record.entities}
+                richText={item.richText}
                 lineHeight={1.3}
               />
             </View>
           ) : undefined}
           <PostEmbeds embed={item.post.embed} style={s.mb10} />
           <PostCtrls
+            itemUri={itemUri}
+            itemCid={itemCid}
             itemHref={itemHref}
             itemTitle={itemTitle}
             isAuthor={item.post.author.did === store.me.did}
diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx
index a3bcfed68..a460b57c4 100644
--- a/src/view/com/post/PostText.tsx
+++ b/src/view/com/post/PostText.tsx
@@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
-import {PostModel} from '../../../state/models/post'
-import {useStores} from '../../../state'
+import {PostModel} from 'state/models/post'
+import {useStores} from 'state/index'
 
 export const PostText = observer(function PostText({
   uri,
diff --git a/src/view/com/posts/ComposerPrompt.tsx b/src/view/com/posts/ComposerPrompt.tsx
index 1ddc28756..c367a17fc 100644
--- a/src/view/com/posts/ComposerPrompt.tsx
+++ b/src/view/com/posts/ComposerPrompt.tsx
@@ -1,47 +1,5 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Text} from '../util/text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
-
-export function ComposerPrompt({
-  onPressCompose,
-}: {
+export function ComposerPrompt(_opts: {
   onPressCompose: (imagesOpen?: boolean) => void
 }) {
-  const pal = usePalette('default')
-  return (
-    <View style={[pal.view, pal.border, styles.container]}>
-      <TouchableOpacity
-        testID="composePromptButton"
-        onPress={() => onPressCompose(false)}
-        style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
-        <Text type="button" style={pal.text}>
-          New post
-        </Text>
-      </TouchableOpacity>
-      <TouchableOpacity
-        onPress={() => onPressCompose(true)}
-        style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
-        <Text type="button" style={pal.text}>
-          Share photo
-        </Text>
-      </TouchableOpacity>
-    </View>
-  )
+  return null
 }
-
-const styles = StyleSheet.create({
-  container: {
-    paddingVertical: 12,
-    paddingHorizontal: 16,
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderTopWidth: 1,
-  },
-  btn: {
-    paddingVertical: 6,
-    paddingHorizontal: 14,
-    borderRadius: 30,
-    marginRight: 10,
-  },
-})
diff --git a/src/view/com/posts/ComposerPrompt.web.tsx b/src/view/com/posts/ComposerPrompt.web.tsx
index 96c09f0b3..a87653cf8 100644
--- a/src/view/com/posts/ComposerPrompt.web.tsx
+++ b/src/view/com/posts/ComposerPrompt.web.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
 import {Text} from '../util/text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {s} from '../../lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
 
 export function ComposerPrompt({
   onPressCompose,
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 409ce4af2..57363ca51 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {EmptyState} from '../util/EmptyState'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {FeedModel} from '../../../state/models/feed-view'
+import {FeedModel} from 'state/models/feed-view'
 import {FeedItem} from './FeedItem'
 import {ComposerPrompt} from './ComposerPrompt'
-import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
-import {s} from '../../lib/styles'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {s} from 'lib/styles'
+import {useAnalytics} from 'lib/analytics'
 
 const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
+const ERROR_FEED_ITEM = {_reactKey: '__error__'}
 
 export const Feed = observer(function Feed({
   feed,
   style,
   scrollElRef,
-  onPressCompose,
   onPressTryAgain,
+  onPressCompose,
   onScroll,
   testID,
+  headerOffset = 0,
 }: {
   feed: FeedModel
   style?: StyleProp<ViewStyle>
   scrollElRef?: MutableRefObject<FlatList<any> | null>
-  onPressCompose: (imagesOpen?: boolean) => void
   onPressTryAgain?: () => void
+  onPressCompose: (imagesOpen?: boolean) => void
   onScroll?: OnScrollCb
   testID?: string
+  headerOffset?: number
 }) {
+  const {track} = useAnalytics()
+  const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+  const data = React.useMemo(() => {
+    let feedItems: any[] = []
+    if (feed.hasLoaded) {
+      feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM])
+      if (feed.hasError) {
+        feedItems = feedItems.concat([ERROR_FEED_ITEM])
+      }
+      if (feed.isEmpty) {
+        feedItems = feedItems.concat([EMPTY_FEED_ITEM])
+      } else {
+        feedItems = feedItems.concat(feed.feed)
+      }
+    }
+    return feedItems
+  }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
+
+  // events
+  // =
+
+  const onRefresh = React.useCallback(async () => {
+    track('Feed:onRefresh')
+    setIsRefreshing(true)
+    try {
+      await feed.refresh()
+    } catch (err) {
+      feed.rootStore.log.error('Failed to refresh posts feed', err)
+    }
+    setIsRefreshing(false)
+  }, [feed, track, setIsRefreshing])
+  const onEndReached = React.useCallback(async () => {
+    track('Feed:onEndReached')
+    try {
+      await feed.loadMore()
+    } catch (err) {
+      feed.rootStore.log.error('Failed to load more posts', err)
+    }
+  }, [feed, track])
+
+  // rendering
+  // =
+
   // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
   //   VirtualizedList: You have a large list that is slow to update - make sure your
   //   renderItem function renders components that follow React performance best practices
   //   like PureComponent, shouldComponentUpdate, etc
-  const renderItem = ({item}: {item: any}) => {
-    if (item === COMPOSE_PROMPT_ITEM) {
-      return <ComposerPrompt onPressCompose={onPressCompose} />
-    } else if (item === EMPTY_FEED_ITEM) {
-      return (
-        <EmptyState
-          icon="bars"
-          message="This feed is empty!"
-          style={styles.emptyState}
-        />
-      )
-    } else {
-      return <FeedItem item={item} />
-    }
-  }
-  const onRefresh = () => {
-    feed
-      .refresh()
-      .catch(err =>
-        feed.rootStore.log.error('Failed to refresh posts feed', err),
-      )
-  }
-  const onEndReached = () => {
-    feed
-      .loadMore()
-      .catch(err => feed.rootStore.log.error('Failed to load more posts', err))
-  }
-  let data
-  if (feed.hasLoaded) {
-    if (feed.isEmpty) {
-      data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM]
-    } else {
-      data = [COMPOSE_PROMPT_ITEM].concat(feed.feed)
-    }
-  }
-  const FeedFooter = () =>
-    feed.isLoading ? (
-      <View style={styles.feedFooter}>
-        <ActivityIndicator />
-      </View>
-    ) : (
-      <View />
-    )
-  return (
-    <View testID={testID} style={style}>
-      <CenteredView>
-        {!data && <ComposerPrompt onPressCompose={onPressCompose} />}
-        {feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
-        {feed.hasError && (
+  const renderItem = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === COMPOSE_PROMPT_ITEM) {
+        return <ComposerPrompt onPressCompose={onPressCompose} />
+      } else if (item === EMPTY_FEED_ITEM) {
+        return (
+          <EmptyState
+            icon="bars"
+            message="This feed is empty!"
+            style={styles.emptyState}
+          />
+        )
+      } else if (item === ERROR_FEED_ITEM) {
+        return (
           <ErrorMessage
             message={feed.error}
             onPressTryAgain={onPressTryAgain}
           />
-        )}
-      </CenteredView>
-      {feed.hasLoaded && data && (
+        )
+      }
+      return <FeedItem item={item} />
+    },
+    [feed, onPressTryAgain, onPressCompose],
+  )
+
+  const FeedFooter = React.useCallback(
+    () =>
+      feed.isLoading ? (
+        <View style={styles.feedFooter}>
+          <ActivityIndicator />
+        </View>
+      ) : (
+        <View />
+      ),
+    [feed],
+  )
+
+  return (
+    <View testID={testID} style={style}>
+      {feed.isLoading && data.length === 0 && (
+        <CenteredView style={{paddingTop: headerOffset}}>
+          <PostFeedLoadingPlaceholder />
+        </CenteredView>
+      )}
+      {data.length > 0 && (
         <FlatList
           ref={scrollElRef}
           data={data}
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
           ListFooterComponent={FeedFooter}
-          refreshing={feed.isRefreshing}
+          refreshing={isRefreshing}
           contentContainerStyle={s.contentContainer}
           onScroll={onScroll}
           onRefresh={onRefresh}
           onEndReached={onEndReached}
+          removeClippedSubviews={true}
+          contentInset={{top: headerOffset}}
+          contentOffset={{x: 0, y: headerOffset * -1}}
+          progressViewOffset={headerOffset}
         />
       )}
     </View>
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index cda2ac0b0..67807b14e 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,7 +8,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {FeedItemModel} from '../../../state/models/feed-view'
+import {FeedItemModel} from 'state/models/feed-view'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
@@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
-import {s} from '../../lib/styles'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {s} from 'lib/styles'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
 
 export const FeedItem = observer(function ({
   item,
@@ -33,8 +34,11 @@ export const FeedItem = observer(function ({
 }) {
   const store = useStores()
   const pal = usePalette('default')
+  const {track} = useAnalytics()
   const [deleted, setDeleted] = useState(false)
   const record = item.postRecord
+  const itemUri = item.post.uri
+  const itemCid = item.post.cid
   const itemHref = useMemo(() => {
     const urip = new AtUri(item.post.uri)
     return `/profile/${item.post.author.handle}/post/${urip.rkey}`
@@ -50,6 +54,7 @@ export const FeedItem = observer(function ({
   }, [record?.reply])
 
   const onPressReply = () => {
+    track('FeedItem:PostReply')
     store.shell.openComposer({
       replyTo: {
         uri: item.post.uri,
@@ -64,12 +69,14 @@ export const FeedItem = observer(function ({
     })
   }
   const onPressToggleRepost = () => {
-    item
+    track('FeedItem:PostRepost')
+    return item
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }
   const onPressToggleUpvote = () => {
-    item
+    track('FeedItem:PostLike')
+    return item
       .toggleUpvote()
       .catch(e => store.log.error('Failed to toggle upvote', e))
   }
@@ -78,6 +85,7 @@ export const FeedItem = observer(function ({
     Toast.show('Copied to clipboard')
   }
   const onDeletePost = () => {
+    track('FeedItem:PostDelete')
     item.delete().then(
       () => {
         setDeleted(true)
@@ -195,12 +203,11 @@ export const FeedItem = observer(function ({
                 <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
                 <Text type="sm">This post is by a muted account.</Text>
               </View>
-            ) : record.text ? (
+            ) : item.richText?.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
                   type="post-text"
-                  text={record.text}
-                  entities={record.entities}
+                  richText={item.richText}
                   lineHeight={1.3}
                 />
               </View>
@@ -210,6 +217,8 @@ export const FeedItem = observer(function ({
             ) : null}
             <PostCtrls
               style={styles.ctrls}
+              itemUri={itemUri}
+              itemCid={itemCid}
               itemHref={itemHref}
               itemTitle={itemTitle}
               isAuthor={item.post.author.did === store.me.did}
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 0cda3ba2a..2f93e59e6 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,23 +1,29 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
-import {s} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+import * as Toast from '../util/Toast'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
 
 export function ProfileCard({
   handle,
   displayName,
   avatar,
+  description,
+  isFollowedBy,
   renderButton,
-  onPressButton,
 }: {
   handle: string
   displayName?: string
   avatar?: string
+  description?: string
+  isFollowedBy?: boolean
   renderButton?: () => JSX.Element
-  onPressButton?: () => void
 }) {
   const pal = usePalette('default')
   return (
@@ -36,30 +42,117 @@ export function ProfileCard({
           />
         </View>
         <View style={styles.layoutContent}>
-          <Text style={[s.bold, pal.text]} numberOfLines={1}>
+          <Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}>
             {displayName || handle}
           </Text>
-          <Text type="sm" style={[pal.textLight]} numberOfLines={1}>
+          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
             @{handle}
           </Text>
+          {isFollowedBy && (
+            <View style={s.flexRow}>
+              <View style={[s.mt5, pal.btn, styles.pill]}>
+                <Text type="xs">Follows You</Text>
+              </View>
+            </View>
+          )}
         </View>
         {renderButton ? (
-          <View style={styles.layoutButton}>
-            <TouchableOpacity
-              onPress={onPressButton}
-              style={[styles.btn, pal.btn]}>
-              {renderButton()}
-            </TouchableOpacity>
-          </View>
+          <View style={styles.layoutButton}>{renderButton()}</View>
         ) : undefined}
       </View>
+      {description ? (
+        <View style={styles.details}>
+          <Text style={pal.text} numberOfLines={4}>
+            {description}
+          </Text>
+        </View>
+      ) : undefined}
     </Link>
   )
 }
 
+export const ProfileCardWithFollowBtn = observer(
+  ({
+    did,
+    declarationCid,
+    handle,
+    displayName,
+    avatar,
+    description,
+    isFollowedBy,
+  }: {
+    did: string
+    declarationCid: string
+    handle: string
+    displayName?: string
+    avatar?: string
+    description?: string
+    isFollowedBy?: boolean
+  }) => {
+    const store = useStores()
+    const isMe = store.me.handle === handle
+    const isFollowing = store.me.follows.isFollowing(did)
+    const onToggleFollow = async () => {
+      if (store.me.follows.isFollowing(did)) {
+        try {
+          await apilib.unfollow(store, store.me.follows.getFollowUri(did))
+          store.me.follows.removeFollow(did)
+        } catch (e: any) {
+          store.log.error('Failed fo delete follow', e)
+          Toast.show('An issue occurred, please try again.')
+        }
+      } else {
+        try {
+          const res = await apilib.follow(store, did, declarationCid)
+          store.me.follows.addFollow(did, res.uri)
+        } catch (e: any) {
+          store.log.error('Failed fo create follow', e)
+          Toast.show('An issue occurred, please try again.')
+        }
+      }
+    }
+    return (
+      <ProfileCard
+        handle={handle}
+        displayName={displayName}
+        avatar={avatar}
+        description={description}
+        isFollowedBy={isFollowedBy}
+        renderButton={
+          isMe
+            ? undefined
+            : () => (
+                <FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
+              )
+        }
+      />
+    )
+  },
+)
+
+function FollowBtn({
+  isFollowing,
+  onPress,
+}: {
+  isFollowing: boolean
+  onPress: () => void
+}) {
+  const pal = usePalette('default')
+  return (
+    <TouchableOpacity onPress={onPress}>
+      <View style={[styles.btn, pal.btn]}>
+        <Text type="button" style={[pal.text]}>
+          {isFollowing ? 'Unfollow' : 'Follow'}
+        </Text>
+      </View>
+    </TouchableOpacity>
+  )
+}
+
 const styles = StyleSheet.create({
   outer: {
     borderTopWidth: 1,
+    paddingHorizontal: 6,
   },
   layout: {
     flexDirection: 'row',
@@ -68,7 +161,7 @@ const styles = StyleSheet.create({
   layoutAvi: {
     width: 60,
     paddingLeft: 10,
-    paddingTop: 10,
+    paddingTop: 8,
     paddingBottom: 10,
   },
   avi: {
@@ -80,19 +173,26 @@ const styles = StyleSheet.create({
   layoutContent: {
     flex: 1,
     paddingRight: 10,
-    paddingTop: 12,
+    paddingTop: 10,
     paddingBottom: 10,
   },
   layoutButton: {
     paddingRight: 10,
   },
+  details: {
+    paddingLeft: 60,
+    paddingRight: 10,
+    paddingBottom: 10,
+  },
+  pill: {
+    borderRadius: 4,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+  },
   btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
     paddingVertical: 7,
-    paddingHorizontal: 14,
     borderRadius: 50,
     marginLeft: 6,
+    paddingHorizontal: 14,
   },
 })
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 13d134c39..7db770e4b 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {
   UserFollowersViewModel,
   FollowerItem,
-} from '../../../state/models/user-followers-view'
+} from 'state/models/user-followers-view'
 import {CenteredView, FlatList} from '../util/Views'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from '../../../state'
-import {s} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ProfileCardWithFollowBtn} from './ProfileCard'
+import {useStores} from 'state/index'
 
 export const ProfileFollowers = observer(function ProfileFollowers({
   name,
@@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({
   // loaded
   // =
   const renderItem = ({item}: {item: FollowerItem}) => (
-    <User key={item.did} item={item} />
+    <ProfileCardWithFollowBtn
+      key={item.did}
+      did={item.did}
+      declarationCid={item.declaration.cid}
+      handle={item.handle}
+      displayName={item.displayName}
+      avatar={item.avatar}
+      isFollowedBy={!!item.viewer?.followedBy}
+    />
   )
   return (
     <FlatList
@@ -84,55 +88,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
   )
 })
 
-const User = ({item}: {item: FollowerItem}) => {
-  const pal = usePalette('default')
-  return (
-    <Link
-      style={[styles.outer, pal.view, pal.border]}
-      href={`/profile/${item.handle}`}
-      title={item.handle}
-      noFeedback>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar
-            size={40}
-            displayName={item.displayName}
-            handle={item.handle}
-            avatar={item.avatar}
-          />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text style={[s.bold, pal.text]}>
-            {item.displayName || item.handle}
-          </Text>
-          <Text type="sm" style={[pal.textLight]}>
-            @{item.handle}
-          </Text>
-        </View>
-      </View>
-    </Link>
-  )
-}
-
 const styles = StyleSheet.create({
-  outer: {
-    borderTopWidth: 1,
-  },
-  layout: {
-    flexDirection: 'row',
-  },
-  layoutAvi: {
-    width: 60,
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  layoutContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
   footer: {
     height: 200,
     paddingTop: 20,
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index de2fe3324..fb7f08ed8 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -2,17 +2,10 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
-import {
-  UserFollowsViewModel,
-  FollowItem,
-} from '../../../state/models/user-follows-view'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
+import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from '../../../state'
-import {s} from '../../lib/styles'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ProfileCardWithFollowBtn} from './ProfileCard'
+import {useStores} from 'state/index'
 
 export const ProfileFollows = observer(function ProfileFollows({
   name,
@@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({
   // loaded
   // =
   const renderItem = ({item}: {item: FollowItem}) => (
-    <User key={item.did} item={item} />
+    <ProfileCardWithFollowBtn
+      key={item.did}
+      did={item.did}
+      declarationCid={item.declaration.cid}
+      handle={item.handle}
+      displayName={item.displayName}
+      avatar={item.avatar}
+      isFollowedBy={!!item.viewer?.followedBy}
+    />
   )
   return (
     <FlatList
@@ -84,59 +85,7 @@ export const ProfileFollows = observer(function ProfileFollows({
   )
 })
 
-const User = ({item}: {item: FollowItem}) => {
-  const pal = usePalette('default')
-  return (
-    <Link
-      style={[styles.outer, pal.view, pal.border]}
-      href={`/profile/${item.handle}`}
-      title={item.handle}
-      noFeedback>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar
-            size={40}
-            displayName={item.displayName}
-            handle={item.handle}
-            avatar={
-              item.avatar as
-                | string
-                | undefined /* HACK: type signature is wrong in the api */
-            }
-          />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text style={[s.bold, pal.text]}>
-            {item.displayName || item.handle}
-          </Text>
-          <Text type="sm" style={[pal.textLight]}>
-            @{item.handle}
-          </Text>
-        </View>
-      </View>
-    </Link>
-  )
-}
-
 const styles = StyleSheet.create({
-  outer: {
-    borderTopWidth: 1,
-  },
-  layout: {
-    flexDirection: 'row',
-  },
-  layoutAvi: {
-    width: 60,
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  layoutContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
   footer: {
     height: 200,
     paddingTop: 20,
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index cebeea788..0ca6e1e74 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -13,15 +13,16 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {BlurView} from '../util/BlurView'
-import {ProfileViewModel} from '../../../state/models/profile-view'
-import {useStores} from '../../../state'
+import {ProfileViewModel} from 'state/models/profile-view'
+import {useStores} from 'state/index'
 import {
   EditProfileModal,
   ReportAccountModal,
   ProfileImageLightbox,
-} from '../../../state/models/shell-ui'
-import {pluralize, toShareUrl} from '../../../lib/strings'
-import {s, gradients} from '../../lib/styles'
+} from 'state/models/shell-ui'
+import {pluralize} from 'lib/strings/helpers'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {s, gradients} from 'lib/styles'
 import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -29,7 +30,8 @@ import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
 
 export const ProfileHeader = observer(function ProfileHeader({
   view,
@@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({
 }) {
   const pal = usePalette('default')
   const store = useStores()
-
+  const {track} = useAnalytics()
   const onPressBack = () => {
     store.nav.tab.goBack()
   }
@@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({
     view?.toggleFollowing().then(
       () => {
         Toast.show(
-          `${view.myState.follow ? 'Following' : 'No longer following'} ${
+          `${view.viewer.following ? 'Following' : 'No longer following'} ${
             view.displayName || view.handle
           }`,
         )
@@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({
     )
   }
   const onPressEditProfile = () => {
+    track('ProfileHeader:EditProfileButtonClicked')
     store.shell.openModal(new EditProfileModal(view, onRefreshAll))
   }
   const onPressFollowers = () => {
+    track('ProfileHeader:FollowersButtonClicked')
     store.nav.navigate(`/profile/${view.handle}/followers`)
   }
   const onPressFollows = () => {
+    track('ProfileHeader:FollowsButtonClicked')
     store.nav.navigate(`/profile/${view.handle}/follows`)
   }
   const onPressShare = () => {
+    track('ProfileHeader:ShareButtonClicked')
     Share.share({url: toShareUrl(`/profile/${view.handle}`)})
   }
   const onPressMuteAccount = async () => {
+    track('ProfileHeader:MuteAccountButtonClicked')
     try {
       await view.muteAccount()
       Toast.show('Account muted')
@@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({
     }
   }
   const onPressUnmuteAccount = async () => {
+    track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
       await view.unmuteAccount()
       Toast.show('Account unmuted')
@@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({
     }
   }
   const onPressReportAccount = () => {
+    track('ProfileHeader:ReportAccountButtonClicked')
     store.shell.openModal(new ReportAccountModal(view.did))
   }
 
@@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({
             <LoadingPlaceholder width={100} height={31} style={styles.br50} />
           </View>
           <View style={styles.displayNameLine}>
-            <Text type="title-xl" style={[pal.text, styles.title]}>
+            <Text type="title-2xl" style={[pal.text, styles.title]}>
               {view.displayName || view.handle}
             </Text>
           </View>
@@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({
   let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}]
   if (!isMe) {
     dropdownItems.push({
-      label: view.myState.muted ? 'Unmute Account' : 'Mute Account',
-      onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount,
+      label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
+      onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
     })
     dropdownItems.push({
       label: 'Report Account',
@@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({
             </TouchableOpacity>
           ) : (
             <>
-              {view.myState.follow ? (
+              {store.me.follows.isFollowing(view.did) ? (
                 <TouchableOpacity
                   onPress={onPressToggleFollow}
                   style={[styles.btn, styles.mainBtn, pal.btn]}>
@@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({
           ) : undefined}
         </View>
         <View style={styles.displayNameLine}>
-          <Text type="title-xl" style={[pal.text, styles.title]}>
+          <Text type="title-2xl" style={[pal.text, styles.title]}>
             {view.displayName || view.handle}
           </Text>
         </View>
         <View style={styles.handleLine}>
+          {view.viewer.followedBy ? (
+            <View style={[styles.pill, pal.btn, s.mr5]}>
+              <Text type="xs" style={[pal.text]}>
+                Follows you
+              </Text>
+            </View>
+          ) : undefined}
           <Text style={pal.textLight}>@{view.handle}</Text>
         </View>
         <View style={styles.metricsLine}>
@@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({
             </Text>
           </View>
         </View>
-        {view.description ? (
+        {view.descriptionRichText ? (
           <RichText
             style={[styles.description, pal.text]}
             numberOfLines={15}
-            text={view.description}
-            entities={view.descriptionEntities}
+            richText={view.descriptionRichText}
           />
         ) : undefined}
-        {view.myState.muted ? (
+        {view.viewer.muted ? (
           <View style={[styles.detailLine, pal.btn, s.p5]}>
             <FontAwesomeIcon
               icon={['far', 'eye-slash']}
               style={[pal.text, s.mr5]}
             />
             <Text type="md" style={[s.mr2, pal.text]}>
-              Account muted.
+              Account muted
             </Text>
           </View>
         ) : undefined}
@@ -369,6 +384,12 @@ const styles = StyleSheet.create({
     marginBottom: 5,
   },
 
+  pill: {
+    borderRadius: 4,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+  },
+
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
 })
diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx
index 7e4300c7c..efcf40b9c 100644
--- a/src/view/com/util/BlurView.web.tsx
+++ b/src/view/com/util/BlurView.web.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleSheet, View, ViewProps} from 'react-native'
-import {addStyle} from '../../lib/addStyle'
+import {addStyle} from 'lib/styles'
 
 type BlurViewProps = ViewProps & {
   blurType?: 'dark' | 'light'
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index 6c5c3f342..2b2c4e657 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -6,8 +6,8 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {Text} from './text/Text'
-import {UserGroupIcon} from '../../lib/icons'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {UserGroupIcon} from 'lib/icons'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function EmptyState({
   icon,
diff --git a/src/view/com/util/FAB.tsx b/src/view/com/util/FAB.tsx
index 1129f3361..007ca0ee4 100644
--- a/src/view/com/util/FAB.tsx
+++ b/src/view/com/util/FAB.tsx
@@ -1,41 +1,53 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {
+  Animated,
   GestureResponderEvent,
   StyleSheet,
   TouchableWithoutFeedback,
-  View,
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
-import {colors, gradients} from '../../lib/styles'
-import {useStores} from '../../../state'
+import {colors, gradients} from 'lib/styles'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {useStores} from 'state/index'
 
 type OnPress = ((event: GestureResponderEvent) => void) | undefined
 export const FAB = observer(
-  ({icon, onPress}: {icon: IconProp; onPress: OnPress}) => {
+  ({
+    testID,
+    icon,
+    onPress,
+  }: {
+    testID?: string
+    icon: IconProp
+    onPress: OnPress
+  }) => {
     const store = useStores()
+    const interp = useAnimatedValue(0)
+    React.useEffect(() => {
+      Animated.timing(interp, {
+        toValue: store.shell.minimalShellMode ? 1 : 0,
+        duration: 100,
+        useNativeDriver: true,
+        isInteraction: false,
+      }).start()
+    }, [interp, store.shell.minimalShellMode])
+    const transform = {
+      transform: [{translateY: Animated.multiply(interp, 60)}],
+    }
     return (
-      <TouchableWithoutFeedback onPress={onPress}>
-        <View
-          style={[
-            styles.outer,
-            store.shell.minimalShellMode ? styles.lower : undefined,
-          ]}>
+      <TouchableWithoutFeedback testID={testID} onPress={onPress}>
+        <Animated.View style={[styles.outer, transform]}>
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
             start={{x: 0, y: 0}}
             end={{x: 1, y: 1}}
             style={styles.inner}>
-            <FontAwesomeIcon
-              size={24}
-              icon={icon}
-              color={colors.white}
-              style={styles.icon}
-            />
+            <FontAwesomeIcon size={24} icon={icon} color={colors.white} />
           </LinearGradient>
-        </View>
+        </Animated.View>
       </TouchableWithoutFeedback>
     )
   },
@@ -46,16 +58,10 @@ const styles = StyleSheet.create({
     position: 'absolute',
     zIndex: 1,
     right: 22,
-    bottom: 84,
+    bottom: 94,
     width: 60,
     height: 60,
     borderRadius: 30,
-    shadowColor: '#000',
-    shadowOpacity: 0.3,
-    shadowOffset: {width: 0, height: 1},
-  },
-  lower: {
-    bottom: 34,
   },
   inner: {
     width: 60,
@@ -64,5 +70,4 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     alignItems: 'center',
   },
-  icon: {},
 })
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 1cbb1af83..bdc447937 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -10,9 +10,9 @@ import {
   ViewStyle,
 } from 'react-native'
 import {Text} from './text/Text'
-import {TypographyVariant} from '../../lib/ThemeContext'
-import {useStores, RootStoreModel} from '../../../state'
-import {convertBskyAppUrlIfNeeded} from '../../../lib/strings'
+import {TypographyVariant} from 'lib/ThemeContext'
+import {useStores, RootStoreModel} from 'state/index'
+import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
 
 export const Link = observer(function Link({
   style,
@@ -22,17 +22,21 @@ export const Link = observer(function Link({
   noFeedback,
 }: {
   style?: StyleProp<ViewStyle>
-  href: string
+  href?: string
   title?: string
   children?: React.ReactNode
   noFeedback?: boolean
 }) {
   const store = useStores()
   const onPress = () => {
-    handleLink(store, href, false)
+    if (href) {
+      handleLink(store, href, false)
+    }
   }
   const onLongPress = () => {
-    handleLink(store, href, true)
+    if (href) {
+      handleLink(store, href, true)
+    }
   }
   if (noFeedback) {
     return (
diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx
index 43fa97e6f..fd05ecc9c 100644
--- a/src/view/com/util/LoadLatestBtn.tsx
+++ b/src/view/com/util/LoadLatestBtn.tsx
@@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite'
 import LinearGradient from 'react-native-linear-gradient'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {Text} from './text/Text'
-import {colors, gradients} from '../../lib/styles'
+import {colors, gradients} from 'lib/styles'
 import {clamp} from 'lodash'
-import {useStores} from '../../../state'
+import {useStores} from 'state/index'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx
index 388927388..182c1ba5d 100644
--- a/src/view/com/util/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/LoadLatestBtn.web.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity} from 'react-native'
 import {Text} from './text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 9bb200d50..9e72640d2 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {HeartIcon} from '../../lib/icons'
-import {s} from '../../lib/styles'
-import {useTheme} from '../../lib/ThemeContext'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {HeartIcon} from 'lib/icons'
+import {s} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function LoadingPlaceholder({
   width,
diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx
index 208ec0492..9007cb1f0 100644
--- a/src/view/com/util/Picker.tsx
+++ b/src/view/com/util/Picker.tsx
@@ -16,7 +16,7 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import RootSiblings from 'react-native-root-siblings'
 import {Text} from './text/Text'
-import {colors} from '../../lib/styles'
+import {colors} from 'lib/styles'
 
 interface PickerItem {
   value: string
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index fca70b687..e42c5e63b 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import {
-  Animated,
   StyleProp,
   StyleSheet,
   TouchableOpacity,
@@ -12,6 +11,11 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import ReactNativeHapticFeedback from 'react-native-haptic-feedback'
+// DISABLED see #135
+// import {
+//   TriggerableAnimated,
+//   TriggerableAnimatedRef,
+// } from './anim/TriggerableAnimated'
 import {Text} from './text/Text'
 import {PostDropdownBtn} from './forms/DropdownButton'
 import {
@@ -19,12 +23,13 @@ import {
   HeartIconSolid,
   RepostIcon,
   CommentBottomArrow,
-} from '../../lib/icons'
-import {s, colors} from '../../lib/styles'
-import {useTheme} from '../../lib/ThemeContext'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
+} from 'lib/icons'
+import {s, colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
 
 interface PostCtrlsOpts {
+  itemUri: string
+  itemCid: string
   itemHref: string
   itemTitle: string
   isAuthor: boolean
@@ -36,91 +41,110 @@ interface PostCtrlsOpts {
   isReposted: boolean
   isUpvoted: boolean
   onPressReply: () => void
-  onPressToggleRepost: () => void
-  onPressToggleUpvote: () => void
+  onPressToggleRepost: () => Promise<void>
+  onPressToggleUpvote: () => Promise<void>
   onCopyPostText: () => void
   onDeletePost: () => void
 }
 
-const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2}
+const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
 
-export function PostCtrls(opts: PostCtrlsOpts) {
-  const theme = useTheme()
-  const defaultCtrlColor = React.useMemo(
-    () => ({
-      color: theme.palette.default.postCtrl,
+// DISABLED see #135
+/*
+function ctrlAnimStart(interp: Animated.Value) {
+  return Animated.sequence([
+    Animated.timing(interp, {
+      toValue: 1,
+      duration: 250,
+      useNativeDriver: true,
     }),
-    [theme],
-  )
-  const interp1 = useAnimatedValue(0)
-  const interp2 = useAnimatedValue(0)
-
-  const anim1Style = {
-    transform: [
-      {
-        scale: interp1.interpolate({
-          inputRange: [0, 1.0],
-          outputRange: [1.0, 4.0],
-        }),
-      },
-    ],
-    opacity: interp1.interpolate({
-      inputRange: [0, 1.0],
-      outputRange: [1.0, 0.0],
+    Animated.delay(50),
+    Animated.timing(interp, {
+      toValue: 0,
+      duration: 20,
+      useNativeDriver: true,
     }),
-  }
-  const anim2Style = {
+  ])
+}
+
+function ctrlAnimStyle(interp: Animated.Value) {
+  return {
     transform: [
       {
-        scale: interp2.interpolate({
+        scale: interp.interpolate({
           inputRange: [0, 1.0],
           outputRange: [1.0, 4.0],
         }),
       },
     ],
-    opacity: interp2.interpolate({
+    opacity: interp.interpolate({
       inputRange: [0, 1.0],
       outputRange: [1.0, 0.0],
     }),
   }
+}
+*/
 
+export function PostCtrls(opts: PostCtrlsOpts) {
+  const theme = useTheme()
+  const defaultCtrlColor = React.useMemo(
+    () => ({
+      color: theme.palette.default.postCtrl,
+    }),
+    [theme],
+  ) as StyleProp<ViewStyle>
+  const [repostMod, setRepostMod] = React.useState<number>(0)
+  const [likeMod, setLikeMod] = React.useState<number>(0)
+  // DISABLED see #135
+  // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
+  // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
   const onPressToggleRepostWrapper = () => {
     if (!opts.isReposted) {
       ReactNativeHapticFeedback.trigger('impactMedium')
-      Animated.sequence([
-        Animated.timing(interp1, {
-          toValue: 1,
-          duration: 400,
-          useNativeDriver: true,
-        }),
-        Animated.delay(100),
-        Animated.timing(interp1, {
-          toValue: 0,
-          duration: 20,
-          useNativeDriver: true,
-        }),
-      ]).start()
+      setRepostMod(1)
+      opts
+        .onPressToggleRepost()
+        .catch(_e => undefined)
+        .then(() => setRepostMod(0))
+      // DISABLED see #135
+      // repostRef.current?.trigger(
+      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
+      //   async () => {
+      //     await opts.onPressToggleRepost().catch(_e => undefined)
+      //     setRepostMod(0)
+      //   },
+      // )
+    } else {
+      setRepostMod(-1)
+      opts
+        .onPressToggleRepost()
+        .catch(_e => undefined)
+        .then(() => setRepostMod(0))
     }
-    opts.onPressToggleRepost()
   }
   const onPressToggleUpvoteWrapper = () => {
     if (!opts.isUpvoted) {
       ReactNativeHapticFeedback.trigger('impactMedium')
-      Animated.sequence([
-        Animated.timing(interp2, {
-          toValue: 1,
-          duration: 400,
-          useNativeDriver: true,
-        }),
-        Animated.delay(100),
-        Animated.timing(interp2, {
-          toValue: 0,
-          duration: 20,
-          useNativeDriver: true,
-        }),
-      ]).start()
+      setLikeMod(1)
+      opts
+        .onPressToggleUpvote()
+        .catch(_e => undefined)
+        .then(() => setLikeMod(0))
+      // DISABLED see #135
+      // likeRef.current?.trigger(
+      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
+      //   async () => {
+      //     await opts.onPressToggleUpvote().catch(_e => undefined)
+      //     setLikeMod(0)
+      //   },
+      // )
+    } else {
+      setLikeMod(-1)
+      opts
+        .onPressToggleUpvote()
+        .catch(_e => undefined)
+        .then(() => setLikeMod(0))
     }
-    opts.onPressToggleUpvote()
   }
 
   return (
@@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           hitSlop={HITSLOP}
           onPress={onPressToggleRepostWrapper}
           style={styles.ctrl}>
-          <Animated.View style={anim1Style}>
+          <RepostIcon
+            style={
+              opts.isReposted || repostMod > 0
+                ? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
+                : defaultCtrlColor
+            }
+            strokeWidth={2.4}
+            size={opts.big ? 24 : 20}
+          />
+          {
+            undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}>
             <RepostIcon
               style={
                 (opts.isReposted
@@ -157,15 +191,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
               strokeWidth={2.4}
               size={opts.big ? 24 : 20}
             />
-          </Animated.View>
+            </TriggerableAnimated>*/
+          }
           {typeof opts.repostCount !== 'undefined' ? (
             <Text
               style={
-                opts.isReposted
+                opts.isReposted || repostMod > 0
                   ? [s.bold, s.green3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.repostCount}
+              {opts.repostCount + repostMod}
             </Text>
           ) : undefined}
         </TouchableOpacity>
@@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           style={styles.ctrl}
           hitSlop={HITSLOP}
           onPress={onPressToggleUpvoteWrapper}>
-          <Animated.View style={anim2Style}>
-            {opts.isUpvoted ? (
+          {opts.isUpvoted || likeMod > 0 ? (
+            <HeartIconSolid
+              style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>}
+              size={opts.big ? 22 : 16}
+            />
+          ) : (
+            <HeartIcon
+              style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
+              strokeWidth={3}
+              size={opts.big ? 20 : 16}
+            />
+          )}
+          {
+            undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
+            {opts.isUpvoted || likeMod > 0 ? (
               <HeartIconSolid
                 style={styles.ctrlIconUpvoted as ViewStyle}
                 size={opts.big ? 22 : 16}
@@ -191,15 +239,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
                 size={opts.big ? 20 : 16}
               />
             )}
-          </Animated.View>
+            </TriggerableAnimated>*/
+          }
           {typeof opts.upvoteCount !== 'undefined' ? (
             <Text
               style={
-                opts.isUpvoted
+                opts.isUpvoted || likeMod > 0
                   ? [s.bold, s.red3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.upvoteCount}
+              {opts.upvoteCount + likeMod}
             </Text>
           ) : undefined}
         </TouchableOpacity>
@@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         {opts.big ? undefined : (
           <PostDropdownBtn
             style={styles.ctrl}
+            itemUri={opts.itemUri}
+            itemCid={opts.itemCid}
             itemHref={opts.itemHref}
             itemTitle={opts.itemTitle}
             isAuthor={opts.isAuthor}
diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
new file mode 100644
index 000000000..e8c63bdb7
--- /dev/null
+++ b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+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'
+
+const ExternalLinkEmbed = ({
+  link,
+  onImagePress,
+  imageChild,
+}: {
+  link: PresentedExternal
+  onImagePress?: () => void
+  imageChild?: React.ReactNode
+}) => {
+  const pal = usePalette('default')
+  return (
+    <>
+      {link.thumb ? (
+        <AutoSizedImage
+          uri={link.thumb}
+          style={styles.extImage}
+          onPress={onImagePress}>
+          {imageChild}
+        </AutoSizedImage>
+      ) : undefined}
+      <View style={styles.extInner}>
+        <Text type="md-bold" numberOfLines={2} style={[pal.text]}>
+          {link.title || link.uri}
+        </Text>
+        <Text
+          type="sm"
+          numberOfLines={1}
+          style={[pal.textLight, styles.extUri]}>
+          {link.uri}
+        </Text>
+        {link.description ? (
+          <Text
+            type="sm"
+            numberOfLines={2}
+            style={[pal.text, styles.extDescription]}>
+            {link.description}
+          </Text>
+        ) : undefined}
+      </View>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  extInner: {
+    padding: 10,
+  },
+  extImage: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+    width: '100%',
+    maxHeight: 200,
+  },
+  extUri: {
+    marginTop: 2,
+  },
+  extDescription: {
+    marginTop: 4,
+  },
+})
+
+export default ExternalLinkEmbed
diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
new file mode 100644
index 000000000..d9425fe4e
--- /dev/null
+++ b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
@@ -0,0 +1,119 @@
+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/PostEmbeds.tsx b/src/view/com/util/PostEmbeds/index.tsx
index 1d8df038b..031f01e88 100644
--- a/src/view/com/util/PostEmbeds.tsx
+++ b/src/view/com/util/PostEmbeds/index.tsx
@@ -1,16 +1,22 @@
 import React from 'react'
-import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
+import {
+  StyleSheet,
+  StyleProp,
+  View,
+  ViewStyle,
+  Image as RNImage,
+} from 'react-native'
 import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
-import LinearGradient from 'react-native-linear-gradient'
-import {Link} from '../util/Link'
-import {Text} from './text/Text'
-import {AutoSizedImage} from './images/AutoSizedImage'
-import {ImageLayoutGrid} from './images/ImageLayoutGrid'
-import {ImagesLightbox} from '../../../state/models/shell-ui'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {gradients} from '../../lib/styles'
-import {saveImageModal} from '../../../lib/images'
+import {Link} from '../Link'
+import {AutoSizedImage} from '../images/AutoSizedImage'
+import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
+import {ImagesLightbox} from 'state/models/shell-ui'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {saveImageModal} from 'lib/images'
+import YoutubeEmbed from './YoutubeEmbed'
+import ExternalLinkEmbed from './ExternalLinkEmbed'
+import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 
 type Embed =
   | AppBskyEmbedImages.Presented
@@ -35,6 +41,16 @@ export function PostEmbeds({
       const onLongPress = (index: number) => {
         saveImageModal({uri: uris[index]})
       }
+      const onPressIn = (index: number) => {
+        const firstImageToShow = uris[index]
+        RNImage.prefetch(firstImageToShow)
+        uris.forEach(uri => {
+          if (firstImageToShow !== uri) {
+            // First image already prefeched above
+            RNImage.prefetch(uri)
+          }
+        })
+      }
 
       if (embed.images.length === 4) {
         return (
@@ -44,6 +60,7 @@ export function PostEmbeds({
               uris={embed.images.map(img => img.thumb)}
               onPress={openLightbox}
               onLongPress={onLongPress}
+              onPressIn={onPressIn}
             />
           </View>
         )
@@ -55,6 +72,7 @@ export function PostEmbeds({
               uris={embed.images.map(img => img.thumb)}
               onPress={openLightbox}
               onLongPress={onLongPress}
+              onPressIn={onPressIn}
             />
           </View>
         )
@@ -66,6 +84,7 @@ export function PostEmbeds({
               uris={embed.images.map(img => img.thumb)}
               onPress={openLightbox}
               onLongPress={onLongPress}
+              onPressIn={onPressIn}
             />
           </View>
         )
@@ -76,7 +95,8 @@ export function PostEmbeds({
               uri={embed.images[0].thumb}
               onPress={() => openLightbox(0)}
               onLongPress={() => onLongPress(0)}
-              containerStyle={styles.singleImage}
+              onPressIn={() => onPressIn(0)}
+              style={styles.singleImage}
             />
           </View>
         )
@@ -85,40 +105,18 @@ export function PostEmbeds({
   }
   if (AppBskyEmbedExternal.isPresented(embed)) {
     const link = embed.external
+    const youtubeVideoId = getYoutubeVideoId(link.uri)
+
+    if (youtubeVideoId) {
+      return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
+    }
+
     return (
       <Link
         style={[styles.extOuter, pal.view, pal.border, style]}
         href={link.uri}
         noFeedback>
-        {link.thumb ? (
-          <AutoSizedImage uri={link.thumb} containerStyle={styles.extImage} />
-        ) : (
-          <LinearGradient
-            colors={[gradients.blueDark.start, gradients.blueDark.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.extImage, styles.extImageFallback]}
-          />
-        )}
-        <View style={styles.extInner}>
-          <Text type="md-bold" numberOfLines={2} style={[pal.text]}>
-            {link.title || link.uri}
-          </Text>
-          <Text
-            type="sm"
-            numberOfLines={1}
-            style={[pal.textLight, styles.extUri]}>
-            {link.uri}
-          </Text>
-          {link.description ? (
-            <Text
-              type="sm"
-              numberOfLines={2}
-              style={[pal.text, styles.extDescription]}>
-              {link.description}
-            </Text>
-          ) : undefined}
-        </View>
+        <ExternalLinkEmbed link={link} />
       </Link>
     )
   }
@@ -131,28 +129,11 @@ const styles = StyleSheet.create({
   },
   singleImage: {
     borderRadius: 8,
+    maxHeight: 500,
   },
   extOuter: {
     borderWidth: 1,
     borderRadius: 8,
     marginTop: 4,
   },
-  extInner: {
-    padding: 10,
-  },
-  extImage: {
-    borderTopLeftRadius: 6,
-    borderTopRightRadius: 6,
-    width: '100%',
-    maxHeight: 200,
-  },
-  extImageFallback: {
-    height: 160,
-  },
-  extUri: {
-    marginTop: 2,
-  },
-  extDescription: {
-    marginTop: 4,
-  },
 })
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 35edeafb4..16b9535ff 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
 import {Platform, StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
-import {ago} from '../../../lib/strings'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ago} from 'lib/strings/time'
+import {usePalette} from 'lib/hooks/usePalette'
 
 interface PostMetaOpts {
   authorHandle: string
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 87540cf38..5b331dc8d 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -6,7 +6,7 @@ import {
   View,
 } from 'react-native'
 import {Text} from './text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
 
 interface Layout {
   x: number
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 287d94412..9b8dd3de5 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,15 +1,23 @@
-import React, {useCallback} from 'react'
-import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native'
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
 import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {HighPriorityImage} from 'view/com/util/images/Image'
 import {
   openCamera,
   openCropper,
   openPicker,
   PickedMedia,
 } from './images/image-crop-picker/ImageCropPicker'
-import {useStores} from '../../../state'
-import {colors, gradients} from '../../lib/styles'
+import {
+  requestPhotoAccessIfNeeded,
+  requestCameraAccessIfNeeded,
+} from 'lib/permissions'
+import {useStores} from 'state/index'
+import {colors, gradients} from 'lib/styles'
+import {DropdownButton} from './forms/DropdownButton'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function UserAvatar({
   size,
@@ -25,40 +33,9 @@ export function UserAvatar({
   onSelectNewAvatar?: (img: PickedMedia) => void
 }) {
   const store = useStores()
+  const pal = usePalette('default')
   const initials = getInitials(displayName || handle)
 
-  const handleEditAvatar = useCallback(() => {
-    Alert.alert('Select upload method', '', [
-      {
-        text: 'Take a new photo',
-        onPress: () => {
-          openCamera(store, {
-            mediaType: 'photo',
-            width: 1000,
-            height: 1000,
-            cropperCircleOverlay: true,
-          }).then(onSelectNewAvatar)
-        },
-      },
-      {
-        text: 'Select from gallery',
-        onPress: () => {
-          openPicker(store, {
-            mediaType: 'photo',
-          }).then(async items => {
-            await openCropper(store, {
-              mediaType: 'photo',
-              path: items[0].path,
-              width: 1000,
-              height: 1000,
-              cropperCircleOverlay: true,
-            }).then(onSelectNewAvatar)
-          })
-        },
-      },
-    ])
-  }, [store, onSelectNewAvatar])
-
   const renderSvg = (svgSize: number, svgInitials: string) => (
     <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
       <Defs>
@@ -80,11 +57,65 @@ export function UserAvatar({
     </Svg>
   )
 
+  const dropdownItems = [
+    {
+      label: 'Camera',
+      icon: 'camera' as IconProp,
+      onPress: async () => {
+        if (!(await requestCameraAccessIfNeeded())) {
+          return
+        }
+        onSelectNewAvatar?.(
+          await openCamera(store, {
+            mediaType: 'photo',
+            width: 1000,
+            height: 1000,
+            cropperCircleOverlay: true,
+          }),
+        )
+      },
+    },
+    {
+      label: 'Library',
+      icon: 'image' as IconProp,
+      onPress: async () => {
+        if (!(await requestPhotoAccessIfNeeded())) {
+          return
+        }
+        const items = await openPicker(store, {
+          mediaType: 'photo',
+        })
+        onSelectNewAvatar?.(
+          await openCropper(store, {
+            mediaType: 'photo',
+            path: items[0].path,
+            width: 1000,
+            height: 1000,
+            cropperCircleOverlay: true,
+          }),
+        )
+      },
+    },
+    // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122
+    // {
+    //   label: 'Remove',
+    //   icon: ['far', 'trash-can'],
+    //   onPress: () => {
+    //   // Remove avatar API call
+    //   },
+    // },
+  ]
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
-    <TouchableOpacity onPress={handleEditAvatar}>
+    <DropdownButton
+      type="bare"
+      items={dropdownItems}
+      openToRight
+      rightOffset={-10}
+      bottomOffset={-10}
+      menuWidth={170}>
       {avatar ? (
-        <Image
+        <HighPriorityImage
           style={{
             width: size,
             height: size,
@@ -95,16 +126,16 @@ export function UserAvatar({
       ) : (
         renderSvg(size, initials)
       )}
-      <View style={styles.editButtonContainer}>
+      <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
           icon="camera"
           size={12}
-          style={{color: colors.white}}
+          color={pal.text.color as string}
         />
       </View>
-    </TouchableOpacity>
+    </DropdownButton>
   ) : avatar ? (
-    <Image
+    <HighPriorityImage
       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 d5d6e3aaa..dc140b035 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,15 +1,23 @@
-import React, {useCallback} from 'react'
-import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
 import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {colors, gradients} from '../../lib/styles'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import Image from 'view/com/util/images/Image'
+import {colors, gradients} from 'lib/styles'
 import {
   openCamera,
   openCropper,
   openPicker,
   PickedMedia,
 } from './images/image-crop-picker/ImageCropPicker'
-import {useStores} from '../../../state'
+import {useStores} from 'state/index'
+import {
+  requestPhotoAccessIfNeeded,
+  requestCameraAccessIfNeeded,
+} from 'lib/permissions'
+import {DropdownButton} from './forms/DropdownButton'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function UserBanner({
   banner,
@@ -19,39 +27,57 @@ export function UserBanner({
   onSelectNewBanner?: (img: PickedMedia) => void
 }) {
   const store = useStores()
-  const handleEditBanner = useCallback(() => {
-    Alert.alert('Select upload method', '', [
-      {
-        text: 'Take a new photo',
-        onPress: () => {
-          openCamera(store, {
+  const pal = usePalette('default')
+  const dropdownItems = [
+    {
+      label: 'Camera',
+      icon: 'camera' as IconProp,
+      onPress: async () => {
+        if (!(await requestCameraAccessIfNeeded())) {
+          return
+        }
+        onSelectNewBanner?.(
+          await openCamera(store, {
             mediaType: 'photo',
             // compressImageMaxWidth: 3000, TODO needed?
             width: 3000,
             // compressImageMaxHeight: 1000, TODO needed?
             height: 1000,
-          }).then(onSelectNewBanner)
-        },
+          }),
+        )
       },
-      {
-        text: 'Select from gallery',
-        onPress: () => {
-          openPicker(store, {
+    },
+    {
+      label: 'Library',
+      icon: 'image' as IconProp,
+      onPress: async () => {
+        if (!(await requestPhotoAccessIfNeeded())) {
+          return
+        }
+        const items = await openPicker(store, {
+          mediaType: 'photo',
+        })
+        onSelectNewBanner?.(
+          await openCropper(store, {
             mediaType: 'photo',
-          }).then(async items => {
-            await openCropper(store, {
-              mediaType: 'photo',
-              path: items[0].path,
-              // compressImageMaxWidth: 3000, TODO needed?
-              width: 3000,
-              // compressImageMaxHeight: 1000, TODO needed?
-              height: 1000,
-            }).then(onSelectNewBanner)
-          })
-        },
+            path: items[0].path,
+            // compressImageMaxWidth: 3000, TODO needed?
+            width: 3000,
+            // compressImageMaxHeight: 1000, TODO needed?
+            height: 1000,
+          }),
+        )
       },
-    ])
-  }, [store, onSelectNewBanner])
+    },
+    // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122
+    // {
+    //   label: 'Remove',
+    //   icon: ['far', 'trash-can'],
+    //   onPress: () => {
+    //     // Remove banner api call
+    //   },
+    // },
+  ]
 
   const renderSvg = () => (
     <Svg width="100%" height="150" viewBox="50 0 200 100">
@@ -72,20 +98,27 @@ export function UserBanner({
 
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
-    <TouchableOpacity onPress={handleEditBanner}>
+    <DropdownButton
+      type="bare"
+      items={dropdownItems}
+      openToRight
+      rightOffset={-200}
+      bottomOffset={-10}
+      menuWidth={170}>
       {banner ? (
         <Image style={styles.bannerImage} source={{uri: banner}} />
       ) : (
         renderSvg()
       )}
-      <View style={styles.editButtonContainer}>
+      <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
           icon="camera"
           size={12}
           style={{color: colors.white}}
+          color={pal.text.color as string}
         />
       </View>
-    </TouchableOpacity>
+    </DropdownButton>
   ) : banner ? (
     <Image
       style={styles.bannerImage}
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index a6daf18b2..d7907aa89 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Link} from './Link'
 import {Text} from './text/Text'
 import {LoadingPlaceholder} from './LoadingPlaceholder'
-import {useStores} from '../../../state'
-import {TypographyVariant} from '../../lib/ThemeContext'
+import {useStores} from 'state/index'
+import {TypographyVariant} from 'lib/ThemeContext'
 
 export function UserInfoText({
   type = 'md',
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 82eff2a81..259196b66 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,56 +1,40 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {CenteredView} from './Views'
+import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {UserAvatar} from './UserAvatar'
 import {Text} from './text/Text'
-import {MagnifyingGlassIcon} from '../../lib/icons'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {colors} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {useAnalytics} from 'lib/analytics'
 
-const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
 
 export const ViewHeader = observer(function ViewHeader({
   title,
-  subtitle,
   canGoBack,
+  hideOnScroll,
 }: {
   title: string
-  subtitle?: string
   canGoBack?: boolean
+  hideOnScroll?: boolean
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const {track} = useAnalytics()
   const onPressBack = () => {
     store.nav.tab.goBack()
   }
   const onPressMenu = () => {
+    track('ViewHeader:MenuButtonClicked')
     store.shell.setMainMenuOpen(true)
   }
-  const onPressSearch = () => {
-    store.nav.navigate('/search')
-  }
-  const onPressReconnect = () => {
-    store.session.connect().catch(e => {
-      store.log.warn('Failed to reconnect to server', e)
-    })
-  }
   if (typeof canGoBack === 'undefined') {
     canGoBack = store.nav.tab.canGoBack
   }
   return (
-    <CenteredView style={[styles.header, pal.view]}>
+    <Container hideOnScroll={hideOnScroll || false}>
       <TouchableOpacity
         testID="viewHeaderBackOrMenuBtn"
         onPress={canGoBack ? onPressBack : onPressMenu}
@@ -75,48 +59,57 @@ export const ViewHeader = observer(function ViewHeader({
         <Text type="title" style={[pal.text, styles.title]}>
           {title}
         </Text>
-        {subtitle ? (
-          <Text
-            type="title-sm"
-            style={[styles.subtitle, pal.textLight]}
-            numberOfLines={1}>
-            {subtitle}
-          </Text>
-        ) : undefined}
       </View>
-      <TouchableOpacity
-        onPress={onPressSearch}
-        hitSlop={HITSLOP}
-        style={styles.btn}>
-        <MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
-      </TouchableOpacity>
-      {!store.session.online ? (
-        <TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
-          {store.session.attemptingConnect ? (
-            <ActivityIndicator />
-          ) : (
-            <>
-              <FontAwesomeIcon
-                icon="signal"
-                style={pal.text as FontAwesomeIconStyle}
-                size={16}
-              />
-              <FontAwesomeIcon
-                icon="x"
-                style={[
-                  styles.littleXIcon,
-                  {backgroundColor: pal.colors.background},
-                ]}
-                size={8}
-              />
-            </>
-          )}
-        </TouchableOpacity>
-      ) : undefined}
-    </CenteredView>
+      <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+    </Container>
   )
 })
 
+const Container = observer(
+  ({
+    children,
+    hideOnScroll,
+  }: {
+    children: React.ReactNode
+    hideOnScroll: boolean
+  }) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const interp = useAnimatedValue(0)
+
+    React.useEffect(() => {
+      if (store.shell.minimalShellMode) {
+        Animated.timing(interp, {
+          toValue: 1,
+          duration: 100,
+          useNativeDriver: true,
+          isInteraction: false,
+        }).start()
+      } else {
+        Animated.timing(interp, {
+          toValue: 0,
+          duration: 100,
+          useNativeDriver: true,
+          isInteraction: false,
+        }).start()
+      }
+    }, [interp, store.shell.minimalShellMode])
+    const transform = {
+      transform: [{translateY: Animated.multiply(interp, -100)}],
+    }
+
+    if (!hideOnScroll) {
+      return <View style={[styles.header, pal.view]}>{children}</View>
+    }
+    return (
+      <Animated.View
+        style={[styles.header, pal.view, styles.headerFloating, transform]}>
+        {children}
+      </Animated.View>
+    )
+  },
+)
+
 const styles = StyleSheet.create({
   header: {
     flexDirection: 'row',
@@ -125,20 +118,20 @@ const styles = StyleSheet.create({
     paddingTop: 6,
     paddingBottom: 6,
   },
+  headerFloating: {
+    position: 'absolute',
+    top: 0,
+    width: '100%',
+  },
 
   titleContainer: {
-    flexDirection: 'row',
-    alignItems: 'baseline',
+    marginLeft: 'auto',
     marginRight: 'auto',
+    paddingRight: 10,
   },
   title: {
     fontWeight: 'bold',
   },
-  subtitle: {
-    marginLeft: 4,
-    maxWidth: 200,
-    fontWeight: 'normal',
-  },
 
   backBtn: {
     width: 30,
@@ -152,19 +145,4 @@ const styles = StyleSheet.create({
   backIcon: {
     marginTop: 6,
   },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: 36,
-    height: 36,
-    borderRadius: 20,
-    marginLeft: 4,
-  },
-  littleXIcon: {
-    color: colors.red3,
-    position: 'absolute',
-    right: 7,
-    bottom: 7,
-  },
 })
diff --git a/src/view/com/util/ViewHeader.web.tsx b/src/view/com/util/ViewHeader.web.tsx
index 0d5c99aac..5c0869e8b 100644
--- a/src/view/com/util/ViewHeader.web.tsx
+++ b/src/view/com/util/ViewHeader.web.tsx
@@ -1,20 +1,12 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {CenteredView} from './Views'
 import {Text} from './text/Text'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {colors} from '../../lib/styles'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {colors} from 'lib/styles'
 
 const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
 
@@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({
   const onPressBack = () => {
     store.nav.tab.goBack()
   }
-  const onPressReconnect = () => {
-    store.session.connect().catch(e => {
-      store.log.warn('Failed to reconnect to server', e)
-    })
-  }
   if (typeof canGoBack === 'undefined') {
     canGoBack = store.nav.tab.canGoBack
   }
@@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({
           </Text>
         </View>
       )}
-      {!store.session.online ? (
-        <TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
-          {store.session.attemptingConnect ? (
-            <ActivityIndicator />
-          ) : (
-            <>
-              <FontAwesomeIcon
-                icon="signal"
-                style={pal.text as FontAwesomeIconStyle}
-                size={16}
-              />
-              <FontAwesomeIcon
-                icon="x"
-                style={[
-                  styles.littleXIcon,
-                  {backgroundColor: pal.colors.background},
-                ]}
-                size={8}
-              />
-            </>
-          )}
-        </TouchableOpacity>
-      ) : undefined}
     </CenteredView>
   )
 })
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index ff5115c51..b786c2290 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -3,10 +3,10 @@ import {View} from 'react-native'
 import {Selector} from './Selector'
 import {HorzSwipe} from './gestures/HorzSwipe'
 import {FlatList} from './Views'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
-import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
-import {clamp} from '../../../lib/numbers'
-import {s} from '../../lib/styles'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {clamp} from 'lib/numbers'
+import {s} from 'lib/styles'
 
 const HEADER_ITEM = {_reactKey: '__header__'}
 const SELECTOR_ITEM = {_reactKey: '__selector__'}
@@ -101,6 +101,7 @@ export function ViewSelector({
         onRefresh={onRefresh}
         onEndReached={onEndReached}
         contentContainerStyle={s.contentContainer}
+        removeClippedSubviews={true}
       />
     </HorzSwipe>
   )
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 2df534144..c16070b2b 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -22,9 +22,8 @@ import {
   View,
   ViewProps,
 } from 'react-native'
-import {useTheme} from '../../lib/ThemeContext'
-import {addStyle} from '../../lib/addStyle'
-import {colors} from '../../lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {addStyle, colors} from 'lib/styles'
 
 export function CenteredView({
   style,
diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx
new file mode 100644
index 000000000..2a3cbb957
--- /dev/null
+++ b/src/view/com/util/anim/TriggerableAnimated.tsx
@@ -0,0 +1,73 @@
+import React from 'react'
+import {Animated, StyleProp, View, ViewStyle} from 'react-native'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+
+type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation
+type FinishCb = () => void
+
+interface TriggeredAnimation {
+  start: CreateAnimFn
+  style: (
+    interp: Animated.Value,
+  ) => Animated.WithAnimatedValue<StyleProp<ViewStyle>>
+}
+
+export interface TriggerableAnimatedRef {
+  trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void
+}
+
+type TriggerableAnimatedProps = React.PropsWithChildren<{}>
+
+type PropsInner = TriggerableAnimatedProps & {
+  anim: TriggeredAnimation
+  onFinish: () => void
+}
+
+export const TriggerableAnimated = React.forwardRef<
+  TriggerableAnimatedRef,
+  TriggerableAnimatedProps
+>(({children, ...props}, ref) => {
+  const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>(
+    undefined,
+  )
+  const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>(
+    undefined,
+  )
+  React.useImperativeHandle(ref, () => ({
+    trigger(v: TriggeredAnimation, cb?: FinishCb) {
+      setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate
+      setAnim(v)
+    },
+  }))
+  const onFinish = () => {
+    finishCb?.()
+    setAnim(undefined)
+    setFinishCb(undefined)
+  }
+  return (
+    <View key="triggerable">
+      {anim ? (
+        <AnimatingView anim={anim} onFinish={onFinish} {...props}>
+          {children}
+        </AnimatingView>
+      ) : (
+        children
+      )}
+    </View>
+  )
+})
+
+function AnimatingView({
+  anim,
+  onFinish,
+  children,
+}: React.PropsWithChildren<PropsInner>) {
+  const interp = useAnimatedValue(0)
+  React.useEffect(() => {
+    anim?.start(interp).start(() => {
+      onFinish()
+    })
+  })
+  const animStyle = anim?.style(interp)
+  return <Animated.View style={animStyle}>{children}</Animated.View>
+}
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index a6d77326e..e6e27fac0 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -11,8 +11,8 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {Text} from '../text/Text'
-import {useTheme} from '../../../lib/ThemeContext'
-import {usePalette} from '../../../lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function ErrorMessage({
   message,
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index f316dbcc6..0221ea153 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -5,9 +5,9 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {Text} from '../text/Text'
-import {colors} from '../../../lib/styles'
-import {useTheme} from '../../../lib/ThemeContext'
-import {usePalette} from '../../../lib/hooks/usePalette'
+import {colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export function ErrorScreen({
   title,
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index b5c4da19d..a070d2f0f 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -7,8 +7,8 @@ import {
   ViewStyle,
 } from 'react-native'
 import {Text} from '../text/Text'
-import {useTheme} from '../../../lib/ThemeContext'
-import {choose} from '../../../../lib/functions'
+import {useTheme} from 'lib/ThemeContext'
+import {choose} from 'lib/functions'
 
 export type ButtonType =
   | 'primary'
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index f911529d2..8fddd5941 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../text/Text'
 import {Button, ButtonType} from './Button'
-import {colors} from '../../../lib/styles'
-import {toShareUrl} from '../../../../lib/strings'
-import {useStores} from '../../../../state'
-import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui'
-import {TABS_ENABLED} from '../../../../build-flags'
+import {colors} from 'lib/styles'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {useStores} from 'state/index'
+import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui'
+import {TABS_ENABLED} from 'lib/build-flags'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
@@ -36,6 +38,9 @@ export function DropdownButton({
   label,
   menuWidth,
   children,
+  openToRight = false,
+  rightOffset = 0,
+  bottomOffset = 0,
 }: {
   type?: DropdownButtonType
   style?: StyleProp<ViewStyle>
@@ -43,6 +48,9 @@ export function DropdownButton({
   label?: string
   menuWidth?: number
   children?: React.ReactNode
+  openToRight?: boolean
+  rightOffset?: number
+  bottomOffset?: number
 }) {
   const ref = useRef<TouchableOpacity>(null)
 
@@ -59,12 +67,11 @@ export function DropdownButton({
         if (!menuWidth) {
           menuWidth = 200
         }
-        createDropdownMenu(
-          pageX + width - menuWidth,
-          pageY + height,
-          menuWidth,
-          items,
-        )
+        const newX = openToRight
+          ? pageX + width + rightOffset
+          : pageX + width - menuWidth
+        const newY = pageY + height + bottomOffset
+        createDropdownMenu(newX, newY, menuWidth, items)
       },
     )
   }
@@ -97,6 +104,8 @@ export function DropdownButton({
 export function PostDropdownBtn({
   style,
   children,
+  itemUri,
+  itemCid,
   itemHref,
   isAuthor,
   onCopyPostText,
@@ -104,6 +113,8 @@ export function PostDropdownBtn({
 }: {
   style?: StyleProp<ViewStyle>
   children?: React.ReactNode
+  itemUri: string
+  itemCid: string
   itemHref: string
   itemTitle: string
   isAuthor: boolean
@@ -140,7 +151,7 @@ export function PostDropdownBtn({
       icon: 'circle-exclamation',
       label: 'Report post',
       onPress() {
-        store.shell.openModal(new ReportPostModal(itemHref))
+        store.shell.openModal(new ReportPostModal(itemUri, itemCid))
       },
     },
     isAuthor
@@ -180,24 +191,14 @@ function createDropdownMenu(
   const onOuterPress = () => sibling.destroy()
   const sibling = new RootSiblings(
     (
-      <>
-        <TouchableWithoutFeedback onPress={onOuterPress}>
-          <View style={styles.bg} />
-        </TouchableWithoutFeedback>
-        <View style={[styles.menu, {left: x, top: y, width}]}>
-          {items.map((item, index) => (
-            <TouchableOpacity
-              key={index}
-              style={[styles.menuItem]}
-              onPress={() => onPressItem(index)}>
-              {item.icon && (
-                <FontAwesomeIcon style={styles.icon} icon={item.icon} />
-              )}
-              <Text style={styles.label}>{item.label}</Text>
-            </TouchableOpacity>
-          ))}
-        </View>
-      </>
+      <DropdownItems
+        onOuterPress={onOuterPress}
+        x={x}
+        y={y}
+        width={width}
+        items={items}
+        onPressItem={onPressItem}
+      />
     ),
   )
   return sibling
@@ -241,3 +242,55 @@ const styles = StyleSheet.create({
     fontSize: 18,
   },
 })
+type DropDownItemProps = {
+  onOuterPress: () => void
+  x: number
+  y: number
+  width: number
+  items: DropdownItem[]
+  onPressItem: (index: number) => void
+}
+
+const DropdownItems = ({
+  onOuterPress,
+  x,
+  y,
+  width,
+  items,
+  onPressItem,
+}: DropDownItemProps) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+
+  return (
+    <>
+      <TouchableWithoutFeedback onPress={onOuterPress}>
+        <View style={[styles.bg]} />
+      </TouchableWithoutFeedback>
+      <View
+        style={[
+          styles.menu,
+          {left: x, top: y, width},
+          dropDownBackgroundColor,
+        ]}>
+        {items.map((item, index) => (
+          <TouchableOpacity
+            key={index}
+            style={[styles.menuItem]}
+            onPress={() => onPressItem(index)}>
+            {item.icon && (
+              <FontAwesomeIcon
+                style={styles.icon}
+                icon={item.icon}
+                color={pal.text.color as string}
+              />
+            )}
+            <Text style={[styles.label, pal.text]}>{item.label}</Text>
+          </TouchableOpacity>
+        ))}
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index 81489c447..57a875cd3 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 import {Text} from '../text/Text'
 import {Button, ButtonType} from './Button'
-import {useTheme} from '../../../lib/ThemeContext'
-import {choose} from '../../../../lib/functions'
+import {useTheme} from 'lib/ThemeContext'
+import {choose} from 'lib/functions'
 
 export function RadioButton({
   type = 'default-light',
diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx
index b33cd9831..901b0cdd8 100644
--- a/src/view/com/util/forms/RadioGroup.tsx
+++ b/src/view/com/util/forms/RadioGroup.tsx
@@ -2,7 +2,7 @@ import React, {useState} from 'react'
 import {View} from 'react-native'
 import {RadioButton} from './RadioButton'
 import {ButtonType} from './Button'
-import {s} from '../../../lib/styles'
+import {s} from 'lib/styles'
 
 export interface RadioGroupItem {
   label: string
diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx
index 77e8fa203..005d1165e 100644
--- a/src/view/com/util/forms/ToggleButton.tsx
+++ b/src/view/com/util/forms/ToggleButton.tsx
@@ -2,9 +2,9 @@ import React from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 import {Text} from '../text/Text'
 import {Button, ButtonType} from './Button'
-import {useTheme} from '../../../lib/ThemeContext'
-import {choose} from '../../../../lib/functions'
-import {colors} from '../../../lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {choose} from 'lib/functions'
+import {colors} from 'lib/styles'
 
 export function ToggleButton({
   type = 'default-light',
diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx
index 22b15afe7..09f6c345f 100644
--- a/src/view/com/util/gestures/HorzSwipe.tsx
+++ b/src/view/com/util/gestures/HorzSwipe.tsx
@@ -9,7 +9,7 @@ import {
   View,
 } from 'react-native'
 import {clamp} from 'lodash'
-import {s} from '../../../lib/styles'
+import {s} from 'lib/styles'
 
 interface Props {
   panX: Animated.Value
@@ -90,6 +90,7 @@ export function HorzSwipe({
       // swiping right
       (diffX < 0 && !canSwipeRight)
     ) {
+      panX.setValue(0)
       return
     }
 
@@ -119,6 +120,7 @@ export function HorzSwipe({
         toValue: final,
         duration: 100,
         useNativeDriver,
+        isInteraction: false,
       }).start(() => {
         onSwipeEnd?.(final)
         panX.flattenOffset()
@@ -130,6 +132,7 @@ export function HorzSwipe({
         toValue: 0,
         duration: 100,
         useNativeDriver,
+        isInteraction: false,
       }).start(() => {
         panX.flattenOffset()
         panX.setValue(0)
diff --git a/src/view/com/util/gestures/SwipeAndZoom.tsx b/src/view/com/util/gestures/SwipeAndZoom.tsx
index ee00edab7..75c679012 100644
--- a/src/view/com/util/gestures/SwipeAndZoom.tsx
+++ b/src/view/com/util/gestures/SwipeAndZoom.tsx
@@ -9,7 +9,7 @@ import {
   View,
 } from 'react-native'
 import {clamp} from 'lodash'
-import {s} from '../../../lib/styles'
+import {s} from 'lib/styles'
 
 export enum Dir {
   None,
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index cdefc7123..0443c7be4 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,125 +1,59 @@
-import React, {useState, useEffect} from 'react'
-import {
-  Image,
-  ImageStyle,
-  LayoutChangeEvent,
-  StyleProp,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {Text} from '../text/Text'
-import {useTheme} from '../../../lib/ThemeContext'
-import {usePalette} from '../../../lib/hooks/usePalette'
-import {DELAY_PRESS_IN} from './constants'
+import React from 'react'
+import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
+import Image, {OnLoadEvent} from 'view/com/util/images/Image'
+import {clamp} from 'lib/numbers'
 
-const MAX_HEIGHT = 300
-
-interface Dim {
-  width: number
-  height: number
-}
+export const DELAY_PRESS_IN = 500
+const MIN_ASPECT_RATIO = 0.33 // 1/3
+const MAX_ASPECT_RATIO = 5 // 5/1
 
 export function AutoSizedImage({
   uri,
   onPress,
   onLongPress,
+  onPressIn,
   style,
-  containerStyle,
+  children = null,
 }: {
   uri: string
   onPress?: () => void
   onLongPress?: () => void
-  style?: StyleProp<ImageStyle>
-  containerStyle?: StyleProp<ViewStyle>
+  onPressIn?: () => void
+  style?: StyleProp<ViewStyle>
+  children?: React.ReactNode
 }) {
-  const theme = useTheme()
-  const errPal = usePalette('error')
-  const [error, setError] = useState<string | undefined>('')
-  const [imgInfo, setImgInfo] = useState<Dim | undefined>()
-  const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
-
-  useEffect(() => {
-    let aborted = false
-    if (!imgInfo) {
-      Image.getSize(
-        uri,
-        (width: number, height: number) => {
-          if (!aborted) {
-            setImgInfo({width, height})
-          }
-        },
-        (err: any) => {
-          if (!aborted) {
-            setError(String(err))
-          }
-        },
-      )
-    }
-    return () => {
-      aborted = true
-    }
-  }, [uri, imgInfo])
-
-  const onLayout = (evt: LayoutChangeEvent) => {
-    setContainerInfo({
-      width: evt.nativeEvent.layout.width,
-      height: evt.nativeEvent.layout.height,
-    })
-  }
-
-  let calculatedStyle: StyleProp<ImageStyle> | undefined
-  if (imgInfo && containerInfo) {
-    // imgInfo.height / imgInfo.width = x / containerInfo.width
-    // x = imgInfo.height / imgInfo.width * containerInfo.width
-    calculatedStyle = {
-      height: Math.min(
-        MAX_HEIGHT,
-        (imgInfo.height / imgInfo.width) * containerInfo.width,
+  const [aspectRatio, setAspectRatio] = React.useState<number>(1)
+  const onLoad = (e: OnLoadEvent) => {
+    setAspectRatio(
+      clamp(
+        e.nativeEvent.width / e.nativeEvent.height,
+        MIN_ASPECT_RATIO,
+        MAX_ASPECT_RATIO,
       ),
-    }
+    )
   }
-
   return (
-    <View style={style}>
-      <TouchableOpacity
-        onPress={onPress}
-        onLongPress={onLongPress}
-        delayPressIn={DELAY_PRESS_IN}>
-        {error ? (
-          <View style={[styles.errorContainer, errPal.view, containerStyle]}>
-            <Text style={errPal.text}>{error}</Text>
-          </View>
-        ) : calculatedStyle ? (
-          <View style={[styles.container, containerStyle]}>
-            <Image style={calculatedStyle} source={{uri}} />
-          </View>
-        ) : (
-          <View
-            style={[
-              style,
-              styles.placeholder,
-              {backgroundColor: theme.palette.default.backgroundLight},
-            ]}
-            onLayout={onLayout}
-          />
-        )}
-      </TouchableOpacity>
-    </View>
+    <TouchableOpacity
+      onPress={onPress}
+      onLongPress={onLongPress}
+      onPressIn={onPressIn}
+      delayPressIn={DELAY_PRESS_IN}
+      style={[styles.container, style]}>
+      <Image
+        style={[styles.image, {aspectRatio}]}
+        source={{uri}}
+        onLoad={onLoad}
+      />
+      {children}
+    </TouchableOpacity>
   )
 }
 
 const styles = StyleSheet.create({
-  placeholder: {
-    width: '100%',
-    aspectRatio: 1,
-  },
-  errorContainer: {
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-  },
   container: {
     overflow: 'hidden',
   },
+  image: {
+    width: '100%',
+  },
 })
diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx
new file mode 100644
index 000000000..8c95a581e
--- /dev/null
+++ b/src/view/com/util/images/Image.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+import FastImage, {FastImageProps, Source} from 'react-native-fast-image'
+export default FastImage
+export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image'
+
+export function HighPriorityImage({source, ...props}: FastImageProps) {
+  const updatedSource = {
+    uri: typeof source === 'object' && source ? source.uri : '',
+    priority: FastImage.priority.high,
+  } as Source
+  return <FastImage source={updatedSource} {...props} />
+}
diff --git a/src/view/com/util/images/Image.web.tsx b/src/view/com/util/images/Image.web.tsx
new file mode 100644
index 000000000..ecd9d730a
--- /dev/null
+++ b/src/view/com/util/images/Image.web.tsx
@@ -0,0 +1,11 @@
+import {
+  Image,
+  NativeSyntheticEvent,
+  ImageLoadEventData,
+  ImageSourcePropType,
+} from 'react-native'
+export default Image
+export const HighPriorityImage = Image
+export type OnLoadEvent = NativeSyntheticEvent<ImageLoadEventData>
+export type Source = ImageSourcePropType
+export type {ImageStyle} from 'react-native'
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index 366424308..bed13406c 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -1,12 +1,12 @@
 import React from 'react'
 import {
-  Image,
   StyleProp,
   StyleSheet,
   TouchableWithoutFeedback,
   View,
   ViewStyle,
 } from 'react-native'
+import Image from 'view/com/util/images/Image'
 
 export function ImageHorzList({
   uris,
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 97ad9d700..a1c732649 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,7 +1,5 @@
 import React from 'react'
 import {
-  Image,
-  ImageStyle,
   LayoutChangeEvent,
   StyleProp,
   StyleSheet,
@@ -9,7 +7,9 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {DELAY_PRESS_IN} from './constants'
+import Image, {ImageStyle} from 'view/com/util/images/Image'
+
+export const DELAY_PRESS_IN = 500
 
 interface Dim {
   width: number
@@ -23,12 +23,14 @@ export function ImageLayoutGrid({
   uris,
   onPress,
   onLongPress,
+  onPressIn,
   style,
 }: {
   type: ImageLayoutGridType
   uris: string[]
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
+  onPressIn?: (index: number) => void
   style?: StyleProp<ViewStyle>
 }) {
   const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
@@ -47,6 +49,7 @@ export function ImageLayoutGrid({
           type={type}
           uris={uris}
           onPress={onPress}
+          onPressIn={onPressIn}
           onLongPress={onLongPress}
           containerInfo={containerInfo}
         />
@@ -60,15 +63,17 @@ function ImageLayoutGridInner({
   uris,
   onPress,
   onLongPress,
+  onPressIn,
   containerInfo,
 }: {
   type: ImageLayoutGridType
   uris: string[]
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
+  onPressIn?: (index: number) => void
   containerInfo: Dim
 }) {
-  const size1 = React.useMemo<ImageStyle>(() => {
+  const size1 = React.useMemo<StyleProp<ImageStyle>>(() => {
     if (type === 'three') {
       const size = (containerInfo.width - 10) / 3
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
@@ -77,7 +82,7 @@ function ImageLayoutGridInner({
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
     }
   }, [type, containerInfo])
-  const size2 = React.useMemo<ImageStyle>(() => {
+  const size2 = React.useMemo<StyleProp<ImageStyle>>(() => {
     if (type === 'three') {
       const size = ((containerInfo.width - 10) / 3) * 2 + 5
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
@@ -93,6 +98,7 @@ function ImageLayoutGridInner({
         <TouchableOpacity
           delayPressIn={DELAY_PRESS_IN}
           onPress={() => onPress?.(0)}
+          onPressIn={() => onPressIn?.(0)}
           onLongPress={() => onLongPress?.(0)}>
           <Image source={{uri: uris[0]}} style={size1} />
         </TouchableOpacity>
@@ -100,6 +106,7 @@ function ImageLayoutGridInner({
         <TouchableOpacity
           delayPressIn={DELAY_PRESS_IN}
           onPress={() => onPress?.(1)}
+          onPressIn={() => onPressIn?.(1)}
           onLongPress={() => onLongPress?.(1)}>
           <Image source={{uri: uris[1]}} style={size1} />
         </TouchableOpacity>
@@ -112,6 +119,7 @@ function ImageLayoutGridInner({
         <TouchableOpacity
           delayPressIn={DELAY_PRESS_IN}
           onPress={() => onPress?.(0)}
+          onPressIn={() => onPressIn?.(0)}
           onLongPress={() => onLongPress?.(0)}>
           <Image source={{uri: uris[0]}} style={size2} />
         </TouchableOpacity>
@@ -120,6 +128,7 @@ function ImageLayoutGridInner({
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
             onPress={() => onPress?.(1)}
+            onPressIn={() => onPressIn?.(1)}
             onLongPress={() => onLongPress?.(1)}>
             <Image source={{uri: uris[1]}} style={size1} />
           </TouchableOpacity>
@@ -127,6 +136,7 @@ function ImageLayoutGridInner({
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
             onPress={() => onPress?.(2)}
+            onPressIn={() => onPressIn?.(2)}
             onLongPress={() => onLongPress?.(2)}>
             <Image source={{uri: uris[2]}} style={size1} />
           </TouchableOpacity>
@@ -141,29 +151,33 @@ function ImageLayoutGridInner({
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
             onPress={() => onPress?.(0)}
+            onPressIn={() => onPressIn?.(0)}
             onLongPress={() => onLongPress?.(0)}>
             <Image source={{uri: uris[0]}} style={size1} />
           </TouchableOpacity>
           <View style={styles.hSpace} />
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
-            onPress={() => onPress?.(1)}
-            onLongPress={() => onLongPress?.(1)}>
-            <Image source={{uri: uris[1]}} style={size1} />
+            onPress={() => onPress?.(2)}
+            onPressIn={() => onPressIn?.(2)}
+            onLongPress={() => onLongPress?.(2)}>
+            <Image source={{uri: uris[2]}} style={size1} />
           </TouchableOpacity>
         </View>
         <View style={styles.wSpace} />
         <View>
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
-            onPress={() => onPress?.(2)}
-            onLongPress={() => onLongPress?.(2)}>
-            <Image source={{uri: uris[2]}} style={size1} />
+            onPress={() => onPress?.(1)}
+            onPressIn={() => onPressIn?.(1)}
+            onLongPress={() => onLongPress?.(1)}>
+            <Image source={{uri: uris[1]}} style={size1} />
           </TouchableOpacity>
           <View style={styles.hSpace} />
           <TouchableOpacity
             delayPressIn={DELAY_PRESS_IN}
             onPress={() => onPress?.(3)}
+            onPressIn={() => onPressIn?.(3)}
             onLongPress={() => onLongPress?.(3)}>
             <Image source={{uri: uris[3]}} style={size1} />
           </TouchableOpacity>
diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts
deleted file mode 100644
index cb2c26cea..000000000
--- a/src/view/com/util/images/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const DELAY_PRESS_IN = 500
diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
index ddc9e87fd..d723fef99 100644
--- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
+++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
@@ -4,7 +4,7 @@ import {
   openCropper as openCropperFn,
   ImageOrVideo,
 } from 'react-native-image-crop-picker'
-import {RootStoreModel} from '../../../../../state'
+import {RootStoreModel} from 'state/index'
 import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
 export type {PickedMedia} from './types'
 
diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
index a7037f3a4..d632590d6 100644
--- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
+++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
@@ -1,9 +1,9 @@
 /// <reference lib="dom" />
 
-import {CropImageModal} from '../../../../../state/models/shell-ui'
+import {CropImageModal} from 'state/models/shell-ui'
 import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
 export type {PickedMedia} from './types'
-import {RootStoreModel} from '../../../../../state'
+import {RootStoreModel} from 'state/index'
 
 interface PickedFile {
   uri: string
@@ -31,17 +31,17 @@ export async function openPicker(
 
 export async function openCamera(
   _store: RootStoreModel,
-  opts: CameraOpts,
+  _opts: CameraOpts,
 ): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
+  // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
 export async function openCropper(
   _store: RootStoreModel,
-  opts: CropperOpts,
+  _opts: CropperOpts,
 ): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
+  // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index f04f4566d..d4cf19172 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -2,29 +2,21 @@ import React from 'react'
 import {TextStyle, StyleProp} from 'react-native'
 import {TextLink} from '../Link'
 import {Text} from './Text'
-import {lh} from '../../../lib/styles'
-import {toShortUrl} from '../../../../lib/strings'
-import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
-import {usePalette} from '../../../lib/hooks/usePalette'
-
-type TextSlice = {start: number; end: number}
-type Entity = {
-  index: TextSlice
-  type: string
-  value: string
-}
+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({
   type = 'md',
-  text,
-  entities,
+  richText,
   lineHeight = 1.2,
   style,
   numberOfLines,
 }: {
   type?: TypographyVariant
-  text: string
-  entities?: Entity[]
+  richText?: RichTextObj
   lineHeight?: number
   style?: StyleProp<TextStyle>
   numberOfLines?: number
@@ -32,6 +24,12 @@ export function RichText({
   const theme = useTheme()
   const pal = usePalette('default')
   const lineHeightStyle = lh(theme, type, lineHeight)
+
+  if (!richText) {
+    return null
+  }
+
+  const {text, entities} = richText
   if (!entities?.length) {
     if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
       style = {
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index c3a8a2194..14c57cf47 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {Text as RNText, TextProps} from 'react-native'
-import {s} from '../../../lib/styles'
-import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
+import {s} from 'lib/styles'
+import {useTheme, TypographyVariant} from 'lib/ThemeContext'
 
 export type CustomTextProps = TextProps & {
   type?: TypographyVariant
diff --git a/src/view/index.ts b/src/view/index.ts
index 8ffd5957a..7cd2c1dfd 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
 import {faX} from '@fortawesome/free-solid-svg-icons/faX'
 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
+import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
+import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
 
 export function setup() {
   library.add(
@@ -133,5 +135,7 @@ export function setup() {
     faTrashCan,
     faX,
     faXmark,
+    faPlay,
+    faPause,
   )
 }
diff --git a/src/view/lib/addStyle.ts b/src/view/lib/addStyle.ts
deleted file mode 100644
index 93a5390a1..000000000
--- a/src/view/lib/addStyle.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {StyleProp} from 'react-native'
-
-export function addStyle<T>(
-  base: StyleProp<T>,
-  addedStyle: StyleProp<T>,
-): StyleProp<T> {
-  if (Array.isArray(base)) {
-    return base.concat([addedStyle])
-  }
-  return [base, addedStyle]
-}
diff --git a/src/view/lib/assets.native.ts b/src/view/lib/assets.native.ts
deleted file mode 100644
index a2db553e7..000000000
--- a/src/view/lib/assets.native.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import {ImageSourcePropType} from 'react-native'
-
-export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg')
-export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg')
-export const CLOUD_SPLASH: ImageSourcePropType = require('../../../public/img/cloud-splash.png')
diff --git a/src/view/lib/assets.ts b/src/view/lib/assets.ts
deleted file mode 100644
index 948f3a22f..000000000
--- a/src/view/lib/assets.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import {ImageSourcePropType} from 'react-native'
-
-export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'}
-export const TABS_EXPLAINER: ImageSourcePropType = {
-  uri: '/img/tabs-explainer.jpg',
-}
-export const CLOUD_SPLASH: ImageSourcePropType = {uri: '/img/cloud-splash.png'}
diff --git a/src/view/routes.ts b/src/view/routes.ts
index 98e8111b3..1cd9ef8e2 100644
--- a/src/view/routes.ts
+++ b/src/view/routes.ts
@@ -1,5 +1,4 @@
-import React, {MutableRefObject} from 'react'
-import {FlatList} from 'react-native'
+import React from 'react'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {Home} from './screens/Home'
 import {Contacts} from './screens/Contacts'
@@ -21,7 +20,6 @@ export type ScreenParams = {
   navIdx: string
   params: Record<string, any>
   visible: boolean
-  scrollElRef?: MutableRefObject<FlatList<any> | null>
 }
 export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp]
 export type MatchResult = {
diff --git a/src/view/screens/Contacts.tsx b/src/view/screens/Contacts.tsx
index cba17f285..21943a10a 100644
--- a/src/view/screens/Contacts.tsx
+++ b/src/view/screens/Contacts.tsx
@@ -4,10 +4,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
 import {Selector} from '../com/util/Selector'
 import {Text} from '../com/util/text/Text'
-import {colors} from '../lib/styles'
+import {colors} from 'lib/styles'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {useAnimatedValue} from '../lib/hooks/useAnimatedValue'
+import {useStores} from 'state/index'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
 export const Contacts = ({navIdx, visible}: ScreenParams) => {
   const store = useStores()
diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx
index 0223e631d..09e3dd46a 100644
--- a/src/view/screens/Debug.tsx
+++ b/src/view/screens/Debug.tsx
@@ -1,11 +1,10 @@
 import React from 'react'
 import {ScrollView, View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
-import {ThemeProvider} from '../lib/ThemeContext'
-import {PaletteColorName} from '../lib/ThemeContext'
-import {usePalette} from '../lib/hooks/usePalette'
-import {s} from '../lib/styles'
-import {displayNotification} from '../lib/notifee'
+import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+import {displayNotification} from 'lib/notifee'
 
 import {Text} from '../com/util/text/Text'
 import {ViewSelector} from '../com/util/ViewSelector'
@@ -284,6 +283,9 @@ function TypographyView() {
         'xs-heavy' lorem ipsum dolor
       </Text>
 
+      <Text type="title-2xl" style={[pal.text]}>
+        'title-2xl' lorem ipsum dolor
+      </Text>
       <Text type="title-xl" style={[pal.text]}>
         'title-xl' lorem ipsum dolor
       </Text>
@@ -296,6 +298,9 @@ function TypographyView() {
       <Text type="button" style={[pal.text]}>
         Button
       </Text>
+      <Text type="button-lg" style={[pal.text]}>
+        Button-lg
+      </Text>
     </View>
   )
 }
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 4222c7513..de7e61ba4 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,23 +1,24 @@
 import React, {useEffect} from 'react'
-import {View} from 'react-native'
+import {FlatList, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import useAppState from 'react-native-appstate-hook'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/posts/Feed'
 import {FAB} from '../com/util/FAB'
 import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 import {ScreenParams} from '../routes'
-import {s} from '../lib/styles'
-import {useOnMainScroll} from '../lib/hooks/useOnMainScroll'
+import {s} from 'lib/styles'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {useAnalytics} from 'lib/analytics'
 
-export const Home = observer(function Home({
-  navIdx,
-  visible,
-  scrollElRef,
-}: ScreenParams) {
+const HEADER_HEIGHT = 42
+
+export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
   const store = useStores()
   const onMainScroll = useOnMainScroll(store)
+  const {screen, track} = useAnalytics()
+  const scrollElRef = React.useRef<FlatList>(null)
   const [wasVisible, setWasVisible] = React.useState<boolean>(false)
   const {appState} = useAppState({
     onForeground: () => doPoll(true),
@@ -31,22 +32,31 @@ export const Home = observer(function Home({
       if (store.me.mainFeed.isLoading) {
         return
       }
-      store.log.debug('Polling home feed')
-      store.me.mainFeed.checkForLatest().catch(e => {
-        store.log.error('Failed to poll feed', e)
-      })
+      store.log.debug('HomeScreen: Polling for new posts')
+      store.me.mainFeed.checkForLatest()
     },
     [appState, visible, store],
   )
 
+  const scrollToTop = React.useCallback(() => {
+    // NOTE: the feed is offset by the height of the collapsing header,
+    //       so we scroll to the negative of that height -prf
+    scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
+  }, [scrollElRef])
+
   useEffect(() => {
+    const softResetSub = store.onScreenSoftReset(scrollToTop)
     const feedCleanup = store.me.mainFeed.registerListeners()
-    const pollInterval = setInterval(() => doPoll(), 15e3)
+    const pollInterval = setInterval(doPoll, 15e3)
     const cleanup = () => {
       clearInterval(pollInterval)
+      softResetSub.remove()
       feedCleanup()
     }
 
+    // guard to only continue when transitioning from !visible -> visible
+    // TODO is this 100% needed? depends on if useEffect() is getting refired
+    //      for reasons other than `visible` changing -prf
     if (!visible) {
       setWasVisible(false)
       return cleanup
@@ -55,17 +65,20 @@ export const Home = observer(function Home({
     }
     setWasVisible(true)
 
+    // just became visible
+    screen('Feed')
     store.nav.setTitle(navIdx, 'Home')
-    store.log.debug('Updating home feed')
+    store.log.debug('HomeScreen: Updating feed')
     if (store.me.mainFeed.hasContent) {
       store.me.mainFeed.update()
     } else {
       store.me.mainFeed.setup()
     }
     return cleanup
-  }, [visible, store, navIdx, doPoll, wasVisible])
+  }, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen])
 
   const onPressCompose = (imagesOpen?: boolean) => {
+    track('Home:ComposeButtonPressed')
     store.shell.openComposer({imagesOpen})
   }
   const onPressTryAgain = () => {
@@ -73,26 +86,31 @@ export const Home = observer(function Home({
   }
   const onPressLoadLatest = () => {
     store.me.mainFeed.refresh()
-    scrollElRef?.current?.scrollToOffset({offset: 0})
+    scrollToTop()
   }
 
   return (
     <View style={s.h100pct}>
-      <ViewHeader title="Bluesky" subtitle="Private Beta" canGoBack={false} />
       <Feed
         testID="homeFeed"
         key="default"
         feed={store.me.mainFeed}
         scrollElRef={scrollElRef}
         style={s.h100pct}
-        onPressCompose={onPressCompose}
         onPressTryAgain={onPressTryAgain}
+        onPressCompose={onPressCompose}
         onScroll={onMainScroll}
+        headerOffset={HEADER_HEIGHT}
       />
+      <ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
       {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
         <LoadLatestBtn onPress={onPressLoadLatest} />
       )}
-      <FAB icon="pen-nib" onPress={() => onPressCompose(false)} />
+      <FAB
+        testID="composeFAB"
+        icon="plus"
+        onPress={() => onPressCompose(false)}
+      />
     </View>
   )
 })
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index c3e156dcb..c067d3506 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -3,13 +3,13 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {ScrollView} from '../com/util/Views'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 import {ScreenParams} from '../routes'
-import {s} from '../lib/styles'
+import {s} from 'lib/styles'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
-import {usePalette} from '../lib/hooks/usePalette'
-import {ago} from '../../lib/strings'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ago} from 'lib/strings/time'
 
 export const Log = observer(function Log({navIdx, visible}: ScreenParams) {
   const pal = usePalette('default')
diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx
index 81a2c9e6b..50b2a34c0 100644
--- a/src/view/screens/Login.tsx
+++ b/src/view/screens/Login.tsx
@@ -1,19 +1,16 @@
-import React, {useState} from 'react'
-import {
-  Image,
-  SafeAreaView,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import React, {useEffect, useState} from 'react'
+import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import Image, {Source as ImageSource} from 'view/com/util/images/Image'
 import {observer} from 'mobx-react-lite'
 import {Signin} from '../com/login/Signin'
 import {CreateAccount} from '../com/login/CreateAccount'
 import {Text} from '../com/util/text/Text'
 import {ErrorBoundary} from '../com/util/ErrorBoundary'
-import {colors} from '../lib/styles'
-import {usePalette} from '../lib/hooks/usePalette'
-import {CLOUD_SPLASH} from '../lib/assets'
+import {colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {CLOUD_SPLASH} from 'lib/assets'
+import {useAnalytics} from 'lib/analytics'
 
 enum ScreenState {
   S_SigninOrCreateAccount,
@@ -28,6 +25,12 @@ const SigninOrCreateAccount = ({
   onPressSignin: () => void
   onPressCreateAccount: () => void
 }) => {
+  const {screen} = useAnalytics()
+
+  useEffect(() => {
+    screen('Login')
+  }, [screen])
+
   const pal = usePalette('default')
   return (
     <>
@@ -57,22 +60,28 @@ const SigninOrCreateAccount = ({
 
 export const Login = observer(() => {
   const pal = usePalette('default')
+  const store = useStores()
   const [screenState, setScreenState] = useState<ScreenState>(
     ScreenState.S_SigninOrCreateAccount,
   )
 
-  if (screenState === ScreenState.S_SigninOrCreateAccount) {
+  if (
+    store.session.isResumingSession ||
+    screenState === ScreenState.S_SigninOrCreateAccount
+  ) {
     return (
       <View style={styles.container}>
-        <Image source={CLOUD_SPLASH} style={styles.bgImg} />
+        <Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
         <SafeAreaView testID="noSessionView" style={styles.container}>
           <ErrorBoundary>
-            <SigninOrCreateAccount
-              onPressSignin={() => setScreenState(ScreenState.S_Signin)}
-              onPressCreateAccount={() =>
-                setScreenState(ScreenState.S_CreateAccount)
-              }
-            />
+            {!store.session.isResumingSession && (
+              <SigninOrCreateAccount
+                onPressSignin={() => setScreenState(ScreenState.S_Signin)}
+                onPressCreateAccount={() =>
+                  setScreenState(ScreenState.S_CreateAccount)
+                }
+              />
+            )}
           </ErrorBoundary>
         </SafeAreaView>
       </View>
diff --git a/src/view/screens/Login.web.tsx b/src/view/screens/Login.web.tsx
index 77149090c..90effc5d6 100644
--- a/src/view/screens/Login.web.tsx
+++ b/src/view/screens/Login.web.tsx
@@ -1,20 +1,13 @@
 import React, {useState} from 'react'
-import {
-  Image,
-  SafeAreaView,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {CenteredView} from '../com/util/Views'
 import {Signin} from '../com/login/Signin'
 import {CreateAccount} from '../com/login/CreateAccount'
 import {Text} from '../com/util/text/Text'
 import {ErrorBoundary} from '../com/util/ErrorBoundary'
-import {colors} from '../lib/styles'
-import {usePalette} from '../lib/hooks/usePalette'
-import {CLOUD_SPLASH} from '../lib/assets'
+import {colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
 
 enum ScreenState {
   S_SigninOrCreateAccount,
@@ -125,6 +118,7 @@ const styles = StyleSheet.create({
     width: '100%',
     height: '100%',
   },
+  hero: {},
   heroText: {
     backgroundColor: colors.white,
     paddingTop: 10,
diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx
index c5c5ff002..77bbdd2aa 100644
--- a/src/view/screens/NotFound.tsx
+++ b/src/view/screens/NotFound.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {Button, StyleSheet, View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 
 export const NotFound = () => {
   const stores = useStores()
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 9b5dc5970..548b0d564 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,35 +1,79 @@
 import React, {useEffect} from 'react'
-import {View} from 'react-native'
+import {FlatList, View} from 'react-native'
+import useAppState from 'react-native-appstate-hook'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 import {ScreenParams} from '../routes'
-import {useOnMainScroll} from '../lib/hooks/useOnMainScroll'
-import {s} from '../lib/styles'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {s} from 'lib/styles'
+import {useAnalytics} from 'lib/analytics'
+
+const NOTIFICATIONS_POLL_INTERVAL = 15e3
 
 export const Notifications = ({navIdx, visible}: ScreenParams) => {
   const store = useStores()
   const onMainScroll = useOnMainScroll(store)
+  const scrollElRef = React.useRef<FlatList>(null)
+  const {screen} = useAnalytics()
+  const {appState} = useAppState({
+    onForeground: () => doPoll(true),
+  })
 
+  // event handlers
+  // =
+  const onPressTryAgain = () => {
+    store.me.notifications.refresh()
+  }
+  const scrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: 0})
+  }, [scrollElRef])
+
+  // periodic polling
+  // =
+  const doPoll = React.useCallback(
+    async (isForegrounding = false) => {
+      if (isForegrounding) {
+        // app is foregrounding, refresh optimistically
+        store.log.debug('NotificationsScreen: Refreshing on app foreground')
+        await Promise.all([
+          store.me.notifications.loadUnreadCount(),
+          store.me.notifications.refresh(),
+        ])
+      } else if (appState === 'active') {
+        // periodic poll, refresh if there are new notifs
+        store.log.debug('NotificationsScreen: Polling for new notifications')
+        const didChange = await store.me.notifications.loadUnreadCount()
+        if (didChange) {
+          store.log.debug('NotificationsScreen: Loading new notifications')
+          await store.me.notifications.loadLatest()
+        }
+      }
+    },
+    [appState, store],
+  )
+  useEffect(() => {
+    const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL)
+    return () => clearInterval(pollInterval)
+  }, [doPoll])
+
+  // on-visible setup
+  // =
   useEffect(() => {
     if (!visible) {
       return
     }
-    store.log.debug('Updating notifications feed')
-    store.me.notifications
-      .update()
-      .catch(e => {
-        store.log.error('Error while updating notifications feed', e)
-      })
-      .then(() => {
-        store.me.notifications.updateReadState()
-      })
+    store.log.debug('NotificationsScreen: Updating feed')
+    const softResetSub = store.onScreenSoftReset(scrollToTop)
+    store.me.notifications.update().then(() => {
+      store.me.notifications.markAllRead()
+    })
+    screen('Notifications')
     store.nav.setTitle(navIdx, 'Notifications')
-  }, [visible, store, navIdx])
-
-  const onPressTryAgain = () => {
-    store.me.notifications.refresh()
-  }
+    return () => {
+      softResetSub.remove()
+    }
+  }, [visible, store, navIdx, screen, scrollToTop])
 
   return (
     <View style={s.h100pct}>
@@ -38,6 +82,7 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
         view={store.me.notifications}
         onPressTryAgain={onPressTryAgain}
         onScroll={onMainScroll}
+        scrollElRef={scrollElRef}
       />
     </View>
   )
diff --git a/src/view/screens/Onboard.tsx b/src/view/screens/Onboard.tsx
index e31b42adc..1485670e7 100644
--- a/src/view/screens/Onboard.tsx
+++ b/src/view/screens/Onboard.tsx
@@ -3,8 +3,8 @@ import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FeatureExplainer} from '../com/onboard/FeatureExplainer'
 import {Follows} from '../com/onboard/Follows'
-import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard'
-import {useStores} from '../../state'
+import {OnboardStage, OnboardStageOrder} from 'state/models/onboard'
+import {useStores} from 'state/index'
 
 export const Onboard = observer(() => {
   const store = useStores()
diff --git a/src/view/screens/PostDownvotedBy.tsx b/src/view/screens/PostDownvotedBy.tsx
index 1401868d4..570482598 100644
--- a/src/view/screens/PostDownvotedBy.tsx
+++ b/src/view/screens/PostDownvotedBy.tsx
@@ -3,8 +3,8 @@ import {View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {makeRecordUri} from '../../lib/strings'
+import {useStores} from 'state/index'
+import {makeRecordUri} from 'lib/strings/url-helpers'
 
 export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx
index bf4d6ec91..4be4b4b42 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/view/screens/PostRepostedBy.tsx
@@ -3,8 +3,8 @@ import {View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {makeRecordUri} from '../../lib/strings'
+import {useStores} from 'state/index'
+import {makeRecordUri} from 'lib/strings/url-helpers'
 
 export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index febaddc09..4b799468d 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,17 +1,16 @@
-import React, {useEffect, useMemo, useState} from 'react'
+import React, {useEffect, useMemo} from 'react'
 import {View} from 'react-native'
-import {makeRecordUri} from '../../lib/strings'
+import {makeRecordUri} from 'lib/strings/url-helpers'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
-import {PostThreadViewModel} from '../../state/models/post-thread-view'
+import {PostThreadViewModel} from 'state/models/post-thread-view'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {s} from '../lib/styles'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
 
 export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
   const {name, rkey} = params
-  const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`)
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
   const view = useMemo<PostThreadViewModel>(
     () => new PostThreadViewModel(store, {uri}),
@@ -24,7 +23,6 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
     const setTitle = () => {
       const author = view.thread?.post.author
       const niceName = author?.handle || name
-      setViewSubtitle(`by ${niceName}`)
       store.nav.setTitle(navIdx, `Post by ${niceName}`)
     }
     if (!visible) {
@@ -52,7 +50,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
 
   return (
     <View style={s.h100pct}>
-      <ViewHeader title="Post" subtitle={viewSubtitle} />
+      <ViewHeader title="Post" />
       <View style={s.h100pct}>
         <PostThreadComponent uri={uri} view={view} />
       </View>
diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostUpvotedBy.tsx
index 4bba222ae..4d6ad4114 100644
--- a/src/view/screens/PostUpvotedBy.tsx
+++ b/src/view/screens/PostUpvotedBy.tsx
@@ -3,8 +3,8 @@ import {View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {makeRecordUri} from '../../lib/strings'
+import {useStores} from 'state/index'
+import {makeRecordUri} from 'lib/strings/url-helpers'
 
 export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 5c6616985..03d973b96 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -4,8 +4,8 @@ import {observer} from 'mobx-react-lite'
 import {ViewSelector} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
 import {ScreenParams} from '../routes'
-import {ProfileUiModel, Sections} from '../../state/models/profile-ui'
-import {useStores} from '../../state'
+import {ProfileUiModel, Sections} from 'state/models/profile-ui'
+import {useStores} from 'state/index'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedItem} from '../com/posts/FeedItem'
 import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
@@ -14,8 +14,9 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {EmptyState} from '../com/util/EmptyState'
 import {Text} from '../com/util/text/Text'
 import {FAB} from '../com/util/FAB'
-import {s, colors} from '../lib/styles'
-import {useOnMainScroll} from '../lib/hooks/useOnMainScroll'
+import {s, colors} from 'lib/styles'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {useAnalytics} from 'lib/analytics'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const END_ITEM = {_reactKey: '__end__'}
@@ -23,6 +24,12 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
 
 export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
+  const {screen} = useAnalytics()
+
+  useEffect(() => {
+    screen('Profile')
+  }, [screen])
+
   const onMainScroll = useOnMainScroll(store)
   const [hasSetup, setHasSetup] = useState<boolean>(false)
   const uiState = React.useMemo(
@@ -128,7 +135,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
           }
           if (!uiState.feed.hasMore) {
             items = items.concat([END_ITEM])
-          } else {
+          } else if (uiState.feed.isLoading) {
             Footer = LoadingMoreFooter
           }
           renderItem = (item: any) => {
@@ -184,7 +191,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
       ) : (
         <CenteredView>{renderHeader()}</CenteredView>
       )}
-      <FAB icon="pen-nib" onPress={onPressCompose} />
+      <FAB icon="plus" onPress={onPressCompose} />
     </View>
   )
 })
diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx
index f7520549e..9f1a9c741 100644
--- a/src/view/screens/ProfileFollowers.tsx
+++ b/src/view/screens/ProfileFollowers.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 
 export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
@@ -18,7 +18,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
 
   return (
     <View>
-      <ViewHeader title="Followers" subtitle={`of ${name}`} />
+      <ViewHeader title="Followers" />
       <ProfileFollowersComponent name={name} />
     </View>
   )
diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx
index 65e4004e9..1cdb5bccf 100644
--- a/src/view/screens/ProfileFollows.tsx
+++ b/src/view/screens/ProfileFollows.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
+import {useStores} from 'state/index'
 
 export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
   const store = useStores()
@@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
 
   return (
     <View>
-      <ViewHeader title="Followed" subtitle={`by ${name}`} />
+      <ViewHeader title="Following" />
       <ProfileFollowsComponent name={name} />
     </View>
   )
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
index 2a1caab89..2e176d98f 100644
--- a/src/view/screens/Search.tsx
+++ b/src/view/screens/Search.tsx
@@ -1,41 +1,73 @@
-import React, {useEffect, useState, useMemo, useRef} from 'react'
+import React from 'react'
 import {
   Keyboard,
   StyleSheet,
   TextInput,
   TouchableOpacity,
+  TouchableWithoutFeedback,
   View,
 } from 'react-native'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {CenteredView, ScrollView} from '../com/util/Views'
-import {SuggestedFollows} from '../com/discover/SuggestedFollows'
+import {ScrollView} from '../com/util/Views'
+import {observer} from 'mobx-react-lite'
 import {UserAvatar} from '../com/util/UserAvatar'
 import {Text} from '../com/util/text/Text'
 import {ScreenParams} from '../routes'
-import {useStores} from '../../state'
-import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view'
-import {s} from '../lib/styles'
-import {MagnifyingGlassIcon} from '../lib/icons'
-import {usePalette} from '../lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {s} from 'lib/styles'
+import {MagnifyingGlassIcon} from 'lib/icons'
+import {WhoToFollow} from '../com/discover/WhoToFollow'
+import {SuggestedPosts} from '../com/discover/SuggestedPosts'
+import {ProfileCard} from '../com/profile/ProfileCard'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {useAnalytics} from 'lib/analytics'
 
-export const Search = ({navIdx, visible, params}: ScreenParams) => {
+const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
+const FIVE_MIN = 5 * 60 * 1e3
+
+export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
   const pal = usePalette('default')
   const store = useStores()
-  const textInput = useRef<TextInput>(null)
-  const [query, setQuery] = useState<string>('')
-  const autocompleteView = useMemo<UserAutocompleteViewModel>(
+  const {track} = useAnalytics()
+  const scrollElRef = React.useRef<ScrollView>(null)
+  const onMainScroll = useOnMainScroll(store)
+  const textInput = React.useRef<TextInput>(null)
+  const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
+  const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
+  const [query, setQuery] = React.useState<string>('')
+  const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
     () => new UserAutocompleteViewModel(store),
     [store],
   )
   const {name} = params
 
-  useEffect(() => {
+  const onSoftReset = () => {
+    scrollElRef.current?.scrollTo({x: 0, y: 0})
+  }
+
+  React.useEffect(() => {
+    const softResetSub = store.onScreenSoftReset(onSoftReset)
+    const cleanup = () => {
+      softResetSub.remove()
+    }
+
     if (visible) {
+      const now = Date.now()
+      if (now - lastRenderTime > FIVE_MIN) {
+        setRenderTime(Date.now()) // trigger reload of suggestions
+      }
       store.shell.setMinimalShellMode(false)
       autocompleteView.setup()
       store.nav.setTitle(navIdx, 'Search')
     }
-  }, [store, visible, name, navIdx, autocompleteView])
+    return cleanup
+  }, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
+
+  const onPressMenu = () => {
+    track('ViewHeader:MenuButtonClicked')
+    store.shell.setMainMenuOpen(true)
+  }
 
   const onChangeQuery = (text: string) => {
     setQuery(text)
@@ -46,87 +78,139 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
       autocompleteView.setActive(false)
     }
   }
-  const onSelect = (handle: string) => {
-    textInput.current?.blur()
-    store.nav.navigate(`/profile/${handle}`)
+  const onPressCancelSearch = () => {
+    setQuery('')
+    autocompleteView.setActive(false)
   }
 
   return (
-    <View style={[pal.view, styles.container]}>
-      <ViewHeader title="Search" />
-      <CenteredView style={[pal.view, pal.border, styles.inputContainer]}>
-        <MagnifyingGlassIcon style={[pal.text, styles.inputIcon]} />
-        <TextInput
-          testID="searchTextInput"
-          ref={textInput}
-          placeholder="Type your query here..."
-          placeholderTextColor={pal.colors.textLight}
-          selectTextOnFocus
-          returnKeyType="search"
-          style={[pal.text, styles.input]}
-          onChangeText={onChangeQuery}
-        />
-      </CenteredView>
-      <View style={styles.outputContainer}>
-        {query ? (
-          <ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}>
-            {autocompleteView.searchRes.map((item, i) => (
-              <TouchableOpacity
-                key={i}
-                style={[pal.view, pal.border, styles.searchResult]}
-                onPress={() => onSelect(item.handle)}>
-                <UserAvatar
-                  handle={item.handle}
-                  displayName={item.displayName}
-                  avatar={item.avatar}
-                  size={36}
-                />
-                <View style={[s.ml10]}>
-                  <Text type="title-sm" style={pal.text}>
-                    {item.displayName || item.handle}
-                  </Text>
-                  <Text style={pal.textLight}>@{item.handle}</Text>
-                </View>
+    <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
+      <ScrollView
+        ref={scrollElRef}
+        testID="searchScrollView"
+        style={[pal.view, styles.container]}
+        onScroll={onMainScroll}
+        scrollEventThrottle={100}>
+        <View style={[pal.view, pal.border, styles.header]}>
+          <TouchableOpacity
+            testID="viewHeaderBackOrMenuBtn"
+            onPress={onPressMenu}
+            hitSlop={MENU_HITSLOP}
+            style={styles.headerMenuBtn}>
+            <UserAvatar
+              size={30}
+              handle={store.me.handle}
+              displayName={store.me.displayName}
+              avatar={store.me.avatar}
+            />
+          </TouchableOpacity>
+          <View
+            style={[
+              {backgroundColor: pal.colors.backgroundLight},
+              styles.headerSearchContainer,
+            ]}>
+            <MagnifyingGlassIcon
+              style={[pal.icon, styles.headerSearchIcon]}
+              size={21}
+            />
+            <TextInput
+              testID="searchTextInput"
+              ref={textInput}
+              placeholder="Search"
+              placeholderTextColor={pal.colors.textLight}
+              selectTextOnFocus
+              returnKeyType="search"
+              value={query}
+              style={[pal.text, styles.headerSearchInput]}
+              onFocus={() => setIsInputFocused(true)}
+              onBlur={() => setIsInputFocused(false)}
+              onChangeText={onChangeQuery}
+            />
+          </View>
+          {query ? (
+            <View style={styles.headerCancelBtn}>
+              <TouchableOpacity onPress={onPressCancelSearch}>
+                <Text>Cancel</Text>
               </TouchableOpacity>
+            </View>
+          ) : undefined}
+        </View>
+        {query && autocompleteView.searchRes.length ? (
+          <>
+            {autocompleteView.searchRes.map(item => (
+              <ProfileCard
+                key={item.did}
+                handle={item.handle}
+                displayName={item.displayName}
+                avatar={item.avatar}
+              />
             ))}
+          </>
+        ) : query && !autocompleteView.searchRes.length ? (
+          <View>
+            <Text style={[pal.textLight, styles.searchPrompt]}>
+              No results found for {autocompleteView.prefix}
+            </Text>
+          </View>
+        ) : isInputFocused ? (
+          <View>
+            <Text style={[pal.textLight, styles.searchPrompt]}>
+              Search for users on the network
+            </Text>
+          </View>
+        ) : (
+          <ScrollView onScroll={Keyboard.dismiss}>
+            <WhoToFollow key={`wtf-${lastRenderTime}`} />
+            <SuggestedPosts key={`sp-${lastRenderTime}`} />
             <View style={s.footerSpacer} />
           </ScrollView>
-        ) : (
-          <SuggestedFollows asLinks />
         )}
-      </View>
-    </View>
+        <View style={s.footerSpacer} />
+      </ScrollView>
+    </TouchableWithoutFeedback>
   )
-}
+})
 
 const styles = StyleSheet.create({
   container: {
     flex: 1,
   },
 
-  inputContainer: {
+  header: {
     flexDirection: 'row',
-    paddingVertical: 16,
-    paddingHorizontal: 16,
-    borderTopWidth: 1,
+    alignItems: 'center',
+    paddingHorizontal: 12,
+    paddingTop: 4,
+    marginBottom: 14,
   },
-  inputIcon: {
-    marginRight: 10,
-    alignSelf: 'center',
+  headerMenuBtn: {
+    width: 40,
+    height: 30,
+    marginLeft: 6,
   },
-  input: {
+  headerSearchContainer: {
     flex: 1,
-    fontSize: 16,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
   },
-
-  outputContainer: {
+  headerSearchIcon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  headerSearchInput: {
     flex: 1,
+    fontSize: 17,
+  },
+  headerCancelBtn: {
+    width: 60,
+    paddingLeft: 10,
   },
 
-  searchResult: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    paddingVertical: 12,
-    paddingHorizontal: 16,
+  searchPrompt: {
+    textAlign: 'center',
+    paddingTop: 10,
   },
 })
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index c2953b59d..94f5acd93 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -7,17 +7,20 @@ import {
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {observer} from 'mobx-react-lite'
-import {useStores} from '../../state'
+import * as AppInfo from 'lib/app-info'
+import {useStores} from 'state/index'
 import {ScreenParams} from '../routes'
-import {s} from '../lib/styles'
+import {s} from 'lib/styles'
 import {ScrollView} from '../com/util/Views'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Link} from '../com/util/Link'
 import {Text} from '../com/util/text/Text'
 import * as Toast from '../com/util/Toast'
 import {UserAvatar} from '../com/util/UserAvatar'
-import {usePalette} from '../lib/hooks/usePalette'
-import {AccountData} from '../../state/models/session'
+import {usePalette} from 'lib/hooks/usePalette'
+import {AccountData} from 'state/models/session'
+import {useAnalytics} from 'lib/analytics'
+import {DeleteAccountModal} from 'state/models/shell-ui'
 
 export const Settings = observer(function Settings({
   navIdx,
@@ -25,9 +28,14 @@ export const Settings = observer(function Settings({
 }: ScreenParams) {
   const pal = usePalette('default')
   const store = useStores()
+  const {screen, track} = useAnalytics()
   const [isSwitching, setIsSwitching] = React.useState(false)
 
   useEffect(() => {
+    screen('Settings')
+  }, [screen])
+
+  useEffect(() => {
     if (!visible) {
       return
     }
@@ -36,22 +44,30 @@ export const Settings = observer(function Settings({
   }, [visible, store, navIdx])
 
   const onPressSwitchAccount = async (acct: AccountData) => {
+    track('Settings:SwitchAccountButtonClicked')
     setIsSwitching(true)
     if (await store.session.resumeSession(acct)) {
       setIsSwitching(false)
+      store.nav.tab.fixedTabReset()
       Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
       return
     }
     setIsSwitching(false)
     Toast.show('Sorry! We need you to enter your password.')
+    store.nav.tab.fixedTabReset()
     store.session.clear()
   }
   const onPressAddAccount = () => {
+    track('Settings:AddAccountButtonClicked')
     store.session.clear()
   }
   const onPressSignout = () => {
+    track('Settings:SignOutButtonClicked')
     store.session.logout()
   }
+  const onPressDeleteAccount = () => {
+    store.shell.openModal(new DeleteAccountModal())
+  }
 
   return (
     <View style={[s.h100pct]} testID="settingsScreen">
@@ -143,22 +159,34 @@ export const Settings = observer(function Settings({
               </Text>
             </View>
           </TouchableOpacity>
+
           <View style={styles.spacer} />
           <Text type="sm-medium" style={[s.mb5]}>
+            Danger zone
+          </Text>
+          <TouchableOpacity
+            style={[pal.view, s.p10, s.mb10]}
+            onPress={onPressDeleteAccount}>
+            <Text style={pal.textLight}>Delete my account</Text>
+          </TouchableOpacity>
+          <Text type="sm-medium" style={[s.mt10, s.mb5]}>
             Developer tools
           </Text>
           <Link
             style={[pal.view, s.p10, s.mb2]}
             href="/sys/log"
             title="System log">
-            <Text style={pal.link}>System log</Text>
+            <Text style={pal.textLight}>System log</Text>
           </Link>
           <Link
             style={[pal.view, s.p10, s.mb2]}
             href="/sys/debug"
             title="Debug tools">
-            <Text style={pal.link}>Storybook</Text>
+            <Text style={pal.textLight}>Storybook</Text>
           </Link>
+          <Text type="sm" style={[s.mt10, pal.textLight]}>
+            Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
+          </Text>
           <View style={s.footerSpacer} />
         </View>
       </ScrollView>
diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/mobile/Composer.tsx
index c93931ab4..304c17725 100644
--- a/src/view/shell/mobile/Composer.tsx
+++ b/src/view/shell/mobile/Composer.tsx
@@ -2,9 +2,9 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
 import {ComposePost} from '../../com/composer/ComposePost'
-import {ComposerOpts} from '../../../state/models/shell-ui'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ComposerOpts} from 'state/models/shell-ui'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export const Composer = observer(
   ({
diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx
index a7d3e2142..ceeda8c58 100644
--- a/src/view/shell/mobile/Menu.tsx
+++ b/src/view/shell/mobile/Menu.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {
-  ScrollView,
+  Linking,
   StyleProp,
   StyleSheet,
   TouchableOpacity,
@@ -8,40 +8,56 @@ import {
   ViewStyle,
 } from 'react-native'
 import {observer} from 'mobx-react-lite'
-import VersionNumber from 'react-native-version-number'
-import {s, colors} from '../../lib/styles'
-import {useStores} from '../../../state'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {s, colors} from 'lib/styles'
+import {FEEDBACK_FORM_URL} from 'lib/constants'
+import {useStores} from 'state/index'
 import {
   HomeIcon,
   BellIcon,
   UserIcon,
   CogIcon,
   MagnifyingGlassIcon,
-} from '../../lib/icons'
+} from 'lib/icons'
+import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation'
 import {UserAvatar} from '../../com/util/UserAvatar'
 import {Text} from '../../com/util/text/Text'
 import {ToggleButton} from '../../com/util/forms/ToggleButton'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
 
 export const Menu = observer(({onClose}: {onClose: () => void}) => {
   const pal = usePalette('default')
   const store = useStores()
+  const {track} = useAnalytics()
 
   // events
   // =
 
   const onNavigate = (url: string) => {
+    track('Menu:ItemClicked', {url})
+
     onClose()
-    if (url === '/notifications') {
-      store.nav.switchTo(1, true)
+    if (url === TabPurposeMainPath[TabPurpose.Notifs]) {
+      store.nav.switchTo(TabPurpose.Notifs, true)
+    } else if (url === TabPurposeMainPath[TabPurpose.Search]) {
+      store.nav.switchTo(TabPurpose.Search, true)
     } else {
-      store.nav.switchTo(0, true)
+      store.nav.switchTo(TabPurpose.Default, true)
       if (url !== '/') {
         store.nav.navigate(url)
       }
     }
   }
 
+  const onPressFeedback = () => {
+    track('Menu:FeedbackClicked')
+    Linking.openURL(FEEDBACK_FORM_URL)
+  }
+
   // rendering
   // =
 
@@ -84,8 +100,19 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
     </TouchableOpacity>
   )
 
+  const onDarkmodePress = () => {
+    track('Menu:ItemClicked', {url: '/darkmode'})
+    store.shell.setDarkMode(!store.shell.darkMode)
+  }
+
   return (
-    <ScrollView testID="menuView" style={[styles.view, pal.view]}>
+    <View
+      testID="menuView"
+      style={[
+        styles.view,
+        pal.view,
+        store.shell.minimalShellMode && styles.viewMinimalShell,
+      ]}>
       <TouchableOpacity
         testID="profileCardButton"
         onPress={() => onNavigate(`/profile/${store.me.handle}`)}
@@ -132,7 +159,7 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
           icon={<BellIcon style={pal.text as StyleProp<ViewStyle>} size="28" />}
           label="Notifications"
           url="/notifications"
-          count={store.me.notificationCount}
+          count={store.me.notifications.unreadCount}
         />
         <MenuItem
           icon={
@@ -161,23 +188,34 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
         <ToggleButton
           label="Dark mode"
           isSelected={store.shell.darkMode}
-          onPress={() => store.shell.setDarkMode(!store.shell.darkMode)}
+          onPress={onDarkmodePress}
         />
       </View>
+      <View style={s.flex1} />
       <View style={styles.footer}>
-        <Text style={[pal.textLight]}>
-          Build version {VersionNumber.appVersion} ({VersionNumber.buildVersion}
-          )
-        </Text>
+        <MenuItem
+          icon={
+            <FontAwesomeIcon
+              style={pal.text as FontAwesomeIconStyle}
+              size={24}
+              icon={['far', 'message']}
+            />
+          }
+          label="Feedback"
+          onPress={onPressFeedback}
+        />
       </View>
-      <View style={s.footerSpacer} />
-    </ScrollView>
+    </View>
   )
 })
 
 const styles = StyleSheet.create({
   view: {
     flex: 1,
+    paddingBottom: 90,
+  },
+  viewMinimalShell: {
+    paddingBottom: 50,
   },
   section: {
     paddingHorizontal: 10,
@@ -254,7 +292,6 @@ const styles = StyleSheet.create({
   },
 
   footer: {
-    paddingHorizontal: 14,
-    paddingVertical: 18,
+    paddingHorizontal: 10,
   },
 })
diff --git a/src/view/shell/mobile/TabsSelector.tsx b/src/view/shell/mobile/TabsSelector.tsx
index 921a0c85b..92854e3b6 100644
--- a/src/view/shell/mobile/TabsSelector.tsx
+++ b/src/view/shell/mobile/TabsSelector.tsx
@@ -12,11 +12,11 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../../com/util/text/Text'
 import Swipeable from 'react-native-gesture-handler/Swipeable'
-import {useStores} from '../../../state'
-import {s, colors} from '../../lib/styles'
-import {toShareUrl} from '../../../lib/strings'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {toShareUrl} from 'lib/strings/url-helpers'
 import {match} from '../../routes'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
 const TAB_HEIGHT = 42
 
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index b0b83b12e..0b3921b7e 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -1,9 +1,8 @@
-import React, {useState, useEffect, useRef} from 'react'
+import React, {useState, useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   Animated,
   Easing,
-  FlatList,
   GestureResponderEvent,
   StatusBar,
   StyleSheet,
@@ -12,15 +11,18 @@ import {
   useColorScheme,
   useWindowDimensions,
   View,
-  ViewStyle,
 } from 'react-native'
 import {ScreenContainer, Screen} from 'react-native-screens'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
-import {TABS_ENABLED} from '../../../build-flags'
-import {useStores} from '../../../state'
-import {NavigationModel} from '../../../state/models/navigation'
+import {TABS_ENABLED} from 'lib/build-flags'
+import {useStores} from 'state/index'
+import {
+  NavigationModel,
+  TabPurpose,
+  TabPurposeMainPath,
+} from 'state/models/navigation'
 import {match, MatchResult} from '../../routes'
 import {Login} from '../../screens/Login'
 import {Menu} from './Menu'
@@ -32,19 +34,21 @@ import {Text} from '../../com/util/text/Text'
 import {ErrorBoundary} from '../../com/util/ErrorBoundary'
 import {TabsSelector} from './TabsSelector'
 import {Composer} from './Composer'
-import {s, colors} from '../../lib/styles'
-import {clamp} from '../../../lib/numbers'
+import {s, colors} from 'lib/styles'
+import {clamp} from 'lib/numbers'
 import {
   GridIcon,
   GridIconSolid,
   HomeIcon,
   HomeIconSolid,
+  MagnifyingGlassIcon,
   BellIcon,
   BellIconSolid,
-} from '../../lib/icons'
-import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
-import {useTheme} from '../../lib/ThemeContext'
-import {usePalette} from '../../lib/hooks/usePalette'
+} from 'lib/icons'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
 
 const Btn = ({
   icon,
@@ -59,6 +63,8 @@ const Btn = ({
     | 'menu-solid'
     | 'home'
     | 'home-solid'
+    | 'search'
+    | 'search-solid'
     | 'bell'
     | 'bell-solid'
   notificationCount?: number
@@ -76,24 +82,40 @@ const Btn = ({
     iconEl = <HomeIcon size={27} style={[styles.ctrlIcon, pal.text]} />
   } else if (icon === 'home-solid') {
     iconEl = <HomeIconSolid size={27} style={[styles.ctrlIcon, pal.text]} />
+  } else if (icon === 'search') {
+    iconEl = (
+      <MagnifyingGlassIcon
+        size={28}
+        style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
+      />
+    )
+  } else if (icon === 'search-solid') {
+    iconEl = (
+      <MagnifyingGlassIcon
+        size={28}
+        strokeWidth={3}
+        style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
+      />
+    )
   } else if (icon === 'bell') {
-    const addedStyles = {position: 'relative', top: -1} as ViewStyle
     iconEl = (
-      <BellIcon size={27} style={[styles.ctrlIcon, pal.text, addedStyles]} />
+      <BellIcon
+        size={27}
+        style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
+      />
     )
   } else if (icon === 'bell-solid') {
-    const addedStyles = {position: 'relative', top: -1} as ViewStyle
     iconEl = (
       <BellIconSolid
         size={27}
-        style={[styles.ctrlIcon, pal.text, addedStyles]}
+        style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
       />
     )
   } else {
     iconEl = (
       <FontAwesomeIcon
-        size={24}
         icon={icon}
+        size={24}
         style={[styles.ctrlIcon, pal.text]}
       />
     )
@@ -125,7 +147,6 @@ export const MobileShell: React.FC = observer(() => {
   const pal = usePalette('default')
   const store = useStores()
   const [isTabsSelectorActive, setTabsSelectorActive] = useState(false)
-  const scrollElRef = useRef<FlatList>(null)
   const winDim = useWindowDimensions()
   const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
   const swipeGestureInterp = useAnimatedValue(0)
@@ -136,32 +157,48 @@ export const MobileShell: React.FC = observer(() => {
   const colorScheme = useColorScheme()
   const safeAreaInsets = useSafeAreaInsets()
   const screenRenderDesc = constructScreenRenderDesc(store.nav)
+  const {track} = useAnalytics()
 
   const onPressHome = () => {
-    if (store.shell.isMainMenuOpen) {
-      store.shell.setMainMenuOpen(false)
+    track('MobileShell:HomeButtonPressed')
+    if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
+      if (!store.nav.tab.canGoBack) {
+        store.emitScreenSoftReset()
+      } else {
+        store.nav.tab.fixedTabReset()
+      }
+    } else {
+      store.nav.switchTo(TabPurpose.Default, false)
+      if (store.nav.tab.index === 0) {
+        store.nav.tab.fixedTabReset()
+      }
     }
-    if (store.nav.tab.fixedTabPurpose === 0) {
-      if (store.nav.tab.current.url === '/') {
-        scrollElRef.current?.scrollToOffset({offset: 0})
+  }
+  const onPressSearch = () => {
+    track('MobileShell:SearchButtonPressed')
+    if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
+      if (!store.nav.tab.canGoBack) {
+        store.emitScreenSoftReset()
       } else {
         store.nav.tab.fixedTabReset()
       }
     } else {
-      store.nav.switchTo(0, false)
+      store.nav.switchTo(TabPurpose.Search, false)
       if (store.nav.tab.index === 0) {
         store.nav.tab.fixedTabReset()
       }
     }
   }
   const onPressNotifications = () => {
-    if (store.shell.isMainMenuOpen) {
-      store.shell.setMainMenuOpen(false)
-    }
-    if (store.nav.tab.fixedTabPurpose === 1) {
-      store.nav.tab.fixedTabReset()
+    track('MobileShell:NotificationsButtonPressed')
+    if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
+      if (!store.nav.tab.canGoBack) {
+        store.emitScreenSoftReset()
+      } else {
+        store.nav.tab.fixedTabReset()
+      }
     } else {
-      store.nav.switchTo(1, false)
+      store.nav.switchTo(TabPurpose.Notifs, false)
       if (store.nav.tab.index === 0) {
         store.nav.tab.fixedTabReset()
       }
@@ -178,12 +215,14 @@ export const MobileShell: React.FC = observer(() => {
         toValue: 1,
         duration: 100,
         useNativeDriver: true,
+        isInteraction: false,
       }).start()
     } else {
       Animated.timing(minimalShellInterp, {
         toValue: 0,
         duration: 100,
         useNativeDriver: true,
+        isInteraction: false,
       }).start()
     }
   }, [minimalShellInterp, store.shell.minimalShellMode])
@@ -343,8 +382,12 @@ export const MobileShell: React.FC = observer(() => {
     )
   }
 
-  const isAtHome = store.nav.tab.current.url === '/'
-  const isAtNotifications = store.nav.tab.current.url === '/notifications'
+  const isAtHome =
+    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
+  const isAtSearch =
+    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
+  const isAtNotifications =
+    store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
 
   const screenBg = {
     backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1,
@@ -358,7 +401,7 @@ export const MobileShell: React.FC = observer(() => {
       />
       <View style={[styles.innerContainer, {paddingTop: safeAreaInsets.top}]}>
         <HorzSwipe
-          distThresholdDivisor={1.5}
+          distThresholdDivisor={2.5}
           useNativeDriver
           panX={swipeGestureInterp}
           swipeEnabled
@@ -405,7 +448,6 @@ export const MobileShell: React.FC = observer(() => {
                           params={params}
                           navIdx={navIdx}
                           visible={current}
-                          scrollElRef={current ? scrollElRef : undefined}
                         />
                       </ErrorBoundary>
                     </Animated.View>
@@ -454,6 +496,11 @@ export const MobileShell: React.FC = observer(() => {
           onPress={onPressHome}
           onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
         />
+        <Btn
+          icon={isAtSearch ? 'search-solid' : 'search'}
+          onPress={onPressSearch}
+          onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
+        />
         {TABS_ENABLED ? (
           <Btn
             icon={isTabsSelectorActive ? 'clone' : ['far', 'clone']}
@@ -465,7 +512,7 @@ export const MobileShell: React.FC = observer(() => {
           icon={isAtNotifications ? 'bell-solid' : 'bell'}
           onPress={onPressNotifications}
           onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined}
-          notificationCount={store.me.notificationCount}
+          notificationCount={store.me.notifications.unreadCount}
         />
       </Animated.View>
       <Modal />
@@ -576,7 +623,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     borderTopWidth: 1,
     paddingLeft: 5,
-    paddingRight: 15,
+    paddingRight: 25,
   },
   ctrl: {
     flex: 1,
@@ -614,4 +661,8 @@ const styles = StyleSheet.create({
   inactive: {
     color: colors.gray3,
   },
+  bumpUpOnePixel: {
+    position: 'relative',
+    top: -1,
+  },
 })
diff --git a/src/view/shell/web/Composer.tsx b/src/view/shell/web/Composer.tsx
index 639040097..5006b3124 100644
--- a/src/view/shell/web/Composer.tsx
+++ b/src/view/shell/web/Composer.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {StyleSheet, View} from 'react-native'
 import {ComposePost} from '../../com/composer/ComposePost'
-import {ComposerOpts} from '../../../state/models/shell-ui'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {ComposerOpts} from 'state/models/shell-ui'
+import {usePalette} from 'lib/hooks/usePalette'
 
 export const Composer = observer(
   ({
diff --git a/src/view/shell/web/DesktopLeftColumn.tsx b/src/view/shell/web/DesktopLeftColumn.tsx
index 44559b6ad..d5fe45e80 100644
--- a/src/view/shell/web/DesktopLeftColumn.tsx
+++ b/src/view/shell/web/DesktopLeftColumn.tsx
@@ -5,9 +5,9 @@ import LinearGradient from 'react-native-linear-gradient'
 import {Link} from '../../com/util/Link'
 import {Text} from '../../com/util/text/Text'
 import {UserAvatar} from '../../com/util/UserAvatar'
-import {s, colors, gradients} from '../../lib/styles'
-import {useStores} from '../../../state'
-import {usePalette} from '../../lib/hooks/usePalette'
+import {s, colors, gradients} from 'lib/styles'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
 import {
   HomeIcon,
   HomeIconSolid,
@@ -15,7 +15,7 @@ import {
   BellIconSolid,
   MagnifyingGlassIcon,
   CogIcon,
-} from '../../lib/icons'
+} from 'lib/icons'
 
 interface NavItemProps {
   label: string
@@ -97,7 +97,7 @@ export const DesktopLeftColumn = observer(() => {
       <NavItem
         href="/notifications"
         label="Notifications"
-        count={store.me.notificationCount}
+        count={store.me.notifications.unreadCount}
         icon={<BellIcon />}
         iconFilled={<BellIconSolid />}
       />
diff --git a/src/view/shell/web/DesktopRightColumn.tsx b/src/view/shell/web/DesktopRightColumn.tsx
index 2daa16f6a..22acac382 100644
--- a/src/view/shell/web/DesktopRightColumn.tsx
+++ b/src/view/shell/web/DesktopRightColumn.tsx
@@ -2,10 +2,10 @@ import React from 'react'
 import {View, StyleSheet} from 'react-native'
 import {Link} from '../../com/util/Link'
 import {Text} from '../../com/util/text/Text'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {MagnifyingGlassIcon} from '../../lib/icons'
+import {usePalette} from 'lib/hooks/usePalette'
+import {MagnifyingGlassIcon} from 'lib/icons'
 import {LiteSuggestedFollows} from '../../com/discover/LiteSuggestedFollows'
-import {s} from '../../lib/styles'
+import {s} from 'lib/styles'
 
 export const DesktopRightColumn: React.FC = () => {
   const pal = usePalette('default')
diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx
index 0eb5cf75c..7d76c01d8 100644
--- a/src/view/shell/web/index.tsx
+++ b/src/view/shell/web/index.tsx
@@ -1,7 +1,9 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {View, StyleSheet} from 'react-native'
-import {useStores} from '../../../state'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {useStores} from 'state/index'
+import {NavigationModel} from 'state/models/navigation'
 import {match, MatchResult} from '../../routes'
 import {DesktopLeftColumn} from './DesktopLeftColumn'
 import {DesktopRightColumn} from './DesktopRightColumn'
@@ -11,8 +13,8 @@ import {ErrorBoundary} from '../../com/util/ErrorBoundary'
 import {Lightbox} from '../../com/lightbox/Lightbox'
 import {Modal} from '../../com/modals/Modal'
 import {Composer} from './Composer'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {s} from '../../lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
 
 export const WebShell: React.FC = observer(() => {
   const pal = usePalette('default')