about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/ErrorBoundary.tsx13
-rw-r--r--src/view/com/util/Link.tsx169
-rw-r--r--src/view/com/util/LoadLatestBtn.web.tsx13
-rw-r--r--src/view/com/util/PostEmbeds/QuoteEmbed.tsx1
-rw-r--r--src/view/com/util/PostMeta.tsx95
-rw-r--r--src/view/com/util/PostMuted.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx78
-rw-r--r--src/view/com/util/UserBanner.tsx28
-rw-r--r--src/view/com/util/UserInfoText.tsx26
-rw-r--r--src/view/com/util/ViewHeader.tsx92
-rw-r--r--src/view/com/util/Views.web.tsx7
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx10
-rw-r--r--src/view/com/util/forms/RadioButton.tsx10
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx12
14 files changed, 335 insertions, 221 deletions
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 017265f48..c7374e195 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
 import React, {Component, ErrorInfo, ReactNode} from 'react'
 import {ErrorScreen} from './error/ErrorScreen'
+import {CenteredView} from './Views'
 
 interface Props {
   children?: ReactNode
@@ -27,11 +28,13 @@ export class ErrorBoundary extends Component<Props, State> {
   public render() {
     if (this.state.hasError) {
       return (
-        <ErrorScreen
-          title="Oh no!"
-          message="There was an unexpected issue in the application. Please let us know if this happened to you!"
-          details={this.state.error.toString()}
-        />
+        <CenteredView>
+          <ErrorScreen
+            title="Oh no!"
+            message="There was an unexpected issue in the application. Please let us know if this happened to you!"
+            details={this.state.error.toString()}
+          />
+        </CenteredView>
       )
     }
 
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index bdc447937..cee4d4136 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -2,6 +2,8 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   Linking,
+  GestureResponderEvent,
+  Platform,
   StyleProp,
   TouchableWithoutFeedback,
   TouchableOpacity,
@@ -9,10 +11,22 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {
+  useLinkProps,
+  useNavigation,
+  StackActions,
+} from '@react-navigation/native'
 import {Text} from './text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '../../../routes'
 import {useStores, RootStoreModel} from 'state/index'
 import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
+import {isDesktopWeb} from 'platform/detection'
+
+type Event =
+  | React.MouseEvent<HTMLAnchorElement, MouseEvent>
+  | GestureResponderEvent
 
 export const Link = observer(function Link({
   style,
@@ -20,30 +34,33 @@ export const Link = observer(function Link({
   title,
   children,
   noFeedback,
+  asAnchor,
 }: {
   style?: StyleProp<ViewStyle>
   href?: string
   title?: string
   children?: React.ReactNode
   noFeedback?: boolean
+  asAnchor?: boolean
 }) {
   const store = useStores()
-  const onPress = () => {
-    if (href) {
-      handleLink(store, href, false)
-    }
-  }
-  const onLongPress = () => {
-    if (href) {
-      handleLink(store, href, true)
-    }
-  }
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPress = React.useCallback(
+    (e?: Event) => {
+      if (typeof href === 'string') {
+        return onPressInner(store, navigation, href, e)
+      }
+    },
+    [store, navigation, href],
+  )
+
   if (noFeedback) {
     return (
       <TouchableWithoutFeedback
         onPress={onPress}
-        onLongPress={onLongPress}
-        delayPressIn={50}>
+        // @ts-ignore web only -prf
+        href={asAnchor ? href : undefined}>
         <View style={style}>
           {children ? children : <Text>{title || 'link'}</Text>}
         </View>
@@ -52,10 +69,10 @@ export const Link = observer(function Link({
   }
   return (
     <TouchableOpacity
+      style={style}
       onPress={onPress}
-      onLongPress={onLongPress}
-      delayPressIn={50}
-      style={style}>
+      // @ts-ignore web only -prf
+      href={asAnchor ? href : undefined}>
       {children ? children : <Text>{title || 'link'}</Text>}
     </TouchableOpacity>
   )
@@ -66,35 +83,123 @@ export const TextLink = observer(function TextLink({
   style,
   href,
   text,
+  numberOfLines,
+  lineHeight,
 }: {
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
-  text: string
+  text: string | JSX.Element
+  numberOfLines?: number
+  lineHeight?: number
 }) {
+  const {...props} = useLinkProps({to: href})
   const store = useStores()
-  const onPress = () => {
-    handleLink(store, href, false)
-  }
-  const onLongPress = () => {
-    handleLink(store, href, true)
+  const navigation = useNavigation<NavigationProp>()
+
+  props.onPress = React.useCallback(
+    (e?: Event) => {
+      return onPressInner(store, navigation, href, e)
+    },
+    [store, navigation, href],
+  )
+
+  return (
+    <Text
+      type={type}
+      style={style}
+      numberOfLines={numberOfLines}
+      lineHeight={lineHeight}
+      {...props}>
+      {text}
+    </Text>
+  )
+})
+
+/**
+ * Only acts as a link on desktop web
+ */
+export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+  type = 'md',
+  style,
+  href,
+  text,
+  numberOfLines,
+  lineHeight,
+}: {
+  type?: TypographyVariant
+  style?: StyleProp<TextStyle>
+  href: string
+  text: string | JSX.Element
+  numberOfLines?: number
+  lineHeight?: number
+}) {
+  if (isDesktopWeb) {
+    return (
+      <TextLink
+        type={type}
+        style={style}
+        href={href}
+        text={text}
+        numberOfLines={numberOfLines}
+        lineHeight={lineHeight}
+      />
+    )
   }
   return (
-    <Text type={type} style={style} onPress={onPress} onLongPress={onLongPress}>
+    <Text
+      type={type}
+      style={style}
+      numberOfLines={numberOfLines}
+      lineHeight={lineHeight}>
       {text}
     </Text>
   )
 })
 
-function handleLink(store: RootStoreModel, href: string, longPress: boolean) {
-  href = convertBskyAppUrlIfNeeded(href)
-  if (href.startsWith('http')) {
-    Linking.openURL(href)
-  } else if (longPress) {
-    store.shell.closeModal() // close any active modals
-    store.nav.newTab(href)
-  } else {
-    store.shell.closeModal() // close any active modals
-    store.nav.navigate(href)
+// NOTE
+// we can't use the onPress given by useLinkProps because it will
+// match most paths to the HomeTab routes while we actually want to
+// preserve the tab the app is currently in
+//
+// we also have some additional behaviors - closing the current modal,
+// converting bsky urls, and opening http/s links in the system browser
+//
+// this method copies from the onPress implementation but adds our
+// needed customizations
+// -prf
+function onPressInner(
+  store: RootStoreModel,
+  navigation: NavigationProp,
+  href: string,
+  e?: Event,
+) {
+  let shouldHandle = false
+
+  if (Platform.OS !== 'web' || !e) {
+    shouldHandle = e ? !e.defaultPrevented : true
+  } else if (
+    !e.defaultPrevented && // onPress prevented default
+    // @ts-ignore Web only -prf
+    !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys
+    // @ts-ignore Web only -prf
+    (e.button == null || e.button === 0) && // ignore everything but left clicks
+    // @ts-ignore Web only -prf
+    [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc.
+  ) {
+    e.preventDefault()
+    shouldHandle = true
+  }
+
+  if (shouldHandle) {
+    href = convertBskyAppUrlIfNeeded(href)
+    if (href.startsWith('http')) {
+      Linking.openURL(href)
+    } else {
+      store.shell.closeModal() // close any active modals
+
+      // @ts-ignore we're not able to type check on this one -prf
+      navigation.dispatch(StackActions.push(...router.matchPath(href)))
+    }
   }
 }
diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx
index 182c1ba5d..ba33f92a7 100644
--- a/src/view/com/util/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/LoadLatestBtn.web.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {StyleSheet, TouchableOpacity} from 'react-native'
 import {Text} from './text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {UpIcon} from 'lib/icons'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
@@ -9,10 +10,11 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
   const pal = usePalette('default')
   return (
     <TouchableOpacity
-      style={[pal.view, styles.loadLatest]}
+      style={[pal.view, pal.borderDark, styles.loadLatest]}
       onPress={onPress}
       hitSlop={HITSLOP}>
       <Text type="md-bold" style={pal.text}>
+        <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
         Load new posts
       </Text>
     </TouchableOpacity>
@@ -29,8 +31,15 @@ const styles = StyleSheet.create({
     shadowOpacity: 0.2,
     shadowOffset: {width: 0, height: 2},
     shadowRadius: 4,
-    paddingHorizontal: 24,
+    paddingLeft: 20,
+    paddingRight: 24,
     paddingVertical: 10,
     borderRadius: 30,
+    borderWidth: 1,
+  },
+  icon: {
+    position: 'relative',
+    top: 2,
+    marginRight: 5,
   },
 })
diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
index 76b71a53d..f98a66b76 100644
--- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
+++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
@@ -25,6 +25,7 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
         authorAvatar={quote.author.avatar}
         authorHandle={quote.author.handle}
         authorDisplayName={quote.author.displayName}
+        postHref={itemHref}
         timestamp={quote.indexedAt}
       />
       <Text type="post-text" style={pal.text} numberOfLines={6}>
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index cde5a3e92..0bb402100 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
+import {DesktopWebTextLink} from './Link'
 import {ago} from 'lib/strings/time'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
@@ -12,6 +13,7 @@ interface PostMetaOpts {
   authorAvatar?: string
   authorHandle: string
   authorDisplayName: string | undefined
+  postHref: string
   timestamp: string
   did?: string
   declarationCid?: string
@@ -20,8 +22,8 @@ interface PostMetaOpts {
 
 export const PostMeta = observer(function (opts: PostMetaOpts) {
   const pal = usePalette('default')
-  let displayName = opts.authorDisplayName || opts.authorHandle
-  let handle = opts.authorHandle
+  const displayName = opts.authorDisplayName || opts.authorHandle
+  const handle = opts.authorHandle
   const store = useStores()
   const isMe = opts.did === store.me.did
   const isFollowing =
@@ -41,31 +43,35 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
   ) {
     // two-liner with follow button
     return (
-      <View style={[styles.metaTwoLine]}>
+      <View style={styles.metaTwoLine}>
         <View>
-          <Text
-            type="lg-bold"
-            style={[pal.text]}
-            numberOfLines={1}
-            lineHeight={1.2}>
-            {displayName}{' '}
-            <Text
+          <View style={styles.metaTwoLineTop}>
+            <DesktopWebTextLink
+              type="lg-bold"
+              style={pal.text}
+              numberOfLines={1}
+              lineHeight={1.2}
+              text={displayName}
+              href={`/profile/${opts.authorHandle}`}
+            />
+            <Text type="md" style={pal.textLight} lineHeight={1.2}>
+              &nbsp;&middot;&nbsp;
+            </Text>
+            <DesktopWebTextLink
               type="md"
               style={[styles.metaItem, pal.textLight]}
-              lineHeight={1.2}>
-              &middot; {ago(opts.timestamp)}
-            </Text>
-          </Text>
-          <Text
+              lineHeight={1.2}
+              text={ago(opts.timestamp)}
+              href={opts.postHref}
+            />
+          </View>
+          <DesktopWebTextLink
             type="md"
             style={[styles.metaItem, pal.textLight]}
-            lineHeight={1.2}>
-            {handle ? (
-              <Text type="md" style={[pal.textLight]}>
-                @{handle}
-              </Text>
-            ) : undefined}
-          </Text>
+            lineHeight={1.2}
+            text={`@${handle}`}
+            href={`/profile/${opts.authorHandle}`}
+          />
         </View>
 
         <View>
@@ -84,31 +90,36 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     <View style={styles.meta}>
       {typeof opts.authorAvatar !== 'undefined' && (
         <View style={[styles.metaItem, styles.avatar]}>
-          <UserAvatar
-            avatar={opts.authorAvatar}
-            handle={opts.authorHandle}
-            displayName={opts.authorDisplayName}
-            size={16}
-          />
+          <UserAvatar avatar={opts.authorAvatar} size={16} />
         </View>
       )}
       <View style={[styles.metaItem, styles.maxWidth]}>
-        <Text
+        <DesktopWebTextLink
           type="lg-bold"
-          style={[pal.text]}
+          style={pal.text}
           numberOfLines={1}
-          lineHeight={1.2}>
-          {displayName}
-          {handle ? (
-            <Text type="md" style={[pal.textLight]}>
-              &nbsp;{handle}
-            </Text>
-          ) : undefined}
-        </Text>
+          lineHeight={1.2}
+          text={
+            <>
+              {displayName}
+              <Text type="md" style={[pal.textLight]}>
+                &nbsp;{handle}
+              </Text>
+            </>
+          }
+          href={`/profile/${opts.authorHandle}`}
+        />
       </View>
-      <Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}>
-        &middot; {ago(opts.timestamp)}
+      <Text type="md" style={pal.textLight} lineHeight={1.2}>
+        &middot;&nbsp;
       </Text>
+      <DesktopWebTextLink
+        type="md"
+        style={[styles.metaItem, pal.textLight]}
+        lineHeight={1.2}
+        text={ago(opts.timestamp)}
+        href={opts.postHref}
+      />
     </View>
   )
 })
@@ -125,6 +136,10 @@ const styles = StyleSheet.create({
     justifyContent: 'space-between',
     paddingBottom: 2,
   },
+  metaTwoLineTop: {
+    flexDirection: 'row',
+    alignItems: 'baseline',
+  },
   metaItem: {
     paddingRight: 5,
   },
diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx
index d8573bd56..539a71ecf 100644
--- a/src/view/com/util/PostMuted.tsx
+++ b/src/view/com/util/PostMuted.tsx
@@ -7,7 +7,7 @@ import {Text} from './text/Text'
 export function PostMutedWrapper({
   isMuted,
   children,
-}: React.PropsWithChildren<{isMuted: boolean}>) {
+}: React.PropsWithChildren<{isMuted?: boolean}>) {
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
   if (!isMuted || override) {
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d0d2c273b..2e0632521 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg'
+import Svg, {Circle, Path} 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'
@@ -11,52 +11,48 @@ import {
   PickedMedia,
 } from '../../../lib/media/picker'
 import {
-  requestPhotoAccessIfNeeded,
-  requestCameraAccessIfNeeded,
-} from 'lib/permissions'
+  usePhotoLibraryPermission,
+  useCameraPermission,
+} from 'lib/hooks/usePermissions'
 import {useStores} from 'state/index'
-import {colors, gradients} from 'lib/styles'
+import {colors} from 'lib/styles'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 
+function DefaultAvatar({size}: {size: number}) {
+  return (
+    <Svg
+      width={size}
+      height={size}
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="none">
+      <Circle cx="12" cy="12" r="12" fill="#0070ff" />
+      <Circle cx="12" cy="9.5" r="3.5" fill="#fff" />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="#fff"
+        d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
+      />
+    </Svg>
+  )
+}
+
 export function UserAvatar({
   size,
-  handle,
   avatar,
-  displayName,
   onSelectNewAvatar,
 }: {
   size: number
-  handle: string
-  displayName: string | undefined
   avatar?: string | null
   onSelectNewAvatar?: (img: PickedMedia | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
-  const initials = getInitials(displayName || handle)
-
-  const renderSvg = (svgSize: number, svgInitials: string) => (
-    <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
-      <Defs>
-        <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
-          <Stop offset="0" stopColor={gradients.blue.start} stopOpacity="1" />
-          <Stop offset="1" stopColor={gradients.blue.end} stopOpacity="1" />
-        </LinearGradient>
-      </Defs>
-      <Circle cx="50" cy="50" r="50" fill="url(#grad)" />
-      <Text
-        fill="white"
-        fontSize="50"
-        fontWeight="bold"
-        x="50"
-        y="67"
-        textAnchor="middle">
-        {svgInitials}
-      </Text>
-    </Svg>
-  )
+  const {requestCameraAccessIfNeeded} = useCameraPermission()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
   const dropdownItems = [
     !isWeb && {
@@ -124,7 +120,7 @@ export function UserAvatar({
           source={{uri: avatar}}
         />
       ) : (
-        renderSvg(size, initials)
+        <DefaultAvatar size={size} />
       )}
       <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
@@ -141,26 +137,10 @@ export function UserAvatar({
       source={{uri: avatar}}
     />
   ) : (
-    renderSvg(size, initials)
+    <DefaultAvatar size={size} />
   )
 }
 
-function getInitials(str: string): string {
-  const tokens = str
-    .toLowerCase()
-    .replace(/[^a-z]/g, '')
-    .split(' ')
-    .filter(Boolean)
-    .map(v => v.trim())
-  if (tokens.length >= 2 && tokens[0][0] && tokens[0][1]) {
-    return tokens[0][0].toUpperCase() + tokens[1][0].toUpperCase()
-  }
-  if (tokens.length === 1 && tokens[0][0]) {
-    return tokens[0][0].toUpperCase()
-  }
-  return 'X'
-}
-
 const styles = StyleSheet.create({
   editButtonContainer: {
     position: 'absolute',
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 16e05311b..d89de9158 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
+import Svg, {Rect} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import Image from 'view/com/util/images/Image'
-import {colors, gradients} from 'lib/styles'
+import {colors} from 'lib/styles'
 import {
   openCamera,
   openCropper,
@@ -13,9 +13,9 @@ import {
 } from '../../../lib/media/picker'
 import {useStores} from 'state/index'
 import {
-  requestPhotoAccessIfNeeded,
-  requestCameraAccessIfNeeded,
-} from 'lib/permissions'
+  usePhotoLibraryPermission,
+  useCameraPermission,
+} from 'lib/hooks/usePermissions'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
@@ -29,6 +29,9 @@ export function UserBanner({
 }) {
   const store = useStores()
   const pal = usePalette('default')
+  const {requestCameraAccessIfNeeded} = useCameraPermission()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+
   const dropdownItems = [
     !isWeb && {
       label: 'Camera',
@@ -80,19 +83,8 @@ export function UserBanner({
   ]
 
   const renderSvg = () => (
-    <Svg width="100%" height="150" viewBox="50 0 200 100">
-      <Defs>
-        <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
-          <Stop
-            offset="0"
-            stopColor={gradients.blueDark.start}
-            stopOpacity="1"
-          />
-          <Stop offset="1" stopColor={gradients.blueDark.end} stopOpacity="1" />
-        </LinearGradient>
-      </Defs>
-      <Rect x="0" y="0" width="400" height="100" fill="url(#grad)" />
-      <Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" />
+    <Svg width="100%" height="150" viewBox="0 0 400 100">
+      <Rect x="0" y="0" width="400" height="100" fill="#0070ff" />
     </Svg>
   )
 
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index 84170b3bf..4753c9b01 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -1,7 +1,7 @@
 import React, {useState, useEffect} from 'react'
 import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
-import {Link} from './Link'
+import {DesktopWebTextLink} from './Link'
 import {Text} from './text/Text'
 import {LoadingPlaceholder} from './LoadingPlaceholder'
 import {useStores} from 'state/index'
@@ -14,7 +14,6 @@ export function UserInfoText({
   failed,
   prefix,
   style,
-  asLink,
 }: {
   type?: TypographyVariant
   did: string
@@ -23,7 +22,6 @@ export function UserInfoText({
   failed?: string
   prefix?: string
   style?: StyleProp<TextStyle>
-  asLink?: boolean
 }) {
   attr = attr || 'handle'
   failed = failed || 'user'
@@ -64,9 +62,14 @@ export function UserInfoText({
     )
   } else if (profile) {
     inner = (
-      <Text type={type} style={style} lineHeight={1.2} numberOfLines={1}>{`${
-        prefix || ''
-      }${profile[attr] || profile.handle}`}</Text>
+      <DesktopWebTextLink
+        type={type}
+        style={style}
+        lineHeight={1.2}
+        numberOfLines={1}
+        href={`/profile/${profile.handle}`}
+        text={`${prefix || ''}${profile[attr] || profile.handle}`}
+      />
     )
   } else {
     inner = (
@@ -78,17 +81,6 @@ export function UserInfoText({
     )
   }
 
-  if (asLink) {
-    const title = profile?.displayName || profile?.handle || 'User'
-    return (
-      <Link
-        href={`/profile/${profile?.handle ? profile.handle : did}`}
-        title={title}>
-        {inner}
-      </Link>
-    )
-  }
-
   return inner
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index ffd1b1d63..a99282512 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -2,17 +2,19 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
 import {UserAvatar} from './UserAvatar'
 import {Text} from './text/Text'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useAnalytics} from 'lib/analytics'
-import {isDesktopWeb} from '../../../platform/detection'
+import {NavigationProp} from 'lib/routes/types'
+import {isDesktopWeb} from 'platform/detection'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
-export const ViewHeader = observer(function ViewHeader({
+export const ViewHeader = observer(function ({
   title,
   canGoBack,
   hideOnScroll,
@@ -23,50 +25,55 @@ export const ViewHeader = observer(function ViewHeader({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const onPressBack = () => {
-    store.nav.tab.goBack()
-  }
-  const onPressMenu = () => {
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressMenu = React.useCallback(() => {
     track('ViewHeader:MenuButtonClicked')
-    store.shell.setMainMenuOpen(true)
-  }
-  if (typeof canGoBack === 'undefined') {
-    canGoBack = store.nav.tab.canGoBack
-  }
+    store.shell.openDrawer()
+  }, [track, store])
+
   if (isDesktopWeb) {
     return <></>
+  } else {
+    if (typeof canGoBack === 'undefined') {
+      canGoBack = navigation.canGoBack()
+    }
+
+    return (
+      <Container hideOnScroll={hideOnScroll || false}>
+        <TouchableOpacity
+          testID="viewHeaderBackOrMenuBtn"
+          onPress={canGoBack ? onPressBack : onPressMenu}
+          hitSlop={BACK_HITSLOP}
+          style={canGoBack ? styles.backBtn : styles.backBtnWide}>
+          {canGoBack ? (
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              style={[styles.backIcon, pal.text]}
+            />
+          ) : (
+            <UserAvatar size={30} avatar={store.me.avatar} />
+          )}
+        </TouchableOpacity>
+        <View style={styles.titleContainer} pointerEvents="none">
+          <Text type="title" style={[pal.text, styles.title]}>
+            {title}
+          </Text>
+        </View>
+        <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+      </Container>
+    )
   }
-  return (
-    <Container hideOnScroll={hideOnScroll || false}>
-      <TouchableOpacity
-        testID="viewHeaderBackOrMenuBtn"
-        onPress={canGoBack ? onPressBack : onPressMenu}
-        hitSlop={BACK_HITSLOP}
-        style={canGoBack ? styles.backBtn : styles.backBtnWide}>
-        {canGoBack ? (
-          <FontAwesomeIcon
-            size={18}
-            icon="angle-left"
-            style={[styles.backIcon, pal.text]}
-          />
-        ) : (
-          <UserAvatar
-            size={30}
-            handle={store.me.handle}
-            displayName={store.me.displayName}
-            avatar={store.me.avatar}
-          />
-        )}
-      </TouchableOpacity>
-      <View style={styles.titleContainer} pointerEvents="none">
-        <Text type="title" style={[pal.text, styles.title]}>
-          {title}
-        </Text>
-      </View>
-      <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
-    </Container>
-  )
 })
 
 const Container = observer(
@@ -119,8 +126,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingHorizontal: 12,
-    paddingTop: 6,
-    paddingBottom: 6,
+    paddingVertical: 6,
   },
   headerFloating: {
     position: 'absolute',
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 8b5adaa04..9a43697b5 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -23,7 +23,6 @@ import {
   ViewProps,
 } from 'react-native'
 import {addStyle, colors} from 'lib/styles'
-import {DESKTOP_HEADER_HEIGHT} from 'lib/constants'
 
 export function CenteredView({
   style,
@@ -73,14 +72,14 @@ export const ScrollView = React.forwardRef(function (
 const styles = StyleSheet.create({
   container: {
     width: '100%',
-    maxWidth: 550,
+    maxWidth: 600,
     marginLeft: 'auto',
     marginRight: 'auto',
   },
   containerScroll: {
     width: '100%',
-    height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`,
-    maxWidth: 550,
+    minHeight: '100vh',
+    maxWidth: 600,
     marginLeft: 'auto',
     marginRight: 'auto',
   },
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index ac83d1a54..d6ae800c6 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -17,7 +17,6 @@ import {Button, ButtonType} from './Button'
 import {colors} from 'lib/styles'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useStores} from 'state/index'
-import {TABS_ENABLED} from 'lib/build-flags'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 
@@ -138,15 +137,6 @@ export function PostDropdownBtn({
   const store = useStores()
 
   const dropdownItems: DropdownItem[] = [
-    TABS_ENABLED
-      ? {
-          icon: ['far', 'clone'],
-          label: 'Open in new tab',
-          onPress() {
-            store.nav.newTab(itemHref)
-          },
-        }
-      : undefined,
     {
       icon: 'language',
       label: 'Translate...',
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index 57a875cd3..d6b2bb119 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -41,6 +41,9 @@ export function RadioButton({
     'secondary-light': {
       borderColor: theme.palette.secondary.border,
     },
+    default: {
+      borderColor: theme.palette.default.border,
+    },
     'default-light': {
       borderColor: theme.palette.default.border,
     },
@@ -69,6 +72,9 @@ export function RadioButton({
       'secondary-light': {
         backgroundColor: theme.palette.secondary.background,
       },
+      default: {
+        backgroundColor: theme.palette.primary.background,
+      },
       'default-light': {
         backgroundColor: theme.palette.primary.background,
       },
@@ -103,6 +109,10 @@ export function RadioButton({
       color: theme.palette.secondary.textInverted,
       fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
     },
+    default: {
+      color: theme.palette.default.text,
+      fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
+    },
     'default-light': {
       color: theme.palette.default.text,
       fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx
index 005d1165e..a6e0ba3fe 100644
--- a/src/view/com/util/forms/ToggleButton.tsx
+++ b/src/view/com/util/forms/ToggleButton.tsx
@@ -42,6 +42,9 @@ export function ToggleButton({
     'secondary-light': {
       borderColor: theme.palette.secondary.border,
     },
+    default: {
+      borderColor: theme.palette.default.border,
+    },
     'default-light': {
       borderColor: theme.palette.default.border,
     },
@@ -77,6 +80,11 @@ export function ToggleButton({
         backgroundColor: theme.palette.secondary.background,
         opacity: isSelected ? 1 : 0.5,
       },
+      default: {
+        backgroundColor: isSelected
+          ? theme.palette.primary.background
+          : colors.gray3,
+      },
       'default-light': {
         backgroundColor: isSelected
           ? theme.palette.primary.background
@@ -113,6 +121,10 @@ export function ToggleButton({
       color: theme.palette.secondary.textInverted,
       fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
     },
+    default: {
+      color: theme.palette.default.text,
+      fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
+    },
     'default-light': {
       color: theme.palette.default.text,
       fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,