diff options
Diffstat (limited to 'src')
54 files changed, 1243 insertions, 1414 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index f99e976ce..f4298c461 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -2,7 +2,6 @@ import 'react-native-url-polyfill/auto' import React, {useState, useEffect} from 'react' import 'lib/sentry' // must be relatively on top import {withSentry} from 'lib/sentry' -import {Linking} from 'react-native' import {RootSiblingParent} from 'react-native-root-siblings' import * as SplashScreen from 'expo-splash-screen' import {GestureHandlerRootView} from 'react-native-gesture-handler' @@ -15,7 +14,6 @@ import {Shell} from './view/shell' import * as notifications from 'lib/notifications/notifications' import * as analytics from 'lib/analytics/analytics' import * as Toast from './view/com/util/Toast' -import {handleLink} from './Navigation' import {QueryClientProvider} from '@tanstack/react-query' import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' @@ -34,15 +32,6 @@ const App = observer(function AppImpl() { setRootStore(store) analytics.init(store) notifications.init(store) - SplashScreen.hideAsync() - Linking.getInitialURL().then((url: string | null) => { - if (url) { - handleLink(url) - } - }) - Linking.addEventListener('url', ({url}) => { - handleLink(url) - }) store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') }) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 97612c9ec..52235ad75 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import {StyleSheet} from 'react-native' +import * as SplashScreen from 'expo-splash-screen' import {observer} from 'mobx-react-lite' import { NavigationContainer, @@ -91,42 +92,42 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <> <Stack.Screen name="NotFound" - component={NotFoundScreen} + getComponent={() => NotFoundScreen} options={{title: title('Not Found')}} /> <Stack.Screen name="Moderation" - component={ModerationScreen} + getComponent={() => ModerationScreen} options={{title: title('Moderation')}} /> <Stack.Screen name="ModerationMuteLists" - component={ModerationMuteListsScreen} + getComponent={() => ModerationMuteListsScreen} options={{title: title('Mute Lists')}} /> <Stack.Screen name="ModerationMutedAccounts" - component={ModerationMutedAccounts} + getComponent={() => ModerationMutedAccounts} options={{title: title('Muted Accounts')}} /> <Stack.Screen name="ModerationBlockedAccounts" - component={ModerationBlockedAccounts} + getComponent={() => ModerationBlockedAccounts} options={{title: title('Blocked Accounts')}} /> <Stack.Screen name="Settings" - component={SettingsScreen} + getComponent={() => SettingsScreen} options={{title: title('Settings')}} /> <Stack.Screen name="LanguageSettings" - component={LanguageSettingsScreen} + getComponent={() => LanguageSettingsScreen} options={{title: title('Language Settings')}} /> <Stack.Screen name="Profile" - component={ProfileScreen} + getComponent={() => ProfileScreen} options={({route}) => ({ title: title(`@${route.params.name}`), animation: 'none', @@ -134,101 +135,101 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> <Stack.Screen name="ProfileFollowers" - component={ProfileFollowersScreen} + getComponent={() => ProfileFollowersScreen} options={({route}) => ({ title: title(`People following @${route.params.name}`), })} /> <Stack.Screen name="ProfileFollows" - component={ProfileFollowsScreen} + getComponent={() => ProfileFollowsScreen} options={({route}) => ({ title: title(`People followed by @${route.params.name}`), })} /> <Stack.Screen name="ProfileList" - component={ProfileListScreen} + getComponent={() => ProfileListScreen} options={{title: title('Mute List')}} /> <Stack.Screen name="PostThread" - component={PostThreadScreen} + getComponent={() => PostThreadScreen} options={({route}) => ({title: title(`Post by @${route.params.name}`)})} /> <Stack.Screen name="PostLikedBy" - component={PostLikedByScreen} + getComponent={() => PostLikedByScreen} options={({route}) => ({title: title(`Post by @${route.params.name}`)})} /> <Stack.Screen name="PostRepostedBy" - component={PostRepostedByScreen} + getComponent={() => PostRepostedByScreen} options={({route}) => ({title: title(`Post by @${route.params.name}`)})} /> <Stack.Screen name="CustomFeed" - component={CustomFeedScreen} + getComponent={() => CustomFeedScreen} options={{title: title('Feed')}} /> <Stack.Screen name="CustomFeedLikedBy" - component={CustomFeedLikedByScreen} + getComponent={() => CustomFeedLikedByScreen} options={{title: title('Liked by')}} /> <Stack.Screen name="Debug" - component={DebugScreen} + getComponent={() => DebugScreen} options={{title: title('Debug')}} /> <Stack.Screen name="Log" - component={LogScreen} + getComponent={() => LogScreen} options={{title: title('Log')}} /> <Stack.Screen name="Support" - component={SupportScreen} + getComponent={() => SupportScreen} options={{title: title('Support')}} /> <Stack.Screen name="PrivacyPolicy" - component={PrivacyPolicyScreen} + getComponent={() => PrivacyPolicyScreen} options={{title: title('Privacy Policy')}} /> <Stack.Screen name="TermsOfService" - component={TermsOfServiceScreen} + getComponent={() => TermsOfServiceScreen} options={{title: title('Terms of Service')}} /> <Stack.Screen name="CommunityGuidelines" - component={CommunityGuidelinesScreen} + getComponent={() => CommunityGuidelinesScreen} options={{title: title('Community Guidelines')}} /> <Stack.Screen name="CopyrightPolicy" - component={CopyrightPolicyScreen} + getComponent={() => CopyrightPolicyScreen} options={{title: title('Copyright Policy')}} /> <Stack.Screen name="AppPasswords" - component={AppPasswords} + getComponent={() => AppPasswords} options={{title: title('App Passwords')}} /> <Stack.Screen name="SavedFeeds" - component={SavedFeeds} + getComponent={() => SavedFeeds} options={{title: title('Edit My Feeds')}} /> <Stack.Screen name="PreferencesHomeFeed" - component={PreferencesHomeFeed} + getComponent={() => PreferencesHomeFeed} options={{title: title('Home Feed Preferences')}} /> <Stack.Screen name="PreferencesThreads" - component={PreferencesThreads} + getComponent={() => PreferencesThreads} options={{title: title('Threads Preferences')}} /> </> @@ -253,14 +254,17 @@ function TabsNavigator() { backBehavior="initialRoute" screenOptions={{headerShown: false, lazy: true}} tabBar={tabBar}> - <Tab.Screen name="HomeTab" component={HomeTabNavigator} /> - <Tab.Screen name="SearchTab" component={SearchTabNavigator} /> - <Tab.Screen name="FeedsTab" component={FeedsTabNavigator} /> + <Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} /> + <Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} /> + <Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} /> <Tab.Screen name="NotificationsTab" - component={NotificationsTabNavigator} + getComponent={() => NotificationsTabNavigator} + /> + <Tab.Screen + name="MyProfileTab" + getComponent={() => MyProfileTabNavigator} /> - <Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} /> </Tab.Navigator> ) } @@ -277,7 +281,7 @@ function HomeTabNavigator() { animationDuration: 250, contentStyle, }}> - <HomeTab.Screen name="Home" component={HomeScreen} /> + <HomeTab.Screen name="Home" getComponent={() => HomeScreen} /> {commonScreens(HomeTab)} </HomeTab.Navigator> ) @@ -294,7 +298,7 @@ function SearchTabNavigator() { animationDuration: 250, contentStyle, }}> - <SearchTab.Screen name="Search" component={SearchScreen} /> + <SearchTab.Screen name="Search" getComponent={() => SearchScreen} /> {commonScreens(SearchTab as typeof HomeTab)} </SearchTab.Navigator> ) @@ -311,7 +315,7 @@ function FeedsTabNavigator() { animationDuration: 250, contentStyle, }}> - <FeedsTab.Screen name="Feeds" component={FeedsScreen} /> + <FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} /> {commonScreens(FeedsTab as typeof HomeTab)} </FeedsTab.Navigator> ) @@ -330,7 +334,7 @@ function NotificationsTabNavigator() { }}> <NotificationsTab.Screen name="Notifications" - component={NotificationsScreen} + getComponent={() => NotificationsScreen} /> {commonScreens(NotificationsTab as typeof HomeTab)} </NotificationsTab.Navigator> @@ -352,7 +356,7 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { <MyProfileTab.Screen name="MyProfile" // @ts-ignore // TODO: fix this broken type in ProfileScreen - component={ProfileScreen} + getComponent={() => ProfileScreen} initialParams={{ name: store.me.did, }} @@ -383,22 +387,22 @@ const FlatNavigator = observer(function FlatNavigatorImpl() { }}> <Flat.Screen name="Home" - component={HomeScreen} + getComponent={() => HomeScreen} options={{title: title('Home')}} /> <Flat.Screen name="Search" - component={SearchScreen} + getComponent={() => SearchScreen} options={{title: title('Search')}} /> <Flat.Screen name="Feeds" - component={FeedsScreen} + getComponent={() => FeedsScreen} options={{title: title('Feeds')}} /> <Flat.Screen name="Notifications" - component={NotificationsScreen} + getComponent={() => NotificationsScreen} options={{title: title('Notifications')}} /> {commonScreens(Flat as typeof HomeTab, unreadCountLabel)} @@ -462,6 +466,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onReady={() => { + SplashScreen.hideAsync() // Register the navigation container with the Sentry instrumentation (only works on native) if (isNative) { const routingInstrumentation = getRoutingInstrumentation() @@ -483,9 +488,21 @@ function navigate<K extends keyof AllNavigatorParams>( 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) + return Promise.race([ + new Promise<void>(resolve => { + const handler = () => { + resolve() + navigationRef.removeListener('state', handler) + } + navigationRef.addListener('state', handler) + + // @ts-ignore I dont know what would make typescript happy but I have a life -prf + navigationRef.navigate(name, params) + }), + timeout(1e3), + ]) } + return Promise.resolve() } function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') { diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx index d1eb50f8a..b3db9149c 100644 --- a/src/lib/analytics/analytics.tsx +++ b/src/lib/analytics/analytics.tsx @@ -51,10 +51,10 @@ export function init(store: RootStoreModel) { store.onSessionLoaded(() => { const sess = store.session.currentSession if (sess) { - if (sess.email) { + if (sess.did) { + const did_hashed = sha256(sess.did) + segmentClient.identify(did_hashed, {did_hashed}) store.log.debug('Ping w/hash') - const email_hashed = sha256(sess.email) - segmentClient.identify(email_hashed, {email_hashed}) } else { store.log.debug('Ping w/o hash') segmentClient.identify() diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx index db9d86e3c..78bd9b42b 100644 --- a/src/lib/analytics/analytics.web.tsx +++ b/src/lib/analytics/analytics.web.tsx @@ -46,10 +46,10 @@ export function init(store: RootStoreModel) { store.onSessionLoaded(() => { const sess = store.session.currentSession if (sess) { - if (sess.email) { + if (sess.did) { + const did_hashed = sha256(sess.did) + segmentClient.identify(did_hashed, {did_hashed}) store.log.debug('Ping w/hash') - const email_hashed = sha256(sess.email) - segmentClient.identify(email_hashed, {email_hashed}) } else { store.log.debug('Ping w/o hash') segmentClient.identify() diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index e28a0e884..475d165d3 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,32 +1,60 @@ import React from 'react' +import {autorun} from 'mobx' import {useStores} from 'state/index' -import {Animated} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' export function useMinimalShellMode() { const store = useStores() - const minimalShellInterp = useAnimatedValue(0) - const footerMinimalShellTransform = { - transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}], - } + const minimalShellInterp = useSharedValue(0) + const footerMinimalShellTransform = useAnimatedStyle(() => { + return { + opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + transform: [ + {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])}, + ], + } + }) + const headerMinimalShellTransform = useAnimatedStyle(() => { + return { + opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + transform: [ + {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])}, + ], + } + }) + const fabMinimalShellTransform = useAnimatedStyle(() => { + return { + transform: [ + {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])}, + ], + } + }) React.useEffect(() => { - if (store.shell.minimalShellMode) { - Animated.timing(minimalShellInterp, { - toValue: 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } else { - Animated.timing(minimalShellInterp, { - toValue: 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } + return autorun(() => { + if (store.shell.minimalShellMode) { + minimalShellInterp.value = withTiming(1, { + duration: 125, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }) + } else { + minimalShellInterp.value = withTiming(0, { + duration: 125, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }) + } + }) }, [minimalShellInterp, store.shell.minimalShellMode]) - return {footerMinimalShellTransform} + return { + footerMinimalShellTransform, + headerMinimalShellTransform, + fabMinimalShellTransform, + } } diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 3c27d8639..106d2ca31 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined { export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) - if (!labelDomain) { - return true - } + let urip try { - const urip = new URL(uri) - return labelDomain !== urip.hostname + urip = new URL(uri) } catch { return true } + + if (urip.hostname === 'bsky.app') { + // if this is a link to internal content, + // warn if it represents itself as a URL to another app + if ( + labelDomain && + labelDomain !== 'bsky.app' && + isPossiblyAUrl(labelDomain) + ) { + return true + } + return false + } else { + // if this is a link to external content, + // warn if the label doesnt match the target + if (!labelDomain) { + return true + } + return labelDomain !== urip.hostname + } } function labelToDomain(label: string): string | undefined { diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 07079b5af..e1e8af509 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -5,6 +5,7 @@ import { moderateProfile, } from '@atproto/api' import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' const MAX_SYNC_PAGES = 10 const SYNC_TTL = 60e3 * 10 // 10 minutes @@ -56,7 +57,7 @@ export class MyFollowsCache { * Syncs a subset of the user's follows * for performance reasons, caps out at 1000 follows */ - async syncIfNeeded() { + syncIfNeeded = bundleAsync(async () => { if (this.lastSync > Date.now() - SYNC_TTL) { return } @@ -81,7 +82,7 @@ export class MyFollowsCache { } this.lastSync = Date.now() - } + }) getFollowState(did: string): FollowState { if (typeof this.byDid[did] === 'undefined') { diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index ae4f29105..d46cced75 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -116,6 +116,7 @@ export class PostsFeedItemModel { }, () => this.rootStore.agent.deleteLike(url), ) + track('Post:Unlike') } else { // like await updateDataOptimistically( @@ -129,11 +130,10 @@ export class PostsFeedItemModel { this.post.viewer!.like = res.uri }, ) + track('Post:Like') } } catch (error) { this.rootStore.log.error('Failed to toggle like', error) - } finally { - track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like') } } @@ -141,6 +141,7 @@ export class PostsFeedItemModel { this.post.viewer = this.post.viewer || {} try { if (this.post.viewer?.repost) { + // unrepost const url = this.post.viewer.repost await updateDataOptimistically( this.post, @@ -150,7 +151,9 @@ export class PostsFeedItemModel { }, () => this.rootStore.agent.deleteRepost(url), ) + track('Post:Unrepost') } else { + // repost await updateDataOptimistically( this.post, () => { @@ -162,11 +165,10 @@ export class PostsFeedItemModel { this.post.viewer!.repost = res.uri }, ) + track('Post:Repost') } } catch (error) { this.rootStore.log.error('Failed to toggle repost', error) - } finally { - track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost') } } @@ -174,13 +176,13 @@ export class PostsFeedItemModel { try { if (this.isThreadMuted) { this.rootStore.mutedThreads.uris.delete(this.rootUri) + track('Post:ThreadUnmute') } else { this.rootStore.mutedThreads.uris.add(this.rootUri) + track('Post:ThreadMute') } } catch (error) { this.rootStore.log.error('Failed to toggle thread mute', error) - } finally { - track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute') } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 8a7a4c851..186e61cf6 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -25,13 +25,13 @@ export class MeModel { savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache - invites: ComAtprotoServerDefs.InviteCode[] | null = [] + invites: ComAtprotoServerDefs.InviteCode[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() lastNotifsUpdate = Date.now() get invitesAvailable() { - return this.invites?.filter(isInviteAvailable).length || null + return this.invites.filter(isInviteAvailable).length } constructor(public rootStore: RootStoreModel) { @@ -180,9 +180,7 @@ export class MeModel { } catch (e) { this.rootStore.log.error('Failed to fetch user invite codes', e) } - if (this.invites) { - await this.rootStore.invitedUsers.fetch(this.invites) - } + await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index 10aef0ff4..c26f9b87c 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> { async crop() { try { // NOTE - // on ios, react-native-image-cropper gives really bad quality + // on ios, react-native-image-crop-picker gives really bad quality // without specifying width and height. on android, however, the // crop stretches incorrectly if you do specify it. these are // both separate bugs in the library. we deal with that by diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts new file mode 100644 index 000000000..ec0eca40d --- /dev/null +++ b/src/state/models/ui/reminders.e2e.ts @@ -0,0 +1,24 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' + +export class Reminders { + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + {serialize: false, hydrate: false}, + {autoBind: true}, + ) + } + + serialize() { + return {} + } + + hydrate(_v: unknown) {} + + get shouldRequestEmailConfirmation() { + return false + } + + setEmailConfirmationRequested() {} +} diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts index 60dbf5d88..c650de004 100644 --- a/src/state/models/ui/reminders.ts +++ b/src/state/models/ui/reminders.ts @@ -3,10 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {toHashCode} from 'lib/strings/helpers' -const DAY = 60e3 * 24 * 1 // 1 day (ms) - export class Reminders { - lastEmailConfirm: Date = new Date() + lastEmailConfirm: Date | null = null constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -45,6 +43,10 @@ export class Reminders { if (this.rootStore.onboarding.isActive) { return false } + // only prompt once + if (this.lastEmailConfirm) { + return false + } const today = new Date() // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when @@ -53,9 +55,7 @@ export class Reminders { if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { return false } - // only ask once a day at most, but because of the bucketing - // this will be more like weekly - return Number(today) - Number(this.lastEmailConfirm) > DAY + return true } setEmailConfirmationRequested() { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index c5d094ea5..2810129f6 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -187,16 +187,19 @@ export const TextInput = forwardRef(function TextInputImpl( const textDecorated = useMemo(() => { let i = 0 - return Array.from(richtext.segments()).map(segment => ( - <Text - key={i++} - style={[ - !segment.facet ? pal.text : pal.link, - styles.textInputFormatting, - ]}> - {segment.text} - </Text> - )) + return Array.from(richtext.segments()).map(segment => { + const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) + return ( + <Text + key={i++} + style={[ + segment.facet && !isTag ? pal.link : pal.text, + styles.textInputFormatting, + ]}> + {segment.text} + </Text> + ) + }) }, [richtext, pal.link, pal.text]) return ( diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx new file mode 100644 index 000000000..725106d59 --- /dev/null +++ b/src/view/com/feeds/FeedPage.tsx @@ -0,0 +1,210 @@ +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useIsFocused} from '@react-navigation/native' +import {useAnalytics} from '@segment/analytics-react-native' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {ComposeIcon2} from 'lib/icons' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import React from 'react' +import {FlatList, View} from 'react-native' +import {useStores} from 'state/index' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' +import {Feed} from '../posts/Feed' +import {TextLink} from '../util/Link' +import {FAB} from '../util/fab/FAB' +import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' +import useAppState from 'react-native-appstate-hook' + +export const FeedPage = observer(function FeedPageImpl({ + testID, + isPageFocused, + feed, + renderEmptyState, + renderEndOfFeed, +}: { + testID?: string + feed: PostsFeedModel + isPageFocused: boolean + renderEmptyState: () => JSX.Element + renderEndOfFeed?: () => JSX.Element +}) { + const store = useStores() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) + const {screen, track} = useAnalytics() + const headerOffset = useHeaderOffset() + const scrollElRef = React.useRef<FlatList>(null) + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) + const isScreenFocused = useIsFocused() + const hasNew = feed.hasNewLatest && !feed.isRefreshing + + React.useEffect(() => { + // called on first load + if (!feed.hasLoaded && isPageFocused) { + feed.setup() + } + }, [isPageFocused, feed]) + + const doPoll = React.useCallback( + (knownActive = false) => { + if ( + (!knownActive && appState !== 'active') || + !isScreenFocused || + !isPageFocused + ) { + return + } + if (feed.isLoading) { + return + } + store.log.debug('HomeScreen: Polling for new posts') + feed.checkForLatest() + }, + [appState, isScreenFocused, isPageFocused, store, feed], + ) + + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + resetMainScroll() + }, [headerOffset, resetMainScroll]) + + const onSoftReset = React.useCallback(() => { + if (isPageFocused) { + scrollToTop() + feed.refresh() + } + }, [isPageFocused, scrollToTop, feed]) + + // fires when page within screen is activated/deactivated + // - check for latest + React.useEffect(() => { + if (!isPageFocused || !isScreenFocused) { + return + } + + const softResetSub = store.onScreenSoftReset(onSoftReset) + const feedCleanup = feed.registerListeners() + const pollInterval = setInterval(doPoll, POLL_FREQ) + + screen('Feed') + store.log.debug('HomeScreen: Updating feed') + feed.checkForLatest() + + return () => { + clearInterval(pollInterval) + softResetSub.remove() + feedCleanup() + } + }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + + const onPressCompose = React.useCallback(() => { + track('HomeScreen:PressCompose') + store.shell.openComposer({}) + }, [store, track]) + + const onPressTryAgain = React.useCallback(() => { + feed.refresh() + }, [feed]) + + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + feed.refresh() + }, [feed, scrollToTop]) + + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> + } + onPress={() => store.emitScreenSoftReset()} + /> + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + accessibilityLabel="Feed Preferences" + accessibilityHint="" + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, store, hasNew]) + + return ( + <View testID={testID} style={s.h100pct}> + <Feed + testID={testID ? `${testID}-feed` : undefined} + key="default" + feed={feed} + scrollElRef={scrollElRef} + onPressTryAgain={onPressTryAgain} + onScroll={onMainScroll} + scrollEventThrottle={100} + renderEmptyState={renderEmptyState} + renderEndOfFeed={renderEndOfFeed} + ListHeaderComponent={ListHeaderComponent} + headerOffset={headerOffset} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) +}) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index 553a4a2e7..7c7ad0616 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,9 +1,8 @@ -import React, {MutableRefObject, useState} from 'react' +import React, {useState} from 'react' import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' import Animated, { - measure, runOnJS, useAnimatedRef, useAnimatedStyle, @@ -12,11 +11,7 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated' -import { - GestureDetector, - Gesture, - GestureType, -} from 'react-native-gesture-handler' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import { createTransform, @@ -39,16 +34,16 @@ const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (isZoomed: boolean) => void - pinchGestureRef: MutableRefObject<GestureType | undefined> isScrollViewBeingDragged: boolean } const ImageItem = ({ imageSrc, + onTap, onZoom, onRequestClose, isScrollViewBeingDragged, - pinchGestureRef, }: Props) => { const [isScaled, setIsScaled] = useState(false) const [isLoaded, setIsLoaded] = useState(false) @@ -140,28 +135,7 @@ const ImageItem = ({ return [dx, dy] } - // This is a hack. - // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. - // However, there is no great reliable way to coordinate this yet in RGNH. - // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. - const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { - if (isScrollViewBeingDragged) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - const measurement = measure(containerRef) - if (!measurement || measurement.pageX !== 0) { - // Steal the gesture (and do nothing, so native ScrollView does its thing). - manager.activate() - return - } - // Fail this "fake" gesture so that the gestures after it can proceed. - manager.fail() - }) - const pinch = Gesture.Pinch() - .withRef(pinchGestureRef) .onStart(e => { pinchOrigin.value = { x: e.focalX - SCREEN.width / 2, @@ -255,6 +229,10 @@ const ImageItem = ({ panTranslation.value = {x: 0, y: 0} }) + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) + const doubleTap = Gesture.Tap() .numberOfTaps(2) .onEnd(e => { @@ -318,22 +296,27 @@ const ImageItem = ({ } }) + const composedGesture = isScrollViewBeingDragged + ? // If the parent is not at rest, provide a no-op gesture. + Gesture.Manual() + : Gesture.Exclusive( + dismissSwipePan, + Gesture.Simultaneous(pinch, pan), + doubleTap, + singleTap, + ) + const isLoading = !isLoaded || !imageDimensions return ( <Animated.View ref={containerRef} style={styles.container}> {isLoading && ( <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> )} - <GestureDetector - gesture={Gesture.Exclusive( - consumeHScroll, - dismissSwipePan, - Gesture.Simultaneous(pinch, pan), - doubleTap, - )}> + <GestureDetector gesture={composedGesture}> <AnimatedImage - source={imageSrc} contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} accessibilityHint="" diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 598b18ed2..f73f355ac 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,162 +6,162 @@ * */ -import React, {MutableRefObject, useCallback, useRef, useState} from 'react' +import React, {useState} from 'react' -import { - Animated, - Dimensions, - ScrollView, - StyleSheet, - View, - NativeScrollEvent, - NativeSyntheticEvent, - NativeTouchEvent, - TouchableWithoutFeedback, -} from 'react-native' +import {Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' -import {GestureType} from 'react-native-gesture-handler' +import Animated, { + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' -const DOUBLE_TAP_DELAY = 300 const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_VELOCITY = 1 const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height -const MIN_ZOOM = 2 -const MAX_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 +const MIN_DOUBLE_TAP_SCALE = 2 type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject<GestureType> isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) -let lastTapTS: number | null = null - -const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const scrollViewRef = useRef<ScrollView>(null) +const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { + const scrollViewRef = useAnimatedRef<Animated.ScrollView>() + const translationY = useSharedValue(0) const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const [scrollValueY] = useState(() => new Animated.Value(0)) - const maxScrollViewZoom = MAX_SCALE / (scale || 1) + const maxZoomScale = imageDimensions + ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + : 1 - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.5, 1, 0.5], + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + translationY.value, + [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + [0.5, 1, 0.5], + ), + } }) - const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - const onScrollEndDrag = useCallback( - ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const currentScaled = nativeEvent?.zoomScale > 1 - - onZoom(currentScaled) - setScaled(currentScaled) - - if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - onRequestClose() + const scrollHandler = useAnimatedScrollHandler({ + onScroll(e) { + const nextIsScaled = e.zoomScale > 1 + translationY.value = nextIsScaled ? 0 : e.contentOffset.y + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onRequestClose, onZoom], - ) + onEndDrag(e) { + const velocityY = e.velocity?.y ?? 0 + const nextIsScaled = e.zoomScale > 1 + if (scaled !== nextIsScaled) { + runOnJS(handleZoom)(nextIsScaled) + } + if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { + runOnJS(onRequestClose)() + } + }, + }) + + function handleZoom(nextIsScaled: boolean) { + onZoom(nextIsScaled) + setScaled(nextIsScaled) + } - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + function handleDoubleTap(absoluteX: number, absoluteY: number) { + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + let nextZoomRect = { + x: 0, + y: 0, + width: SCREEN.width, + height: SCREEN.height, + } - if (nativeEvent?.zoomScale > 1) { - return + const willZoom = !scaled + if (willZoom) { + nextZoomRect = getZoomRectAfterDoubleTap( + imageDimensions, + absoluteX, + absoluteY, + ) } - scrollValueY.setValue(offsetY) + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + ...nextZoomRect, // This rect is in screen coordinates + animated: true, + }) } - const handleDoubleTap = useCallback( - (event: NativeSyntheticEvent<NativeTouchEvent>) => { - const nowTS = new Date().getTime() - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() - - if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { - let nextZoomRect = { - x: 0, - y: 0, - width: SCREEN.width, - height: SCREEN.height, - } + const singleTap = Gesture.Tap().onEnd(() => { + runOnJS(onTap)() + }) - const willZoom = !scaled - if (willZoom) { - const {pageX, pageY} = event.nativeEvent - nextZoomRect = getZoomRectAfterDoubleTap( - imageDimensions, - pageX, - pageY, - ) - } + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + const {absoluteX, absoluteY} = e + runOnJS(handleDoubleTap)(absoluteX, absoluteY) + }) - // @ts-ignore - scrollResponderRef?.scrollResponderZoomTo({ - ...nextZoomRect, // This rect is in screen coordinates - animated: true, - }) - } else { - lastTapTS = nowTS - } - }, - [imageDimensions, scaled], - ) + const composedGesture = Gesture.Exclusive(doubleTap, singleTap) return ( - <View> - <ScrollView + <GestureDetector gesture={composedGesture}> + <Animated.ScrollView + // @ts-ignore Something's up with the types here ref={scrollViewRef} style={styles.listItem} pinchGestureEnabled showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - maximumZoomScale={maxScrollViewZoom} + maximumZoomScale={maxZoomScale} contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={true} - onScroll={onScroll} - onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={1}> + onScroll={scrollHandler}> {(!loaded || !imageDimensions) && <ImageLoading />} - <TouchableWithoutFeedback - onPress={handleDoubleTap} - accessibilityRole="image" + <AnimatedImage + contentFit="contain" + // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. + source={{uri: imageSrc.uri}} + style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} - accessibilityHint=""> - <AnimatedImage - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={() => setLoaded(true)} - /> - </TouchableWithoutFeedback> - </ScrollView> - </View> + accessibilityHint="" + onLoad={() => setLoaded(true)} + /> + </Animated.ScrollView> + </GestureDetector> ) } const styles = StyleSheet.create({ + imageScrollContainer: { + height: SCREEN.height, + }, listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + width: SCREEN.width, + height: SCREEN.height, }, - imageScrollContainer: { - height: SCREEN_HEIGHT, + image: { + width: SCREEN.width, + height: SCREEN.height, }, }) @@ -191,7 +191,7 @@ const getZoomRectAfterDoubleTap = ( const zoom = Math.max( imageAspect / screenAspect, screenAspect / imageAspect, - MIN_ZOOM, + MIN_DOUBLE_TAP_SCALE, ) // Unlike in the Android version, we don't constrain the *max* zoom level here. // Instead, this is done in the ScrollView props so that it constraints pinch too. @@ -253,61 +253,4 @@ const getZoomRectAfterDoubleTap = ( } } -const getImageStyles = ( - image: ImageDimensions | null, - translate: {readonly x: number; readonly y: number} | undefined, - scale?: number, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} - } - const transform = [] - if (translate) { - transform.push({translateX: translate.x}) - transform.push({translateY: translate.y}) - } - if (scale) { - // @ts-ignore TODO - is scale incorrect? might need to remove -prf - transform.push({scale}, {perspective: new Animated.Value(1000)}) - } - return { - width: image.width, - height: image.height, - transform, - } -} - -const getImageTransform = ( - image: ImageDimensions | null, - screen: ImageDimensions, -) => { - if (!image?.width || !image?.height) { - return [] as const - } - - const wScale = screen.width / image.width - const hScale = screen.height / image.height - const scale = Math.min(wScale, hScale) - const {x, y} = getImageTranslate(image, screen) - - return [{x, y}, scale] as const -} - -const getImageTranslate = ( - image: ImageDimensions, - screen: ImageDimensions, -): {x: number; y: number} => { - const getTranslateForAxis = (axis: 'x' | 'y'): number => { - const imageSize = axis === 'x' ? image.width : image.height - const screenSize = axis === 'x' ? screen.width : screen.height - - return (screenSize - imageSize) / 2 - } - - return { - x: getTranslateForAxis('x'), - y: getTranslateForAxis('y'), - } -} - export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 898b00c78..16688b820 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,15 +1,14 @@ // default implementation fallback for web -import React, {MutableRefObject} from 'react' +import React from 'react' import {View} from 'react-native' -import {GestureType} from 'react-native-gesture-handler' import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void + onTap: () => void onZoom: (scaled: boolean) => void - pinchGestureRef: MutableRefObject<GestureType | undefined> isScrollViewBeingDragged: boolean } diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts index 7f0851af3..cb46fd0d9 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => { // eslint-disable-next-line @typescript-eslint/no-shadow const getImageDimensions = (image: ImageSource): Promise<Dimensions> => { return new Promise(resolve => { - if (typeof image === 'number') { - const cacheKey = `${image}` - let imageDimensions = imageDimensionsCache.get(cacheKey) - - if (!imageDimensions) { - const {width, height} = Image.resolveAssetSource(image) - imageDimensions = {width, height} - imageDimensionsCache.set(cacheKey, imageDimensions) - } - - resolve(imageDimensions) - - return - } - - // @ts-ignore if (image.uri) { const source = image as ImageURISource - const cacheKey = source.uri as string - const imageDimensions = imageDimensionsCache.get(cacheKey) - if (imageDimensions) { resolve(imageDimensions) } else { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index bc2a8a448..b6835793d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -8,121 +8,72 @@ // Original code copied and simplified from the link below as the codebase is currently not maintained: // https://github.com/jobtoday/react-native-image-viewing -import React, { - ComponentType, - createRef, - useCallback, - useRef, - useMemo, - useState, -} from 'react' -import { - Animated, - Dimensions, - NativeSyntheticEvent, - NativeScrollEvent, - StyleSheet, - View, - VirtualizedList, - ModalProps, - Platform, -} from 'react-native' -import {ModalsContainer} from '../../modals/Modal' +import React, {ComponentType, useCallback, useMemo, useState} from 'react' +import {StyleSheet, View, Platform} from 'react-native' import ImageItem from './components/ImageItem/ImageItem' import ImageDefaultHeader from './components/ImageDefaultHeader' import {ImageSource} from './@types' -import {ScrollView, GestureType} from 'react-native-gesture-handler' +import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import PagerView from 'react-native-pager-view' type Props = { images: ImageSource[] - keyExtractor?: (imageSrc: ImageSource, index: number) => string - imageIndex: number + initialImageIndex: number visible: boolean onRequestClose: () => void - presentationStyle?: ModalProps['presentationStyle'] - animationType?: ModalProps['animationType'] backgroundColor?: string HeaderComponent?: ComponentType<{imageIndex: number}> FooterComponent?: ComponentType<{imageIndex: number}> } const DEFAULT_BG_COLOR = '#000' -const SCREEN = Dimensions.get('screen') -const SCREEN_WIDTH = SCREEN.width -const INITIAL_POSITION = {x: 0, y: 0} -const ANIMATION_CONFIG = { - duration: 200, - useNativeDriver: true, -} function ImageViewing({ images, - keyExtractor, - imageIndex, + initialImageIndex, visible, onRequestClose, backgroundColor = DEFAULT_BG_COLOR, HeaderComponent, FooterComponent, }: Props) { - const imageList = useRef<VirtualizedList<ImageSource>>(null) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) - const [opacity, setOpacity] = useState(1) - const [currentImageIndex, setImageIndex] = useState(imageIndex) - const [headerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) - const [footerTranslate] = useState( - () => new Animated.ValueXY(INITIAL_POSITION), - ) - - const toggleBarsVisible = (isVisible: boolean) => { - if (isVisible) { - Animated.parallel([ - Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), - ]).start() - } else { - Animated.parallel([ - Animated.timing(headerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: -300, - }), - Animated.timing(footerTranslate.y, { - ...ANIMATION_CONFIG, - toValue: 300, - }), - ]).start() - } - } - - const onRequestCloseEnhanced = () => { - setOpacity(0) - onRequestClose() - setTimeout(() => setOpacity(1), 0) - } - - const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const { - nativeEvent: { - contentOffset: {x: scrollX}, + const [imageIndex, setImageIndex] = useState(initialImageIndex) + const [showControls, setShowControls] = useState(true) + + const animatedHeaderStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(showControls ? 0 : -30), + }, + ], + })) + const animatedFooterStyle = useAnimatedStyle(() => ({ + pointerEvents: showControls ? 'auto' : 'none', + opacity: withClampedSpring(showControls ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(showControls ? 0 : 30), }, - } = event + ], + })) - if (SCREEN.width) { - const nextIndex = Math.round(scrollX / SCREEN.width) - setImageIndex(nextIndex < 0 ? 0 : nextIndex) - } - } + const onTap = useCallback(() => { + setShowControls(show => !show) + }, []) - const onZoom = (nextIsScaled: boolean) => { - toggleBarsVisible(!nextIsScaled) - setIsScaled(false) - } + const onZoom = useCallback((nextIsScaled: boolean) => { + setIsScaled(nextIsScaled) + if (nextIsScaled) { + setShowControls(false) + } + }, []) const edges = useMemo(() => { if (Platform.OS === 'android') { @@ -131,100 +82,54 @@ function ImageViewing({ return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area }, []) - const onLayout = useCallback(() => { - if (imageIndex) { - imageList.current?.scrollToIndex({index: imageIndex, animated: false}) - } - }, [imageList, imageIndex]) - - // This is a hack. - // RNGH doesn't have an easy way to express that pinch of individual items - // should "steal" all pinches from the scroll view. So we're keeping a ref - // to all pinch gestures so that we may give them to <ScrollView waitFor={...}>. - const [pinchGestureRefs] = useState(new Map()) - for (let imageSrc of images) { - if (!pinchGestureRefs.get(imageSrc)) { - pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>()) - } - } - if (!visible) { return null } - const headerTransform = headerTranslate.getTranslateTransform() - const footerTransform = footerTranslate.getTranslateTransform() return ( <SafeAreaView style={styles.screen} - onLayout={onLayout} edges={edges} aria-modal accessibilityViewIsModal> - <ModalsContainer /> - <View style={[styles.container, {opacity, backgroundColor}]}> - <Animated.View style={[styles.header, {transform: headerTransform}]}> + <View style={[styles.container, {backgroundColor}]}> + <Animated.View style={[styles.header, animatedHeaderStyle]}> {typeof HeaderComponent !== 'undefined' ? ( React.createElement(HeaderComponent, { - imageIndex: currentImageIndex, + imageIndex, }) ) : ( - <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} /> + <ImageDefaultHeader onRequestClose={onRequestClose} /> )} </Animated.View> - <VirtualizedList - ref={imageList} - data={images} - horizontal - pagingEnabled - scrollEnabled={!isScaled || isDragging} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - getItem={(_, index) => images[index]} - getItemCount={() => images.length} - getItemLayout={(_, index) => ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, - index, - })} - renderItem={({item: imageSrc}) => ( - <ImageItem - onZoom={onZoom} - imageSrc={imageSrc} - onRequestClose={onRequestCloseEnhanced} - pinchGestureRef={pinchGestureRefs.get(imageSrc)} - isScrollViewBeingDragged={isDragging} - /> - )} - renderScrollComponent={props => ( - <ScrollView - {...props} - waitFor={Array.from(pinchGestureRefs.values())} - /> - )} - onScrollBeginDrag={() => { - setIsDragging(true) - }} - onScrollEndDrag={() => { - setIsDragging(false) - }} - onMomentumScrollEnd={e => { + <PagerView + scrollEnabled={!isScaled} + initialPage={initialImageIndex} + onPageSelected={e => { + setImageIndex(e.nativeEvent.position) setIsScaled(false) - onScroll(e) }} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } - /> + onPageScrollStateChanged={e => { + setIsDragging(e.nativeEvent.pageScrollState !== 'idle') + }} + overdrag={true} + style={styles.pager}> + {images.map(imageSrc => ( + <View key={imageSrc.uri}> + <ImageItem + onTap={onTap} + onZoom={onZoom} + imageSrc={imageSrc} + onRequestClose={onRequestClose} + isScrollViewBeingDragged={isDragging} + /> + </View> + ))} + </PagerView> {typeof FooterComponent !== 'undefined' && ( - <Animated.View style={[styles.footer, {transform: footerTransform}]}> + <Animated.View style={[styles.footer, animatedFooterStyle]}> {React.createElement(FooterComponent, { - imageIndex: currentImageIndex, + imageIndex, })} </Animated.View> )} @@ -236,11 +141,18 @@ function ImageViewing({ const styles = StyleSheet.create({ screen: { position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, }, container: { flex: 1, backgroundColor: '#000', }, + pager: { + flex: 1, + }, header: { position: 'absolute', width: '100%', @@ -257,7 +169,12 @@ const styles = StyleSheet.create({ }) const EnhancedImageViewing = (props: Props) => ( - <ImageViewing key={props.imageIndex} {...props} /> + <ImageViewing key={props.initialImageIndex} {...props} /> ) +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true, stiffness: 300}) +} + export default EnhancedImageViewing diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index ad66dce32..92c30f491 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() { return ( <ImageView images={[{uri: opts.profileView.avatar || ''}]} - imageIndex={0} + initialImageIndex={0} visible onRequestClose={onClose} FooterComponent={LightboxFooter} @@ -37,7 +37,7 @@ export const Lightbox = observer(function Lightbox() { return ( <ImageView images={opts.images.map(img => ({...img}))} - imageIndex={opts.index} + initialImageIndex={opts.index} visible onRequestClose={onClose} FooterComponent={LightboxFooter} diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index c92dabdca..012570556 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -1,11 +1,5 @@ import React, {useState} from 'react' -import { - ActivityIndicator, - KeyboardAvoidingView, - SafeAreaView, - StyleSheet, - View, -} from 'react-native' +import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView, TextInput} from './util' import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' @@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) { } return ( - <KeyboardAvoidingView - behavior="padding" - style={[pal.view, styles.container]}> - <SafeAreaView style={s.flex1}> - <ScrollView - testID="changeEmailModal" - style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.InputEmail ? 'Change Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} - {stage === Stages.Done ? 'Email Updated' : ''} - </Text> - </View> - - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - {stage === Stages.InputEmail ? ( - <>Enter your new email address below.</> - ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to your previous address,{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> - ) : ( - <> - Your email has been updated but not verified. As a next step, - please verify your new email. - </> - )} + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + testID="changeEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.InputEmail ? 'Change Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} + {stage === Stages.Done ? 'Email Updated' : ''} </Text> + </View> - {stage === Stages.InputEmail && ( - <TextInput - testID="emailInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="alice@mail.com" - placeholderTextColor={pal.colors.textLight} - value={email} - onChangeText={setEmail} - accessible={true} - accessibilityLabel="Email" - accessibilityHint="" - autoCapitalize="none" - autoComplete="email" - autoCorrect={false} - /> - )} - {stage === Stages.ConfirmCode && ( - <TextInput - testID="confirmCodeInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="XXXXX-XXXXX" - placeholderTextColor={pal.colors.textLight} - value={confirmationCode} - onChangeText={setConfirmationCode} - accessible={true} - accessibilityLabel="Confirmation code" - accessibilityHint="" - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.InputEmail ? ( + <>Enter your new email address below.</> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to your previous address,{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + <> + Your email has been updated but not verified. As a next step, + please verify your new email. + </> )} + </Text> - {error ? ( - <ErrorMessage message={error} style={styles.error} /> - ) : undefined} + {stage === Stages.InputEmail && ( + <TextInput + testID="emailInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="alice@mail.com" + placeholderTextColor={pal.colors.textLight} + value={email} + onChangeText={setEmail} + accessible={true} + accessibilityLabel="Email" + accessibilityHint="" + autoCapitalize="none" + autoComplete="email" + autoCorrect={false} + /> + )} + {stage === Stages.ConfirmCode && ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + )} - <View style={[styles.btnContainer]}> - {isProcessing ? ( - <View style={styles.btn}> - <ActivityIndicator color="#fff" /> - </View> - ) : ( - <View style={{gap: 6}}> - {stage === Stages.InputEmail && ( - <Button - testID="requestChangeBtn" - type="primary" - onPress={onRequestChange} - accessibilityLabel="Request Change" - accessibilityHint="" - label="Request Change" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - {stage === Stages.ConfirmCode && ( - <Button - testID="confirmBtn" - type="primary" - onPress={onConfirm} - accessibilityLabel="Confirm Change" - accessibilityHint="" - label="Confirm Change" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - {stage === Stages.Done && ( - <Button - testID="verifyBtn" - type="primary" - onPress={onVerify} - accessibilityLabel="Verify New Email" - accessibilityHint="" - label="Verify New Email" - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.InputEmail && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestChange} + accessibilityLabel="Request Change" + accessibilityHint="" + label="Request Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ConfirmCode && ( <Button - testID="cancelBtn" - type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm Change" accessibilityHint="" - label="Cancel" + label="Confirm Change" labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> - </View> - )} - </View> - </ScrollView> - </SafeAreaView> - </KeyboardAvoidingView> + )} + {stage === Stages.Done && ( + <Button + testID="verifyBtn" + type="primary" + onPress={onVerify} + accessibilityLabel="Verify New Email" + accessibilityHint="" + label="Verify New Email" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Cancel" + accessibilityHint="" + label="Cancel" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> ) }) const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: isWeb ? 0 : 40, - }, titleSection: { paddingTop: isWeb ? 0 : 4, paddingBottom: isWeb ? 14 : 10, diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx index 3f3cfc5f0..4a440afeb 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -18,7 +18,7 @@ import {ListModel} from 'state/models/content/list' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' -import {UserAvatar} from '../util/UserAvatar' +import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' @@ -148,7 +148,7 @@ export function Component({ )} <Text style={[styles.label, pal.text]}>List Avatar</Text> <View style={[styles.avi, {borderColor: pal.colors.background}]}> - <UserAvatar + <EditableUserAvatar type="list" size={80} avatar={avatar} diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 620aad9fc..58d0857ad 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -20,7 +20,7 @@ import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' import {compressIfNeeded} from 'lib/media/manip' import {UserBanner} from '../util/UserBanner' -import {UserAvatar} from '../util/UserAvatar' +import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' @@ -153,7 +153,7 @@ export function Component({ onSelectNewBanner={onSelectNewBanner} /> <View style={[styles.avi, {borderColor: pal.colors.background}]}> - <UserAvatar + <EditableUserAvatar size={80} avatar={userAvatar} onSelectNewAvatar={onSelectNewAvatar} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 0cb0c56aa..09cfd4de7 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -26,33 +26,6 @@ export function Component({}: {}) { store.shell.closeModal() }, [store]) - if (store.me.invites === null) { - return ( - <View style={[styles.container, pal.view]} testID="inviteCodesModal"> - <Text type="title-xl" style={[styles.title, pal.text]}> - Error - </Text> - <Text type="lg" style={[styles.description, pal.text]}> - An error occurred while loading invite codes. - </Text> - <View style={styles.flex1} /> - <View - style={[ - styles.btnContainer, - isTabletOrDesktop && styles.btnContainerDesktop, - ]}> - <Button - type="primary" - label="Done" - style={styles.btn} - labelStyle={styles.btnLabel} - onPress={onClose} - /> - </View> - </View> - ) - } - if (store.me.invites.length === 0) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 4f3f424a3..1fe1299d7 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,11 +1,12 @@ import React, {useRef, useEffect} from 'react' import {StyleSheet} from 'react-native' -import {SafeAreaView} from 'react-native-safe-area-context' +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' +import {timeout} from 'lib/async/timeout' import {navigate} from '../../../Navigation' import once from 'lodash.once' @@ -36,11 +37,13 @@ import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' const DEFAULT_SNAPPOINTS = ['90%'] +const HANDLE_HEIGHT = 24 export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') + const safeAreaInsets = useSafeAreaInsets() const activeModal = store.shell.activeModals[store.shell.activeModals.length - 1] @@ -53,12 +56,16 @@ export const ModalsContainer = observer(function ModalsContainer() { navigateOnce('Profile', {name: activeModal.did}) } } - const onBottomSheetChange = (snapPoint: number) => { + const onBottomSheetChange = async (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { - // ensure we navigate to Profile and close the modal - navigateOnce('Profile', {name: activeModal.did}) + await navigateOnce('Profile', {name: activeModal.did}) + // There is no particular callback for when the view has actually been presented. + // This delay gives us a decent chance the navigation has flushed *and* images have loaded. + // It's acceptable because the data is already being fetched + it usually takes longer anyway. + // TODO: Figure out why avatar/cover don't always show instantly from cache. + await timeout(200) store.shell.closeModal() } } @@ -75,6 +82,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name]) + let needsSafeTopInset = false let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element if (activeModal?.name === 'confirm') { @@ -86,6 +94,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'profile-preview') { snapPoints = ProfilePreviewModal.snapPoints element = <ProfilePreviewModal.Component {...activeModal} /> + needsSafeTopInset = true // Need to align with the target profile screen. } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = <ServerInputModal.Component {...activeModal} /> @@ -164,10 +173,13 @@ export const ModalsContainer = observer(function ModalsContainer() { ) } + const topInset = needsSafeTopInset ? safeAreaInsets.top - HANDLE_HEIGHT : 0 return ( <BottomSheet ref={bottomSheetRef} snapPoints={snapPoints} + topInset={topInset} + handleHeight={HANDLE_HEIGHT} index={store.shell.isModalActive ? 0 : -1} enablePanDownToClose android_keyboardInputMode="adjustResize" diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index 225a3972b..dad02aa5e 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -9,7 +9,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' import {InfoCircleIcon} from 'lib/icons' import {useNavigationState} from '@react-navigation/native' -import {isIOS} from 'platform/detection' import {s} from 'lib/styles' export const snapPoints = [520, '100%'] @@ -36,11 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({ return ( <View testID="profilePreview" style={[pal.view, s.flex1]}> - <View - style={[ - styles.headerWrapper, - isLoading && isIOS && styles.headerPositionAdjust, - ]}> + <View style={[styles.headerWrapper]}> <ProfileHeader view={model} hideBackButton @@ -70,10 +65,6 @@ const styles = StyleSheet.create({ headerWrapper: { height: 440, }, - headerPositionAdjust: { - // HACK align the header for the profilescreen transition -prf - paddingTop: 23, - }, hintWrapper: { height: 80, }, diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 0a626a4ef..9fe8811b0 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -1,7 +1,6 @@ import React, {useState} from 'react' import { ActivityIndicator, - KeyboardAvoidingView, Pressable, SafeAreaView, StyleSheet, @@ -82,169 +81,163 @@ export const Component = observer(function Component({ } return ( - <KeyboardAvoidingView - behavior="padding" - style={[pal.view, styles.container]}> - <SafeAreaView style={s.flex1}> - <ScrollView - testID="verifyEmailModal" - style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> - {stage === Stages.Reminder && <ReminderIllustration />} - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} - {stage === Stages.Email ? 'Verify Your Email' : ''} - </Text> - </View> - - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - {stage === Stages.Reminder ? ( - <> - Your email has not yet been verified. This is an important - security step which we recommend. - </> - ) : stage === Stages.Email ? ( - <> - This is important in case you ever need to change your email or - reset your password. - </> - ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> - ) : ( - '' - )} + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + testID="verifyEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + {stage === Stages.Reminder && <ReminderIllustration />} + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} + {stage === Stages.Email ? 'Verify Your Email' : ''} </Text> + </View> - {stage === Stages.Email ? ( + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.Reminder ? ( <> - <View style={styles.emailContainer}> - <FontAwesomeIcon - icon="envelope" - color={pal.colors.text} - size={16} - /> - <Text - type="xl-medium" - style={[pal.text, s.flex1, {minWidth: 0}]}> - {store.session.currentSession?.email || ''} - </Text> - </View> - <Pressable - accessibilityRole="link" - accessibilityLabel="Change my email" - accessibilityHint="" - onPress={onEmailIncorrect} - style={styles.changeEmailLink}> - <Text type="lg" style={pal.link}> - Change - </Text> - </Pressable> + Your email has not yet been verified. This is an important + security step which we recommend. + </> + ) : stage === Stages.Email ? ( + <> + This is important in case you ever need to change your email or + reset your password. </> ) : stage === Stages.ConfirmCode ? ( - <TextInput - testID="confirmCodeInput" - style={[styles.textInput, pal.border, pal.text]} - placeholder="XXXXX-XXXXX" - placeholderTextColor={pal.colors.textLight} - value={confirmationCode} - onChangeText={setConfirmationCode} - accessible={true} - accessibilityLabel="Confirmation code" + <> + An email has been sent to{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + '' + )} + </Text> + + {stage === Stages.Email ? ( + <> + <View style={styles.emailContainer}> + <FontAwesomeIcon + icon="envelope" + color={pal.colors.text} + size={16} + /> + <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> + {store.session.currentSession?.email || ''} + </Text> + </View> + <Pressable + accessibilityRole="link" + accessibilityLabel="Change my email" accessibilityHint="" - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> - ) : undefined} + onPress={onEmailIncorrect} + style={styles.changeEmailLink}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Pressable> + </> + ) : stage === Stages.ConfirmCode ? ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + ) : undefined} - {error ? ( - <ErrorMessage message={error} style={styles.error} /> - ) : undefined} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} - <View style={[styles.btnContainer]}> - {isProcessing ? ( - <View style={styles.btn}> - <ActivityIndicator color="#fff" /> - </View> - ) : ( - <View style={{gap: 6}}> - {stage === Stages.Reminder && ( + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.Reminder && ( + <Button + testID="getStartedBtn" + type="primary" + onPress={() => setStage(Stages.Email)} + accessibilityLabel="Get Started" + accessibilityHint="" + label="Get Started" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Email && ( + <> <Button - testID="getStartedBtn" + testID="sendEmailBtn" type="primary" - onPress={() => setStage(Stages.Email)} - accessibilityLabel="Get Started" + onPress={onSendEmail} + accessibilityLabel="Send Confirmation Email" accessibilityHint="" - label="Get Started" - labelContainerStyle={{justifyContent: 'center', padding: 4}} + label="Send Confirmation Email" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} labelStyle={[s.f18]} /> - )} - {stage === Stages.Email && ( - <> - <Button - testID="sendEmailBtn" - type="primary" - onPress={onSendEmail} - accessibilityLabel="Send Confirmation Email" - accessibilityHint="" - label="Send Confirmation Email" - labelContainerStyle={{ - justifyContent: 'center', - padding: 4, - }} - labelStyle={[s.f18]} - /> - <Button - testID="haveCodeBtn" - type="default" - accessibilityLabel="I have a code" - accessibilityHint="" - label="I have a confirmation code" - labelContainerStyle={{ - justifyContent: 'center', - padding: 4, - }} - labelStyle={[s.f18]} - onPress={() => setStage(Stages.ConfirmCode)} - /> - </> - )} - {stage === Stages.ConfirmCode && ( <Button - testID="confirmBtn" - type="primary" - onPress={onConfirm} - accessibilityLabel="Confirm" + testID="haveCodeBtn" + type="default" + accessibilityLabel="I have a code" accessibilityHint="" - label="Confirm" - labelContainerStyle={{justifyContent: 'center', padding: 4}} + label="I have a confirmation code" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} labelStyle={[s.f18]} + onPress={() => setStage(Stages.ConfirmCode)} /> - )} + </> + )} + {stage === Stages.ConfirmCode && ( <Button - testID="cancelBtn" - type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel={ - stage === Stages.Reminder ? 'Not right now' : 'Cancel' - } + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm" accessibilityHint="" - label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + label="Confirm" labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> - </View> - )} - </View> - </ScrollView> - </SafeAreaView> - </KeyboardAvoidingView> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel={ + stage === Stages.Reminder ? 'Not right now' : 'Cancel' + } + accessibilityHint="" + label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> ) }) @@ -274,10 +267,6 @@ function ReminderIllustration() { } const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: isWeb ? 0 : 40, - }, titleSection: { paddingTop: isWeb ? 0 : 4, paddingBottom: isWeb ? 14 : 10, diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index 1104c0a39..0fb371fe4 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -77,6 +77,8 @@ export function Component({}: {}) { keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} + onSubmitEditing={onPressSignup} + enterKeyHint="done" accessible={true} accessibilityLabel="Email" accessibilityHint="Input your email to get on the Bluesky waitlist" diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index c5959cf4c..8e35201d1 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -100,7 +100,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to wide"> <RectWideIcon size={24} - style={as === AspectRatio.Wide ? s.blue3 : undefined} + style={as === AspectRatio.Wide ? s.blue3 : pal.text} /> </TouchableOpacity> <TouchableOpacity @@ -110,7 +110,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to tall"> <RectTallIcon size={24} - style={as === AspectRatio.Tall ? s.blue3 : undefined} + style={as === AspectRatio.Tall ? s.blue3 : pal.text} /> </TouchableOpacity> <TouchableOpacity @@ -120,7 +120,7 @@ export function Component({ accessibilityHint="Sets image aspect ratio to square"> <SquareIcon size={24} - style={as === AspectRatio.Square ? s.blue3 : undefined} + style={as === AspectRatio.Square ? s.blue3 : pal.text} /> </TouchableOpacity> </View> diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 02aa623cc..dc91bd296 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,13 +1,14 @@ import React, {useMemo} from 'react' -import {Animated, StyleSheet} from 'react-native' +import {StyleSheet} from 'react-native' +import Animated from 'react-native-reanimated' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -31,26 +32,12 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( [store.me.savedFeeds.pinnedFeedNames], ) const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [ - {translateX: '-50%'}, - {translateY: Animated.multiply(interp, -100)}, - ], - } + const {headerMinimalShellTransform} = useMinimalShellMode() return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf - <Animated.View style={[pal.view, styles.tabBar, transform]}> + <Animated.View + style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> <TabBar key={items.join(',')} {...props} @@ -65,7 +52,8 @@ const styles = StyleSheet.create({ tabBar: { position: 'absolute', zIndex: 1, - left: '50%', + // @ts-ignore Web only -prf + left: 'calc(50% - 299px)', width: 598, top: 0, flexDirection: 'row', diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index e39e2dd68..d8579badc 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,11 +1,10 @@ import React, {useMemo} from 'react' -import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -13,27 +12,17 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const store = useStores() const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) + const {headerMinimalShellTransform} = useMinimalShellMode() const onPressAvi = React.useCallback(() => { store.shell.openDrawer() @@ -44,8 +33,19 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( [store.me.savedFeeds.pinnedFeedNames], ) + const tabBarKey = useMemo(() => { + return items.join(',') + }, [items]) + return ( - <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}> + <Animated.View + style={[ + pal.view, + pal.border, + styles.tabBar, + headerMinimalShellTransform, + store.shell.minimalShellMode && styles.disabled, + ]}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity @@ -81,8 +81,11 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </View> </View> <TabBar - key={items.join(',')} - {...props} + key={tabBarKey} + onPressSelected={props.onPressSelected} + selectedPage={props.selectedPage} + onSelect={props.onSelect} + testID={props.testID} items={items} indicatorColor={pal.colors.link} /> @@ -113,4 +116,7 @@ const styles = StyleSheet.create({ title: { fontSize: 21, }, + disabled: { + pointerEvents: 'none', + }, }) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 319d28f95..8614bdf64 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -64,6 +64,7 @@ export function TabBar({ ) const styles = isDesktop || isTablet ? desktopStyles : mobileStyles + return ( <View testID={testID} style={[pal.view, styles.outer]}> <DraggableScrollView diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index b095fe07b..74883f82a 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -45,7 +45,7 @@ export const Feed = observer(function Feed({ onPressTryAgain?: () => void onScroll?: OnScrollCb scrollEventThrottle?: number - renderEmptyState?: () => JSX.Element + renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number @@ -96,7 +96,7 @@ export const Feed = observer(function Feed({ }, [feed, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded) return + if (!feed.hasLoaded || !feed.hasMore) return track('Feed:onEndReached') try { @@ -116,10 +116,7 @@ export const Feed = observer(function Feed({ const renderItem = React.useCallback( ({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> + return renderEmptyState() } else if (item === ERROR_ITEM) { return ( <ErrorMessage @@ -181,7 +178,7 @@ export const Feed = observer(function Feed({ scrollEventThrottle={scrollEventThrottle} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} removeClippedSubviews={true} contentOffset={{x: 0, y: headerOffset * -1}} extraData={extraData} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index baf95af6c..5514bf98e 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -60,14 +60,14 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ if (!view || !view.hasLoaded) { return ( <View style={pal.view}> - <LoadingPlaceholder width="100%" height={120} /> + <LoadingPlaceholder width="100%" height={153} /> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> <LoadingPlaceholder width={80} height={80} style={styles.br40} /> </View> <View style={styles.content}> <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={100} height={31} style={styles.br50} /> + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> </View> <View> <Text type="title-2xl" style={[pal.text, styles.title]}> @@ -132,20 +132,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, [store, view]) const onPressToggleFollow = React.useCallback(() => { - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', - ) view?.toggleFollowing().then( () => { setShowSuggestedFollows(Boolean(view.viewer.following)) - Toast.show( `${ view.viewer.following ? 'Following' : 'No longer following' } ${sanitizeDisplayName(view.displayName || view.handle)}`, ) + track( + view.viewer.following + ? 'ProfileHeader:FollowButtonClicked' + : 'ProfileHeader:UnfollowButtonClicked', + ) }, err => store.log.error('Failed to toggle follow', err), ) diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 41e4022d5..cf759ddd1 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View, StyleSheet, ScrollView, Pressable} from 'react-native' +import {View, StyleSheet, Pressable, ScrollView} from 'react-native' import Animated, { useSharedValue, withTiming, @@ -26,6 +26,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {Link} from 'view/com/util/Link' import {useAnalytics} from 'lib/analytics/analytics' +import {isWeb} from 'platform/detection' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -100,7 +101,6 @@ export function ProfileHeaderSuggestedFollows({ backgroundColor: pal.viewLight.backgroundColor, height: '100%', paddingTop: INNER_PADDING / 2, - paddingBottom: INNER_PADDING, }}> <View style={{ @@ -130,11 +130,15 @@ export function ProfileHeaderSuggestedFollows({ </View> <ScrollView - horizontal - showsHorizontalScrollIndicator={false} + horizontal={true} + showsHorizontalScrollIndicator={isWeb} + persistentScrollbar={true} + scrollIndicatorInsets={{bottom: 0}} + scrollEnabled={true} contentContainerStyle={{ alignItems: 'flex-start', paddingLeft: INNER_PADDING / 2, + paddingBottom: INNER_PADDING, }}> {isLoading ? ( <> @@ -223,9 +227,9 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({ const onPress = React.useCallback(async () => { try { - const {following} = await toggle() + const {following: isFollowing} = await toggle() - if (following) { + if (isFollowing) { track('ProfileHeader:SuggestedFollowFollowed') } } catch (e: any) { diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 6915d3e08..35524bcc6 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,5 +1,4 @@ -import React, {ComponentProps, useMemo} from 'react' -import {observer} from 'mobx-react-lite' +import React, {ComponentProps, memo, useMemo} from 'react' import { Linking, GestureResponderEvent, @@ -50,7 +49,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { anchorNoUnderline?: boolean } -export const Link = observer(function Link({ +export const Link = memo(function Link({ testID, style, href, @@ -136,7 +135,7 @@ export const Link = observer(function Link({ ) }) -export const TextLink = observer(function TextLink({ +export const TextLink = memo(function TextLink({ testID, type = 'md', style, @@ -236,7 +235,7 @@ interface DesktopWebTextLinkProps extends TextProps { accessibilityHint?: string title?: string } -export const DesktopWebTextLink = observer(function DesktopWebTextLink({ +export const DesktopWebTextLink = memo(function DesktopWebTextLink({ testID, type = 'md', style, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d24e47499..fbc0b5e11 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -23,14 +23,18 @@ interface BaseUserAvatarProps { type?: Type size: number avatar?: string | null - moderation?: ModerationUI } interface UserAvatarProps extends BaseUserAvatarProps { - onSelectNewAvatar?: (img: RNImage | null) => void + moderation?: ModerationUI +} + +interface EditableUserAvatarProps extends BaseUserAvatarProps { + onSelectNewAvatar: (img: RNImage | null) => void } interface PreviewableUserAvatarProps extends BaseUserAvatarProps { + moderation?: ModerationUI did: string handle: string } @@ -106,8 +110,65 @@ export function UserAvatar({ size, avatar, moderation, - onSelectNewAvatar, }: UserAvatarProps) { + const pal = usePalette('default') + + const aviStyle = useMemo(() => { + if (type === 'algo' || type === 'list') { + return { + width: size, + height: size, + borderRadius: size > 32 ? 8 : 3, + } + } + return { + width: size, + height: size, + borderRadius: Math.floor(size / 2), + } + }, [type, size]) + + const alert = useMemo(() => { + if (!moderation?.alert) { + return null + } + return ( + <View style={[styles.alertIconContainer, pal.view]}> + <FontAwesomeIcon + icon="exclamation-circle" + style={styles.alertIcon} + size={Math.floor(size / 3)} + /> + </View> + ) + }, [moderation?.alert, size, pal]) + + return avatar && + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( + <View style={{width: size, height: size}}> + <HighPriorityImage + testID="userAvatarImage" + style={aviStyle} + contentFit="cover" + source={{uri: avatar}} + blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} + /> + {alert} + </View> + ) : ( + <View style={{width: size, height: size}}> + <DefaultAvatar type={type} size={size} /> + {alert} + </View> + ) +} + +export function EditableUserAvatar({ + type = 'user', + size, + avatar, + onSelectNewAvatar, +}: EditableUserAvatarProps) { const store = useStores() const pal = usePalette('default') const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -146,7 +207,7 @@ export function UserAvatar({ return } - onSelectNewAvatar?.( + onSelectNewAvatar( await openCamera(store, { width: 1000, height: 1000, @@ -186,7 +247,7 @@ export function UserAvatar({ path: item.path, }) - onSelectNewAvatar?.(croppedImage) + onSelectNewAvatar(croppedImage) }, }, !!avatar && { @@ -203,7 +264,7 @@ export function UserAvatar({ web: 'trash', }, onPress: async () => { - onSelectNewAvatar?.(null) + onSelectNewAvatar(null) }, }, ].filter(Boolean) as DropdownItem[], @@ -216,23 +277,7 @@ export function UserAvatar({ ], ) - const alert = useMemo(() => { - if (!moderation?.alert) { - return null - } - return ( - <View style={[styles.alertIconContainer, pal.view]}> - <FontAwesomeIcon - icon="exclamation-circle" - style={styles.alertIcon} - size={Math.floor(size / 3)} - /> - </View> - ) - }, [moderation?.alert, size, pal]) - - // onSelectNewAvatar is only passed as prop on the EditProfile component - return onSelectNewAvatar ? ( + return ( <NativeDropdown testID="changeAvatarBtn" items={dropdownItems} @@ -256,23 +301,6 @@ export function UserAvatar({ /> </View> </NativeDropdown> - ) : avatar && - !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( - <View style={{width: size, height: size}}> - <HighPriorityImage - testID="userAvatarImage" - style={aviStyle} - contentFit="cover" - source={{uri: avatar}} - blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} - /> - {alert} - </View> - ) : ( - <View style={{width: size, height: size}}> - <DefaultAvatar type={type} size={size} /> - {alert} - </View> ) } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 164028708..ec459b4eb 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,16 +1,17 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {CenteredView} from './Views' import {Text} from './text/Text' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -149,30 +150,8 @@ const Container = observer(function ContainerImpl({ hideOnScroll: boolean showBorder?: boolean }) { - const store = useStores() const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - if (store.shell.minimalShellMode) { - Animated.timing(interp, { - toValue: 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } else { - Animated.timing(interp, { - toValue: 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } + const {headerMinimalShellTransform} = useMinimalShellMode() if (!hideOnScroll) { return ( @@ -195,7 +174,7 @@ const Container = observer(function ContainerImpl({ styles.headerFloating, pal.view, pal.border, - transform, + headerMinimalShellTransform, showBorder && styles.border, ]}> {children} diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index 6c0e4c6cc..935d93033 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -144,8 +144,6 @@ export function Selector({ items: string[] onSelect?: (index: number) => void }) { - const [height, setHeight] = useState(0) - const pal = usePalette('default') const borderColor = useColorSchemeStyle( {borderColor: colors.black}, @@ -160,22 +158,13 @@ export function Selector({ <View style={{ width: '100%', - position: 'relative', - overflow: 'hidden', - height, backgroundColor: pal.colors.background, }}> <ScrollView testID="selector" horizontal - showsHorizontalScrollIndicator={false} - style={{position: 'absolute'}}> - <View - style={[pal.view, styles.outer]} - onLayout={e => { - const {height: layoutHeight} = e.nativeEvent.layout - setHeight(layoutHeight || 60) - }}> + showsHorizontalScrollIndicator={false}> + <View style={[pal.view, styles.outer]}> {items.map((item, i) => { const selected = i === selectedIndex return ( diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 6c96eef2c..5b1d5d888 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,13 +1,13 @@ import React, {ComponentProps} from 'react' import {observer} from 'mobx-react-lite' -import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native' +import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {gradients} from 'lib/styles' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lib/numbers' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' export interface FABProps extends ComponentProps<typeof TouchableWithoutFeedback> { @@ -21,28 +21,30 @@ export const FABInner = observer(function FABInnerImpl({ ...props }: FABProps) { const insets = useSafeAreaInsets() - const {isTablet} = useWebMediaQueries() - const store = useStores() - const interp = useAnimatedValue(0) - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 0 : 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = isTablet - ? undefined - : { - transform: [{translateY: Animated.multiply(interp, -44)}], - } - const size = isTablet ? styles.sizeLarge : styles.sizeRegular - const right = isTablet ? 50 : 24 - const bottom = isTablet ? 50 : clamp(insets.bottom, 15, 60) + 15 + const {isMobile, isTablet} = useWebMediaQueries() + const {fabMinimalShellTransform} = useMinimalShellMode() + + const size = React.useMemo(() => { + return isTablet ? styles.sizeLarge : styles.sizeRegular + }, [isTablet]) + const tabletSpacing = React.useMemo(() => { + return isTablet + ? {right: 50, bottom: 50} + : { + right: 24, + bottom: clamp(insets.bottom, 15, 60) + 15, + } + }, [insets.bottom, isTablet]) + return ( <TouchableWithoutFeedback testID={testID} {...props}> - <Animated.View style={[styles.outer, size, {right, bottom}, transform]}> + <Animated.View + style={[ + styles.outer, + size, + tabletSpacing, + isMobile && fabMinimalShellTransform, + ]}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 679f71c99..094b0c56c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -23,19 +23,19 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onLongPress, }) => { const image = images[index] - return ( - <View> + <View style={styles.fullWidth}> <Pressable onPress={onPress ? () => onPress(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} + style={styles.fullWidth} accessibilityRole="button" accessibilityLabel={image.alt || 'Image'} accessibilityHint=""> <Image source={{uri: image.thumb}} - style={imageStyle} + style={[styles.image, imageStyle]} accessible={true} accessibilityLabel={image.alt} accessibilityHint="" @@ -54,14 +54,21 @@ export const GalleryItem: FC<GalleryItemProps> = ({ } const styles = StyleSheet.create({ + fullWidth: { + flex: 1, + }, + image: { + flex: 1, + borderRadius: 4, + }, altContainer: { backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', - left: 6, - bottom: 6, + left: 8, + bottom: 8, }, alt: { color: 'white', diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 4c0901304..4aa6f28de 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,13 +1,5 @@ -import React, {useMemo, useState} from 'react' -import { - LayoutChangeEvent, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {ImageStyle} from 'expo-image' -import {Dimensions} from 'lib/media/types' +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {AppBskyEmbedImages} from '@atproto/api' import {GalleryItem} from './Gallery' @@ -20,21 +12,11 @@ interface ImageLayoutGridProps { } export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { - const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() - - const onLayout = (evt: LayoutChangeEvent) => { - const {width, height} = evt.nativeEvent.layout - setContainerInfo({ - width, - height, - }) - } - return ( - <View style={style} onLayout={onLayout}> - {containerInfo ? ( - <ImageLayoutGridInner {...props} containerInfo={containerInfo} /> - ) : undefined} + <View style={style}> + <View style={styles.container}> + <ImageLayoutGridInner {...props} /> + </View> </View> ) } @@ -44,70 +26,80 @@ interface ImageLayoutGridInnerProps { onPress?: (index: number) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void - containerInfo: Dimensions } -function ImageLayoutGridInner({ - containerInfo, - ...props -}: ImageLayoutGridInnerProps) { +function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const count = props.images.length - const size1 = useMemo<ImageStyle>(() => { - if (count === 3) { - const size = (containerInfo.width - 10) / 3 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } else { - const size = (containerInfo.width - 5) / 2 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } - }, [count, containerInfo]) - const size2 = React.useMemo<ImageStyle>(() => { - if (count === 3) { - const size = ((containerInfo.width - 10) / 3) * 2 + 5 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } else { - const size = (containerInfo.width - 5) / 2 - return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} - } - }, [count, containerInfo]) switch (count) { case 2: return ( <View style={styles.flexRow}> - <GalleryItem index={0} {...props} imageStyle={size1} /> - <GalleryItem index={1} {...props} imageStyle={size1} /> + <View style={styles.smallItem}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> </View> ) + case 3: return ( <View style={styles.flexRow}> - <GalleryItem index={0} {...props} imageStyle={size2} /> - <View style={styles.flexColumn}> - <GalleryItem index={1} {...props} imageStyle={size1} /> - <GalleryItem index={2} {...props} imageStyle={size1} /> + <View style={{flex: 2, aspectRatio: 1}}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={{flex: 1}}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> + </View> </View> </View> ) + case 4: return ( - <View style={styles.flexRow}> - <View style={styles.flexColumn}> - <GalleryItem index={0} {...props} imageStyle={size1} /> - <GalleryItem index={2} {...props} imageStyle={size1} /> + <> + <View style={styles.flexRow}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={0} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> + </View> </View> - <View style={styles.flexColumn}> - <GalleryItem index={1} {...props} imageStyle={size1} /> - <GalleryItem index={3} {...props} imageStyle={size1} /> + <View style={styles.flexRow}> + <View style={styles.smallItem}> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> + </View> + <View style={styles.smallItem}> + <GalleryItem {...props} index={3} imageStyle={styles.image} /> + </View> </View> - </View> + </> ) + default: return null } } +// This is used to compute margins (rather than flexbox gap) due to Yoga bugs: +// https://github.com/facebook/yoga/issues/1418 +const IMAGE_GAP = 5 + const styles = StyleSheet.create({ - flexRow: {flexDirection: 'row', gap: 5}, - flexColumn: {flexDirection: 'column', gap: 5}, + container: { + marginHorizontal: -IMAGE_GAP / 2, + marginVertical: -IMAGE_GAP / 2, + }, + flexRow: {flexDirection: 'row'}, + smallItem: {flex: 1, aspectRatio: 1}, + image: { + margin: IMAGE_GAP / 2, + }, }) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index f5d12ce2c..b16a42396 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -2,14 +2,14 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {colors} from 'lib/styles' import {HITSLOP_20} from 'lib/constants' -import {isWeb} from 'platform/detection' -import {clamp} from 'lib/numbers' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import Animated from 'react-native-reanimated' +const AnimatedTouchableOpacity = + Animated.createAnimatedComponent(TouchableOpacity) export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ onPress, @@ -19,26 +19,20 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ onPress: () => void label: string showIndicator: boolean - minimalShellMode?: boolean // NOTE not used on mobile -prf }) { - const store = useStores() const pal = usePalette('default') - const {isDesktop, isTablet} = useWebMediaQueries() - const safeAreaInsets = useSafeAreaInsets() - const minMode = store.shell.minimalShellMode - const bottom = isTablet - ? 50 - : (minMode || isDesktop ? 16 : 60) + - (isWeb ? 20 : clamp(safeAreaInsets.bottom, 15, 60)) + const {isDesktop, isTablet, isMobile} = useWebMediaQueries() + const {fabMinimalShellTransform} = useMinimalShellMode() + return ( - <TouchableOpacity + <AnimatedTouchableOpacity style={[ styles.loadLatest, isDesktop && styles.loadLatestDesktop, isTablet && styles.loadLatestTablet, pal.borderDark, pal.view, - {bottom}, + isMobile && fabMinimalShellTransform, ]} onPress={onPress} hitSlop={HITSLOP_20} @@ -47,7 +41,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ accessibilityHint=""> <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} - </TouchableOpacity> + </AnimatedTouchableOpacity> ) }) @@ -66,15 +60,11 @@ const styles = StyleSheet.create({ }, loadLatestTablet: { // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-282px)', + left: 'calc(50vw - 282px)', }, loadLatestDesktop: { // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-382px)', + left: 'calc(50vw - 382px)', }, indicator: { position: 'absolute', diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 8560ad445..ad47e9f9b 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,33 +1,22 @@ import React from 'react' -import {FlatList, View, useWindowDimensions} from 'react-native' -import {useFocusEffect, useIsFocused} from '@react-navigation/native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {useWindowDimensions} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' -import useAppState from 'react-native-appstate-hook' import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {TextLink} from 'view/com/util/Link' -import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' -import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {FAB} from '../com/util/fab/FAB' import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s, colors} from 'lib/styles' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {ComposeIcon2} from 'lib/icons' +import {FeedPage} from 'view/com/feeds/FeedPage' -const POLL_FREQ = 30e3 // 30sec +export const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired( @@ -98,7 +87,9 @@ export const HomeScreen = withAuthRequired( (props: RenderTabBarFnProps) => { return ( <FeedsTabBar - {...props} + key="FEEDS_TAB_BAR" + selectedPage={props.selectedPage} + onSelect={props.onSelect} testID="homeScreenFeedTabs" onPressSelected={onPressSelected} /> @@ -111,10 +102,6 @@ export const HomeScreen = withAuthRequired( return <FollowingEmptyState /> }, []) - const renderFollowingEndOfFeed = React.useCallback(() => { - return <FollowingEndOfFeed /> - }, []) - const renderCustomFeedEmptyState = React.useCallback(() => { return <CustomFeedEmptyState /> }, []) @@ -132,7 +119,7 @@ export const HomeScreen = withAuthRequired( isPageFocused={selectedPage === 0} feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={renderFollowingEndOfFeed} + renderEndOfFeed={FollowingEndOfFeed} /> {customFeeds.map((f, index) => { return ( @@ -150,196 +137,7 @@ export const HomeScreen = withAuthRequired( }), ) -const FeedPage = observer(function FeedPageImpl({ - testID, - isPageFocused, - feed, - renderEmptyState, - renderEndOfFeed, -}: { - testID?: string - feed: PostsFeedModel - isPageFocused: boolean - renderEmptyState?: () => JSX.Element - renderEndOfFeed?: () => JSX.Element -}) { - const store = useStores() - const pal = usePalette('default') - const {isDesktop} = useWebMediaQueries() - const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) - const {screen, track} = useAnalytics() - const headerOffset = useHeaderOffset() - const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) - const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - store.log.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, store, feed], - ) - - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: -headerOffset}) - resetMainScroll() - }, [headerOffset, resetMainScroll]) - - const onSoftReset = React.useCallback(() => { - if (isPageFocused) { - scrollToTop() - feed.refresh() - } - }, [isPageFocused, scrollToTop, feed]) - - // fires when page within screen is activated/deactivated - // - check for latest - React.useEffect(() => { - if (!isPageFocused || !isScreenFocused) { - return - } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - - screen('Feed') - store.log.debug('HomeScreen: Updating feed') - feed.checkForLatest() - - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) - - const onPressCompose = React.useCallback(() => { - track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) - - const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) - - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) - - const ListHeaderComponent = React.useCallback(() => { - if (isDesktop) { - return ( - <View - style={[ - pal.view, - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 18, - paddingVertical: 12, - }, - ]}> - <TextLink - type="title-lg" - href="/" - style={[pal.text, {fontWeight: 'bold'}]} - text={ - <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} - {hasNew && ( - <View - style={{ - top: -8, - backgroundColor: colors.blue3, - width: 8, - height: 8, - borderRadius: 4, - }} - /> - )} - </> - } - onPress={() => store.emitScreenSoftReset()} - /> - <TextLink - type="title-lg" - href="/settings/home-feed" - style={{fontWeight: 'bold'}} - accessibilityLabel="Feed Preferences" - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } - /> - </View> - ) - } - return <></> - }, [isDesktop, pal, store, hasNew]) - - return ( - <View testID={testID} style={s.h100pct}> - <Feed - testID={testID ? `${testID}-feed` : undefined} - key="default" - feed={feed} - scrollElRef={scrollElRef} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollEventThrottle={100} - renderEmptyState={renderEmptyState} - renderEndOfFeed={renderEndOfFeed} - ListHeaderComponent={ListHeaderComponent} - headerOffset={headerOffset} - /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new posts" - showIndicator={hasNew} - minimalShellMode={store.shell.minimalShellMode} - /> - )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) -}) - -function useHeaderOffset() { +export function useHeaderOffset() { const {isDesktop, isTablet} = useWebMediaQueries() const {fontScale} = useWindowDimensions() if (isDesktop) { diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 977401350..b00bfb765 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -156,7 +156,6 @@ export const NotificationsScreen = withAuthRequired( onPress={onPressLoadLatest} label="Load new notifications" showIndicator={hasNew} - minimalShellMode={true} /> )} </View> diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index f75222c1f..2112ec7d1 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -322,45 +322,37 @@ export const SettingsScreen = withAuthRequired( <View style={styles.spacer20} /> - {store.me.invitesAvailable !== null && ( - <> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Invite a Friend - </Text> - <TouchableOpacity - testID="inviteFriendBtn" - style={[ - styles.linkCard, - pal.view, - isSwitching && styles.dimmed, - ]} - onPress={isSwitching ? undefined : onPressInviteCodes} - accessibilityRole="button" - accessibilityLabel="Invite" - accessibilityHint="Opens invite code list"> - <View - style={[ - styles.iconContainer, - store.me.invitesAvailable > 0 ? primaryBg : pal.btn, - ]}> - <FontAwesomeIcon - icon="ticket" - style={ - (store.me.invitesAvailable > 0 - ? primaryText - : pal.text) as FontAwesomeIconStyle - } - /> - </View> - <Text - type="lg" - style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> - </> - )} + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Invite a Friend + </Text> + <TouchableOpacity + testID="inviteFriendBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={isSwitching ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel="Invite" + accessibilityHint="Opens invite code list"> + <View + style={[ + styles.iconContainer, + store.me.invitesAvailable > 0 ? primaryBg : pal.btn, + ]}> + <FontAwesomeIcon + icon="ticket" + style={ + (store.me.invitesAvailable > 0 + ? primaryText + : pal.text) as FontAwesomeIconStyle + } + /> + </View> + <Text + type="lg" + style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} available + </Text> + </TouchableOpacity> <View style={styles.spacer20} /> diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index de1b38b84..dc00d473d 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -9,6 +9,7 @@ import {TextLink} from 'view/com/util/Link' import {CenteredView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {HELP_DESK_URL} from 'lib/constants' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'> export const SupportScreen = (_props: Props) => { @@ -29,14 +30,13 @@ export const SupportScreen = (_props: Props) => { Support </Text> <Text style={[pal.text, s.p20]}> - If you need help, email us at{' '} + The support form has been moved. If you need help, please <TextLink - href="mailto:support@bsky.app" - text="support@bsky.app" + href={HELP_DESK_URL} + text=" click here" style={pal.link} />{' '} - with a description of your issue and information about how we can help - you. + or visit {HELP_DESK_URL} to get in touch with us. </Text> </CenteredView> </View> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 48341170c..51a846c4a 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -426,34 +426,32 @@ const InviteCodes = observer(function InviteCodesImpl({ store.shell.openModal({name: 'invite-codes'}) }, [store, track]) return ( - store.me.invitesAvailable !== null && ( - <TouchableOpacity - testID="menuItemInviteCodes" - style={[styles.inviteCodes, style]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <FontAwesomeIcon - icon="ticket" - style={[ - styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, - ]} - size={18} - /> - <Text - type="lg-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} - </Text> - </TouchableOpacity> - ) + <TouchableOpacity + testID="menuItemInviteCodes" + style={[styles.inviteCodes, style]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <FontAwesomeIcon + icon="ticket" + style={[ + styles.inviteCodesIcon, + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + ]} + size={18} + /> + <Text + type="lg-medium" + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} + </Text> + </TouchableOpacity> ) }) diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 4758c5e01..cfd4d46d0 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,10 +1,6 @@ import React, {ComponentProps} from 'react' -import { - Animated, - GestureResponderEvent, - TouchableOpacity, - View, -} from 'react-native' +import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' +import Animated from 'react-native-reanimated' import {StackActions} from '@react-navigation/native' import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -87,6 +83,7 @@ export const BottomBar = observer(function BottomBarImpl({ pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, footerMinimalShellTransform, + store.shell.minimalShellMode && styles.disabled, ]}> <Btn testID="bottomBarHomeBtn" diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index ae9381440..c175ed848 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -65,4 +65,7 @@ export const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 100, }, + disabled: { + pointerEvents: 'none', + }, }) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index e20214235..ebcc527a1 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -2,8 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {Animated} from 'react-native' import {useNavigationState} from '@react-navigation/native' +import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {getCurrentRoute, isTab} from 'lib/routes/helpers' import {styles} from './BottomBarStyles' diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index f0e986bf4..84d7d7854 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -7,7 +7,6 @@ import {DesktopSearch} from './Search' import {DesktopFeeds} from './Feeds' import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {s} from 'lib/styles' import {useStores} from 'state/index' @@ -90,41 +89,32 @@ const InviteCodes = observer(function InviteCodesImpl() { const onPress = React.useCallback(() => { store.shell.openModal({name: 'invite-codes'}) }, [store]) - return ( - <View style={[styles.separator, pal.border]}> - {store.me.invitesAvailable === null ? ( - <View style={[s.p10]}> - <LoadingPlaceholder width={186} height={32} style={[styles.br40]} /> - </View> - ) : ( - <TouchableOpacity - style={[styles.inviteCodes]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <FontAwesomeIcon - icon="ticket" - style={[ - styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, - ]} - size={16} - /> - <Text - type="md-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> - )} - </View> + <TouchableOpacity + style={[styles.inviteCodes, pal.border]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <FontAwesomeIcon + icon="ticket" + style={[ + styles.inviteCodesIcon, + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + ]} + size={16} + /> + <Text + type="md-medium" + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> + {formatCount(store.me.invitesAvailable)} invite{' '} + {pluralize(store.me.invitesAvailable, 'code')} available + </Text> + </TouchableOpacity> ) }) @@ -141,20 +131,16 @@ const styles = StyleSheet.create({ message: { paddingVertical: 18, - paddingHorizontal: 12, + paddingHorizontal: 10, }, messageLine: { marginBottom: 10, }, - separator: { - borderTopWidth: 1, - }, - br40: {borderRadius: 40}, - inviteCodes: { - paddingHorizontal: 12, - paddingVertical: 16, + borderTopWidth: 1, + paddingHorizontal: 16, + paddingVertical: 12, flexDirection: 'row', alignItems: 'center', }, diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 53a58c39d..caecea4a8 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() { ) const navigation = useNavigation<NavigationProp>() + // initial setup + React.useEffect(() => { + if (store.me.did) { + autocompleteView.setup() + } + }, [autocompleteView, store.me.did]) + const onChangeQuery = React.useCallback( (text: string) => { setQuery(text) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 3119715e9..b564f99f8 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -21,7 +21,10 @@ import {usePalette} from 'lib/hooks/usePalette' import * as backHandler from 'lib/routes/back-handler' import {RoutesContainer, TabsNavigator} from '../../Navigation' import {isStateAtTabRoot} from 'lib/routes/helpers' -import {SafeAreaProvider} from 'react-native-safe-area-context' +import { + SafeAreaProvider, + initialWindowMetrics, +} from 'react-native-safe-area-context' import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' const ShellInner = observer(function ShellInnerImpl() { @@ -87,7 +90,7 @@ export const Shell: React.FC = observer(function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( - <SafeAreaProvider style={pal.view}> + <SafeAreaProvider initialMetrics={initialWindowMetrics} style={pal.view}> <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} /> <RoutesContainer> |