about summary refs log tree commit diff
diff options
context:
space:
mode:
authorJoão Ferreiro <ferreiro@pinkroom.dev>2022-11-28 16:56:05 +0000
committerJoão Ferreiro <ferreiro@pinkroom.dev>2022-11-28 16:56:05 +0000
commitc5f3200d6b561af94ec98259e731d9e090719df0 (patch)
tree1847500a3041ed9d1642e12c6f01e3581b8aafb6
parent5ea750599d08229d4b5b10d0e724ca14c73735f5 (diff)
parentb9c9895c45158b3db52e07114ad4305d85e803ea (diff)
downloadvoidsky-c5f3200d6b561af94ec98259e731d9e090719df0.tar.zst
Merge branch 'main' into upload-image
-rw-r--r--__tests__/string-utils.ts22
-rw-r--r--package.json3
-rw-r--r--src/App.web.tsx2
-rw-r--r--src/lib/strings.ts21
-rw-r--r--src/state/models/feed-view.ts15
-rw-r--r--src/state/models/notifications-view.ts10
-rw-r--r--src/view/com/composer/ComposePost.tsx136
-rw-r--r--src/view/com/composer/Prompt.tsx40
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx12
-rw-r--r--src/view/com/modals/CreateScene.tsx6
-rw-r--r--src/view/com/modals/EditProfile.tsx6
-rw-r--r--src/view/com/modals/InviteToScene.tsx12
-rw-r--r--src/view/com/notifications/InviteAccepter.tsx7
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx219
-rw-r--r--src/view/com/post/Post.tsx14
-rw-r--r--src/view/com/posts/FeedItem.tsx14
-rw-r--r--src/view/com/profile/ProfileHeader.tsx68
-rw-r--r--src/view/com/util/PostCtrls.tsx70
-rw-r--r--src/view/com/util/Toast.native.tsx2
-rw-r--r--src/view/com/util/Toast.tsx69
-rw-r--r--src/view/com/util/ViewHeader.tsx42
-rw-r--r--src/view/lib/icons.tsx4
-rw-r--r--src/view/screens/Home.tsx12
-rw-r--r--src/view/screens/Profile.tsx11
-rw-r--r--src/view/shell/mobile/MainMenu.tsx17
-rw-r--r--src/view/shell/mobile/index.tsx15
-rw-r--r--yarn.lock5
27 files changed, 425 insertions, 429 deletions
diff --git a/__tests__/string-utils.ts b/__tests__/string-utils.ts
index c677b44d3..fc7a8f272 100644
--- a/__tests__/string-utils.ts
+++ b/__tests__/string-utils.ts
@@ -31,6 +31,11 @@ describe('extractEntities', () => {
     'start middle end.com/foo/bar?baz=bux#hash',
     'newline1.com\nnewline2.com',
     'not.. a..url ..here',
+    'e.g.',
+    'something-cool.jpg',
+    'website.com.jpg',
+    'e.g./foo',
+    'website.com.jpg/foo',
   ]
   interface Output {
     type: string
@@ -80,6 +85,11 @@ describe('extractEntities', () => {
       {type: 'link', value: 'newline2.com', noScheme: true},
     ],
     [],
+    [],
+    [],
+    [],
+    [],
+    [],
   ]
   it('correctly handles a set of text inputs', () => {
     for (let i = 0; i < inputs.length; i++) {
@@ -145,6 +155,12 @@ describe('detectLinkables', () => {
     'start middle end.com/foo/bar?baz=bux#hash',
     'newline1.com\nnewline2.com',
     'not.. a..url ..here',
+    'e.g.',
+    'e.g. real.com fake.notreal',
+    'something-cool.jpg',
+    'website.com.jpg',
+    'e.g./foo',
+    'website.com.jpg/foo',
   ]
   const outputs = [
     ['no linkable'],
@@ -171,6 +187,12 @@ describe('detectLinkables', () => {
     ['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
     [{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
     ['not.. a..url ..here'],
+    ['e.g.'],
+    ['e.g. ', {link: 'real.com'}, ' fake.notreal'],
+    ['something-cool.jpg'],
+    ['website.com.jpg'],
+    ['e.g./foo'],
+    ['website.com.jpg/foo'],
   ]
   it('correctly handles a set of text inputs', () => {
     for (let i = 0; i < inputs.length; i++) {
diff --git a/package.json b/package.json
index 35e4615a9..13af3997a 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,8 @@
     "react-native-svg": "^12.4.0",
     "react-native-tab-view": "^3.3.0",
     "react-native-url-polyfill": "^1.3.0",
-    "react-native-web": "^0.17.7"
+    "react-native-web": "^0.17.7",
+    "tlds": "^1.234.0"
   },
   "devDependencies": {
     "@babel/core": "^7.12.9",
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 06da5e4e3..cc6f3815b 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -2,7 +2,7 @@ import React, {useState, useEffect} from 'react'
 import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
 import {DesktopWebShell} from './view/shell/desktop-web'
-import Toast from './view/com/util/Toast'
+import Toast from 'react-native-root-toast'
 
 function App() {
   const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
diff --git a/src/lib/strings.ts b/src/lib/strings.ts
index 032eec566..fb9d15b29 100644
--- a/src/lib/strings.ts
+++ b/src/lib/strings.ts
@@ -1,6 +1,7 @@
 import {AtUri} from '../third-party/uri'
 import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
 import {PROD_SERVICE} from '../state'
+import TLDs from 'tlds'
 
 export const MAX_DISPLAY_NAME = 64
 export const MAX_DESCRIPTION = 256
@@ -57,6 +58,14 @@ export function ago(date: number | string | Date): string {
   }
 }
 
+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>,
@@ -85,10 +94,14 @@ export function extractEntities(
   {
     // links
     const re =
-      /(^|\s)((https?:\/\/[\S]+)|([a-z][a-z0-9]*(\.[a-z0-9]+)+[\S]*))(\b)/dg
+      /(^|\s)((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))(\b)/dg
     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}`
       }
       ents.push({
@@ -110,7 +123,7 @@ interface DetectedLink {
 type DetectedLinkable = string | DetectedLink
 export function detectLinkables(text: string): DetectedLinkable[] {
   const re =
-    /((^|\s)@[a-z0-9\.-]*)|((^|\s)https?:\/\/[\S]+)|((^|\s)[a-z][a-z0-9]*(\.[a-z0-9]+)+[\S]*)/gi
+    /((^|\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
@@ -118,6 +131,10 @@ export function detectLinkables(text: string): DetectedLinkable[] {
     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
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 92c394dfa..33db426a4 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -7,6 +7,8 @@ import * as apilib from '../lib/api'
 import {cleanError} from '../../lib/strings'
 import {isObj, hasProp} from '../lib/type-guards'
 
+const PAGE_SIZE = 30
+
 type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
 type FeedItemWithThreadMeta = FeedItem & {
   _isThreadParent?: boolean
@@ -166,6 +168,7 @@ export class FeedModel {
   params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
   hasMore = true
   loadMoreCursor: string | undefined
+  pollCursor: string | undefined
   _loadPromise: Promise<void> | undefined
   _loadMorePromise: Promise<void> | undefined
   _loadLatestPromise: Promise<void> | undefined
@@ -300,7 +303,7 @@ export class FeedModel {
     const res = await this._getFeed({limit: 1})
     this.setHasNewLatest(
       res.data.feed[0] &&
-        (this.feed.length === 0 || res.data.feed[0].uri !== this.feed[0]?.uri),
+        (this.feed.length === 0 || res.data.feed[0].uri !== this.pollCursor),
     )
   }
 
@@ -341,7 +344,7 @@ export class FeedModel {
   private async _initialLoad(isRefreshing = false) {
     this._xLoading(isRefreshing)
     try {
-      const res = await this._getFeed()
+      const res = await this._getFeed({limit: PAGE_SIZE})
       this._replaceAll(res)
       this._xIdle()
     } catch (e: any) {
@@ -352,7 +355,7 @@ export class FeedModel {
   private async _loadLatest() {
     this._xLoading()
     try {
-      const res = await this._getFeed()
+      const res = await this._getFeed({limit: PAGE_SIZE})
       this._prependAll(res)
       this._xIdle()
     } catch (e: any) {
@@ -368,6 +371,7 @@ export class FeedModel {
     try {
       const res = await this._getFeed({
         before: this.loadMoreCursor,
+        limit: PAGE_SIZE,
       })
       this._appendAll(res)
       this._xIdle()
@@ -402,6 +406,7 @@ export class FeedModel {
 
   private _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
     this.feed.length = 0
+    this.pollCursor = res.data.feed[0]?.uri
     this._appendAll(res)
   }
 
@@ -434,6 +439,7 @@ export class FeedModel {
   }
 
   private _prependAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+    this.pollCursor = res.data.feed[0]?.uri
     let counter = this.feed.length
     const toPrepend = []
     for (const item of res.data.feed) {
@@ -493,8 +499,7 @@ function preprocessFeed(
   for (let i = feed.length - 1; i >= 0; i--) {
     const item = feed[i] as FeedItemWithThreadMeta
 
-    // dont dedup the first item so that polling works properly
-    if (dedup && i !== 0) {
+    if (dedup) {
       if (reorg.find(item2 => item2.uri === item.uri)) {
         continue
       }
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
index 09189cfbb..80e5c80c6 100644
--- a/src/state/models/notifications-view.ts
+++ b/src/state/models/notifications-view.ts
@@ -7,7 +7,7 @@ import {APP_BSKY_GRAPH} from '../../third-party/api'
 import {cleanError} from '../../lib/strings'
 
 const UNGROUPABLE_REASONS = ['trend', 'assertion']
-
+const PAGE_SIZE = 30
 const MS_60MIN = 1e3 * 60 * 60
 
 export interface GroupedNotification extends ListNotifications.Notification {
@@ -242,9 +242,10 @@ export class NotificationsViewModel {
   private async _initialLoad(isRefreshing = false) {
     this._xLoading(isRefreshing)
     try {
-      const res = await this.rootStore.api.app.bsky.notification.list(
-        this.params,
-      )
+      const params = Object.assign({}, this.params, {
+        limit: PAGE_SIZE,
+      })
+      const res = await this.rootStore.api.app.bsky.notification.list(params)
       this._replaceAll(res)
       this._xIdle()
     } catch (e: any) {
@@ -259,6 +260,7 @@ export class NotificationsViewModel {
     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)
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index 10305adb6..ce42ee17e 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useMemo, useState} from 'react'
+import React, {useEffect, useMemo, useRef, useState} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
@@ -17,9 +17,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
 import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
 import {Autocomplete} from './Autocomplete'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import ProgressCircle from '../util/ProgressCircle'
 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'
@@ -28,7 +29,6 @@ import {detectLinkables} from '../../../lib/strings'
 import {openPicker, openCamera} from 'react-native-image-crop-picker'
 
 const MAX_TEXT_LENGTH = 256
-const WARNING_TEXT_LENGTH = 200
 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
 
 export const ComposePost = observer(function ComposePost({
@@ -41,6 +41,7 @@ export const ComposePost = observer(function ComposePost({
   onClose: () => void
 }) {
   const store = useStores()
+  const textInput = useRef<TextInput>(null)
   const [isProcessing, setIsProcessing] = useState(false)
   const [error, setError] = useState('')
   const [text, setText] = useState('')
@@ -57,6 +58,22 @@ export const ComposePost = observer(function ComposePost({
   useEffect(() => {
     autocompleteView.setup()
   })
+  useEffect(() => {
+    // HACK
+    // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
+    // -prf
+    let to: NodeJS.Timeout | undefined
+    if (textInput.current) {
+      to = setTimeout(() => {
+        textInput.current?.focus()
+      }, 250)
+    }
+    return () => {
+      if (to) {
+        clearTimeout(to)
+      }
+    }
+  }, [textInput.current])
 
   useEffect(() => {
     localPhotos.setup()
@@ -90,7 +107,10 @@ export const ComposePost = observer(function ComposePost({
     }
     setIsProcessing(true)
     try {
-      await apilib.post(store, text, replyTo, autocompleteView.knownHandles)
+      const replyRef = replyTo
+        ? {uri: replyTo.uri, cid: replyTo.cid}
+        : undefined
+      await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
     } catch (e: any) {
       console.error(`Failed to create post: ${e.toString()}`)
       setError(
@@ -101,13 +121,7 @@ export const ComposePost = observer(function ComposePost({
     }
     onPost?.()
     onClose()
-    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`, {
-      duration: Toast.durations.LONG,
-      position: Toast.positions.TOP,
-      shadow: true,
-      animation: true,
-      hideOnPress: true,
-    })
+    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
   }
   const onSelectAutocompleteItem = (item: string) => {
     setText(replaceTextAutocompletePrefix(text, item))
@@ -115,12 +129,7 @@ export const ComposePost = observer(function ComposePost({
   }
 
   const canPost = text.length <= MAX_TEXT_LENGTH
-  const progressColor =
-    text.length > DANGER_TEXT_LENGTH
-      ? '#e60000'
-      : text.length > WARNING_TEXT_LENGTH
-      ? '#f7c600'
-      : undefined
+  const progressColor = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
 
   const textDecorated = useMemo(() => {
     let i = 0
@@ -142,7 +151,7 @@ export const ComposePost = observer(function ComposePost({
       <SafeAreaView style={s.flex1}>
         <View style={styles.topbar}>
           <TouchableOpacity onPress={onPressCancel}>
-            <Text style={[s.blue3, s.f16]}>Cancel</Text>
+            <Text style={[s.blue3, s.f18]}>Cancel</Text>
           </TouchableOpacity>
           <View style={s.flex1} />
           {isProcessing ? (
@@ -156,7 +165,9 @@ export const ComposePost = observer(function ComposePost({
                 start={{x: 0, y: 0}}
                 end={{x: 1, y: 1}}
                 style={styles.postBtn}>
-                <Text style={[s.white, s.f16, s.bold]}>Post</Text>
+                <Text style={[s.white, s.f16, s.bold]}>
+                  {replyTo ? 'Reply' : 'Post'}
+                </Text>
               </LinearGradient>
             </TouchableOpacity>
           ) : (
@@ -178,39 +189,46 @@ export const ComposePost = observer(function ComposePost({
           </View>
         )}
         {replyTo ? (
-          <View>
-            <Text style={s.gray4}>
-              Replying to{' '}
+          <View style={styles.replyToLayout}>
+            <UserAvatar
+              handle={replyTo.author.handle}
+              displayName={replyTo.author.displayName}
+              size={50}
+            />
+            <View style={styles.replyToPost}>
               <TextLink
                 href={`/profile/${replyTo.author.handle}`}
-                text={'@' + replyTo.author.handle}
-                style={[s.bold, s.gray5]}
+                text={replyTo.author.displayName || replyTo.author.handle}
+                style={[s.f16, s.bold]}
               />
-            </Text>
-            <View style={styles.replyToPost}>
-              <Text style={s.gray5}>{replyTo.text}</Text>
+              <Text style={[s.f16, s['lh16-1.3']]} numberOfLines={6}>
+                {replyTo.text}
+              </Text>
             </View>
           </View>
         ) : undefined}
-        <TextInput
-          multiline
-          scrollEnabled
-          onChangeText={(text: string) => onChangeText(text)}
-          placeholder={
-            replyTo
-              ? 'Write your reply'
-              : photoUris.length === 0
-              ? "What's up?"
-              : 'Add a comment...'
-          }
-          style={styles.textInput}>
-          {textDecorated}
-        </TextInput>
+        <View style={styles.textInputLayout}>
+          <UserAvatar
+            handle={store.me.handle || ''}
+            displayName={store.me.displayName}
+            size={50}
+          />
+          <TextInput
+            ref={textInput}
+            multiline
+            scrollEnabled
+            onChangeText={(text: string) => onChangeText(text)}
+            placeholder={replyTo ? 'Write your reply' : "What's up?"}
+            style={styles.textInput}>
+            {textDecorated}
+          </TextInput>
+        </View>
         {photoUris.length !== 0 && (
           <View style={styles.selectedImageContainer}>
             {photoUris.length !== 0 &&
-              photoUris.map(item => (
+              photoUris.map((item, index) => (
                 <View
+                  key={`selected-image-${index}`}
                   style={[
                     styles.selectedImage,
                     photoUris.length === 1
@@ -264,8 +282,9 @@ export const ComposePost = observer(function ComposePost({
                 style={{color: colors.blue3}}
               />
             </TouchableOpacity>
-            {localPhotos.photos.map(item => (
+            {localPhotos.photos.map((item, index) => (
               <TouchableOpacity
+                key={`local-image-${index}`}
                 style={styles.photoButton}
                 onPress={() => {
                   setPhotoUris([item.node.image.uri, ...photoUris])
@@ -343,9 +362,9 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingTop: 10,
-    paddingBottom: 5,
+    paddingBottom: 10,
     paddingHorizontal: 5,
-    height: 50,
+    height: 55,
   },
   postBtn: {
     borderRadius: 20,
@@ -371,19 +390,30 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     marginRight: 5,
   },
+  textInputLayout: {
+    flexDirection: 'row',
+    flex: 1,
+    borderTopWidth: 1,
+    borderTopColor: colors.gray2,
+    paddingTop: 16,
+  },
   textInput: {
     flex: 1,
     padding: 5,
-    fontSize: 21,
+    fontSize: 18,
+    marginLeft: 8,
+  },
+  replyToLayout: {
+    flexDirection: 'row',
+    borderTopWidth: 1,
+    borderTopColor: colors.gray2,
+    paddingTop: 16,
+    paddingBottom: 16,
   },
   replyToPost: {
-    paddingHorizontal: 8,
-    paddingVertical: 6,
-    borderWidth: 1,
-    borderColor: colors.gray2,
-    borderRadius: 6,
-    marginTop: 5,
-    marginBottom: 10,
+    flex: 1,
+    paddingLeft: 13,
+    paddingRight: 8,
   },
   contentCenter: {alignItems: 'center'},
   selectedImageContainer: {
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index f9fd7e7d3..7805e00dd 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -1,29 +1,42 @@
 import React from 'react'
 import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {colors} from '../../lib/styles'
 import {useStores} from '../../../state'
 import {UserAvatar} from '../util/UserAvatar'
 
-export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
+export function ComposePrompt({
+  noAvi = false,
+  text = "What's up?",
+  btn = 'Post',
+  onPressCompose,
+}: {
+  noAvi?: boolean
+  text?: string
+  btn?: string
+  onPressCompose: () => void
+}) {
   const store = useStores()
   const onPressAvatar = () => {
     store.nav.navigate(`/profile/${store.me.handle}`)
   }
   return (
-    <TouchableOpacity style={styles.container} onPress={onPressCompose}>
-      <TouchableOpacity style={styles.avatar} onPress={onPressAvatar}>
-        <UserAvatar
-          size={50}
-          handle={store.me.handle || ''}
-          displayName={store.me.displayName}
-        />
-      </TouchableOpacity>
+    <TouchableOpacity
+      style={[styles.container, noAvi ? styles.noAviContainer : undefined]}
+      onPress={onPressCompose}>
+      {!noAvi ? (
+        <TouchableOpacity style={styles.avatar} onPress={onPressAvatar}>
+          <UserAvatar
+            size={50}
+            handle={store.me.handle || ''}
+            displayName={store.me.displayName}
+          />
+        </TouchableOpacity>
+      ) : undefined}
       <View style={styles.textContainer}>
-        <Text style={styles.text}>What's up?</Text>
+        <Text style={styles.text}>{text}</Text>
       </View>
       <View style={styles.btn}>
-        <Text style={styles.btnText}>Post</Text>
+        <Text style={styles.btnText}>{btn}</Text>
       </View>
     </TouchableOpacity>
   )
@@ -40,6 +53,9 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     backgroundColor: colors.white,
   },
+  noAviContainer: {
+    paddingVertical: 14,
+  },
   avatar: {
     width: 50,
   },
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx
index d8cb0c4db..d5875f0f7 100644
--- a/src/view/com/discover/SuggestedFollows.tsx
+++ b/src/view/com/discover/SuggestedFollows.tsx
@@ -14,7 +14,7 @@ import _omit from 'lodash.omit'
 import {ErrorScreen} from '../util/ErrorScreen'
 import {Link} from '../util/Link'
 import {UserAvatar} from '../util/UserAvatar'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {useStores} from '../../../state'
 import * as apilib from '../../../state/lib/api'
 import {
@@ -63,10 +63,7 @@ export const SuggestedFollows = observer(
         setFollows({[item.did]: res.uri, ...follows})
       } catch (e) {
         console.log(e)
-        Toast.show('An issue occurred, please try again.', {
-          duration: Toast.durations.LONG,
-          position: Toast.positions.TOP,
-        })
+        Toast.show('An issue occurred, please try again.')
       }
     }
     const onPressUnfollow = async (item: SuggestedActor) => {
@@ -75,10 +72,7 @@ export const SuggestedFollows = observer(
         setFollows(_omit(follows, [item.did]))
       } catch (e) {
         console.log(e)
-        Toast.show('An issue occurred, please try again.', {
-          duration: Toast.durations.LONG,
-          position: Toast.positions.TOP,
-        })
+        Toast.show('An issue occurred, please try again.')
       }
     }
 
diff --git a/src/view/com/modals/CreateScene.tsx b/src/view/com/modals/CreateScene.tsx
index 445374623..646d5b242 100644
--- a/src/view/com/modals/CreateScene.tsx
+++ b/src/view/com/modals/CreateScene.tsx
@@ -1,5 +1,5 @@
 import React, {useState} from 'react'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
   StyleSheet,
@@ -71,9 +71,7 @@ export function Component({}: {}) {
           },
         )
         .catch(e => console.error(e)) // an error here is not critical
-      Toast.show('Scene created', {
-        position: Toast.positions.TOP,
-      })
+      Toast.show('Scene created')
       store.shell.closeModal()
       store.nav.navigate(`/profile/${fullHandle}`)
     } catch (e: any) {
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index f739b0843..50acccf67 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -1,5 +1,5 @@
 import React, {useState} from 'react'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {BottomSheetScrollView, BottomSheetTextInput} from '@gorhom/bottom-sheet'
@@ -52,9 +52,7 @@ export function Component({
           }
         },
       )
-      Toast.show('Profile updated', {
-        position: Toast.positions.TOP,
-      })
+      Toast.show('Profile updated')
       onUpdate?.()
       store.shell.closeModal()
     } catch (e: any) {
diff --git a/src/view/com/modals/InviteToScene.tsx b/src/view/com/modals/InviteToScene.tsx
index 2d4e372c1..8df38daf0 100644
--- a/src/view/com/modals/InviteToScene.tsx
+++ b/src/view/com/modals/InviteToScene.tsx
@@ -1,6 +1,6 @@
 import React, {useState, useEffect, useMemo} from 'react'
 import {observer} from 'mobx-react-lite'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
   FlatList,
@@ -83,10 +83,7 @@ export const Component = observer(function Component({
         follow.declaration.cid,
       )
       setCreatedInvites({[follow.did]: assertionUri, ...createdInvites})
-      Toast.show('Invite sent', {
-        duration: Toast.durations.LONG,
-        position: Toast.positions.TOP,
-      })
+      Toast.show('Invite sent')
     } catch (e) {
       setError('There was an issue with the invite. Please try again.')
       console.error(e)
@@ -119,10 +116,7 @@ export const Component = observer(function Component({
         [assertion.uri]: true,
         ...deletedPendingInvites,
       })
-      Toast.show('Invite removed', {
-        duration: Toast.durations.LONG,
-        position: Toast.positions.TOP,
-      })
+      Toast.show('Invite removed')
     } catch (e) {
       setError('There was an issue with the invite. Please try again.')
       console.error(e)
diff --git a/src/view/com/notifications/InviteAccepter.tsx b/src/view/com/notifications/InviteAccepter.tsx
index 7d735a66b..72bc06764 100644
--- a/src/view/com/notifications/InviteAccepter.tsx
+++ b/src/view/com/notifications/InviteAccepter.tsx
@@ -7,7 +7,7 @@ import {NotificationsViewItemModel} from '../../../state/models/notifications-vi
 import {ConfirmModel} from '../../../state/models/shell-ui'
 import {useStores} from '../../../state'
 import {ProfileCard} from '../profile/ProfileCard'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {s, colors, gradients} from '../../lib/styles'
 
 export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
@@ -46,10 +46,7 @@ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
       },
     })
     store.me.refreshMemberships()
-    Toast.show('Invite accepted', {
-      duration: Toast.durations.LONG,
-      position: Toast.positions.TOP,
-    })
+    Toast.show('Invite accepted')
     setConfirmationUri(uri)
   }
   return (
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 95b02837d..85c241ce4 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -8,7 +8,7 @@ import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
 import {Link} from '../util/Link'
 import {RichText} from '../util/RichText'
 import {PostDropdownBtn} from '../util/DropdownBtn'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {s, colors} from '../../lib/styles'
 import {ago, pluralize} from '../../../lib/strings'
@@ -16,6 +16,7 @@ import {useStores} from '../../../state'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/PostEmbeds'
 import {PostCtrls} from '../util/PostCtrls'
+import {ComposePrompt} from '../composer/Prompt'
 
 const PARENT_REPLY_LINE_LENGTH = 8
 const REPLYING_TO_LINE_LENGTH = 6
@@ -78,131 +79,133 @@ export const PostThreadItem = observer(function PostThreadItem({
     item.delete().then(
       () => {
         setDeleted(true)
-        Toast.show('Post deleted', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Post deleted')
       },
       e => {
         console.error(e)
-        Toast.show('Failed to delete post, please try again', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Failed to delete post, please try again')
       },
     )
   }
 
   if (item._isHighlightedPost) {
     return (
-      <View style={styles.outer}>
-        <View style={styles.layout}>
-          <View style={styles.layoutAvi}>
-            <Link href={authorHref} title={authorTitle}>
-              <UserAvatar
-                size={50}
-                displayName={item.author.displayName}
-                handle={item.author.handle}
-              />
-            </Link>
-          </View>
-          <View style={styles.layoutContent}>
-            <View style={[styles.meta, {paddingTop: 5, paddingBottom: 0}]}>
-              <Link
-                style={styles.metaItem}
-                href={authorHref}
-                title={authorTitle}>
-                <Text style={[s.f16, s.bold]} numberOfLines={1}>
-                  {item.author.displayName || item.author.handle}
-                </Text>
-              </Link>
-              <Text style={[styles.metaItem, s.f15, s.gray5]}>
-                &middot; {ago(item.indexedAt)}
-              </Text>
-              <View style={s.flex1} />
-              <PostDropdownBtn
-                style={styles.metaItem}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                isAuthor={item.author.did === store.me.did}
-                onDeletePost={onDeletePost}>
-                <FontAwesomeIcon
-                  icon="ellipsis-h"
-                  size={14}
-                  style={[s.mt2, s.mr5]}
+      <>
+        <View style={styles.outer}>
+          <View style={styles.layout}>
+            <View style={styles.layoutAvi}>
+              <Link href={authorHref} title={authorTitle}>
+                <UserAvatar
+                  size={50}
+                  displayName={item.author.displayName}
+                  handle={item.author.handle}
                 />
-              </PostDropdownBtn>
-            </View>
-            <View style={styles.meta}>
-              <Link
-                style={styles.metaItem}
-                href={authorHref}
-                title={authorTitle}>
-                <Text style={[s.f15, s.gray5]} numberOfLines={1}>
-                  @{item.author.handle}
-                </Text>
               </Link>
             </View>
-          </View>
-        </View>
-        <View style={[s.pl10, s.pr10, s.pb10]}>
-          <View
-            style={[styles.postTextContainer, styles.postTextLargeContainer]}>
-            <RichText
-              text={record.text}
-              entities={record.entities}
-              style={[styles.postText, styles.postTextLarge]}
-            />
-          </View>
-          <PostEmbeds entities={record.entities} />
-          {item._isHighlightedPost && hasEngagement ? (
-            <View style={styles.expandedInfo}>
-              {item.repostCount ? (
+            <View style={styles.layoutContent}>
+              <View style={[styles.meta, {paddingTop: 5, paddingBottom: 0}]}>
                 <Link
-                  style={styles.expandedInfoItem}
-                  href={repostsHref}
-                  title={repostsTitle}>
-                  <Text style={[s.gray5, s.semiBold, s.f18]}>
-                    <Text style={[s.bold, s.black, s.f18]}>
-                      {item.repostCount}
-                    </Text>{' '}
-                    {pluralize(item.repostCount, 'repost')}
+                  style={styles.metaItem}
+                  href={authorHref}
+                  title={authorTitle}>
+                  <Text style={[s.f16, s.bold]} numberOfLines={1}>
+                    {item.author.displayName || item.author.handle}
                   </Text>
                 </Link>
-              ) : (
-                <></>
-              )}
-              {item.upvoteCount ? (
+                <Text style={[styles.metaItem, s.f15, s.gray5]}>
+                  &middot; {ago(item.indexedAt)}
+                </Text>
+                <View style={s.flex1} />
+                <PostDropdownBtn
+                  style={styles.metaItem}
+                  itemHref={itemHref}
+                  itemTitle={itemTitle}
+                  isAuthor={item.author.did === store.me.did}
+                  onDeletePost={onDeletePost}>
+                  <FontAwesomeIcon
+                    icon="ellipsis-h"
+                    size={14}
+                    style={[s.mt2, s.mr5]}
+                  />
+                </PostDropdownBtn>
+              </View>
+              <View style={styles.meta}>
                 <Link
-                  style={styles.expandedInfoItem}
-                  href={upvotesHref}
-                  title={upvotesTitle}>
-                  <Text style={[s.gray5, s.semiBold, s.f18]}>
-                    <Text style={[s.bold, s.black, s.f18]}>
-                      {item.upvoteCount}
-                    </Text>{' '}
-                    {pluralize(item.upvoteCount, 'upvote')}
+                  style={styles.metaItem}
+                  href={authorHref}
+                  title={authorTitle}>
+                  <Text style={[s.f15, s.gray5]} numberOfLines={1}>
+                    @{item.author.handle}
                   </Text>
                 </Link>
-              ) : (
-                <></>
-              )}
+              </View>
+            </View>
+          </View>
+          <View style={[s.pl10, s.pr10, s.pb10]}>
+            <View
+              style={[styles.postTextContainer, styles.postTextLargeContainer]}>
+              <RichText
+                text={record.text}
+                entities={record.entities}
+                style={[styles.postText, styles.postTextLarge]}
+              />
+            </View>
+            <PostEmbeds entities={record.entities} style={s.mb10} />
+            {item._isHighlightedPost && hasEngagement ? (
+              <View style={styles.expandedInfo}>
+                {item.repostCount ? (
+                  <Link
+                    style={styles.expandedInfoItem}
+                    href={repostsHref}
+                    title={repostsTitle}>
+                    <Text style={[s.gray5, s.semiBold, s.f17]}>
+                      <Text style={[s.bold, s.black, s.f17]}>
+                        {item.repostCount}
+                      </Text>{' '}
+                      {pluralize(item.repostCount, 'repost')}
+                    </Text>
+                  </Link>
+                ) : (
+                  <></>
+                )}
+                {item.upvoteCount ? (
+                  <Link
+                    style={styles.expandedInfoItem}
+                    href={upvotesHref}
+                    title={upvotesTitle}>
+                    <Text style={[s.gray5, s.semiBold, s.f17]}>
+                      <Text style={[s.bold, s.black, s.f17]}>
+                        {item.upvoteCount}
+                      </Text>{' '}
+                      {pluralize(item.upvoteCount, 'upvote')}
+                    </Text>
+                  </Link>
+                ) : (
+                  <></>
+                )}
+              </View>
+            ) : (
+              <></>
+            )}
+            <View style={[s.pl10, s.pb5]}>
+              <PostCtrls
+                big
+                isReposted={!!item.myState.repost}
+                isUpvoted={!!item.myState.upvote}
+                onPressReply={onPressReply}
+                onPressToggleRepost={onPressToggleRepost}
+                onPressToggleUpvote={onPressToggleUpvote}
+              />
             </View>
-          ) : (
-            <></>
-          )}
-          <View style={[s.pl10]}>
-            <PostCtrls
-              replyCount={item.replyCount}
-              repostCount={item.repostCount}
-              upvoteCount={item.upvoteCount}
-              isReposted={!!item.myState.repost}
-              isUpvoted={!!item.myState.upvote}
-              onPressReply={onPressReply}
-              onPressToggleRepost={onPressToggleRepost}
-              onPressToggleUpvote={onPressToggleUpvote}
-            />
           </View>
         </View>
-      </View>
+        <ComposePrompt
+          noAvi
+          text="Write your reply"
+          btn="Reply"
+          onPressCompose={onPressReply}
+        />
+      </>
     )
   } else {
     return (
@@ -345,8 +348,8 @@ const styles = StyleSheet.create({
   },
   postText: {
     fontFamily: 'Helvetica Neue',
-    fontSize: 17,
-    lineHeight: 22.1, // 1.3 of 17px
+    fontSize: 16,
+    lineHeight: 20.8, // 1.3 of 16px
   },
   postTextContainer: {
     flexDirection: 'row',
@@ -371,7 +374,7 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
     borderBottomWidth: 1,
     marginTop: 5,
-    marginBottom: 10,
+    marginBottom: 15,
   },
   expandedInfoItem: {
     marginRight: 10,
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index d0df1b295..4d668cac3 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -10,7 +10,7 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/PostCtrls'
 import {RichText} from '../util/RichText'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {useStores} from '../../../state'
 import {s, colors} from '../../lib/styles'
@@ -99,15 +99,11 @@ export const Post = observer(function Post({uri}: {uri: string}) {
     item.delete().then(
       () => {
         setDeleted(true)
-        Toast.show('Post deleted', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Post deleted')
       },
       e => {
         console.error(e)
-        Toast.show('Failed to delete post, please try again', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Failed to delete post, please try again')
       },
     )
   }
@@ -196,7 +192,7 @@ const styles = StyleSheet.create({
   },
   postText: {
     fontFamily: 'Helvetica Neue',
-    fontSize: 17,
-    lineHeight: 22.1, // 1.3 of 17px
+    fontSize: 16,
+    lineHeight: 20.8, // 1.3 of 16px
   },
 })
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 4063b2008..4d50531bd 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -11,7 +11,7 @@ import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/PostCtrls'
 import {PostEmbeds} from '../util/PostEmbeds'
 import {RichText} from '../util/RichText'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {s, colors} from '../../lib/styles'
 import {useStores} from '../../../state'
@@ -70,15 +70,11 @@ export const FeedItem = observer(function FeedItem({
     item.delete().then(
       () => {
         setDeleted(true)
-        Toast.show('Post deleted', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Post deleted')
       },
       e => {
         console.error(e)
-        Toast.show('Failed to delete post, please try again', {
-          position: Toast.positions.TOP,
-        })
+        Toast.show('Failed to delete post, please try again')
       },
     )
   }
@@ -254,7 +250,7 @@ const styles = StyleSheet.create({
   },
   postText: {
     fontFamily: 'Helvetica Neue',
-    fontSize: 17,
-    lineHeight: 22.1, // 1.3 of 17px
+    fontSize: 16,
+    lineHeight: 20.8, // 1.3 of 16px
   },
 })
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 9325a88a3..1b25c7c13 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -1,12 +1,6 @@
 import React, {useMemo} from 'react'
 import {observer} from 'mobx-react-lite'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  Text,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '../../../third-party/uri'
@@ -20,9 +14,8 @@ import {
 import {pluralize} from '../../../lib/strings'
 import {s, colors} from '../../lib/styles'
 import {getGradient} from '../../lib/asset-gen'
-import {MagnifyingGlassIcon} from '../../lib/icons'
 import {DropdownBtn, DropdownItem} from '../util/DropdownBtn'
-import Toast from '../util/Toast'
+import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {RichText} from '../util/RichText'
 import {UserAvatar} from '../util/UserAvatar'
@@ -55,10 +48,6 @@ export const ProfileHeader = observer(function ProfileHeader({
           `${view.myState.follow ? 'Following' : 'No longer following'} ${
             view.displayName || view.handle
           }`,
-          {
-            duration: Toast.durations.LONG,
-            position: Toast.positions.TOP,
-          },
         )
       },
       err => console.error('Failed to toggle follow', err),
@@ -94,10 +83,7 @@ export const ProfileHeader = observer(function ProfileHeader({
         did: store.me.did || '',
         rkey: new AtUri(view.myState.member).rkey,
       })
-      Toast.show(`Scene left`, {
-        duration: Toast.durations.LONG,
-        position: Toast.positions.TOP,
-      })
+      Toast.show(`Scene left`)
     }
     onRefreshAll()
   }
@@ -108,18 +94,6 @@ export const ProfileHeader = observer(function ProfileHeader({
     return (
       <View style={styles.outer}>
         <LoadingPlaceholder width="100%" height={120} />
-        {store.nav.tab.canGoBack ? (
-          <TouchableOpacity style={styles.backButton} onPress={onPressBack}>
-            <FontAwesomeIcon
-              size={18}
-              icon="angle-left"
-              style={styles.backIcon}
-            />
-          </TouchableOpacity>
-        ) : undefined}
-        <TouchableOpacity style={styles.searchBtn} onPress={onPressSearch}>
-          <MagnifyingGlassIcon size={19} style={styles.searchIcon} />
-        </TouchableOpacity>
         <View style={styles.avi}>
           <LoadingPlaceholder
             width={80}
@@ -179,18 +153,6 @@ export const ProfileHeader = observer(function ProfileHeader({
   return (
     <View style={styles.outer}>
       <UserBanner handle={view.handle} />
-      {store.nav.tab.canGoBack ? (
-        <TouchableOpacity style={styles.backButton} onPress={onPressBack}>
-          <FontAwesomeIcon
-            size={18}
-            icon="angle-left"
-            style={styles.backIcon}
-          />
-        </TouchableOpacity>
-      ) : undefined}
-      <TouchableOpacity style={styles.searchBtn} onPress={onPressSearch}>
-        <MagnifyingGlassIcon size={19} style={styles.searchIcon} />
-      </TouchableOpacity>
       <View style={styles.avi}>
         <UserAvatar
           size={80}
@@ -353,30 +315,6 @@ const styles = StyleSheet.create({
     width: '100%',
     height: 120,
   },
-  backButton: {
-    position: 'absolute',
-    top: 10,
-    left: 12,
-    backgroundColor: '#ffff',
-    padding: 6,
-    borderRadius: 30,
-  },
-  backIcon: {
-    width: 14,
-    height: 14,
-    color: colors.black,
-  },
-  searchBtn: {
-    position: 'absolute',
-    top: 10,
-    right: 12,
-    backgroundColor: '#ffff',
-    padding: 5,
-    borderRadius: 30,
-  },
-  searchIcon: {
-    color: colors.black,
-  },
   avi: {
     position: 'absolute',
     top: 80,
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index a316d8959..10b54be3f 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -12,9 +12,10 @@ import {UpIcon, UpIconSolid} from '../../lib/icons'
 import {s, colors} from '../../lib/styles'
 
 interface PostCtrlsOpts {
-  replyCount: number
-  repostCount: number
-  upvoteCount: number
+  big?: boolean
+  replyCount?: number
+  repostCount?: number
+  upvoteCount?: number
   isReposted: boolean
   isUpvoted: boolean
   onPressReply: () => void
@@ -30,17 +31,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
   const interp2 = useSharedValue<number>(0)
 
   const anim1Style = useAnimatedStyle(() => ({
-    transform: [{scale: interpolate(interp1.value, [0, 1.0], [1.0, 3.0])}],
+    transform: [{scale: interpolate(interp1.value, [0, 1.0], [1.0, 4.0])}],
     opacity: interpolate(interp1.value, [0, 1.0], [1.0, 0.0]),
   }))
   const anim2Style = useAnimatedStyle(() => ({
-    transform: [{scale: interpolate(interp2.value, [0, 1.0], [1.0, 3.0])}],
+    transform: [{scale: interpolate(interp2.value, [0, 1.0], [1.0, 4.0])}],
     opacity: interpolate(interp2.value, [0, 1.0], [1.0, 0.0]),
   }))
 
   const onPressToggleRepostWrapper = () => {
     if (!opts.isReposted) {
-      interp1.value = withTiming(1, {duration: 300}, () => {
+      interp1.value = withTiming(1, {duration: 400}, () => {
         interp1.value = withDelay(100, withTiming(0, {duration: 20}))
       })
     }
@@ -48,7 +49,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
   }
   const onPressToggleUpvoteWrapper = () => {
     if (!opts.isUpvoted) {
-      interp2.value = withTiming(1, {duration: 300}, () => {
+      interp2.value = withTiming(1, {duration: 400}, () => {
         interp2.value = withDelay(100, withTiming(0, {duration: 20}))
       })
     }
@@ -62,9 +63,11 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           <FontAwesomeIcon
             style={styles.ctrlIcon}
             icon={['far', 'comment']}
-            size={14}
+            size={opts.big ? 20 : 14}
           />
-          <Text style={[sRedgray, s.ml5, s.f16]}>{opts.replyCount}</Text>
+          {typeof opts.replyCount !== 'undefined' ? (
+            <Text style={[sRedgray, s.ml5, s.f16]}>{opts.replyCount}</Text>
+          ) : undefined}
         </TouchableOpacity>
       </View>
       <View style={s.flex1}>
@@ -77,17 +80,19 @@ export function PostCtrls(opts: PostCtrlsOpts) {
                 opts.isReposted ? styles.ctrlIconReposted : styles.ctrlIcon
               }
               icon="retweet"
-              size={18}
+              size={opts.big ? 22 : 18}
             />
           </Animated.View>
-          <Text
-            style={
-              opts.isReposted
-                ? [s.bold, s.green3, s.f16, s.ml5]
-                : [sRedgray, s.f16, s.ml5]
-            }>
-            {opts.repostCount}
-          </Text>
+          {typeof opts.repostCount !== 'undefined' ? (
+            <Text
+              style={
+                opts.isReposted
+                  ? [s.bold, s.green3, s.f16, s.ml5]
+                  : [sRedgray, s.f16, s.ml5]
+              }>
+              {opts.repostCount}
+            </Text>
+          ) : undefined}
         </TouchableOpacity>
       </View>
       <View style={s.flex1}>
@@ -96,19 +101,28 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           onPress={onPressToggleUpvoteWrapper}>
           <Animated.View style={anim2Style}>
             {opts.isUpvoted ? (
-              <UpIconSolid style={[styles.ctrlIconUpvoted]} size={18} />
+              <UpIconSolid
+                style={[styles.ctrlIconUpvoted]}
+                size={opts.big ? 22 : 18}
+              />
             ) : (
-              <UpIcon style={[styles.ctrlIcon]} size={18} strokeWidth={1.5} />
+              <UpIcon
+                style={[styles.ctrlIcon]}
+                size={opts.big ? 22 : 18}
+                strokeWidth={1.5}
+              />
             )}
           </Animated.View>
-          <Text
-            style={
-              opts.isUpvoted
-                ? [s.bold, s.red3, s.f16, s.ml5]
-                : [sRedgray, s.f16, s.ml5]
-            }>
-            {opts.upvoteCount}
-          </Text>
+          {typeof opts.upvoteCount !== 'undefined' ? (
+            <Text
+              style={
+                opts.isUpvoted
+                  ? [s.bold, s.red3, s.f16, s.ml5]
+                  : [sRedgray, s.f16, s.ml5]
+              }>
+              {opts.upvoteCount}
+            </Text>
+          ) : undefined}
         </TouchableOpacity>
       </View>
       <View style={s.flex1}></View>
diff --git a/src/view/com/util/Toast.native.tsx b/src/view/com/util/Toast.native.tsx
deleted file mode 100644
index 4b9fd7f80..000000000
--- a/src/view/com/util/Toast.native.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import Toast from 'react-native-root-toast'
-export default Toast
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 1726b71b3..197f47422 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -1,62 +1,11 @@
-/*
- * Note: the dataSet properties are used to leverage custom CSS in public/index.html
- */
-
-import React, {useState, useEffect} from 'react'
-// @ts-ignore no declarations available -prf
-import {Text, View} from 'react-native-web'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-
-interface ActiveToast {
-  text: string
-}
-type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
-
-// globals
-// =
-let globalSetActiveToast: GlobalSetActiveToast | undefined
-let toastTimeout: NodeJS.Timeout | undefined
-
-// components
-// =
-type ToastContainerProps = {}
-const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
-  const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
-  useEffect(() => {
-    globalSetActiveToast = (t: ActiveToast | undefined) => {
-      setActiveToast(t)
-    }
+import Toast from 'react-native-root-toast'
+
+export function show(message: string) {
+  Toast.show(message, {
+    duration: Toast.durations.LONG,
+    position: 50,
+    shadow: true,
+    animation: true,
+    hideOnPress: true,
   })
-  return (
-    <>
-      {activeToast && (
-        <View dataSet={{'toast-container': 1}}>
-          <FontAwesomeIcon icon="check" size={24} />
-          <Text>{activeToast.text}</Text>
-        </View>
-      )}
-    </>
-  )
-}
-
-// exports
-// =
-export default {
-  show(text: string, _opts: any) {
-    console.log('TODO: toast', text)
-    if (toastTimeout) {
-      clearTimeout(toastTimeout)
-    }
-    globalSetActiveToast?.({text})
-    toastTimeout = setTimeout(() => {
-      globalSetActiveToast?.(undefined)
-    }, 2e3)
-  },
-  positions: {
-    TOP: 0,
-  },
-  durations: {
-    LONG: 0,
-  },
-  ToastContainer,
 }
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 55a71ea26..50b7e6532 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {UserAvatar} from './UserAvatar'
 import {colors} from '../../lib/styles'
 import {MagnifyingGlassIcon} from '../../lib/icons'
 import {useStores} from '../../../state'
@@ -9,14 +8,19 @@ import {useStores} from '../../../state'
 export function ViewHeader({
   title,
   subtitle,
+  onPost,
 }: {
   title: string
   subtitle?: string
+  onPost?: () => void
 }) {
   const store = useStores()
   const onPressBack = () => {
     store.nav.tab.goBack()
   }
+  const onPressCompose = () => {
+    store.shell.openComposer({onPost})
+  }
   const onPressSearch = () => {
     store.nav.navigate(`/search`)
   }
@@ -26,9 +30,7 @@ export function ViewHeader({
         <TouchableOpacity onPress={onPressBack} style={styles.backIcon}>
           <FontAwesomeIcon size={18} icon="angle-left" style={{marginTop: 6}} />
         </TouchableOpacity>
-      ) : (
-        <View style={styles.cornerPlaceholder} />
-      )}
+      ) : undefined}
       <View style={styles.titleContainer}>
         <Text style={styles.title}>{title}</Text>
         {subtitle ? (
@@ -37,8 +39,17 @@ export function ViewHeader({
           </Text>
         ) : undefined}
       </View>
-      <TouchableOpacity onPress={onPressSearch} style={styles.searchBtn}>
-        <MagnifyingGlassIcon size={17} style={styles.searchBtnIcon} />
+      <TouchableOpacity onPress={onPressCompose} style={styles.btn}>
+        <FontAwesomeIcon size={18} icon="plus" />
+      </TouchableOpacity>
+      <TouchableOpacity
+        onPress={onPressSearch}
+        style={[styles.btn, {marginLeft: 8}]}>
+        <MagnifyingGlassIcon
+          size={18}
+          strokeWidth={3}
+          style={styles.searchBtnIcon}
+        />
       </TouchableOpacity>
     </View>
   )
@@ -59,33 +70,28 @@ const styles = StyleSheet.create({
   titleContainer: {
     flexDirection: 'row',
     alignItems: 'baseline',
-    marginLeft: 'auto',
     marginRight: 'auto',
   },
   title: {
-    fontSize: 16,
+    fontSize: 21,
     fontWeight: '600',
   },
   subtitle: {
-    fontSize: 15,
-    marginLeft: 3,
+    fontSize: 18,
+    marginLeft: 6,
     color: colors.gray4,
     maxWidth: 200,
   },
 
-  cornerPlaceholder: {
-    width: 30,
-    height: 30,
-  },
   backIcon: {width: 30, height: 30},
-  searchBtn: {
+  btn: {
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'center',
     backgroundColor: colors.gray1,
-    width: 30,
-    height: 30,
-    borderRadius: 15,
+    width: 36,
+    height: 36,
+    borderRadius: 20,
   },
   searchBtnIcon: {
     color: colors.black,
diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx
index 05b1ec601..7e3313597 100644
--- a/src/view/lib/icons.tsx
+++ b/src/view/lib/icons.tsx
@@ -94,15 +94,17 @@ export function HomeIconSolid({
 export function MagnifyingGlassIcon({
   style,
   size,
+  strokeWidth = 2,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
+  strokeWidth?: number
 }) {
   return (
     <Svg
       fill="none"
       viewBox="0 0 24 24"
-      strokeWidth={2}
+      strokeWidth={strokeWidth}
       stroke="currentColor"
       width={size || 24}
       height={size || 24}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 8dd7ca411..5925b6f80 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -47,6 +47,7 @@ export const Home = observer(function Home({
     if (!visible) {
       return
     }
+
     if (hasSetup) {
       console.log('Updating home feed')
       defaultFeedView.update()
@@ -80,7 +81,11 @@ export const Home = observer(function Home({
 
   return (
     <View style={s.flex1}>
-      <ViewHeader title="Bluesky" subtitle="Private Beta" />
+      <ViewHeader
+        title="Bluesky"
+        subtitle="Private Beta"
+        onPost={onCreatePost}
+      />
       <Feed
         key="default"
         feed={defaultFeedView}
@@ -106,8 +111,8 @@ const styles = StyleSheet.create({
     left: 10,
     bottom: 15,
     backgroundColor: colors.pink3,
-    paddingHorizontal: 10,
-    paddingVertical: 8,
+    paddingHorizontal: 12,
+    paddingVertical: 10,
     borderRadius: 30,
     shadowColor: '#000',
     shadowOpacity: 0.3,
@@ -117,5 +122,6 @@ const styles = StyleSheet.create({
     color: colors.white,
     fontWeight: 'bold',
     marginLeft: 5,
+    fontSize: 16,
   },
 })
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index d1abcd731..2cfcf975c 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -15,7 +15,8 @@ import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
 import {ErrorScreen} from '../com/util/ErrorScreen'
 import {ErrorMessage} from '../com/util/ErrorMessage'
 import {EmptyState} from '../com/util/EmptyState'
-import Toast from '../com/util/Toast'
+import {ViewHeader} from '../com/util/ViewHeader'
+import * as Toast from '../com/util/Toast'
 import {s, colors} from '../lib/styles'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
@@ -77,10 +78,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
         `You'll be able to invite them again if you change your mind.`,
         async () => {
           await uiState.members.removeMember(membership.did)
-          Toast.show(`User removed`, {
-            duration: Toast.durations.LONG,
-            position: Toast.positions.TOP,
-          })
+          Toast.show(`User removed`)
         },
       ),
     )
@@ -219,8 +217,11 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
     renderItem = () => <View />
   }
 
+  const title =
+    uiState.profile.displayName || uiState.profile.handle || params.name
   return (
     <View style={styles.container}>
+      <ViewHeader title={title} />
       {uiState.profile.hasError ? (
         <ErrorScreen
           title="Failed to load profile"
diff --git a/src/view/shell/mobile/MainMenu.tsx b/src/view/shell/mobile/MainMenu.tsx
index d05e70a81..8a7264612 100644
--- a/src/view/shell/mobile/MainMenu.tsx
+++ b/src/view/shell/mobile/MainMenu.tsx
@@ -8,7 +8,6 @@ import {
   TouchableWithoutFeedback,
   View,
 } from 'react-native'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import Animated, {
   useSharedValue,
   useAnimatedStyle,
@@ -25,10 +24,17 @@ import {CreateSceneModel} from '../../../state/models/shell-ui'
 import {s, colors} from '../../lib/styles'
 
 export const MainMenu = observer(
-  ({active, onClose}: {active: boolean; onClose: () => void}) => {
+  ({
+    active,
+    insetBottom,
+    onClose,
+  }: {
+    active: boolean
+    insetBottom: number
+    onClose: () => void
+  }) => {
     const store = useStores()
     const initInterp = useSharedValue<number>(0)
-    const insets = useSafeAreaInsets()
 
     useEffect(() => {
       if (active) {
@@ -172,7 +178,7 @@ export const MainMenu = observer(
         <Animated.View
           style={[
             styles.wrapper,
-            {bottom: insets.bottom + 55},
+            {bottom: insetBottom + 45},
             wrapperAnimStyle,
           ]}>
           <SafeAreaView>
@@ -267,7 +273,8 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     height: 40,
     paddingHorizontal: 10,
-    marginBottom: 16,
+    marginTop: 12,
+    marginBottom: 20,
   },
   section: {
     paddingHorizontal: 10,
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index e7c695ca9..ccde52a2c 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -70,7 +70,7 @@ const Btn = ({
   onPress?: (event: GestureResponderEvent) => void
   onLongPress?: (event: GestureResponderEvent) => void
 }) => {
-  let size = 21
+  let size = 24
   let addedStyles
   let IconEl
   if (icon === 'menu') {
@@ -79,17 +79,17 @@ const Btn = ({
     IconEl = GridIconSolid
   } else if (icon === 'home') {
     IconEl = HomeIcon
-    size = 24
+    size = 27
   } else if (icon === 'home-solid') {
     IconEl = HomeIconSolid
-    size = 24
+    size = 27
   } else if (icon === 'bell') {
     IconEl = BellIcon
-    size = 24
+    size = 27
     addedStyles = {position: 'relative', top: -1} as ViewStyle
   } else if (icon === 'bell-solid') {
     IconEl = BellIconSolid
-    size = 24
+    size = 27
     addedStyles = {position: 'relative', top: -1} as ViewStyle
   } else {
     IconEl = FontAwesomeIcon
@@ -316,7 +316,7 @@ export const MobileShell: React.FC = observer(() => {
       <View
         style={[
           styles.bottomBar,
-          {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
+          {paddingBottom: clamp(safeAreaInsets.bottom, 15, 40)},
         ]}>
         <Btn
           icon={isAtHome ? 'home-solid' : 'home'}
@@ -343,6 +343,7 @@ export const MobileShell: React.FC = observer(() => {
       </View>
       <MainMenu
         active={isMainMenuActive}
+        insetBottom={clamp(safeAreaInsets.bottom, 15, 40)}
         onClose={() => setMainMenuActive(false)}
       />
       <Modal />
@@ -491,7 +492,7 @@ const styles = StyleSheet.create({
   },
   ctrl: {
     flex: 1,
-    paddingTop: 15,
+    paddingTop: 12,
     paddingBottom: 5,
   },
   notificationCount: {
diff --git a/yarn.lock b/yarn.lock
index 76c7f64f5..5209a89db 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11718,6 +11718,11 @@ thunky@^1.0.2:
   resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
   integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
 
+tlds@^1.234.0:
+  version "1.234.0"
+  resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.234.0.tgz#f61fe73f6e85c51f8503181f47dcfbd18c6910db"
+  integrity sha512-TNDfeyDIC+oroH44bMbWC+Jn/2qNrfRvDK2EXt1icOXYG5NMqoRyUosADrukfb4D8lJ3S1waaBWSvQro0erdng==
+
 tmpl@1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"