Move to expo and react-navigation (#288)

* WIP - adding expo

* WIP - adding expo 2

* Fix tsc

* Finish adding expo

* Disable the 'require cycle' warning

* Tweak plist

* Modify some dependency versions to make expo happy

* Fix icon fill

* Get Web compiling for expo

* 1.7

* Switch to react-navigation in expo2 (#287)

* WIP Switch to react-navigation

* WIP Switch to react-navigation 2

* WIP Switch to react-navigation 3

* Convert all screens to react navigation

* Update BottomBar for react navigation

* Update mobile menu to be react-native drawer

* Fixes to drawer and bottombar

* Factor out some helpers

* Replace the navigation model with react-navigation

* Restructure the shell folder and fix the header positioning

* Restore the error boundary

* Fix tsc

* Implement not-found page

* Remove react-native-gesture-handler (no longer used)

* Handle notifee card presses

* Handle all navigations from the state layer

* Fix drawer behaviors

* Fix two linking issues

* Switch to our react-native-progress fork to fix an svg rendering issue

* Get Web working with react-navigation

* Refactor routes and navigation for a bit more clarity

* Remove dead code

* Rework Web shell to left/right nav to make this easier

* Fix ViewHeader for desktop web

* Hide profileheader back btn on desktop web

* Move the compose button to the left nav

* Implement reply prompt in threads for desktop web

* Composer refactors

* Factor out all platform-specific text input behaviors from the composer

* Small fix

* Update the web build to use tiptap for the composer

* Tune up the mention autocomplete dropdown

* Simplify the default avatar and banner

* Fixes to link cards in web composer

* Fix dropdowns on web

* Tweak load latest on desktop

* Add web beta message and feedback link

* Fix up links in desktop web
This commit is contained in:
Paul Frazee 2023-03-13 16:01:43 -05:00 committed by GitHub
parent 503e03d91e
commit 56cf890deb
222 changed files with 8705 additions and 6338 deletions

View file

@ -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

View file

@ -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') ||

View file

@ -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')

View file

@ -1,2 +1 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false

View file

@ -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

View file

@ -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
}

View file

@ -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}
}

View file

@ -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}>

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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(';'))
}

7
src/lib/media/util.ts Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

77
src/lib/routes/helpers.ts Normal file
View file

@ -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}],
},
},
],
}
}

55
src/lib/routes/router.ts Normal file
View file

@ -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',
)
},
}
}

61
src/lib/routes/types.ts Normal file
View file

@ -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
}

View file

@ -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'},