diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/analytics.tsx | 2 | ||||
-rw-r--r-- | src/lib/api/index.ts | 4 | ||||
-rw-r--r-- | src/lib/assets.native.ts | 6 | ||||
-rw-r--r-- | src/lib/build-flags.ts | 1 | ||||
-rw-r--r-- | src/lib/constants.ts | 2 | ||||
-rw-r--r-- | src/lib/hooks/useColorSchemeStyle.ts | 4 | ||||
-rw-r--r-- | src/lib/hooks/usePermissions.ts | 50 | ||||
-rw-r--r-- | src/lib/icons.tsx | 83 | ||||
-rw-r--r-- | src/lib/link-meta/bsky.ts | 153 | ||||
-rw-r--r-- | src/lib/media/manip.web.ts | 41 | ||||
-rw-r--r-- | src/lib/media/picker.web.tsx | 5 | ||||
-rw-r--r-- | src/lib/media/util.ts | 7 | ||||
-rw-r--r-- | src/lib/notifee.ts | 4 | ||||
-rw-r--r-- | src/lib/permissions.ts | 61 | ||||
-rw-r--r-- | src/lib/permissions.web.ts | 22 | ||||
-rw-r--r-- | src/lib/routes/helpers.ts | 77 | ||||
-rw-r--r-- | src/lib/routes/router.ts | 55 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 61 | ||||
-rw-r--r-- | src/lib/styles.ts | 6 |
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'}, |