From 56cf890debeb9872f791ccb992a5587f2c05fd9e Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Mar 2023 16:01:43 -0500 Subject: 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 --- src/App.native.tsx | 33 +- src/App.web.tsx | 24 +- src/Navigation.tsx | 287 ++++++++++ src/app.json | 4 - src/index.js | 12 - src/lib/analytics.tsx | 2 +- src/lib/api/index.ts | 4 +- src/lib/assets.native.ts | 6 +- src/lib/build-flags.ts | 1 - src/lib/constants.ts | 2 - src/lib/hooks/useColorSchemeStyle.ts | 4 +- src/lib/hooks/usePermissions.ts | 50 ++ src/lib/icons.tsx | 83 ++- src/lib/link-meta/bsky.ts | 153 ++--- src/lib/media/manip.web.ts | 41 +- src/lib/media/picker.web.tsx | 5 +- src/lib/media/util.ts | 7 + src/lib/notifee.ts | 4 +- src/lib/permissions.ts | 61 -- src/lib/permissions.web.ts | 22 - src/lib/routes/helpers.ts | 77 +++ src/lib/routes/router.ts | 55 ++ src/lib/routes/types.ts | 61 ++ src/lib/styles.ts | 6 +- src/routes.ts | 16 + src/state/models/navigation.ts | 434 -------------- src/state/models/root-store.ts | 12 +- src/state/models/shell-ui.ts | 11 +- src/state/models/user-local-photos.ts | 24 - src/view/com/composer/ComposePost.tsx | 637 --------------------- src/view/com/composer/Composer.tsx | 425 ++++++++++++++ src/view/com/composer/ExternalEmbed.tsx | 1 + src/view/com/composer/Prompt.tsx | 26 +- src/view/com/composer/SelectedPhoto.tsx | 96 ---- .../com/composer/autocomplete/Autocomplete.tsx | 77 --- .../com/composer/autocomplete/Autocomplete.web.tsx | 59 -- src/view/com/composer/photos/OpenCameraBtn.tsx | 84 +++ .../com/composer/photos/PhotoCarouselPicker.tsx | 187 ------ .../composer/photos/PhotoCarouselPicker.web.tsx | 10 - src/view/com/composer/photos/SelectPhotoBtn.tsx | 94 +++ src/view/com/composer/photos/SelectedPhotos.tsx | 96 ++++ src/view/com/composer/text-input/TextInput.tsx | 252 ++++++-- src/view/com/composer/text-input/TextInput.web.tsx | 169 ++++-- .../composer/text-input/mobile/Autocomplete.tsx | 75 +++ .../com/composer/text-input/web/Autocomplete.tsx | 157 +++++ src/view/com/composer/useExternalLinkFetch.ts | 90 +++ src/view/com/login/CreateAccount.tsx | 3 + src/view/com/login/Signin.tsx | 22 +- src/view/com/modals/ChangeHandle.tsx | 5 + src/view/com/modals/DeleteAccount.tsx | 7 +- src/view/com/modals/EditProfile.tsx | 5 +- src/view/com/modals/ServerInput.tsx | 3 + src/view/com/modals/crop-image/CropImage.web.tsx | 3 +- src/view/com/notifications/FeedItem.tsx | 40 +- src/view/com/post-thread/PostThread.tsx | 56 +- src/view/com/post-thread/PostThreadItem.tsx | 19 +- src/view/com/post/Post.tsx | 10 +- src/view/com/posts/Feed.tsx | 14 +- src/view/com/posts/FeedItem.tsx | 24 +- src/view/com/profile/ProfileCard.tsx | 10 +- src/view/com/profile/ProfileHeader.tsx | 105 ++-- src/view/com/util/ErrorBoundary.tsx | 13 +- src/view/com/util/Link.tsx | 169 ++++-- src/view/com/util/LoadLatestBtn.web.tsx | 13 +- src/view/com/util/PostEmbeds/QuoteEmbed.tsx | 1 + src/view/com/util/PostMeta.tsx | 95 +-- src/view/com/util/PostMuted.tsx | 2 +- src/view/com/util/UserAvatar.tsx | 78 +-- src/view/com/util/UserBanner.tsx | 28 +- src/view/com/util/UserInfoText.tsx | 26 +- src/view/com/util/ViewHeader.tsx | 92 +-- src/view/com/util/Views.web.tsx | 7 +- src/view/com/util/forms/DropdownButton.tsx | 10 - src/view/com/util/forms/RadioButton.tsx | 10 + src/view/com/util/forms/ToggleButton.tsx | 12 + src/view/routes.ts | 91 --- src/view/screens/Contacts.tsx | 88 --- src/view/screens/Debug.tsx | 6 +- src/view/screens/Home.tsx | 68 +-- src/view/screens/Log.tsx | 22 +- src/view/screens/NotFound.tsx | 48 +- src/view/screens/Notifications.tsx | 40 +- src/view/screens/PostDownvotedBy.tsx | 27 - src/view/screens/PostRepostedBy.tsx | 19 +- src/view/screens/PostThread.tsx | 78 ++- src/view/screens/PostUpvotedBy.tsx | 20 +- src/view/screens/Profile.tsx | 57 +- src/view/screens/ProfileFollowers.tsx | 19 +- src/view/screens/ProfileFollows.tsx | 19 +- src/view/screens/Search.tsx | 53 +- src/view/screens/Search.web.tsx | 28 +- src/view/screens/Settings.tsx | 54 +- src/view/shell/BottomBar.tsx | 255 +++++++++ src/view/shell/Composer.tsx | 83 +++ src/view/shell/Composer.web.tsx | 59 ++ src/view/shell/Drawer.tsx | 386 +++++++++++++ src/view/shell/desktop/LeftNav.tsx | 254 ++++++++ src/view/shell/desktop/RightNav.tsx | 46 ++ src/view/shell/desktop/Search.tsx | 145 +++++ src/view/shell/index.tsx | 139 +++++ src/view/shell/index.web.tsx | 113 ++++ src/view/shell/mobile/BottomBar.tsx | 271 --------- src/view/shell/mobile/Composer.tsx | 86 --- src/view/shell/mobile/Menu.tsx | 354 ------------ src/view/shell/mobile/index.tsx | 335 ----------- src/view/shell/web/Composer.tsx | 66 --- src/view/shell/web/DesktopHeader.tsx | 222 ------- src/view/shell/web/DesktopSearch.tsx | 139 ----- src/view/shell/web/index.tsx | 150 ----- 109 files changed, 4412 insertions(+), 4346 deletions(-) create mode 100644 src/Navigation.tsx delete mode 100644 src/app.json delete mode 100644 src/index.js create mode 100644 src/lib/hooks/usePermissions.ts create mode 100644 src/lib/media/util.ts delete mode 100644 src/lib/permissions.ts delete mode 100644 src/lib/permissions.web.ts create mode 100644 src/lib/routes/helpers.ts create mode 100644 src/lib/routes/router.ts create mode 100644 src/lib/routes/types.ts create mode 100644 src/routes.ts delete mode 100644 src/state/models/navigation.ts delete mode 100644 src/state/models/user-local-photos.ts delete mode 100644 src/view/com/composer/ComposePost.tsx create mode 100644 src/view/com/composer/Composer.tsx delete mode 100644 src/view/com/composer/SelectedPhoto.tsx delete mode 100644 src/view/com/composer/autocomplete/Autocomplete.tsx delete mode 100644 src/view/com/composer/autocomplete/Autocomplete.web.tsx create mode 100644 src/view/com/composer/photos/OpenCameraBtn.tsx delete mode 100644 src/view/com/composer/photos/PhotoCarouselPicker.tsx delete mode 100644 src/view/com/composer/photos/PhotoCarouselPicker.web.tsx create mode 100644 src/view/com/composer/photos/SelectPhotoBtn.tsx create mode 100644 src/view/com/composer/photos/SelectedPhotos.tsx create mode 100644 src/view/com/composer/text-input/mobile/Autocomplete.tsx create mode 100644 src/view/com/composer/text-input/web/Autocomplete.tsx create mode 100644 src/view/com/composer/useExternalLinkFetch.ts delete mode 100644 src/view/routes.ts delete mode 100644 src/view/screens/Contacts.tsx delete mode 100644 src/view/screens/PostDownvotedBy.tsx create mode 100644 src/view/shell/BottomBar.tsx create mode 100644 src/view/shell/Composer.tsx create mode 100644 src/view/shell/Composer.web.tsx create mode 100644 src/view/shell/Drawer.tsx create mode 100644 src/view/shell/desktop/LeftNav.tsx create mode 100644 src/view/shell/desktop/RightNav.tsx create mode 100644 src/view/shell/desktop/Search.tsx create mode 100644 src/view/shell/index.tsx create mode 100644 src/view/shell/index.web.tsx delete mode 100644 src/view/shell/mobile/BottomBar.tsx delete mode 100644 src/view/shell/mobile/Composer.tsx delete mode 100644 src/view/shell/mobile/Menu.tsx delete mode 100644 src/view/shell/mobile/index.tsx delete mode 100644 src/view/shell/web/Composer.tsx delete mode 100644 src/view/shell/web/DesktopHeader.tsx delete mode 100644 src/view/shell/web/DesktopSearch.tsx delete mode 100644 src/view/shell/web/index.tsx (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index 8bb204923..fcd6e787b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -2,18 +2,17 @@ import 'react-native-url-polyfill/auto' import React, {useState, useEffect} from 'react' import {Linking} from 'react-native' import {RootSiblingParent} from 'react-native-root-siblings' -import {GestureHandlerRootView} from 'react-native-gesture-handler' import SplashScreen from 'react-native-splash-screen' import {SafeAreaProvider} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import {ThemeProvider} from 'lib/ThemeContext' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' -import {MobileShell} from './view/shell/mobile' -import {s} from 'lib/styles' +import {Shell} from './view/shell' import * as notifee from 'lib/notifee' import * as analytics from 'lib/analytics' import * as Toast from './view/com/util/Toast' +import {handleLink} from './Navigation' const App = observer(() => { const [rootStore, setRootStore] = useState( @@ -31,11 +30,11 @@ const App = observer(() => { store.hackCheckIfUpgradeNeeded() Linking.getInitialURL().then((url: string | null) => { if (url) { - store.nav.handleLink(url) + handleLink(url) } }) Linking.addEventListener('url', ({url}) => { - store.nav.handleLink(url) + handleLink(url) }) store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') @@ -48,19 +47,17 @@ const App = observer(() => { return null } return ( - - - - - - - - - - - - - + + + + + + + + + + + ) }) diff --git a/src/App.web.tsx b/src/App.web.tsx index 84d3b6cd6..0bfa909be 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,9 +1,9 @@ import React, {useState, useEffect} from 'react' import {SafeAreaProvider} from 'react-native-safe-area-context' -import {getInitialURL} from 'platform/urls' +import {RootSiblingParent} from 'react-native-root-siblings' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' -import {WebShell} from './view/shell/web' +import {Shell} from './view/shell/index' import {ToastContainer} from './view/com/util/Toast.web' function App() { @@ -16,12 +16,6 @@ function App() { view.setup() setupState().then(store => { setRootStore(store) - store.nav.bindWebNavigation() - getInitialURL().then(url => { - if (url) { - store.nav.handleLink(url) - } - }) }) }, []) @@ -31,12 +25,14 @@ function App() { } return ( - - - - - - + + + + + + + + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx new file mode 100644 index 000000000..22d8d8b21 --- /dev/null +++ b/src/Navigation.tsx @@ -0,0 +1,287 @@ +import * as React from 'react' +import {StyleSheet} from 'react-native' +import { + NavigationContainer, + createNavigationContainerRef, + StackActions, +} from '@react-navigation/native' +import {createNativeStackNavigator} from '@react-navigation/native-stack' +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs' +import { + HomeTabNavigatorParams, + SearchTabNavigatorParams, + NotificationsTabNavigatorParams, + FlatNavigatorParams, + AllNavigatorParams, +} from 'lib/routes/types' +import {BottomBar} from './view/shell/BottomBar' +import {buildStateObject} from 'lib/routes/helpers' +import {State, RouteParams} from 'lib/routes/types' +import {colors} from 'lib/styles' +import {isNative} from 'platform/detection' +import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {router} from './routes' + +import {HomeScreen} from './view/screens/Home' +import {SearchScreen} from './view/screens/Search' +import {NotificationsScreen} from './view/screens/Notifications' +import {NotFoundScreen} from './view/screens/NotFound' +import {SettingsScreen} from './view/screens/Settings' +import {ProfileScreen} from './view/screens/Profile' +import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' +import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {PostThreadScreen} from './view/screens/PostThread' +import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy' +import {PostRepostedByScreen} from './view/screens/PostRepostedBy' +import {DebugScreen} from './view/screens/Debug' +import {LogScreen} from './view/screens/Log' + +const navigationRef = createNavigationContainerRef() + +const HomeTab = createNativeStackNavigator() +const SearchTab = createNativeStackNavigator() +const NotificationsTab = + createNativeStackNavigator() +const Flat = createNativeStackNavigator() +const Tab = createBottomTabNavigator() + +/** + * These "common screens" are reused across stacks. + */ +function commonScreens(Stack: typeof HomeTab) { + return ( + <> + + + + + + + + + + + + ) +} + +/** + * The TabsNavigator is used by native mobile to represent the routes + * in 3 distinct tab-stacks with a different root screen on each. + */ +function TabsNavigator() { + const tabBar = React.useCallback(props => , []) + return ( + + + + + + ) +} + +function HomeTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + + + {commonScreens(HomeTab)} + + ) +} + +function SearchTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + + + {commonScreens(SearchTab as typeof HomeTab)} + + ) +} + +function NotificationsTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + + + {commonScreens(NotificationsTab as typeof HomeTab)} + + ) +} + +/** + * The FlatNavigator is used by Web to represent the routes + * in a single ("flat") stack. + */ +function FlatNavigator() { + return ( + + + + + {commonScreens(Flat as typeof HomeTab)} + + ) +} + +/** + * The RoutesContainer should wrap all components which need access + * to the navigation context. + */ + +const LINKING = { + prefixes: ['bsky://', 'https://bsky.app'], + + getPathFromState(state: State) { + // find the current node in the navigation tree + let node = state.routes[state.index || 0] + while (node.state?.routes && typeof node.state?.index === 'number') { + node = node.state?.routes[node.state?.index] + } + + // build the path + const route = router.matchName(node.name) + if (typeof route === 'undefined') { + return '/' // default to home + } + return route.build((node.params || {}) as RouteParams) + }, + + getStateFromPath(path: string) { + const [name, params] = router.matchPath(path) + if (isNative) { + if (name === 'Search') { + return buildStateObject('SearchTab', 'Search', params) + } + if (name === 'Notifications') { + return buildStateObject('NotificationsTab', 'Notifications', params) + } + return buildStateObject('HomeTab', name, params) + } else { + return buildStateObject('Flat', name, params) + } + }, +} + +function RoutesContainer({children}: React.PropsWithChildren<{}>) { + return ( + + {children} + + ) +} + +/** + * These helpers can be used from outside of the RoutesContainer + * (eg in the state models). + */ + +function navigate( + name: K, + params?: AllNavigatorParams[K], +) { + if (navigationRef.isReady()) { + // @ts-ignore I dont know what would make typescript happy but I have a life -prf + navigationRef.navigate(name, params) + } +} + +function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') { + if (navigationRef.isReady()) { + navigate(tabName) + navigationRef.dispatch(StackActions.popToTop()) + } +} + +function handleLink(url: string) { + let path + if (url.startsWith('/')) { + path = url + } else if (url.startsWith('http')) { + try { + path = new URL(url).pathname + } catch (e) { + console.error('Invalid url', url, e) + return + } + } else { + console.error('Invalid url', url) + return + } + + const [name, params] = router.matchPath(path) + if (isNative) { + if (name === 'Search') { + resetToTab('SearchTab') + } else if (name === 'Notifications') { + resetToTab('NotificationsTab') + } else { + resetToTab('HomeTab') + // @ts-ignore matchPath doesnt give us type-checked output -prf + navigate(name, params) + } + } else { + // @ts-ignore matchPath doesnt give us type-checked output -prf + navigate(name, params) + } +} + +const styles = StyleSheet.create({ + bgDark: { + backgroundColor: colors.black, + }, + bgLight: { + backgroundColor: colors.gray1, + }, +}) + +export { + navigate, + resetToTab, + handleLink, + TabsNavigator, + FlatNavigator, + RoutesContainer, +} diff --git a/src/app.json b/src/app.json deleted file mode 100644 index 1f9e391e0..000000000 --- a/src/app.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "xyz.blueskyweb.app", - "displayName": "Bluesky" -} \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 45a06f40a..000000000 --- a/src/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @format - */ - -import {AppRegistry} from 'react-native' -import App from './App' - -AppRegistry.registerComponent('App', () => App) - -AppRegistry.runApplication('App', { - rootTag: document.getElementById('root'), -}) 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 size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( - @@ -158,12 +151,10 @@ export function MagnifyingGlassIcon2Solid({ style, size, strokeWidth = 2, - fillOpacity = 1, }: { style?: StyleProp size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( @@ -219,12 +209,10 @@ export function BellIconSolid({ style, size, strokeWidth = 1.5, - fillOpacity = 1, }: { style?: StyleProp size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( - ) @@ -278,6 +263,34 @@ export function CogIcon({ ) } +export function CogIconSolid({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + // 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 + size?: string | number + strokeWidth?: number +}) { + return ( + + + + + + ) +} + // Copyright (c) 2020 Refactoring UI Inc. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function UserGroupIcon({ @@ -674,6 +726,7 @@ export function ComposeIcon2({ 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 { 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 { - // 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 { await promise return {width: img.width, height: img.height} } + +function blobToDataUri(blob: Blob): Promise { + 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 { 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 { - const status = await check(perm) - return isntANo(status) -} - -export async function requestAccessIfNeeded( - perm: Permission, -): Promise { - 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 { - return true -} - -export async function requestAccessIfNeeded(_perm: any): Promise { - 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) { + 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) { + 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 + +export type State = + | NavigationState + | Omit, 'stale'> + +export type RouteParams = Record +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'}, diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 000000000..5987177ef --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,16 @@ +import {Router} from 'lib/routes/router' + +export const router = new Router({ + Home: '/', + Search: '/search', + Notifications: '/notifications', + Settings: '/settings', + Profile: '/profile/:name', + ProfileFollowers: '/profile/:name/followers', + ProfileFollows: '/profile/:name/follows', + PostThread: '/profile/:name/post/:rkey', + PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by', + PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + Debug: '/sys/debug', + Log: '/sys/log', +}) diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts deleted file mode 100644 index 11af65912..000000000 --- a/src/state/models/navigation.ts +++ /dev/null @@ -1,434 +0,0 @@ -import {RootStoreModel} from './root-store' -import {makeAutoObservable} from 'mobx' -import {TABS_ENABLED} from 'lib/build-flags' -import * as analytics from 'lib/analytics' -import {isNative} from 'platform/detection' - -let __id = 0 -function genId() { - return String(++__id) -} - -// NOTE -// this model was originally built for a freeform "tabs" concept like a browser -// we've since decided to pause that idea and do something more traditional -// until we're fully sure what that is, the tabs are being repurposed into a fixed topology -// - Tab 0: The "Default" tab -// - Tab 1: The "Search" tab -// - Tab 2: The "Notifications" tab -// These tabs always retain the first item in their history. -// -prf -export enum TabPurpose { - Default = 0, - Search = 1, - Notifs = 2, -} - -export const TabPurposeMainPath: Record = { - [TabPurpose.Default]: '/', - [TabPurpose.Search]: '/search', - [TabPurpose.Notifs]: '/notifications', -} - -interface HistoryItem { - url: string - ts: number - title?: string - id: string -} - -export type HistoryPtr = string // `{tabId}-{historyId}` - -export class NavigationTabModel { - id = genId() - history: HistoryItem[] - index = 0 - isNewTab = false - - constructor(public fixedTabPurpose: TabPurpose) { - this.history = [ - {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()}, - ] - makeAutoObservable(this, { - serialize: false, - hydrate: false, - }) - } - // accessors - // = - - get current() { - return this.history[this.index] - } - - get canGoBack() { - return this.index > 0 - } - - get canGoForward() { - return this.index < this.history.length - 1 - } - - getBackList(n: number) { - const start = Math.max(this.index - n, 0) - const end = this.index - return this.history.slice(start, end).map((item, i) => ({ - url: item.url, - title: item.title, - index: start + i, - id: item.id, - })) - } - - get backTen() { - return this.getBackList(10) - } - - getForwardList(n: number) { - const start = Math.min(this.index + 1, this.history.length) - const end = Math.min(this.index + n + 1, this.history.length) - return this.history.slice(start, end).map((item, i) => ({ - url: item.url, - title: item.title, - index: start + i, - id: item.id, - })) - } - - get forwardTen() { - return this.getForwardList(10) - } - - // navigation - // = - - navigate(url: string, title?: string) { - try { - const path = url.split('/')[1] - analytics.track('Navigation', { - path, - }) - } catch (error) {} - - if (this.current?.url === url) { - this.refresh() - } else { - if (this.index < this.history.length - 1) { - this.history.length = this.index + 1 - } - // TEMP ensure the tab has its purpose's main view -prf - if (this.history.length < 1) { - const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose] - this.history.push({url: fixedUrl, ts: Date.now(), id: genId()}) - } - this.history.push({url, title, ts: Date.now(), id: genId()}) - this.index = this.history.length - 1 - if (!isNative) { - window.history.pushState({hindex: this.index, hurl: url}, '', url) - } - } - } - - refresh() { - this.history = [ - ...this.history.slice(0, this.index), - { - url: this.current.url, - title: this.current.title, - ts: Date.now(), - id: this.current.id, - }, - ...this.history.slice(this.index + 1), - ] - } - - goBack() { - if (this.canGoBack) { - this.index-- - if (!isNative) { - window.history.back() - } - } - } - - // TEMP - // a helper to bring the tab back to its base state - // -prf - fixedTabReset() { - this.index = 0 - } - - goForward() { - if (this.canGoForward) { - this.index++ - if (!isNative) { - window.history.forward() - } - } - } - - goToIndex(index: number) { - if (index >= 0 && index <= this.history.length - 1) { - const delta = index - this.index - this.index = index - if (!isNative) { - window.history.go(delta) - } - } - } - - setTitle(id: string, title: string) { - this.history = this.history.map(h => { - if (h.id === id) { - return {...h, title} - } - return h - }) - } - - setIsNewTab(v: boolean) { - this.isNewTab = v - } - - // browser only - // = - - resetTo(url: string) { - this.index = 0 - this.history.push({url, title: '', ts: Date.now(), id: genId()}) - this.index = this.history.length - 1 - } - - // persistence - // = - - serialize(): unknown { - return { - history: this.history, - index: this.index, - } - } - - hydrate(_v: unknown) { - // TODO fixme - // if (isObj(v)) { - // if (hasProp(v, 'history') && Array.isArray(v.history)) { - // for (const item of v.history) { - // if ( - // isObj(item) && - // hasProp(item, 'url') && - // typeof item.url === 'string' - // ) { - // let copy: HistoryItem = { - // url: item.url, - // ts: - // hasProp(item, 'ts') && typeof item.ts === 'number' - // ? item.ts - // : Date.now(), - // } - // if (hasProp(item, 'title') && typeof item.title === 'string') { - // copy.title = item.title - // } - // this.history.push(copy) - // } - // } - // } - // if (hasProp(v, 'index') && typeof v.index === 'number') { - // this.index = v.index - // } - // if (this.index >= this.history.length - 1) { - // this.index = this.history.length - 1 - // } - // } - } -} - -export class NavigationModel { - tabs: NavigationTabModel[] = isNative - ? [ - new NavigationTabModel(TabPurpose.Default), - new NavigationTabModel(TabPurpose.Search), - new NavigationTabModel(TabPurpose.Notifs), - ] - : [new NavigationTabModel(TabPurpose.Default)] - tabIndex = 0 - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - serialize: false, - hydrate: false, - }) - } - - /** - * Used only in the web build to sync with browser history state - */ - bindWebNavigation() { - if (!isNative) { - window.addEventListener('popstate', e => { - const {hindex, hurl} = e.state - if (hindex >= 0 && hindex <= this.tab.history.length - 1) { - this.tab.index = hindex - } - if (this.tab.current.url !== hurl) { - // desynced because they went back to an old tab session- - // do a reset to match that - this.tab.resetTo(hurl) - } - - // sanity check - if (this.tab.current.url !== window.location.pathname) { - // state has completely desynced, reload - window.location.reload() - } - }) - } - } - - clear() { - this.tabs = isNative - ? [ - new NavigationTabModel(TabPurpose.Default), - new NavigationTabModel(TabPurpose.Search), - new NavigationTabModel(TabPurpose.Notifs), - ] - : [new NavigationTabModel(TabPurpose.Default)] - this.tabIndex = 0 - } - - // accessors - // = - - get tab() { - return this.tabs[this.tabIndex] - } - - get tabCount() { - return this.tabs.length - } - - isCurrentScreen(tabId: string, index: number) { - return this.tab.id === tabId && this.tab.index === index - } - - // navigation - // = - - navigate(url: string, title?: string) { - this.rootStore.emitNavigation() - this.tab.navigate(url, title) - } - - refresh() { - this.tab.refresh() - } - - setTitle(ptr: HistoryPtr, title: string) { - const [tid, hid] = ptr.split('-') - this.tabs.find(t => t.id === tid)?.setTitle(hid, title) - } - - handleLink(url: string) { - let path - if (url.startsWith('/')) { - path = url - } else if (url.startsWith('http')) { - try { - path = new URL(url).pathname - } catch (e) { - console.error('Invalid url', url, e) - return - } - } else { - console.error('Invalid url', url) - return - } - this.navigate(path) - } - - // tab management - // = - - // TEMP - // fixed tab helper function - // -prf - switchTo(purpose: TabPurpose, reset: boolean) { - this.rootStore.emitNavigation() - switch (purpose) { - case TabPurpose.Notifs: - this.tabIndex = 2 - break - case TabPurpose.Search: - this.tabIndex = 1 - break - default: - this.tabIndex = 0 - } - if (reset) { - this.tab.fixedTabReset() - } - } - - newTab(url: string, title?: string) { - if (!TABS_ENABLED) { - return this.navigate(url) - } - const tab = new NavigationTabModel(TabPurpose.Default) - tab.navigate(url, title) - tab.isNewTab = true - this.tabs.push(tab) - this.tabIndex = this.tabs.length - 1 - } - - setActiveTab(tabIndex: number) { - if (!TABS_ENABLED) { - return - } - this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0) - } - - closeTab(tabIndex: number) { - if (!TABS_ENABLED) { - return - } - this.tabs = [ - ...this.tabs.slice(0, tabIndex), - ...this.tabs.slice(tabIndex + 1), - ] - if (this.tabs.length === 0) { - this.newTab('/') - } else if (this.tabIndex >= this.tabs.length) { - this.tabIndex = this.tabs.length - 1 - } - } - - // persistence - // = - - serialize(): unknown { - return { - tabs: this.tabs.map(t => t.serialize()), - tabIndex: this.tabIndex, - } - } - - hydrate(_v: unknown) { - // TODO fixme - this.clear() - /*if (isObj(v)) { - if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) { - for (const tab of v.tabs) { - const copy = new NavigationTabModel() - copy.hydrate(tab) - if (copy.history.length) { - this.tabs.push(copy) - } - } - } - if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') { - this.tabIndex = v.tabIndex - } - }*/ - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 4b62f501e..203dacce8 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -11,12 +11,12 @@ import {z} from 'zod' import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' -import {NavigationModel} from './navigation' import {ShellUiModel} from './shell-ui' import {ProfilesViewModel} from './profiles-view' import {LinkMetasViewModel} from './link-metas-view' import {NotificationsViewItemModel} from './notifications-view' import {MeModel} from './me' +import {resetToTab} from '../../Navigation' export const appInfo = z.object({ build: z.string(), @@ -31,7 +31,6 @@ export class RootStoreModel { appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) - nav = new NavigationModel(this) shell = new ShellUiModel(this) me = new MeModel(this) profiles = new ProfilesViewModel(this) @@ -82,7 +81,6 @@ export class RootStoreModel { log: this.log.serialize(), session: this.session.serialize(), me: this.me.serialize(), - nav: this.nav.serialize(), shell: this.shell.serialize(), } } @@ -101,9 +99,6 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } - if (hasProp(v, 'nav')) { - this.nav.hydrate(v.nav) - } if (hasProp(v, 'session')) { this.session.hydrate(v.session) } @@ -144,7 +139,7 @@ export class RootStoreModel { */ async handleSessionDrop() { this.log.debug('RootStoreModel:handleSessionDrop') - this.nav.clear() + resetToTab('HomeTab') this.me.clear() this.emitSessionDropped() } @@ -155,7 +150,7 @@ export class RootStoreModel { clearAllSessionState() { this.log.debug('RootStoreModel:clearAllSessionState') this.session.clear() - this.nav.clear() + resetToTab('HomeTab') this.me.clear() } @@ -203,6 +198,7 @@ export class RootStoreModel { } // the current screen has changed + // TODO is this still needed? onNavigation(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('navigation', handler) } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 68d9cd3d0..8e4eed6eb 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -108,7 +108,6 @@ export interface ComposerOptsQuote { } } export interface ComposerOpts { - imagesOpen?: boolean replyTo?: ComposerOptsPostRef onPost?: () => void quote?: ComposerOptsQuote @@ -117,7 +116,7 @@ export interface ComposerOpts { export class ShellUiModel { darkMode = false minimalShellMode = false - isMainMenuOpen = false + isDrawerOpen = false isModalActive = false activeModals: Modal[] = [] isLightboxActive = false @@ -156,8 +155,12 @@ export class ShellUiModel { this.minimalShellMode = v } - setMainMenuOpen(v: boolean) { - this.isMainMenuOpen = v + openDrawer() { + this.isDrawerOpen = true + } + + closeDrawer() { + this.isDrawerOpen = false } openModal(modal: Modal) { diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts deleted file mode 100644 index b14e8a6a4..000000000 --- a/src/state/models/user-local-photos.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll' -import {makeAutoObservable, runInAction} from 'mobx' -import {CameraRoll} from '@react-native-camera-roll/camera-roll' -import {RootStoreModel} from './root-store' - -export type {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll' - -export class UserLocalPhotosModel { - // state - photos: PhotoIdentifier[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) - } - - async setup() { - const r = await CameraRoll.getPhotos({first: 20}) - runInAction(() => { - this.photos = r.edges - }) - } -} diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx deleted file mode 100644 index f45c6340d..000000000 --- a/src/view/com/composer/ComposePost.tsx +++ /dev/null @@ -1,637 +0,0 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react' -import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - KeyboardAvoidingView, - NativeSyntheticEvent, - Platform, - SafeAreaView, - ScrollView, - StyleSheet, - TextInputSelectionChangeEventData, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics' -import _isEqual from 'lodash.isequal' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' -import {Autocomplete} from './autocomplete/Autocomplete' -import {ExternalEmbed} from './ExternalEmbed' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {TextInput, TextInputRef} from './text-input/TextInput' -import {CharProgress} from './char-progress/CharProgress' -import {TextLink} from '../util/Link' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' -import * as apilib from 'lib/api/index' -import {ComposerOpts} from 'state/models/shell-ui' -import {s, colors, gradients} from 'lib/styles' -import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' -import {getLinkMeta} from 'lib/link-meta/link-meta' -import {getPostAsQuote} from 'lib/link-meta/bsky' -import {getImageDim, downloadAndResize} from 'lib/media/manip' -import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker' -import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker' -import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' -import {SelectedPhoto} from './SelectedPhoto' -import {usePalette} from 'lib/hooks/usePalette' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' -import {isWeb} from 'platform/detection' -import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' - -const MAX_TEXT_LENGTH = 256 -const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} - -interface Selection { - start: number - end: number -} - -export const ComposePost = observer(function ComposePost({ - replyTo, - imagesOpen, - onPost, - onClose, - quote: initQuote, -}: { - replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] - onPost?: ComposerOpts['onPost'] - onClose: () => void - quote?: ComposerOpts['quote'] -}) { - const {track} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const textInput = useRef(null) - const textInputSelection = useRef({start: 0, end: 0}) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') - const [error, setError] = useState('') - const [text, setText] = useState('') - const [quote, setQuote] = useState( - initQuote, - ) - const [extLink, setExtLink] = useState( - undefined, - ) - const [suggestedExtLinks, setSuggestedExtLinks] = useState>( - new Set(), - ) - const [isSelectingPhotos, setIsSelectingPhotos] = useState( - imagesOpen || false, - ) - const [selectedPhotos, setSelectedPhotos] = useState([]) - - const autocompleteView = React.useMemo( - () => new UserAutocompleteViewModel(store), - [store], - ) - - // HACK - // there's a bug with @mattermost/react-native-paste-input where if the input - // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) - // manually blurring before closing gets around that - // -prf - const hackfixOnClose = () => { - textInput.current?.blur() - onClose() - } - - // initial setup - useEffect(() => { - autocompleteView.setup() - }, [autocompleteView]) - - // external link metadata-fetch flow - useEffect(() => { - let aborted = false - const cleanup = () => { - aborted = true - } - if (!extLink) { - return cleanup - } - if (!extLink.meta) { - if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( - newQuote => { - if (aborted) { - return - } - setQuote(newQuote) - setExtLink(undefined) - }, - err => { - store.log.error('Failed to fetch post for quote embedding', {err}) - setExtLink(undefined) - }, - ) - } else { - getLinkMeta(store, extLink.uri).then(meta => { - if (aborted) { - return - } - setExtLink({ - uri: extLink.uri, - isLoading: !!meta.image, - meta, - }) - }) - } - return cleanup - } - if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { - downloadAndResize({ - uri: extLink.meta.image, - width: 2000, - height: 2000, - mode: 'contain', - maxSize: 1000000, - timeout: 15e3, - }) - .catch(() => undefined) - .then(localThumb => { - if (aborted) { - return - } - setExtLink({ - ...extLink, - isLoading: false, // done - localThumb, - }) - }) - return cleanup - } - if (extLink.isLoading) { - setExtLink({ - ...extLink, - isLoading: false, // done - }) - } - return cleanup - }, [store, extLink]) - - useEffect(() => { - // HACK - // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view - // -prf - let to: NodeJS.Timeout | undefined - if (textInput.current) { - to = setTimeout(() => { - textInput.current?.focus() - }, 250) - } - return () => { - if (to) { - clearTimeout(to) - } - } - }, []) - - const onPressContainer = () => { - textInput.current?.focus() - } - const onPressSelectPhotos = async () => { - track('ComposePost:SelectPhotos') - if (isWeb) { - if (selectedPhotos.length < 4) { - const images = await pickImagesFlow( - store, - 4 - selectedPhotos.length, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - setSelectedPhotos([...selectedPhotos, ...images]) - } - } else { - if (isSelectingPhotos) { - setIsSelectingPhotos(false) - } else if (selectedPhotos.length < 4) { - setIsSelectingPhotos(true) - } - } - } - const onSelectPhotos = (photos: string[]) => { - track('ComposePost:SelectPhotos:Done') - setSelectedPhotos(photos) - if (photos.length >= 4) { - setIsSelectingPhotos(false) - } - } - const onPressAddLinkCard = (uri: string) => { - setExtLink({uri, isLoading: true}) - } - const onChangeText = (newText: string) => { - setText(newText) - - const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) - if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) - } - - if (!extLink) { - const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') - const set = new Set(ents ? ents.map(e => e.value) : []) - if (!_isEqual(set, suggestedExtLinks)) { - setSuggestedExtLinks(set) - } - } - } - const onPaste = async (err: string | undefined, uris: string[]) => { - if (err) { - return setError(cleanError(err)) - } - if (selectedPhotos.length >= 4) { - return - } - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) - if (imgUri) { - let imgDim - try { - imgDim = await getImageDim(imgUri) - } catch (e) { - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} - } - const finalImgPath = await cropAndCompressFlow( - store, - imgUri, - imgDim, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, finalImgPath]) - } - } - const onSelectionChange = ( - evt: NativeSyntheticEvent, - ) => { - // NOTE we track the input selection using a ref to avoid excessive renders -prf - textInputSelection.current = evt.nativeEvent.selection - } - const onSelectAutocompleteItem = (item: string) => { - setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) - autocompleteView.setActive(false) - } - const onPressCancel = () => hackfixOnClose() - const onPressPublish = async () => { - if (isProcessing) { - return - } - if (text.length > MAX_TEXT_LENGTH) { - return - } - setError('') - if (text.trim().length === 0 && selectedPhotos.length === 0) { - setError('Did you want to say anything?') - return false - } - setIsProcessing(true) - try { - await apilib.post(store, { - rawText: text, - replyTo: replyTo?.uri, - images: selectedPhotos, - quote: quote, - extLink: extLink, - onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, - }) - track('Create Post', { - imageCount: selectedPhotos.length, - }) - } catch (e: any) { - if (extLink) { - setExtLink({ - ...extLink, - isLoading: true, - localThumb: undefined, - } as apilib.ExternalEmbedDraft) - } - setError(cleanError(e.message)) - setIsProcessing(false) - return - } - store.me.mainFeed.loadLatest() - onPost?.() - hackfixOnClose() - Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - } - - const canPost = text.length <= MAX_TEXT_LENGTH - - const selectTextInputLayout = - selectedPhotos.length !== 0 - ? styles.textInputLayoutWithPhoto - : styles.textInputLayoutWithoutPhoto - const selectTextInputPlaceholder = replyTo - ? 'Write your reply' - : selectedPhotos.length !== 0 - ? 'Write a comment' - : "What's up?" - - const textDecorated = useMemo(() => { - let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { - return ( - - {v} - - ) - } else { - return ( - - {v.link} - - ) - } - }) - }, [text, pal.link, pal.text]) - - return ( - - - - - - Cancel - - - {isProcessing ? ( - - - - ) : canPost ? ( - - - - {replyTo ? 'Reply' : 'Post'} - - - - ) : ( - - Post - - )} - - {isProcessing ? ( - - {processingState} - - ) : undefined} - {error !== '' && ( - - - - - {error} - - )} - - {replyTo ? ( - - - - - - {replyTo.text} - - - - ) : undefined} - - - - onChangeText(str)} - onPaste={onPaste} - onSelectionChange={onSelectionChange} - placeholder={selectTextInputPlaceholder} - style={[ - pal.text, - styles.textInput, - styles.textInputFormatting, - ]}> - {textDecorated} - - - - {quote ? ( - - - - ) : undefined} - - - {!selectedPhotos.length && extLink && ( - setExtLink(undefined)} - /> - )} - - {isSelectingPhotos && selectedPhotos.length < 4 ? ( - - ) : !extLink && - selectedPhotos.length === 0 && - suggestedExtLinks.size > 0 && - !quote ? ( - - {Array.from(suggestedExtLinks).map(url => ( - onPressAddLinkCard(url)}> - - Add link card: {url} - - - ))} - - ) : null} - - {quote ? undefined : ( - - - - )} - - - - - - - - ) -}) - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'column', - flex: 1, - padding: 15, - height: '100%', - }, - topbar: { - flexDirection: 'row', - alignItems: 'center', - paddingBottom: 10, - paddingHorizontal: 5, - height: 55, - }, - postBtn: { - borderRadius: 20, - paddingHorizontal: 20, - paddingVertical: 6, - }, - processingLine: { - borderRadius: 6, - paddingHorizontal: 8, - paddingVertical: 6, - marginBottom: 6, - }, - errorLine: { - flexDirection: 'row', - backgroundColor: colors.red1, - borderRadius: 6, - paddingHorizontal: 8, - paddingVertical: 6, - marginVertical: 6, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.red4, - color: colors.red4, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, - textInputLayoutWithPhoto: { - flexWrap: 'wrap', - }, - textInputLayoutWithoutPhoto: { - flex: 1, - }, - textInputLayout: { - flexDirection: 'row', - borderTopWidth: 1, - paddingTop: 16, - }, - textInput: { - flex: 1, - padding: 5, - marginLeft: 8, - alignSelf: 'flex-start', - }, - textInputFormatting: { - fontSize: 18, - letterSpacing: 0.2, - fontWeight: '400', - lineHeight: 23.4, // 1.3*16 - }, - replyToLayout: { - flexDirection: 'row', - borderTopWidth: 1, - paddingTop: 16, - paddingBottom: 16, - }, - replyToPost: { - flex: 1, - paddingLeft: 13, - paddingRight: 8, - }, - addExtLinkBtn: { - borderWidth: 1, - borderRadius: 24, - paddingHorizontal: 16, - paddingVertical: 12, - marginBottom: 4, - }, - bottomBar: { - flexDirection: 'row', - paddingVertical: 10, - paddingRight: 5, - alignItems: 'center', - borderTopWidth: 1, - }, -}) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx new file mode 100644 index 000000000..e9b728d73 --- /dev/null +++ b/src/view/com/composer/Composer.tsx @@ -0,0 +1,425 @@ +import React, {useEffect, useRef, useState} from 'react' +import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {ExternalEmbed} from './ExternalEmbed' +import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' +import {TextInput, TextInputRef} from './text-input/TextInput' +import {CharProgress} from './char-progress/CharProgress' +import {UserAvatar} from '../util/UserAvatar' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {ComposerOpts} from 'state/models/shell-ui' +import {s, colors, gradients} from 'lib/styles' +import {cleanError} from 'lib/strings/errors' +import {SelectPhotoBtn} from './photos/SelectPhotoBtn' +import {OpenCameraBtn} from './photos/OpenCameraBtn' +import {SelectedPhotos} from './photos/SelectedPhotos' +import {usePalette} from 'lib/hooks/usePalette' +import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import {useExternalLinkFetch} from './useExternalLinkFetch' + +const MAX_TEXT_LENGTH = 256 + +export const ComposePost = observer(function ComposePost({ + replyTo, + onPost, + onClose, + quote: initQuote, +}: { + replyTo?: ComposerOpts['replyTo'] + onPost?: ComposerOpts['onPost'] + onClose: () => void + quote?: ComposerOpts['quote'] +}) { + const {track} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const textInput = useRef(null) + const [isProcessing, setIsProcessing] = useState(false) + const [processingState, setProcessingState] = useState('') + const [error, setError] = useState('') + const [text, setText] = useState('') + const [quote, setQuote] = useState( + initQuote, + ) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) + const [selectedPhotos, setSelectedPhotos] = useState([]) + + const autocompleteView = React.useMemo( + () => new UserAutocompleteViewModel(store), + [store], + ) + + // HACK + // there's a bug with @mattermost/react-native-paste-input where if the input + // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) + // manually blurring before closing gets around that + // -prf + const hackfixOnClose = React.useCallback(() => { + textInput.current?.blur() + onClose() + }, [textInput, onClose]) + + // initial setup + useEffect(() => { + autocompleteView.setup() + }, [autocompleteView]) + + useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onPressContainer = React.useCallback(() => { + textInput.current?.focus() + }, [textInput]) + + const onSelectPhotos = React.useCallback( + (photos: string[]) => { + track('Composer:SelectedPhotos') + setSelectedPhotos(photos) + }, + [track, setSelectedPhotos], + ) + + const onPressAddLinkCard = React.useCallback( + (uri: string) => { + setExtLink({uri, isLoading: true}) + }, + [setExtLink], + ) + + const onPhotoPasted = React.useCallback( + async (uri: string) => { + if (selectedPhotos.length >= 4) { + return + } + onSelectPhotos([...selectedPhotos, uri]) + }, + [selectedPhotos, onSelectPhotos], + ) + + const onPressPublish = React.useCallback(async () => { + if (isProcessing) { + return + } + if (text.length > MAX_TEXT_LENGTH) { + return + } + setError('') + if (text.trim().length === 0 && selectedPhotos.length === 0) { + setError('Did you want to say anything?') + return false + } + setIsProcessing(true) + try { + await apilib.post(store, { + rawText: text, + replyTo: replyTo?.uri, + images: selectedPhotos, + quote: quote, + extLink: extLink, + onStateChange: setProcessingState, + knownHandles: autocompleteView.knownHandles, + }) + track('Create Post', { + imageCount: selectedPhotos.length, + }) + } catch (e: any) { + if (extLink) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) + } + setError(cleanError(e.message)) + setIsProcessing(false) + return + } + store.me.mainFeed.loadLatest() + onPost?.() + hackfixOnClose() + Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) + }, [ + isProcessing, + text, + setError, + setIsProcessing, + replyTo, + autocompleteView.knownHandles, + extLink, + hackfixOnClose, + onPost, + quote, + selectedPhotos, + setExtLink, + store, + track, + ]) + + const canPost = text.length <= MAX_TEXT_LENGTH + + const selectTextInputLayout = + selectedPhotos.length !== 0 + ? styles.textInputLayoutWithPhoto + : styles.textInputLayoutWithoutPhoto + const selectTextInputPlaceholder = replyTo + ? 'Write your reply' + : selectedPhotos.length !== 0 + ? 'Write a comment' + : "What's up?" + + return ( + + + + + + Cancel + + + {isProcessing ? ( + + + + ) : canPost ? ( + + + + {replyTo ? 'Reply' : 'Post'} + + + + ) : ( + + Post + + )} + + {isProcessing ? ( + + {processingState} + + ) : undefined} + {error !== '' && ( + + + + + {error} + + )} + + {replyTo ? ( + + + + + {replyTo.author.displayName || replyTo.author.handle} + + + {replyTo.text} + + + + ) : undefined} + + + + + + + {quote ? ( + + + + ) : undefined} + + + {!selectedPhotos.length && extLink && ( + setExtLink(undefined)} + /> + )} + + {!extLink && + selectedPhotos.length === 0 && + suggestedLinks.size > 0 && + !quote ? ( + + {Array.from(suggestedLinks).map(url => ( + onPressAddLinkCard(url)}> + + Add link card: {url} + + + ))} + + ) : null} + + + + + + + + + + ) +}) + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'column', + flex: 1, + padding: 15, + height: '100%', + }, + topbar: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: 10, + paddingHorizontal: 5, + height: 55, + }, + postBtn: { + borderRadius: 20, + paddingHorizontal: 20, + paddingVertical: 6, + }, + processingLine: { + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 6, + marginBottom: 6, + }, + errorLine: { + flexDirection: 'row', + backgroundColor: colors.red1, + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 6, + marginVertical: 6, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.red4, + color: colors.red4, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + textInputLayoutWithPhoto: { + flexWrap: 'wrap', + }, + textInputLayoutWithoutPhoto: { + flex: 1, + }, + textInputLayout: { + flexDirection: 'row', + borderTopWidth: 1, + paddingTop: 16, + }, + replyToLayout: { + flexDirection: 'row', + borderTopWidth: 1, + paddingTop: 16, + paddingBottom: 16, + }, + replyToPost: { + flex: 1, + paddingLeft: 13, + paddingRight: 8, + }, + addExtLinkBtn: { + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 4, + }, + bottomBar: { + flexDirection: 'row', + paddingVertical: 10, + paddingRight: 5, + alignItems: 'center', + borderTopWidth: 1, + }, +}) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 23dcaffd5..658023330 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -75,6 +75,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 8, marginTop: 20, + marginBottom: 10, }, inner: { padding: 10, diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 88d5de2bf..301b90093 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -4,12 +4,9 @@ import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' -export function ComposePrompt({ - onPressCompose, -}: { - onPressCompose: (imagesOpen?: boolean) => void -}) { +export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { const store = useStores() const pal = usePalette('default') return ( @@ -17,13 +14,13 @@ export function ComposePrompt({ testID="replyPromptBtn" style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()}> - - + + Write your reply @@ -39,7 +36,10 @@ const styles = StyleSheet.create({ alignItems: 'center', borderTopWidth: 1, }, - label: { + labelMobile: { paddingLeft: 12, }, + labelDesktopWeb: { + paddingLeft: 20, + }, }) diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx deleted file mode 100644 index 6aeda33cd..000000000 --- a/src/view/com/composer/SelectedPhoto.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, {useCallback} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import Image from 'view/com/util/images/Image' -import {colors} from 'lib/styles' - -export const SelectedPhoto = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const imageStyle = - selectedPhotos.length === 1 - ? styles.image250 - : selectedPhotos.length === 2 - ? styles.image175 - : styles.image85 - - const handleRemovePhoto = useCallback( - item => { - onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item)) - }, - [selectedPhotos, onSelectPhotos], - ) - - return selectedPhotos.length !== 0 ? ( - - {selectedPhotos.length !== 0 && - selectedPhotos.map((item, index) => ( - - handleRemovePhoto(item)} - style={styles.removePhotoButton}> - - - - - - ))} - - ) : null -} - -const styles = StyleSheet.create({ - gallery: { - flex: 1, - flexDirection: 'row', - marginTop: 16, - }, - imageContainer: { - margin: 2, - }, - image: { - resizeMode: 'cover', - borderRadius: 8, - }, - image250: { - width: 250, - height: 250, - }, - image175: { - width: 175, - height: 175, - }, - image85: { - width: 85, - height: 85, - }, - removePhotoButton: { - position: 'absolute', - top: 8, - right: 8, - width: 24, - height: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.black, - zIndex: 1, - borderColor: colors.gray4, - borderWidth: 0.5, - }, -}) diff --git a/src/view/com/composer/autocomplete/Autocomplete.tsx b/src/view/com/composer/autocomplete/Autocomplete.tsx deleted file mode 100644 index 82fb239da..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, {useEffect} from 'react' -import { - Animated, - TouchableOpacity, - StyleSheet, - useWindowDimensions, -} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - const winDim = useWindowDimensions() - const positionInterp = useAnimatedValue(0) - - useEffect(() => { - Animated.timing(positionInterp, { - toValue: active ? 1 : 0, - duration: 200, - useNativeDriver: false, - }).start() - }, [positionInterp, active]) - - const topAnimStyle = { - top: positionInterp.interpolate({ - inputRange: [0, 1], - outputRange: [winDim.height, winDim.height / 4], - }), - } - return ( - - {items.map((item, i) => ( - onSelect(item.handle)}> - - {item.displayName || item.handle} - -  @{item.handle} - - - - ))} - - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - borderTopWidth: 1, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/autocomplete/Autocomplete.web.tsx b/src/view/com/composer/autocomplete/Autocomplete.web.tsx deleted file mode 100644 index b6be1c21e..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.web.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - - if (!active) { - return - } - return ( - - {items.map((item, i) => ( - onSelect(item.handle)}> - - {item.displayName || item.handle} - -  @{item.handle} - - - - ))} - - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderWidth: 1, - borderRadius: 8, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx new file mode 100644 index 000000000..cf4a4c7d1 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openCamera} from 'lib/media/picker' +import {compressIfNeeded} from 'lib/media/manip' +import {useCameraPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function OpenCameraBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestCameraAccessIfNeeded} = useCameraPermission() + + const onPressTakePicture = React.useCallback(async () => { + track('Composer:CameraOpened') + if (!enabled) { + return + } + try { + if (!(await requestCameraAccessIfNeeded())) { + return + } + const cameraRes = await openCamera(store, { + mediaType: 'photo', + width: POST_IMG_MAX_WIDTH, + height: POST_IMG_MAX_HEIGHT, + freeStyleCropEnabled: true, + }) + const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) + onSelectPhotos([...selectedPhotos, img.path]) + } catch (err: any) { + // ignore + store.log.warn('Error using camera', err) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestCameraAccessIfNeeded, + ]) + + if (isDesktopWeb) { + return <> + } + + return ( + + + + ) +} diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx deleted file mode 100644 index 580e9746e..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics' -import { - openPicker, - openCamera, - cropAndCompressFlow, -} from '../../../../lib/media/picker' -import { - UserLocalPhotosModel, - PhotoIdentifier, -} from 'state/models/user-local-photos' -import {compressIfNeeded} from 'lib/media/manip' -import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' -import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' - -export const PhotoCarouselPicker = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const [isSetup, setIsSetup] = React.useState(false) - - const localPhotos = React.useMemo( - () => new UserLocalPhotosModel(store), - [store], - ) - - React.useEffect(() => { - // initial setup - localPhotos.setup().then(() => { - setIsSetup(true) - }) - }, [localPhotos]) - - const handleOpenCamera = useCallback(async () => { - try { - if (!(await requestCameraAccessIfNeeded())) { - return - } - const cameraRes = await openCamera(store, { - mediaType: 'photo', - width: POST_IMG_MAX_WIDTH, - height: POST_IMG_MAX_HEIGHT, - freeStyleCropEnabled: true, - }) - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) - onSelectPhotos([...selectedPhotos, img.path]) - } catch (err: any) { - // ignore - store.log.warn('Error using camera', err) - } - }, [store, selectedPhotos, onSelectPhotos]) - - const handleSelectPhoto = useCallback( - async (item: PhotoIdentifier) => { - track('PhotoCarouselPicker:PhotoSelected') - try { - const imgPath = await cropAndCompressFlow( - store, - item.node.image.uri, - { - width: item.node.image.width, - height: item.node.image.height, - }, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, imgPath]) - } catch (err: any) { - // ignore - store.log.warn('Error selecting photo', err) - } - }, - [track, store, onSelectPhotos, selectedPhotos], - ) - - const handleOpenGallery = useCallback(async () => { - track('PhotoCarouselPicker:GalleryOpened') - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker(store, { - multiple: true, - maxFiles: 4 - selectedPhotos.length, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow( - store, - image.path, - image, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ), - ) - } - onSelectPhotos([...selectedPhotos, ...result]) - }, [track, store, selectedPhotos, onSelectPhotos]) - - return ( - - - - - - - - {isSetup && - localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - handleSelectPhoto(item)}> - - - ))} - - ) -} - -const styles = StyleSheet.create({ - photosContainer: { - width: '100%', - maxHeight: 96, - padding: 8, - overflow: 'hidden', - }, - galleryButton: { - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, - photoButton: { - width: 75, - height: 75, - marginRight: 8, - borderWidth: 1, - borderRadius: 16, - }, - photo: { - width: 75, - height: 75, - marginRight: 8, - borderRadius: 16, - }, -}) diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx deleted file mode 100644 index ff4350b0c..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -// Not used on Web - -export const PhotoCarouselPicker = (_opts: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - return <> -} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx new file mode 100644 index 000000000..bdcb0534a --- /dev/null +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' +import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function SelectPhotoBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + + const onPressSelectPhotos = React.useCallback(async () => { + track('Composer:GalleryOpened') + if (!enabled) { + return + } + if (isDesktopWeb) { + const images = await pickImagesFlow( + store, + 4 - selectedPhotos.length, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onSelectPhotos([...selectedPhotos, ...images]) + } else { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + multiple: true, + maxFiles: 4 - selectedPhotos.length, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow( + store, + image.path, + image, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ), + ) + } + onSelectPhotos([...selectedPhotos, ...result]) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestPhotoAccessIfNeeded, + ]) + + return ( + + + + ) +} diff --git a/src/view/com/composer/photos/SelectedPhotos.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx new file mode 100644 index 000000000..c2a00ce53 --- /dev/null +++ b/src/view/com/composer/photos/SelectedPhotos.tsx @@ -0,0 +1,96 @@ +import React, {useCallback} from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import Image from 'view/com/util/images/Image' +import {colors} from 'lib/styles' + +export const SelectedPhotos = ({ + selectedPhotos, + onSelectPhotos, +}: { + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) => { + const imageStyle = + selectedPhotos.length === 1 + ? styles.image250 + : selectedPhotos.length === 2 + ? styles.image175 + : styles.image85 + + const handleRemovePhoto = useCallback( + item => { + onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item)) + }, + [selectedPhotos, onSelectPhotos], + ) + + return selectedPhotos.length !== 0 ? ( + + {selectedPhotos.length !== 0 && + selectedPhotos.map((item, index) => ( + + handleRemovePhoto(item)} + style={styles.removePhotoButton}> + + + + + + ))} + + ) : null +} + +const styles = StyleSheet.create({ + gallery: { + flex: 1, + flexDirection: 'row', + marginTop: 16, + }, + imageContainer: { + margin: 2, + }, + image: { + resizeMode: 'cover', + borderRadius: 8, + }, + image250: { + width: 250, + height: 250, + }, + image175: { + width: 175, + height: 175, + }, + image85: { + width: 85, + height: 85, + }, + removePhotoButton: { + position: 'absolute', + top: 8, + right: 8, + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.black, + zIndex: 1, + borderColor: colors.gray4, + borderWidth: 0.5, + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index be6150e11..2a40fb518 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,64 +1,222 @@ import React from 'react' import { NativeSyntheticEvent, - StyleProp, + StyleSheet, TextInputSelectionChangeEventData, - TextStyle, } from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {Autocomplete} from './mobile/Autocomplete' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getImageDim} from 'lib/media/manip' +import {cropAndCompressFlow} from 'lib/media/picker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' -export type TextInputRef = PasteInputRef +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref + text: string placeholder: string - style: StyleProp - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - - ) +interface Selection { + start: number + end: number } + +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + onPhotoPasted, + onSuggestedLinksChanged, + onError, + }: TextInputProps, + ref, + ) => { + const pal = usePalette('default') + const store = useStores() + const textInput = React.useRef(null) + const textInputSelection = React.useRef({start: 0, end: 0}) + const theme = useTheme() + + React.useImperativeHandle(ref, () => ({ + focus: () => textInput.current?.focus(), + blur: () => textInput.current?.blur(), + })) + + React.useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onChangeText = React.useCallback( + (newText: string) => { + onTextChanged(newText) + + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix.value) + } else { + autocompleteView.setActive(false) + } + + const ents = extractEntities(newText)?.filter( + ent => ent.type === 'link', + ) + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) + } + }, + [ + onTextChanged, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + ], + ) + + const onPaste = React.useCallback( + async (err: string | undefined, files: PastedFile[]) => { + if (err) { + return onError(cleanError(err)) + } + const uris = files.map(f => f.uri) + const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) + if (imgUri) { + let imgDim + try { + imgDim = await getImageDim(imgUri) + } catch (e) { + imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + } + const finalImgPath = await cropAndCompressFlow( + store, + imgUri, + imgDim, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(finalImgPath) + } + }, + [store, onError, onPhotoPasted], + ) + + const onSelectionChange = React.useCallback( + (evt: NativeSyntheticEvent) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + }, + [textInputSelection], + ) + + const onSelectAutocompleteItem = React.useCallback( + (item: string) => { + onChangeText( + insertMentionAt(text, textInputSelection.current?.start || 0, item), + ) + autocompleteView.setActive(false) + }, + [onChangeText, text, autocompleteView], + ) + + const textDecorated = React.useMemo(() => { + let i = 0 + return detectLinkables(text).map(v => { + if (typeof v === 'string') { + return ( + + {v} + + ) + } else { + return ( + + {v.link} + + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + + {textDecorated} + + + + ) + }, +) + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + padding: 5, + marginLeft: 8, + alignSelf: 'flex-start', + }, + textInputFormatting: { + fontSize: 18, + letterSpacing: 0.2, + fontWeight: '400', + lineHeight: 23.4, // 1.3*16 + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 2b610850c..67ef836a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,58 +1,133 @@ import React from 'react' -import { - NativeSyntheticEvent, - StyleProp, - StyleSheet, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, - TextStyle, -} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {addStyle} from 'lib/styles' - -export type TextInputRef = RNTextInput +import {StyleSheet, View} from 'react-native' +import {useEditor, EditorContent, JSONContent} from '@tiptap/react' +import {Document} from '@tiptap/extension-document' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' +import {Paragraph} from '@tiptap/extension-paragraph' +import {Placeholder} from '@tiptap/extension-placeholder' +import {Text} from '@tiptap/extension-text' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {createSuggestion} from './web/Autocomplete' + +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref + text: string placeholder: string - style: StyleProp - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - - ) +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + // onPhotoPasted, TODO + onSuggestedLinksChanged, + }: // onError, TODO + TextInputProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + Link.configure({ + protocols: ['http', 'https'], + autolink: true, + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + ], + content: text, + autofocus: true, + editable: true, + injectCSS: true, + onUpdate({editor: editorProp}) { + const json = editorProp.getJSON() + const newText = editorJsonToText(json).trim() + onTextChanged(newText) + + const newSuggestedLinks = new Set(editorJsonToLinks(json)) + if (!isEqual(newSuggestedLinks, suggestedLinks)) { + onSuggestedLinksChanged(newSuggestedLinks) + } + }, + }) + + React.useImperativeHandle(ref, () => ({ + focus: () => {}, // TODO + blur: () => {}, // TODO + })) + + return ( + + + + ) + }, +) + +function editorJsonToText(json: JSONContent): string { + let text = '' + if (json.type === 'doc' || json.type === 'paragraph') { + if (json.content?.length) { + for (const node of json.content) { + text += editorJsonToText(node) + } + } + text += '\n' + } else if (json.type === 'text') { + text += json.text || '' + } else if (json.type === 'mention') { + text += json.attrs?.id || '' + } + return text +} + +function editorJsonToLinks(json: JSONContent): string[] { + let links: string[] = [] + if (json.content?.length) { + for (const node of json.content) { + links = links.concat(editorJsonToLinks(node)) + } + } + + const link = json.marks?.find(m => m.type === 'link') + if (link?.attrs?.href) { + links.push(link.attrs.href) + } + + return links } const styles = StyleSheet.create({ - input: { - minHeight: 140, + container: { + flex: 1, + alignSelf: 'flex-start', + padding: 5, + marginLeft: 8, + marginBottom: 10, }, }) diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx new file mode 100644 index 000000000..424a8629f --- /dev/null +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -0,0 +1,75 @@ +import React, {useEffect} from 'react' +import { + Animated, + TouchableOpacity, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export const Autocomplete = observer( + ({ + view, + onSelect, + }: { + view: UserAutocompleteViewModel + onSelect: (item: string) => void + }) => { + const pal = usePalette('default') + const winDim = useWindowDimensions() + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [positionInterp, view.isActive]) + + const topAnimStyle = { + top: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winDim.height, winDim.height / 4], + }), + } + return ( + + {view.suggestions.map(item => ( + onSelect(item.handle)}> + + {item.displayName || item.handle} + +  @{item.handle} + + + + ))} + + ) + }, +) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 16, + paddingHorizontal: 16, + height: 50, + }, +}) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx new file mode 100644 index 000000000..fbe438969 --- /dev/null +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -0,0 +1,157 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' + +interface MentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createSuggestion({ + autocompleteView, +}: { + autocompleteView: UserAutocompleteViewModel +}): Omit { + return { + async items({query}) { + autocompleteView.setActive(true) + await autocompleteView.setPrefix(query) + return autocompleteView.suggestions.slice(0, 8).map(s => s.handle) + }, + + render: () => { + let component: ReactRenderer | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const MentionList = forwardRef( + (props: SuggestionProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({id: item}) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( +
+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ) + }, +) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts new file mode 100644 index 000000000..75f833e84 --- /dev/null +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -0,0 +1,90 @@ +import {useState, useEffect} from 'react' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {getPostAsQuote} from 'lib/link-meta/bsky' +import {downloadAndResize} from 'lib/media/manip' +import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {ComposerOpts} from 'state/models/shell-ui' + +export function useExternalLinkFetch({ + setQuote, +}: { + setQuote: (opts: ComposerOpts['quote']) => void +}) { + const store = useStores() + const [extLink, setExtLink] = useState( + undefined, + ) + + useEffect(() => { + let aborted = false + const cleanup = () => { + aborted = true + } + if (!extLink) { + return cleanup + } + if (!extLink.meta) { + if (isBskyPostUrl(extLink.uri)) { + getPostAsQuote(store, extLink.uri).then( + newQuote => { + if (aborted) { + return + } + setQuote(newQuote) + setExtLink(undefined) + }, + err => { + store.log.error('Failed to fetch post for quote embedding', {err}) + setExtLink(undefined) + }, + ) + } else { + getLinkMeta(store, extLink.uri).then(meta => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: !!meta.image, + meta, + }) + }) + } + return cleanup + } + if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { + console.log('attempting download') + downloadAndResize({ + uri: extLink.meta.image, + width: 2000, + height: 2000, + mode: 'contain', + maxSize: 1000000, + timeout: 15e3, + }) + .catch(() => undefined) + .then(localThumb => { + if (aborted) { + return + } + setExtLink({ + ...extLink, + isLoading: false, // done + localThumb, + }) + }) + return cleanup + } + if (extLink.isLoading) { + setExtLink({ + ...extLink, + isLoading: false, // done + }) + } + return cleanup + }, [store, extLink, setQuote]) + + return {extLink, setExtLink} +} diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index 3c09a6cc2..a24dc4e35 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -27,11 +27,13 @@ import {toNiceDomain} from 'lib/strings/url-helpers' import {useStores, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const {track, screen, identify} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isProcessing, setIsProcessing] = React.useState(false) const [serviceUrl, setServiceUrl] = React.useState(DEFAULT_SERVICE) @@ -220,6 +222,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { autoCapitalize="none" autoCorrect={false} autoFocus + keyboardAppearance={theme.colorScheme} value={inviteCode} onChangeText={setInviteCode} onBlur={onBlurInviteCode} diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 4f994f831..6faf5ff12 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -26,6 +26,7 @@ import {ServiceDescription} from 'state/models/session' import {AccountData} from 'state/models/session' import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' enum Forms { @@ -195,12 +196,7 @@ const ChooseAccountForm = ({ - + @@ -273,6 +269,7 @@ const LoginForm = ({ }) => { const {track} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [identifier, setIdentifier] = useState(initialHandle) const [password, setPassword] = useState('') @@ -383,6 +380,7 @@ const LoginForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={identifier} onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} @@ -400,6 +398,7 @@ const LoginForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} @@ -479,6 +478,7 @@ const ForgotPasswordForm = ({ onEmailSent: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') const {screen} = useAnalytics() @@ -567,6 +567,7 @@ const ForgotPasswordForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} editable={!isProcessing} @@ -630,11 +631,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const {screen} = useAnalytics() - // useEffect(() => { - screen('Signin:SetNewPasswordForm') - // }, [screen]) + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) const [isProcessing, setIsProcessing] = useState(false) const [resetCode, setResetCode] = useState('') @@ -692,6 +694,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} autoFocus value={resetCode} onChangeText={setResetCode} @@ -710,6 +713,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 519be7b2e..0795d6d20 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -17,6 +17,7 @@ import {ServiceDescription} from 'state/models/session' import {s} from 'lib/styles' import {makeValidHandle, createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError} from 'lib/strings/errors' @@ -212,6 +213,7 @@ function ProvidedHandleForm({ setCanSave: (v: boolean) => void }) { const pal = usePalette('default') + const theme = useTheme() // events // = @@ -239,6 +241,7 @@ function ProvidedHandleForm({ placeholder="eg alice" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} @@ -283,6 +286,7 @@ function CustomHandleForm({ const pal = usePalette('default') const palSecondary = usePalette('secondary') const palError = usePalette('error') + const theme = useTheme() const [isVerifying, setIsVerifying] = React.useState(false) const [error, setError] = React.useState('') @@ -348,6 +352,7 @@ function CustomHandleForm({ placeholder="eg alice.com" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index de29e728d..62fa9f386 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -12,13 +12,16 @@ import {Text} from '../util/text/Text' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' +import {resetToTab} from '../../../Navigation' export const snapPoints = ['60%'] export function Component({}: {}) { const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isEmailSent, setIsEmailSent] = React.useState(false) const [confirmCode, setConfirmCode] = React.useState('') @@ -46,7 +49,7 @@ export function Component({}: {}) { token: confirmCode, }) Toast.show('Your account has been deleted') - store.nav.tab.fixedTabReset() + resetToTab('HomeTab') store.session.clear() store.shell.closeModal() } catch (e: any) { @@ -117,6 +120,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} placeholder="Confirmation code" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} value={confirmCode} onChangeText={setConfirmCode} /> @@ -127,6 +131,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text]} placeholder="Password" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 121831ada..6eb21d17d 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -20,6 +20,7 @@ import {compressIfNeeded} from 'lib/media/manip' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError, isNetworkError} from 'lib/strings/errors' @@ -35,6 +36,7 @@ export function Component({ const store = useStores() const [error, setError] = useState('') const pal = usePalette('default') + const theme = useTheme() const {track} = useAnalytics() const [isProcessing, setProcessing] = useState(false) @@ -133,9 +135,7 @@ export function Component({ @@ -160,6 +160,7 @@ export function Component({ style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} + keyboardAppearance={theme.colorScheme} multiline value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 5a9a4cfed..1d352cec9 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -8,12 +8,14 @@ import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] export function Component({onSelect}: {onSelect: (url: string) => void}) { + const theme = useTheme() const store = useStores() const [customUrl, setCustomUrl] = useState('') @@ -74,6 +76,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { autoCapitalize="none" autoComplete="off" autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} /> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index b21681c7f..306686557 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,6 +5,7 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' import {PickedMedia} from 'lib/media/types' +import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -54,7 +55,7 @@ export function Component({ mediaType: 'photo', path: dataUri, mime: 'image/jpeg', - size: Math.round((dataUri.length * 3) / 4), // very rough estimate + size: getDataUriSize(dataUri), width: DIMS[as].width, height: DIMS[as].height, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index acd00a67d..1c2299b03 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -24,7 +24,7 @@ import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' -import {Link} from '../util/Link' +import {Link, TextLink} from '../util/Link' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' @@ -186,15 +186,12 @@ export const FeedItem = observer(function FeedItem({ authors={authors} /> - - - {authors[0].displayName || authors[0].handle} - - + text={authors[0].displayName || authors[0].handle} + /> {authors.length > 1 ? ( <> and @@ -256,13 +253,9 @@ function CondensedAuthorsList({ - + title={`@${authors[0].handle}`} + asAnchor> + ) @@ -271,12 +264,7 @@ function CondensedAuthorsList({ {authors.slice(0, MAX_AUTHORS).map(author => ( - + ))} {authors.length > MAX_AUTHORS ? ( @@ -326,14 +314,10 @@ function ExpandedAuthorsList({ key={author.href} href={author.href} title={author.displayName || author.handle} - style={styles.expandedAuthor}> + style={styles.expandedAuthor} + asAnchor> - + void }) { + const pal = usePalette('default') const ref = useRef(null) const [isRefreshing, setIsRefreshing] = React.useState(false) - const posts = React.useMemo( - () => (view.thread ? Array.from(flattenThread(view.thread)) : []), - [view.thread], - ) + const posts = React.useMemo(() => { + if (view.thread) { + return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) + } + return [] + }, [view.thread]) // events // = @@ -58,6 +73,23 @@ export const PostThread = observer(function PostThread({ }, [ref], ) + const renderItem = React.useCallback( + ({item}: {item: YieldedItem}) => { + if (item === REPLY_PROMPT) { + return + } else if (item === BOTTOM_BORDER) { + // HACK + // due to some complexities with how flatlist works, this is the easiest way + // I could find to get a border positioned directly under the last item + // -prf + return + } else if (item instanceof PostThreadViewPostModel) { + return + } + return <> + }, + [onRefresh, onPressReply, pal], + ) // loading // = @@ -81,9 +113,6 @@ export const PostThread = observer(function PostThread({ // loaded // = - const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( - - ) return ( { +): Generator { if (post.parent) { if ('notFound' in post.parent && post.parent.notFound) { // TODO render not found @@ -113,6 +142,9 @@ function* flattenThread( } } yield post + if (isDesktopWeb && post._isHighlightedPost) { + yield REPLY_PROMPT + } if (post.replies?.length) { for (const reply of post.replies) { if ('notFound' in reply && reply.notFound) { @@ -125,3 +157,9 @@ function* flattenThread( post._hasMore = true } } + +const styles = StyleSheet.create({ + bottomBorder: { + borderBottomWidth: 1, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 1413148a9..17c7943d9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -135,13 +135,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> - - + + @@ -299,13 +294,8 @@ export const PostThreadItem = observer(function PostThreadItem({ )} - - + + @@ -313,6 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7b4161afc..ac7d1cc55 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -150,13 +150,8 @@ export const Post = observer(function Post({ {showReplyLine && } - - + + @@ -164,6 +159,7 @@ export const Post = observer(function Post({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 5751faa68..8f57900b5 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -7,6 +7,7 @@ import { StyleSheet, ViewStyle, } from 'react-native' +import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {CenteredView, FlatList} from '../util/Views' @@ -18,10 +19,10 @@ import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_FEED_ITEM = {_reactKey: '__error__'} @@ -47,9 +48,9 @@ export const Feed = observer(function Feed({ }) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const navigation = useNavigation() const data = React.useMemo(() => { let feedItems: any[] = [] @@ -112,7 +113,12 @@ export const Feed = observer(function Feed({