about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/analytics.tsx2
-rw-r--r--src/lib/api/index.ts4
-rw-r--r--src/lib/assets.native.ts6
-rw-r--r--src/lib/build-flags.ts1
-rw-r--r--src/lib/constants.ts2
-rw-r--r--src/lib/hooks/useColorSchemeStyle.ts4
-rw-r--r--src/lib/hooks/usePermissions.ts50
-rw-r--r--src/lib/icons.tsx83
-rw-r--r--src/lib/link-meta/bsky.ts153
-rw-r--r--src/lib/media/manip.web.ts41
-rw-r--r--src/lib/media/picker.web.tsx5
-rw-r--r--src/lib/media/util.ts7
-rw-r--r--src/lib/notifee.ts4
-rw-r--r--src/lib/permissions.ts61
-rw-r--r--src/lib/permissions.web.ts22
-rw-r--r--src/lib/routes/helpers.ts77
-rw-r--r--src/lib/routes/router.ts55
-rw-r--r--src/lib/routes/types.ts61
-rw-r--r--src/lib/styles.ts6
19 files changed, 443 insertions, 201 deletions
diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx
index 5358a8682..725dd2328 100644
--- a/src/lib/analytics.tsx
+++ b/src/lib/analytics.tsx
@@ -16,7 +16,7 @@ export function init(store: RootStoreModel) {
   // this method is a copy of segment's own lifecycle event tracking
   // we handle it manually to ensure that it never fires while the app is backgrounded
   // -prf
-  segmentClient.onContextLoaded(() => {
+  segmentClient.isReady.onChange(() => {
     if (AppState.currentState !== 'active') {
       store.log.debug('Prevented a metrics ping while the app was backgrounded')
       return
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 3b8af44e8..85eca4a61 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -117,7 +117,9 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     if (opts.extLink.localThumb) {
       opts.onStateChange?.('Uploading link thumbnail...')
       let encoding
-      if (opts.extLink.localThumb.path.endsWith('.png')) {
+      if (opts.extLink.localThumb.mime) {
+        encoding = opts.extLink.localThumb.mime
+      } else if (opts.extLink.localThumb.path.endsWith('.png')) {
         encoding = 'image/png'
       } else if (
         opts.extLink.localThumb.path.endsWith('.jpeg') ||
diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts
index d7f4a7287..d7ef9a05e 100644
--- a/src/lib/assets.native.ts
+++ b/src/lib/assets.native.ts
@@ -1,5 +1,5 @@
 import {ImageRequireSource} from 'react-native'
 
-export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg')
-export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg')
-export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png')
+export const DEF_AVATAR: ImageRequireSource = require('../../assets/default-avatar.jpg')
+export const TABS_EXPLAINER: ImageRequireSource = require('../../assets/tabs-explainer.jpg')
+export const CLOUD_SPLASH: ImageRequireSource = require('../../assets/cloud-splash.png')
diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts
index 155230e5d..28b650b6f 100644
--- a/src/lib/build-flags.ts
+++ b/src/lib/build-flags.ts
@@ -1,2 +1 @@
 export const LOGIN_INCLUDE_DEV_SERVERS = true
-export const TABS_ENABLED = false
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 31947cd8f..ef4bb0f08 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -166,5 +166,3 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
 export const POST_IMG_MAX_WIDTH = 2000
 export const POST_IMG_MAX_HEIGHT = 2000
 export const POST_IMG_MAX_SIZE = 1000000
-
-export const DESKTOP_HEADER_HEIGHT = 57
diff --git a/src/lib/hooks/useColorSchemeStyle.ts b/src/lib/hooks/useColorSchemeStyle.ts
index 61e3d7cc9..18c48b961 100644
--- a/src/lib/hooks/useColorSchemeStyle.ts
+++ b/src/lib/hooks/useColorSchemeStyle.ts
@@ -1,6 +1,6 @@
-import {useColorScheme} from 'react-native'
+import {useTheme} from 'lib/ThemeContext'
 
 export function useColorSchemeStyle(lightStyle: any, darkStyle: any) {
-  const colorScheme = useColorScheme()
+  const colorScheme = useTheme().colorScheme
   return colorScheme === 'dark' ? darkStyle : lightStyle
 }
diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts
new file mode 100644
index 000000000..36a92ac32
--- /dev/null
+++ b/src/lib/hooks/usePermissions.ts
@@ -0,0 +1,50 @@
+import {Alert} from 'react-native'
+import {Camera} from 'expo-camera'
+import * as MediaLibrary from 'expo-media-library'
+import {Linking} from 'react-native'
+
+const openSettings = () => {
+  Linking.openURL('app-settings:')
+}
+
+const openPermissionAlert = (perm: string) => {
+  Alert.alert(
+    'Permission needed',
+    `Bluesky does not have permission to access your ${perm}.`,
+    [
+      {
+        text: 'Cancel',
+        style: 'cancel',
+      },
+      {text: 'Open Settings', onPress: () => openSettings()},
+    ],
+  )
+}
+
+export function usePhotoLibraryPermission() {
+  const [mediaLibraryPermissions] = MediaLibrary.usePermissions()
+  const requestPhotoAccessIfNeeded = async () => {
+    if (mediaLibraryPermissions?.status === 'granted') {
+      return true
+    } else {
+      openPermissionAlert('photo library')
+      return false
+    }
+  }
+  return {requestPhotoAccessIfNeeded}
+}
+
+export function useCameraPermission() {
+  const [cameraPermissionStatus] = Camera.useCameraPermissions()
+
+  const requestCameraAccessIfNeeded = async () => {
+    if (cameraPermissionStatus?.granted) {
+      return true
+    } else {
+      openPermissionAlert('camera')
+      return false
+    }
+  }
+
+  return {requestCameraAccessIfNeeded}
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index f82ea2602..e194e7a87 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -73,12 +73,10 @@ export function HomeIconSolid({
   style,
   size,
   strokeWidth = 4,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -89,11 +87,6 @@ export function HomeIconSolid({
       style={style}>
       <Path
         fill="currentColor"
-        stroke="none"
-        opacity={fillOpacity}
-        d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
-      />
-      <Path
         strokeWidth={strokeWidth}
         d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
       />
@@ -158,12 +151,10 @@ export function MagnifyingGlassIcon2Solid({
   style,
   size,
   strokeWidth = 2,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -181,7 +172,6 @@ export function MagnifyingGlassIcon2Solid({
         ry="7"
         stroke="none"
         fill="currentColor"
-        opacity={fillOpacity}
       />
       <Ellipse cx="12" cy="11" rx="9" ry="9" />
       <Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
@@ -219,12 +209,10 @@ export function BellIconSolid({
   style,
   size,
   strokeWidth = 1.5,
-  fillOpacity = 1,
 }: {
   style?: StyleProp<ViewStyle>
   size?: string | number
   strokeWidth?: number
-  fillOpacity?: number
 }) {
   return (
     <Svg
@@ -237,10 +225,7 @@ export function BellIconSolid({
       <Path
         d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z"
         fill="currentColor"
-        stroke="none"
-        opacity={fillOpacity}
       />
-      <Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" />
       <Line x1="9" y1="22" x2="15" y2="22" />
     </Svg>
   )
@@ -278,6 +263,34 @@ export function CogIcon({
   )
 }
 
+export function CogIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size || 32}
+      height={size || 32}
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M 9.594 3.94 C 9.684 3.398 10.154 3 10.704 3 L 13.297 3 C 13.847 3 14.317 3.398 14.407 3.94 L 14.62 5.221 C 14.683 5.595 14.933 5.907 15.265 6.091 C 15.339 6.131 15.412 6.174 15.485 6.218 C 15.809 6.414 16.205 6.475 16.56 6.342 L 17.777 5.886 C 18.292 5.692 18.872 5.9 19.147 6.376 L 20.443 8.623 C 20.718 9.099 20.608 9.705 20.183 10.054 L 19.18 10.881 C 18.887 11.121 18.742 11.494 18.749 11.873 C 18.751 11.958 18.751 12.043 18.749 12.128 C 18.742 12.506 18.887 12.878 19.179 13.118 L 20.184 13.946 C 20.608 14.296 20.718 14.9 20.444 15.376 L 19.146 17.623 C 18.871 18.099 18.292 18.307 17.777 18.114 L 16.56 17.658 C 16.205 17.525 15.81 17.586 15.484 17.782 C 15.412 17.826 15.338 17.869 15.264 17.91 C 14.933 18.093 14.683 18.405 14.62 18.779 L 14.407 20.059 C 14.317 20.602 13.847 21 13.297 21 L 10.703 21 C 10.153 21 9.683 20.602 9.593 20.06 L 9.38 18.779 C 9.318 18.405 9.068 18.093 8.736 17.909 C 8.662 17.868 8.589 17.826 8.516 17.782 C 8.191 17.586 7.796 17.525 7.44 17.658 L 6.223 18.114 C 5.708 18.307 5.129 18.1 4.854 17.624 L 3.557 15.377 C 3.282 14.901 3.392 14.295 3.817 13.946 L 4.821 13.119 C 5.113 12.879 5.258 12.506 5.251 12.127 C 5.249 12.042 5.249 11.957 5.251 11.872 C 5.258 11.494 5.113 11.122 4.821 10.882 L 3.817 10.054 C 3.393 9.705 3.283 9.1 3.557 8.624 L 4.854 6.377 C 5.129 5.9 5.709 5.692 6.224 5.886 L 7.44 6.342 C 7.796 6.475 8.191 6.414 8.516 6.218 C 8.588 6.174 8.662 6.131 8.736 6.09 C 9.068 5.907 9.318 5.595 9.38 5.221 Z M 13.5 9.402 C 11.5 8.247 9 9.691 9 12 C 9 13.072 9.572 14.062 10.5 14.598 C 12.5 15.753 15 14.309 15 12 C 15 10.928 14.428 9.938 13.5 9.402 Z"
+        fill="currentColor"
+      />
+    </Svg>
+  )
+}
+
 // Copyright (c) 2020 Refactoring UI Inc.
 // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
 export function MoonIcon({
@@ -336,6 +349,45 @@ export function UserIcon({
   )
 }
 
+export function UserIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size || 32}
+      height={size || 32}
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="currentColor"
+        d="M 15 9.75 C 15 12.059 12.5 13.503 10.5 12.348 C 9.572 11.812 9 10.822 9 9.75 C 9 7.441 11.5 5.997 13.5 7.152 C 14.428 7.688 15 8.678 15 9.75 Z"
+      />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        fill="currentColor"
+        d="M 17.982 18.725 C 16.565 16.849 14.35 15.748 12 15.75 C 9.65 15.748 7.435 16.849 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
+      />
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M 17.981 18.725 C 23.158 14.12 21.409 5.639 14.833 3.458 C 8.257 1.277 1.786 7.033 3.185 13.818 C 3.576 15.716 4.57 17.437 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
+      />
+    </Svg>
+  )
+}
+
 // Copyright (c) 2020 Refactoring UI Inc.
 // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
 export function UserGroupIcon({
@@ -674,6 +726,7 @@ export function ComposeIcon2({
     <Svg
       viewBox="0 0 24 24"
       stroke="currentColor"
+      fill="none"
       width={size || 24}
       height={size || 24}
       style={style}>
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index c9c2ed31a..0d8e8c69b 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,19 +1,20 @@
 import {LikelyType, LinkMeta} from './link-meta'
-import {match as matchRoute} from 'view/routes'
+// import {match as matchRoute} from 'view/routes'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 import {RootStoreModel} from 'state/index'
 import {PostThreadViewModel} from 'state/models/post-thread-view'
 import {ComposerOptsQuote} from 'state/models/shell-ui'
 
-import {Home} from 'view/screens/Home'
-import {Search} from 'view/screens/Search'
-import {Notifications} from 'view/screens/Notifications'
-import {PostThread} from 'view/screens/PostThread'
-import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
-import {PostRepostedBy} from 'view/screens/PostRepostedBy'
-import {Profile} from 'view/screens/Profile'
-import {ProfileFollowers} from 'view/screens/ProfileFollowers'
-import {ProfileFollows} from 'view/screens/ProfileFollows'
+// TODO
+// import {Home} from 'view/screens/Home'
+// import {Search} from 'view/screens/Search'
+// import {Notifications} from 'view/screens/Notifications'
+// import {PostThread} from 'view/screens/PostThread'
+// import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
+// import {PostRepostedBy} from 'view/screens/PostRepostedBy'
+// import {Profile} from 'view/screens/Profile'
+// import {ProfileFollowers} from 'view/screens/ProfileFollowers'
+// import {ProfileFollows} from 'view/screens/ProfileFollows'
 
 // NOTE
 // this is a hack around the lack of hosted social metadata
@@ -24,77 +25,77 @@ export async function extractBskyMeta(
   url: string,
 ): Promise<LinkMeta> {
   url = convertBskyAppUrlIfNeeded(url)
-  const route = matchRoute(url)
+  // const route = matchRoute(url)
   let meta: LinkMeta = {
     likelyType: LikelyType.AtpData,
     url,
-    title: route.defaultTitle,
+    // title: route.defaultTitle,
   }
 
-  if (route.Com === Home) {
-    meta = {
-      ...meta,
-      title: 'Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (route.Com === Search) {
-    meta = {
-      ...meta,
-      title: 'Search - Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (route.Com === Notifications) {
-    meta = {
-      ...meta,
-      title: 'Notifications - Bluesky',
-      description: 'A new kind of social network',
-    }
-  } else if (
-    route.Com === PostThread ||
-    route.Com === PostUpvotedBy ||
-    route.Com === PostRepostedBy
-  ) {
-    // post and post-related screens
-    const threadUri = makeRecordUri(
-      route.params.name,
-      'app.bsky.feed.post',
-      route.params.rkey,
-    )
-    const threadView = new PostThreadViewModel(store, {
-      uri: threadUri,
-      depth: 0,
-    })
-    await threadView.setup().catch(_err => undefined)
-    const title = [
-      route.Com === PostUpvotedBy
-        ? 'Likes on a post by'
-        : route.Com === PostRepostedBy
-        ? 'Reposts of a post by'
-        : 'Post by',
-      threadView.thread?.post.author.displayName ||
-        threadView.thread?.post.author.handle ||
-        'a bluesky user',
-    ].join(' ')
-    meta = {
-      ...meta,
-      title,
-      description: threadView.thread?.postRecord?.text,
-    }
-  } else if (
-    route.Com === Profile ||
-    route.Com === ProfileFollowers ||
-    route.Com === ProfileFollows
-  ) {
-    // profile and profile-related screens
-    const profile = await store.profiles.getProfile(route.params.name)
-    if (profile?.data) {
-      meta = {
-        ...meta,
-        title: profile.data.displayName || profile.data.handle,
-        description: profile.data.description,
-      }
-    }
-  }
+  // if (route.Com === Home) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (route.Com === Search) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Search - Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (route.Com === Notifications) {
+  //   meta = {
+  //     ...meta,
+  //     title: 'Notifications - Bluesky',
+  //     description: 'A new kind of social network',
+  //   }
+  // } else if (
+  //   route.Com === PostThread ||
+  //   route.Com === PostUpvotedBy ||
+  //   route.Com === PostRepostedBy
+  // ) {
+  //   // post and post-related screens
+  //   const threadUri = makeRecordUri(
+  //     route.params.name,
+  //     'app.bsky.feed.post',
+  //     route.params.rkey,
+  //   )
+  //   const threadView = new PostThreadViewModel(store, {
+  //     uri: threadUri,
+  //     depth: 0,
+  //   })
+  //   await threadView.setup().catch(_err => undefined)
+  //   const title = [
+  //     route.Com === PostUpvotedBy
+  //       ? 'Likes on a post by'
+  //       : route.Com === PostRepostedBy
+  //       ? 'Reposts of a post by'
+  //       : 'Post by',
+  //     threadView.thread?.post.author.displayName ||
+  //       threadView.thread?.post.author.handle ||
+  //       'a bluesky user',
+  //   ].join(' ')
+  //   meta = {
+  //     ...meta,
+  //     title,
+  //     description: threadView.thread?.postRecord?.text,
+  //   }
+  // } else if (
+  //   route.Com === Profile ||
+  //   route.Com === ProfileFollowers ||
+  //   route.Com === ProfileFollows
+  // ) {
+  //   // profile and profile-related screens
+  //   const profile = await store.profiles.getProfile(route.params.name)
+  //   if (profile?.data) {
+  //     meta = {
+  //       ...meta,
+  //       title: profile.data.displayName || profile.data.handle,
+  //       description: profile.data.description,
+  //     }
+  //   }
+  // }
 
   return meta
 }
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index e617d01af..cd0bb3bc9 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,5 +1,6 @@
 // import {Share} from 'react-native'
 // import * as Toast from 'view/com/util/Toast'
+import {extractDataUriMime, getDataUriSize} from './util'
 
 export interface DownloadAndResizeOpts {
   uri: string
@@ -18,9 +19,15 @@ export interface Image {
   height: number
 }
 
-export async function downloadAndResize(_opts: DownloadAndResizeOpts) {
-  // TODO
-  throw new Error('TODO')
+export async function downloadAndResize(opts: DownloadAndResizeOpts) {
+  const controller = new AbortController()
+  const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
+  const res = await fetch(opts.uri)
+  const resBody = await res.blob()
+  clearTimeout(to)
+
+  const dataUri = await blobToDataUri(resBody)
+  return await resize(dataUri, opts)
 }
 
 export interface ResizeOpts {
@@ -31,11 +38,18 @@ export interface ResizeOpts {
 }
 
 export async function resize(
-  _localUri: string,
+  dataUri: string,
   _opts: ResizeOpts,
 ): Promise<Image> {
-  // TODO
-  throw new Error('TODO')
+  const dim = await getImageDim(dataUri)
+  // TODO -- need to resize
+  return {
+    path: dataUri,
+    mime: extractDataUriMime(dataUri),
+    size: getDataUriSize(dataUri),
+    width: dim.width,
+    height: dim.height,
+  }
 }
 
 export async function compressIfNeeded(
@@ -86,3 +100,18 @@ export async function getImageDim(path: string): Promise<Dim> {
   await promise
   return {width: img.width, height: img.height}
 }
+
+function blobToDataUri(blob: Blob): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onloadend = () => {
+      if (typeof reader.result === 'string') {
+        resolve(reader.result)
+      } else {
+        reject(new Error('Failed to read blob'))
+      }
+    }
+    reader.onerror = reject
+    reader.readAsDataURL(blob)
+  })
+}
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 746feaedd..43675074e 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -10,6 +10,7 @@ import {
   compressIfNeeded,
   moveToPremanantPath,
 } from 'lib/media/manip'
+import {extractDataUriMime} from './util'
 
 interface PickedFile {
   uri: string
@@ -138,7 +139,3 @@ function selectFile(opts: PickerOpts): Promise<PickedFile> {
     input.click()
   })
 }
-
-function extractDataUriMime(uri: string): string {
-  return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
-}
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
new file mode 100644
index 000000000..a27c71d82
--- /dev/null
+++ b/src/lib/media/util.ts
@@ -0,0 +1,7 @@
+export function extractDataUriMime(uri: string): string {
+  return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
+}
+
+export function getDataUriSize(uri: string): number {
+  return Math.round((uri.length * 3) / 4) // very rough estimate
+}
diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts
index fb0afdd60..4baf64050 100644
--- a/src/lib/notifee.ts
+++ b/src/lib/notifee.ts
@@ -1,9 +1,9 @@
 import notifee, {EventType} from '@notifee/react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {RootStoreModel} from 'state/models/root-store'
-import {TabPurpose} from 'state/models/navigation'
 import {NotificationsViewItemModel} from 'state/models/notifications-view'
 import {enforceLen} from 'lib/strings/helpers'
+import {resetToTab} from '../Navigation'
 
 export function init(store: RootStoreModel) {
   store.onUnreadNotifications(count => notifee.setBadgeCount(count))
@@ -16,7 +16,7 @@ export function init(store: RootStoreModel) {
     store.log.debug('Notifee foreground event', {type})
     if (type === EventType.PRESS) {
       store.log.debug('User pressed a notifee, opening notifications')
-      store.nav.switchTo(TabPurpose.Notifs, true)
+      resetToTab('NotificationsTab')
     }
   })
   notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
deleted file mode 100644
index ab2c73ca6..000000000
--- a/src/lib/permissions.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import {Alert} from 'react-native'
-import {
-  check,
-  openSettings,
-  Permission,
-  PermissionStatus,
-  PERMISSIONS,
-  RESULTS,
-} from 'react-native-permissions'
-
-export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY
-export const CAMERA = PERMISSIONS.IOS.CAMERA
-
-/**
- * Returns `true` if the user has granted permission or hasn't made
- * a decision yet. Returns `false` if unavailable or not granted.
- */
-export async function hasAccess(perm: Permission): Promise<boolean> {
-  const status = await check(perm)
-  return isntANo(status)
-}
-
-export async function requestAccessIfNeeded(
-  perm: Permission,
-): Promise<boolean> {
-  if (await hasAccess(perm)) {
-    return true
-  }
-  let permDescription
-  if (perm === PHOTO_LIBRARY) {
-    permDescription = 'photo library'
-  } else if (perm === CAMERA) {
-    permDescription = 'camera'
-  } else {
-    return false
-  }
-  Alert.alert(
-    'Permission needed',
-    `Bluesky does not have permission to access your ${permDescription}.`,
-    [
-      {
-        text: 'Cancel',
-        style: 'cancel',
-      },
-      {text: 'Open Settings', onPress: () => openSettings()},
-    ],
-  )
-  return false
-}
-
-export async function requestPhotoAccessIfNeeded() {
-  return requestAccessIfNeeded(PHOTO_LIBRARY)
-}
-
-export async function requestCameraAccessIfNeeded() {
-  return requestAccessIfNeeded(CAMERA)
-}
-
-function isntANo(status: PermissionStatus): boolean {
-  return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED
-}
diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts
deleted file mode 100644
index 5b69637ed..000000000
--- a/src/lib/permissions.web.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
-At the moment, Web doesn't have any equivalence for these.
-*/
-
-export const PHOTO_LIBRARY = ''
-export const CAMERA = ''
-
-export async function hasAccess(_perm: any): Promise<boolean> {
-  return true
-}
-
-export async function requestAccessIfNeeded(_perm: any): Promise<boolean> {
-  return true
-}
-
-export async function requestPhotoAccessIfNeeded() {
-  return requestAccessIfNeeded(PHOTO_LIBRARY)
-}
-
-export async function requestCameraAccessIfNeeded() {
-  return requestAccessIfNeeded(CAMERA)
-}
diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts
new file mode 100644
index 000000000..be76b9669
--- /dev/null
+++ b/src/lib/routes/helpers.ts
@@ -0,0 +1,77 @@
+import {State, RouteParams} from './types'
+
+export function getCurrentRoute(state: State) {
+  let node = state.routes[state.index || 0]
+  while (node.state?.routes && typeof node.state?.index === 'number') {
+    node = node.state?.routes[node.state?.index]
+  }
+  return node
+}
+
+export function isStateAtTabRoot(state: State | undefined) {
+  if (!state) {
+    // NOTE
+    // if state is not defined it's because init is occuring
+    // and therefore we can safely assume we're at root
+    // -prf
+    return true
+  }
+  const currentRoute = getCurrentRoute(state)
+  return (
+    isTab(currentRoute.name, 'Home') ||
+    isTab(currentRoute.name, 'Search') ||
+    isTab(currentRoute.name, 'Notifications')
+  )
+}
+
+export function isTab(current: string, route: string) {
+  // NOTE
+  // our tab routes can be variously referenced by 3 different names
+  // this helper deals with that weirdness
+  // -prf
+  return (
+    current === route ||
+    current === `${route}Tab` ||
+    current === `${route}Inner`
+  )
+}
+
+export enum TabState {
+  InsideAtRoot,
+  Inside,
+  Outside,
+}
+export function getTabState(state: State | undefined, tab: string): TabState {
+  if (!state) {
+    return TabState.Outside
+  }
+  const currentRoute = getCurrentRoute(state)
+  if (isTab(currentRoute.name, tab)) {
+    return TabState.InsideAtRoot
+  } else if (isTab(state.routes[state.index || 0].name, tab)) {
+    return TabState.Inside
+  }
+  return TabState.Outside
+}
+
+export function buildStateObject(
+  stack: string,
+  route: string,
+  params: RouteParams,
+) {
+  if (stack === 'Flat') {
+    return {
+      routes: [{name: route, params}],
+    }
+  }
+  return {
+    routes: [
+      {
+        name: stack,
+        state: {
+          routes: [{name: route, params}],
+        },
+      },
+    ],
+  }
+}
diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts
new file mode 100644
index 000000000..05e0a63de
--- /dev/null
+++ b/src/lib/routes/router.ts
@@ -0,0 +1,55 @@
+import {RouteParams, Route} from './types'
+
+export class Router {
+  routes: [string, Route][] = []
+  constructor(description: Record<string, string>) {
+    for (const [screen, pattern] of Object.entries(description)) {
+      this.routes.push([screen, createRoute(pattern)])
+    }
+  }
+
+  matchName(name: string): Route | undefined {
+    for (const [screenName, route] of this.routes) {
+      if (screenName === name) {
+        return route
+      }
+    }
+  }
+
+  matchPath(path: string): [string, RouteParams] {
+    let name = 'NotFound'
+    let params: RouteParams = {}
+    for (const [screenName, route] of this.routes) {
+      const res = route.match(path)
+      if (res) {
+        name = screenName
+        params = res.params
+        break
+      }
+    }
+    return [name, params]
+  }
+}
+
+function createRoute(pattern: string): Route {
+  let matcherReInternal = pattern.replace(
+    /:([\w]+)/g,
+    (_m, name) => `(?<${name}>[^/]+)`,
+  )
+  const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i')
+  return {
+    match(path: string) {
+      const res = matcherRe.exec(path)
+      if (res) {
+        return {params: res.groups || {}}
+      }
+      return undefined
+    },
+    build(params: Record<string, string>) {
+      return pattern.replace(
+        /:([\w]+)/g,
+        (_m, name) => params[name] || 'undefined',
+      )
+    },
+  }
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
new file mode 100644
index 000000000..e339a46bf
--- /dev/null
+++ b/src/lib/routes/types.ts
@@ -0,0 +1,61 @@
+import {NavigationState, PartialState} from '@react-navigation/native'
+import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
+
+export type {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+export type CommonNavigatorParams = {
+  NotFound: undefined
+  Settings: undefined
+  Profile: {name: string}
+  ProfileFollowers: {name: string}
+  ProfileFollows: {name: string}
+  PostThread: {name: string; rkey: string}
+  PostUpvotedBy: {name: string; rkey: string}
+  PostRepostedBy: {name: string; rkey: string}
+  Debug: undefined
+  Log: undefined
+}
+
+export type HomeTabNavigatorParams = CommonNavigatorParams & {
+  Home: undefined
+}
+
+export type SearchTabNavigatorParams = CommonNavigatorParams & {
+  Search: undefined
+}
+
+export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
+  Notifications: undefined
+}
+
+export type FlatNavigatorParams = CommonNavigatorParams & {
+  Home: undefined
+  Search: undefined
+  Notifications: undefined
+}
+
+export type AllNavigatorParams = CommonNavigatorParams & {
+  HomeTab: undefined
+  Home: undefined
+  SearchTab: undefined
+  Search: undefined
+  NotificationsTab: undefined
+  Notifications: undefined
+}
+
+// NOTE
+// this isn't strictly correct but it should be close enough
+// a TS wizard might be able to get this 100%
+// -prf
+export type NavigationProp = NativeStackNavigationProp<AllNavigatorParams>
+
+export type State =
+  | NavigationState
+  | Omit<PartialState<NavigationState>, 'stale'>
+
+export type RouteParams = Record<string, string>
+export type MatchResult = {params: RouteParams}
+export type Route = {
+  match: (path: string) => MatchResult | undefined
+  build: (params: RouteParams) => string
+}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index dbce39178..328229f46 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,7 +1,5 @@
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
-import {isDesktopWeb} from 'platform/detection'
-import {DESKTOP_HEADER_HEIGHT} from './constants'
 
 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
 export const colors = {
@@ -161,9 +159,7 @@ export const s = StyleSheet.create({
   // dimensions
   w100pct: {width: '100%'},
   h100pct: {height: '100%'},
-  hContentRegion: isDesktopWeb
-    ? {height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`}
-    : {height: '100%'},
+  hContentRegion: {height: '100%'},
 
   // text align
   textLeft: {textAlign: 'left'},