diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index bf1aa1e21..e0c5840c3 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -3,6 +3,7 @@ import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {logError} from '@utils/log'; export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly = false) => { try { @@ -12,6 +13,7 @@ export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly prepareRecordsOnly, }); } catch (error) { + logError('storeGlobal', error); return {error}; } }; @@ -24,6 +26,10 @@ export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => { return storeGlobal(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, 'true', prepareRecordsOnly); }; +export const storeOnboardingViewedValue = async (value = true) => { + return storeGlobal(GLOBAL_IDENTIFIERS.ONBOARDING, value, false); +}; + export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => { return storeGlobal(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, 'true', prepareRecordsOnly); }; diff --git a/app/constants/database.ts b/app/constants/database.ts index 7f2e85e2f..acf719cf0 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -78,6 +78,7 @@ export const GLOBAL_IDENTIFIERS = { LAST_ASK_FOR_REVIEW: 'lastAskForReview', MULTI_SERVER_TUTORIAL: 'multiServerTutorial', PROFILE_LONG_PRESS_TUTORIAL: 'profileLongPressTutorial', + ONBOARDING: 'onboarding', }; export enum OperationType { diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 7bbacbc99..dbc815695 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -33,6 +33,7 @@ export const LATEX = 'Latex'; export const LOGIN = 'Login'; export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; +export const ONBOARDING = 'Onboarding'; export const PERMALINK = 'Permalink'; export const PINNED_MESSAGES = 'PinnedMessages'; export const POST_OPTIONS = 'PostOptions'; @@ -97,6 +98,7 @@ export default { LOGIN, MENTIONS, MFA, + ONBOARDING, PERMALINK, PINNED_MESSAGES, POST_OPTIONS, diff --git a/app/init/launch.ts b/app/init/launch.ts index ddcb307f3..885705374 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -6,13 +6,15 @@ import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native'; import {Notifications} from 'react-native-notifications'; import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry'; +import LocalConfig from '@assets/config.json'; import {Screens, DeepLink, Events, Launch, PushNotification} from '@constants'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; +import {getOnboardingViewed} from '@queries/app/global'; import {getThemeForCurrentTeam} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; -import {goToScreen, resetToHome, resetToSelectServer, resetToTeams} from '@screens/navigation'; +import {goToScreen, resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; @@ -58,6 +60,13 @@ const launchAppFromNotification = async (notification: NotificationWithData, col launchApp(props); }; +/** + * + * @param props set of properties used to determine how to launch the app depending on the containing values + * @param resetNavigation used when loading the add_server screen and remove all the navigation stack + + * @returns a redirection to a screen, either onboarding, add_server, login or home depending on the scenario + */ const launchApp = async (props: LaunchProps, resetNavigation = true) => { let serverUrl: string | undefined; switch (props?.launchType) { @@ -126,6 +135,13 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { } } + const onboardingViewed = LocalConfig.ShowOnboarding && await getOnboardingViewed(); + + // if the config value is set and the onboarding has not been seeing yet, show the onboarding + if (LocalConfig.ShowOnboarding && !onboardingViewed) { + return resetToOnboarding(props); + } + return launchToServer(props, resetNavigation); }; diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 63445e3d6..d8d44495c 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -5,6 +5,7 @@ import CookieManager, {Cookie} from '@react-native-cookies/cookies'; import {AppState, AppStateStatus, DeviceEventEmitter, Platform} from 'react-native'; import FastImage from 'react-native-fast-image'; +import {storeOnboardingViewedValue} from '@actions/app/global'; import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session'; import {Events, Launch} from '@constants'; import DatabaseManager from '@database/manager'; @@ -15,7 +16,7 @@ import PushNotifications from '@init/push_notifications'; import * as analytics from '@managers/analytics'; import NetworkManager from '@managers/network_manager'; import WebsocketManager from '@managers/websocket_manager'; -import {queryServerName} from '@queries/app/servers'; +import {queryAllServers, queryServerName} from '@queries/app/servers'; import {getCurrentUser} from '@queries/servers/user'; import {getThemeFromState} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; @@ -177,6 +178,14 @@ class SessionManager { } } + // set the onboardingViewed value to false so the launch will show the onboarding screen after all servers were removed + if (DatabaseManager.appDatabase) { + const servers = await queryAllServers(DatabaseManager.appDatabase.database); + if (!servers.length) { + await storeOnboardingViewedValue(false); + } + } + relaunchApp({launchType, serverUrl, displayName}, true); } }; diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index c72840486..acecf327d 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -41,6 +41,16 @@ export const observeMultiServerTutorial = () => { ); }; +export const getOnboardingViewed = async (): Promise => { + try { + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + const onboardingVal = await database.get(GLOBAL).find(GLOBAL_IDENTIFIERS.ONBOARDING); + return onboardingVal?.value ?? false; + } catch { + return false; + } +}; + export const observeProfileLongPresTutorial = () => { const query = queryGlobalValue(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL); if (!query) { diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 0846e950b..308242f71 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -249,6 +249,8 @@ Navigation.setLazyComponentRegistrator((screenName) => { export function registerScreens() { const homeScreen = require('@screens/home').default; const serverScreen = require('@screens/server').default; + const onboardingScreen = require('@screens/onboarding').default; + Navigation.registerComponent(Screens.ONBOARDING, () => withGestures(withIntl(withManagedConfig(onboardingScreen)), undefined)); Navigation.registerComponent(Screens.SERVER, () => withGestures(withIntl(withManagedConfig(serverScreen)), undefined)); Navigation.registerComponent(Screens.HOME, () => withGestures(withSafeAreaInsets(withServerDatabase(withManagedConfig(homeScreen))), undefined)); } diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 383a826bf..843e9b64e 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -124,6 +124,19 @@ export const bottomSheetModalOptions = (theme: Theme, closeButtonId?: string): O // This locks phones to portrait for all screens while keeps // all orientations available for Tablets. Navigation.setDefaultOptions({ + animations: { + setRoot: { + enter: { + waitForRender: true, + enabled: true, + alpha: { + from: 0, + to: 1, + duration: 300, + }, + }, + }, + }, layout: { orientation: Device.IS_TABLET ? undefined : ['portrait'], }, @@ -148,7 +161,7 @@ Appearance.addChangeListener(() => { const theme = getThemeFromState(); const screens = NavigationStore.getAllNavigationComponents(); - if (screens.includes(Screens.SERVER)) { + if (screens.includes(Screens.SERVER) || screens.includes(Screens.ONBOARDING)) { for (const screen of screens) { if (appearanceControlledScreens.has(screen)) { Navigation.updateProps(screen, {theme}); @@ -286,6 +299,54 @@ export function resetToSelectServer(passProps: LaunchProps) { }); } +export function resetToOnboarding(passProps: LaunchProps) { + const theme = getDefaultThemeByAppearance(); + const isDark = tinyColor(theme.sidebarBg).isDark(); + StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content'); + + NavigationStore.clearNavigationComponents(); + + const children = [{ + component: { + id: Screens.ONBOARDING, + name: Screens.ONBOARDING, + passProps: { + ...passProps, + theme, + }, + options: { + layout: { + backgroundColor: theme.centerChannelBg, + componentBackgroundColor: theme.centerChannelBg, + }, + statusBar: { + visible: true, + backgroundColor: theme.sidebarBg, + }, + topBar: { + backButton: { + color: theme.sidebarHeaderTextColor, + title: '', + }, + background: { + color: theme.sidebarBg, + }, + visible: false, + height: 0, + }, + }, + }, + }]; + + return Navigation.setRoot({ + root: { + stack: { + children, + }, + }, + }); +} + export function resetToTeams() { const theme = getThemeFromState(); const isDark = tinyColor(theme.sidebarBg).isDark(); diff --git a/app/screens/onboarding/footer_buttons.tsx b/app/screens/onboarding/footer_buttons.tsx new file mode 100644 index 000000000..cf01c3ce2 --- /dev/null +++ b/app/screens/onboarding/footer_buttons.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Pressable, useWindowDimensions, View} from 'react-native'; +import Animated, {Extrapolate, interpolate, useAnimatedStyle} from 'react-native-reanimated'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {ONBOARDING_CONTENT_MAX_WIDTH} from '@screens/onboarding/slide'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; + lastSlideIndex: number; + nextSlideHandler: () => void; + signInHandler: () => void; + scrollX: Animated.SharedValue; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + button: { + marginTop: 5, + }, + rowIcon: { + color: theme.buttonColor, + fontSize: 12, + marginLeft: 5, + marginTop: 4.5, + }, + nextButtonText: { + flexDirection: 'row', + position: 'absolute', + }, + signInButtonText: { + flexDirection: 'row', + }, + footerButtonsContainer: { + flexDirection: 'column', + height: 120, + marginTop: 25, + marginBottom: 15, + width: '100%', + alignItems: 'center', + }, +})); + +const AnimatedButton = Animated.createAnimatedComponent(Pressable); +const BUTTON_SIZE = 100; + +const FooterButtons = ({ + theme, + nextSlideHandler, + signInHandler, + lastSlideIndex, + scrollX, +}: Props) => { + const {width} = useWindowDimensions(); + const buttonWidth = Math.min(width * 0.8, ONBOARDING_CONTENT_MAX_WIDTH); + const styles = getStyleSheet(theme); + + // keep in mind penultimate and ultimate slides to run buttons text/opacity/size animations + const penultimateSlide = lastSlideIndex - 1; + + const inputRange = [(penultimateSlide * width), lastSlideIndex * width]; + + // the next button must resize in the last slide to be the 80% wide of the screen with animation + const resizeStyle = useAnimatedStyle(() => { + const interpolatedWidth = interpolate( + scrollX.value, + inputRange, + [BUTTON_SIZE, buttonWidth], + Extrapolate.CLAMP, + ); + + return {width: interpolatedWidth}; + }); + + // use for the opacity of the button text in the penultimate and last slide + const opacityNextTextStyle = useAnimatedStyle(() => { + const interpolatedScale = interpolate( + scrollX.value, + [penultimateSlide * width, ((penultimateSlide * width) + (width / 2))], + [1, 0], // from last to penultimate it must fade out (from 1 to 0), once it starts getting the penultimate range it must fade in again (from 0 to 1) + Extrapolate.CLAMP, + ); + + return {opacity: interpolatedScale}; + }); + + const opacitySignInTextStyle = useAnimatedStyle(() => { + const interpolatedScale = interpolate( + scrollX.value, + [lastSlideIndex * width, ((penultimateSlide * width) + (width / 2))], + [1, 0], // from last to penultimate it must fade out (from 1 to 0), once it starts getting the penultimate range it must fade in again (from 0 to 1) + Extrapolate.CLAMP, + ); + + return {opacity: interpolatedScale}; + }); + + // the sign in button should fade out until dissappear in the last slide + const opacitySignInButton = useAnimatedStyle(() => { + const interpolatedScale = interpolate( + scrollX.value, + inputRange, + [1, 0], + Extrapolate.CLAMP, + ); + + return {opacity: interpolatedScale}; + }); + + const nextButtonText = ( + + + + + ); + + const signInButtonText = ( + + + + ); + + return ( + + + {nextButtonText} + {signInButtonText} + + + + + + ); +}; + +export default FooterButtons; diff --git a/app/screens/onboarding/illustrations/calls.tsx b/app/screens/onboarding/illustrations/calls.tsx new file mode 100644 index 000000000..183a9ffc7 --- /dev/null +++ b/app/screens/onboarding/illustrations/calls.tsx @@ -0,0 +1,244 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Svg, { + Ellipse, + Path, + Mask, + G, +} from 'react-native-svg'; + +type Props = { + theme: Theme; + styles: StyleProp; +}; + +const CallsSvg = ({theme, styles}: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CallsSvg; diff --git a/app/screens/onboarding/illustrations/chat.tsx b/app/screens/onboarding/illustrations/chat.tsx new file mode 100644 index 000000000..4126a79ae --- /dev/null +++ b/app/screens/onboarding/illustrations/chat.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Svg, { + G, + Path, + Ellipse, +} from 'react-native-svg'; + +type Props = { + theme: Theme; + styles: StyleProp; +}; + +const ChatSvg = ({theme, styles}: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ChatSvg; diff --git a/app/screens/onboarding/illustrations/integrations.tsx b/app/screens/onboarding/illustrations/integrations.tsx new file mode 100644 index 000000000..17fad9ac7 --- /dev/null +++ b/app/screens/onboarding/illustrations/integrations.tsx @@ -0,0 +1,540 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Svg, { + Rect, + Path, + Circle, + Ellipse, + Mask, + G, + Defs, + LinearGradient, + Stop, +} from 'react-native-svg'; + +type Props = { + theme: Theme; + styles: StyleProp; +}; + +const IntegrationsSvg = ({theme, styles}: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default IntegrationsSvg; diff --git a/app/screens/onboarding/illustrations/team_communication.tsx b/app/screens/onboarding/illustrations/team_communication.tsx new file mode 100644 index 000000000..d5ec426c7 --- /dev/null +++ b/app/screens/onboarding/illustrations/team_communication.tsx @@ -0,0 +1,261 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Svg, { + Ellipse, + Path, + Mask, + G, +} from 'react-native-svg'; + +type Props = { + theme: Theme; + styles: StyleProp; +} + +const TeamCommunicationSvg = ({theme, styles}: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default TeamCommunicationSvg; diff --git a/app/screens/onboarding/index.tsx b/app/screens/onboarding/index.tsx new file mode 100644 index 000000000..821a61376 --- /dev/null +++ b/app/screens/onboarding/index.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef} from 'react'; +import { + View, + useWindowDimensions, + SafeAreaView, + ScrollView, + StyleSheet, + Platform, + NativeSyntheticEvent, + NativeScrollEvent, +} from 'react-native'; +import {Navigation} from 'react-native-navigation'; +import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated'; + +import {storeOnboardingViewedValue} from '@actions/app/global'; +import {Screens} from '@constants'; +import Background from '@screens/background'; +import {goToScreen, loginAnimationOptions} from '@screens/navigation'; + +import FooterButtons from './footer_buttons'; +import Paginator from './paginator'; +import SlideItem from './slide'; +import useSlidesData from './slides_data'; + +import type {LaunchProps} from '@typings/launch'; + +interface OnboardingProps extends LaunchProps { + theme: Theme; +} + +const styles = StyleSheet.create({ + onBoardingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + verticalAlign: 'top', + }, + scrollContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); + +const Onboarding = ({ + theme, +}: OnboardingProps) => { + const {width} = useWindowDimensions(); + const {slidesData} = useSlidesData(); + const LAST_SLIDE_INDEX = slidesData.length - 1; + const slidesRef = useRef(null); + + const scrollX = useSharedValue(0); + + // used to smothly animate the whole onboarding screen during the appear event scenario (from server screen back to onboarding screen) + const translateX = useSharedValue(width); + + const currentIndex = useDerivedValue(() => Math.round(scrollX.value / width)); + + const moveToSlide = useCallback((slideIndexToMove: number) => { + slidesRef.current?.scrollTo({ + x: (slideIndexToMove * width), + animated: true, + }); + }, [width]); + + const signInHandler = useCallback(() => { + // mark the onboarding as already viewed + storeOnboardingViewedValue(); + + goToScreen(Screens.SERVER, '', {animated: true, theme}, loginAnimationOptions()); + }, []); + + const nextSlide = useCallback(() => { + const nextSlideIndex = currentIndex.value + 1; + if (slidesRef.current && currentIndex.value < LAST_SLIDE_INDEX) { + moveToSlide(nextSlideIndex); + } else if (slidesRef.current && currentIndex.value === LAST_SLIDE_INDEX) { + signInHandler(); + } + }, [currentIndex, moveToSlide, signInHandler]); + + const scrollHandler = useCallback((event: NativeSyntheticEvent) => { + scrollX.value = event.nativeEvent.contentOffset.x; + }, []); + + useEffect(() => { + const listener = { + componentDidAppear: () => { + translateX.value = 0; + }, + componentDidDisappear: () => { + translateX.value = -width; + }, + }; + const unsubscribe = Navigation.events().registerComponentListener(listener, Screens.ONBOARDING); + + return () => unsubscribe.remove(); + }, [width]); + + const transform = useAnimatedStyle(() => { + const duration = Platform.OS === 'android' ? 250 : 350; + return { + transform: [{translateX: withTiming(translateX.value, {duration})}], + }; + }, []); + + return ( + + + + + {slidesData.map((item, index) => { + return ( + + ); + })} + + + + + + ); +}; + +export default Onboarding; diff --git a/app/screens/onboarding/paginator.tsx b/app/screens/onboarding/paginator.tsx new file mode 100644 index 000000000..c002a8061 --- /dev/null +++ b/app/screens/onboarding/paginator.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, useWindowDimensions, TouchableOpacity} from 'react-native'; +import Animated, {interpolate, useAnimatedStyle} from 'react-native-reanimated'; + +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + dataLength: number; + theme: Theme; + scrollX: Animated.SharedValue; + moveToSlide: (slideIndexToMove: number) => void; +}; + +const DOT_SIZE = 16; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const commonStyle = { + height: DOT_SIZE / 2, + borderRadius: 5, + backgroundColor: theme.buttonBg, + marginHorizontal: DOT_SIZE / 2, + width: DOT_SIZE / 2, + opacity: 0.25, + }; + return { + dot: { + ...commonStyle, + }, + fixedDot: { + ...commonStyle, + position: 'absolute', + }, + outerDot: { + height: DOT_SIZE, + borderRadius: DOT_SIZE / 2, + backgroundColor: theme.buttonBg, + marginHorizontal: 4, + marginTop: -4, + position: 'absolute', + width: DOT_SIZE, + opacity: 0.15, + }, + paginatorContainer: { + flexDirection: 'row', + height: 15, + justifyContent: 'space-between', + width: 120, + }, + }; +}); + +const Paginator = ({ + theme, + dataLength, + scrollX, + moveToSlide, +}: Props) => { + const styles = getStyleSheet(theme); + return ( + + {[...Array(dataLength)].map((_, index: number) => { + return ( + + ); + })} + + ); +}; + +type DotProps = { + index: number; + scrollX: Animated.SharedValue; + theme: Theme; + moveToSlide: (slideIndexToMove: number) => void; +}; + +// this has to be extracted as a component since the useAnimatedStyle hook cant be used inside a loop +const Dot = ({ + index, + scrollX, + theme, + moveToSlide, +}: DotProps) => { + const {width} = useWindowDimensions(); + const styles = getStyleSheet(theme); + const inputRange = [(index - 1) * width, index * width, (index + 1) * width]; + + const dotOpacity = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + inputRange, + [0, 1, 0], + ); + + return {opacity}; + }); + + const outerDotOpacity = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + inputRange, + [0, 0.15, 0], + ); + + return {opacity}; + }); + + return ( + moveToSlide(index)} + hitSlop={{top: 8, left: 8, right: 8, bottom: 8}} + > + + + + + ); +}; + +export default Paginator; diff --git a/app/screens/onboarding/slide.tsx b/app/screens/onboarding/slide.tsx new file mode 100644 index 000000000..5e5c15edc --- /dev/null +++ b/app/screens/onboarding/slide.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo, useState} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import Animated, {Extrapolate, interpolate, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type {OnboardingItem} from '@typings/screens/onboarding'; + +type Props = { + item: OnboardingItem; + theme: Theme; + scrollX: Animated.SharedValue; + index: number; + lastSlideIndex: number; +}; + +export const ONBOARDING_CONTENT_MAX_WIDTH = 520; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + image: { + justifyContent: 'center', + maxHeight: 180, + }, + title: { + color: theme.centerChannelColor, + textAlign: 'center', + paddingHorizontal: 20, + maxWidth: ONBOARDING_CONTENT_MAX_WIDTH, + marginBottom: 12, + }, + fontTitle: { + marginTop: 32, + ...typography('Heading', 1000, 'SemiBold'), + }, + fontFirstTitle: { + ...typography('Heading', 1200, 'SemiBold'), + paddingTop: 48, + letterSpacing: -1, + }, + widthLastSlide: { + paddingHorizontal: 50, + }, + firstSlideInitialPosition: { + left: 200, + opacity: 0, + }, + description: { + textAlign: 'center', + paddingHorizontal: 20, + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 200, 'Regular'), + maxWidth: ONBOARDING_CONTENT_MAX_WIDTH, + }, + itemContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +})); + +const FIRST_SLIDE = 0; + +const SlideItem = ({theme, item, scrollX, index, lastSlideIndex}: Props) => { + const {width} = useWindowDimensions(); + const styles = getStyleSheet(theme); + + /** + * Code used to animate the first image load + */ + const [firstLoad, setFirstLoad] = useState(true); + + const initialImagePosition = useSharedValue(width); + const initialTitlePosition = useSharedValue(width); + const initialDescriptionPosition = useSharedValue(width); + + const initialElementsOpacity = useSharedValue(0); + + useEffect(() => { + if (index === FIRST_SLIDE) { + initialImagePosition.value = withTiming(0, {duration: 600}); + initialTitlePosition.value = withTiming(0, {duration: 800}); + initialDescriptionPosition.value = withTiming(0, {duration: 1000}); + + initialElementsOpacity.value = withTiming(1, {duration: 1000}); + setFirstLoad(false); + } + }, []); + + const translateFirstImageOnLoad = useAnimatedStyle(() => { + return { + transform: [{ + translateX: initialImagePosition.value, + }], + opacity: initialElementsOpacity.value, + }; + }); + + const translateFirstTitleOnLoad = useAnimatedStyle(() => { + return { + transform: [{ + translateX: initialTitlePosition.value, + }], + opacity: initialElementsOpacity.value, + }; + }); + + const translateFirstDescriptionOnLoad = useAnimatedStyle(() => { + return { + transform: [{ + translateX: initialDescriptionPosition.value, + }], + opacity: initialElementsOpacity.value, + }; + }); + + // end of code for animating first image load + + const inputRange = useMemo(() => [(index - 1) * width, index * width, (index + 1) * width], [width, index]); + + const translateImage = useAnimatedStyle(() => { + const translateImageInterpolate = interpolate( + scrollX.value, + inputRange, + [width * 2, 0, -width * 2], + Extrapolate.CLAMP, + ); + + return { + transform: [{ + translateX: translateImageInterpolate, + }], + }; + }); + + const translateTitle = useAnimatedStyle(() => { + const translateTitleInterpolate = interpolate( + scrollX.value, + inputRange, + [width * 0.6, 0, -width * 0.6], + Extrapolate.CLAMP, + ); + + return { + transform: [{ + translateX: translateTitleInterpolate, + }], + }; + }); + + const translateDescription = useAnimatedStyle(() => { + const translateDescriptionInterpolate = interpolate( + scrollX.value, + inputRange, + [width * 0.2, 0, -width * 0.2], + Extrapolate.CLAMP, + ); + + return { + transform: [{ + translateX: translateDescriptionInterpolate, + }], + }; + }); + + const opacity = useAnimatedStyle(() => { + const opacityInterpolate = interpolate( + scrollX.value, + inputRange, + [0.2, 1, 0.2], + Extrapolate.CLAMP, + ); + + return {opacity: opacityInterpolate}; + }); + + return ( + + + {item.image} + + + + {item.title} + + + {item.description} + + + + ); +}; + +export default React.memo(SlideItem); diff --git a/app/screens/onboarding/slides_data.tsx b/app/screens/onboarding/slides_data.tsx new file mode 100644 index 000000000..217853577 --- /dev/null +++ b/app/screens/onboarding/slides_data.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {OnboardingItem} from '@typings/screens/onboarding'; + +import CallsSvg from './illustrations/calls'; +import ChatSvg from './illustrations/chat'; +import IntegrationsSvg from './illustrations/integrations'; +import TeamCommunicationSvg from './illustrations/team_communication'; + +const styles = StyleSheet.create({ + image: { + justifyContent: 'center', + height: 200, + }, + lastSlideImage: { + height: 250, + left: 8, + }, +}); + +const useSlidesData = () => { + const intl = useIntl(); + const theme = useTheme(); + const callsSvg = ( + + ); + const chatSvg = ( + + ); + const teamCommunicationSvg = ( + + ); + const integrationsSvg = ( + + ); + + const slidesData: OnboardingItem[] = [ + { + title: intl.formatMessage({id: 'onboarding.welcome', defaultMessage: 'Welcome'}), + description: intl.formatMessage({id: 'onboaring.welcome_description', defaultMessage: 'Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with your tools.'}), + image: chatSvg, + }, + { + title: intl.formatMessage({id: 'onboarding.realtime_collaboration', defaultMessage: 'Collaborate in real‑time'}), + description: intl.formatMessage({id: 'onboarding.realtime_collaboration_description', defaultMessage: 'Persistent channels, direct messaging, and file sharing works seamlessly so you can stay connected, wherever you are.'}), + image: teamCommunicationSvg, + }, + { + title: intl.formatMessage({id: 'onboarding.calls', defaultMessage: 'Start secure audio calls instantly'}), + description: intl.formatMessage({id: 'onboarding.calls_description', defaultMessage: 'When typing isn’t fast enough, switch from channel-based chat to secure audio calls with a single tap.'}), + image: callsSvg, + }, + { + title: intl.formatMessage({id: 'onboarding.integrations', defaultMessage: 'Integrate with tools you love'}), + description: intl.formatMessage({id: 'onboarding.integrations_description', defaultMessage: 'Go beyond chat with tightly-integrated product solutions matched to common development processes.'}), + image: integrationsSvg, + }, + ]; + + return {slidesData}; +}; + +export default useSlidesData; diff --git a/app/screens/server/header.tsx b/app/screens/server/header.tsx index d7fd475cb..dac91de96 100644 --- a/app/screens/server/header.tsx +++ b/app/screens/server/header.tsx @@ -27,7 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ ...typography('Heading', 400, 'SemiBold'), }, connect: { - width: 270, + width: 320, letterSpacing: -1, color: theme.centerChannelColor, marginVertical: 12, diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 1422820be..181a81f30 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -34,6 +34,7 @@ import ServerHeader from './header'; import type {DeepLinkWithData, LaunchProps} from '@typings/launch'; interface ServerProps extends LaunchProps { + animated?: boolean; closeButtonId?: string; componentId: string; theme: Theme; @@ -46,9 +47,24 @@ const defaultServerUrlMessage = { defaultMessage: 'Please enter a valid server URL', }; +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + appInfo: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + flex: { + flex: 1, + }, + scrollContainer: { + alignItems: 'center', + height: '100%', + justifyContent: 'center', + }, +})); + const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); const Server = ({ + animated, closeButtonId, componentId, displayName: defaultDisplayName, @@ -61,7 +77,7 @@ const Server = ({ const intl = useIntl(); const managedConfig = useManagedConfig(); const dimensions = useWindowDimensions(); - const translateX = useSharedValue(0); + const translateX = useSharedValue(animated ? dimensions.width : 0); const keyboardAwareRef = useRef(null); const [connecting, setConnecting] = useState(false); const [displayName, setDisplayName] = useState(''); @@ -348,18 +364,4 @@ const Server = ({ ); }; -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - appInfo: { - color: changeOpacity(theme.centerChannelColor, 0.56), - }, - flex: { - flex: 1, - }, - scrollContainer: { - alignItems: 'center', - height: '100%', - justifyContent: 'center', - }, -})); - export default Server; diff --git a/app/utils/navigation/index.ts b/app/utils/navigation/index.ts index 31dbabdac..041d43e29 100644 --- a/app/utils/navigation/index.ts +++ b/app/utils/navigation/index.ts @@ -8,6 +8,7 @@ import {Navigation, Options} from 'react-native-navigation'; import {Screens} from '@constants'; export const appearanceControlledScreens = new Set([ + Screens.ONBOARDING, Screens.SERVER, Screens.LOGIN, Screens.FORGOT_PASSWORD, diff --git a/app/utils/typography.ts b/app/utils/typography.ts index 812bf5f35..50ecbf763 100644 --- a/app/utils/typography.ts +++ b/app/utils/typography.ts @@ -6,7 +6,7 @@ import {StyleSheet, TextStyle} from 'react-native'; // type FontFamilies = 'OpenSans' | 'Metropolis'; type FontTypes = 'Heading' | 'Body'; type FontStyles = 'SemiBold' | 'Regular' | 'Light'; -type FontSizes = 25 | 50 | 75 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000; +type FontSizes = 25 | 50 | 75 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 | 1200; const fontFamily = StyleSheet.create({ OpenSans: { @@ -30,6 +30,11 @@ const fontStyle = StyleSheet.create({ }); const fontSize = StyleSheet.create({ + 1200: { + fontSize: 66, + lineHeight: 48, + letterSpacing: -0.02, + }, 1000: { fontSize: 40, lineHeight: 48, diff --git a/assets/base/config.json b/assets/base/config.json index c14a831b7..5aded61ad 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -36,5 +36,6 @@ "ShowSentryDebugOptions": false, "CustomRequestHeaders": {}, - "ShowReview": false + "ShowReview": false, + "ShowOnboarding": false } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 623ed1237..9bbd6a3cf 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -505,6 +505,9 @@ "mobile.oauth.switch_to_browser.error_title": "Sign in error", "mobile.oauth.switch_to_browser.title": "Redirecting...", "mobile.oauth.try_again": "Try again", + "mobile.onboarding.next": "Next", + "mobile.onboarding.sign_in": "Sign in", + "mobile.onboarding.sign_in_to_get_started": "Sign in to get started", "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", "mobile.participants.header": "Thread Participants", "mobile.permission_denied_dismiss": "Don't Allow", @@ -652,6 +655,14 @@ "notification.message_not_found": "Message not found", "notification.not_channel_member": "This message belongs to a channel where you are not a member.", "notification.not_team_member": "This message belongs to a team where you are not a member.", + "onboarding.calls": "Start secure audio calls instantly", + "onboarding.calls_description": "When typing isn’t fast enough, switch from channel-based chat to secure audio calls with a single tap.", + "onboarding.integrations": "Integrate with tools you love", + "onboarding.integrations_description": "Go beyond chat with tightly-integrated product solutions matched to common development processes.", + "onboarding.realtime_collaboration": "Collaborate in real‑time", + "onboarding.realtime_collaboration_description": "Persistent channels, direct messaging, and file sharing works seamlessly so you can stay connected, wherever you are.", + "onboarding.welcome": "Welcome", + "onboaring.welcome_description": "Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with your tools.", "password_send.description": "To reset your password, enter the email address you used to sign up", "password_send.error": "Please enter a valid email address.", "password_send.generic_error": "We were unable to send you a reset password link. Please contact your System Admin for assistance.", diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1d061ae0a..2d14828f8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -170,6 +170,11 @@ lane :configure do json['ShowReview'] = true end + # Configure Show Onboarding + if ENV['SHOW_ONBOARDING'] == 'true' + json['ShowOnboarding'] = true + end + # Save the config.json file save_json_as_file('../dist/assets/config.json', json) diff --git a/tsconfig.json b/tsconfig.json index 6b405d1cf..0dec6dd84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,7 +63,7 @@ "*": ["./*", "node_modules/*"], } }, - "include": ["app/**/*", "share_extensionn/**/*", "test/**/*", "types/**/*"], + "include": ["app/**/*", "share_extension/**/*", "test/**/*", "types/**/*"], "exclude": [ "node_modules", "build", diff --git a/types/screens/onboarding.ts b/types/screens/onboarding.ts new file mode 100644 index 000000000..a1809e20c --- /dev/null +++ b/types/screens/onboarding.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +export type OnboardingItem = { + title: string; + description: string; + image: React.ReactElement; +};