diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b3cb70322..8f3284d00 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,7 +30,7 @@ android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" - android:windowSoftInputMode="adjustResize" + android:windowSoftInputMode="adjustPan" android:launchMode="singleTask" android:taskAffinity=""> diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index e4f1c5d56..c40798c18 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -3,6 +3,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {t} from '@i18n'; import NetworkManager from '@init/network_manager'; import {queryExpandedLinks} from '@queries/servers/system'; @@ -12,16 +13,21 @@ import type {Client} from '@client/rest'; import type {ClientResponse} from '@mattermost/react-native-network-client'; export const doPing = async (serverUrl: string) => { - const client = await NetworkManager.createClient(serverUrl); + let client: Client; + try { + client = await NetworkManager.createClient(serverUrl); + } catch (error) { + return {error}; + } const certificateError = { - id: 'mobile.server_requires_client_certificate', - defaultMessage: 'Server required client certificate for authentication.', + id: t('mobile.server_requires_client_certificate'), + defaultMessage: 'Server requires client certificate for authentication.', }; const pingError = { - id: 'mobile.server_ping_failed', - defaultMessage: 'Cannot connect to the server. Please check your server URL and internet connection.', + id: t('mobile.server_ping_failed'), + defaultMessage: 'Cannot connect to the server.', }; let response: ClientResponse; @@ -41,7 +47,7 @@ export const doPing = async (serverUrl: string) => { } } catch (error) { NetworkManager.invalidateClient(serverUrl); - return {error}; + return {error: {intl: pingError}}; } return {error: undefined}; diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 7cee87510..e7e73aadd 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -86,7 +86,7 @@ export const getSessions = async (serverUrl: string, currentUserId: string) => { return undefined; }; -export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password, config}: LoginArgs): Promise => { +export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password, config, serverDisplayName}: LoginArgs): Promise => { let deviceToken; let user: UserProfile; @@ -117,6 +117,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo dbName: serverUrl, serverUrl, identifier: config.DiagnosticId, + displayName: serverDisplayName, }, }); await DatabaseManager.setActiveServerDatabase(serverUrl); @@ -180,7 +181,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) = }; }; -export const ssoLogin = async (serverUrl: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise => { +export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise => { let deviceToken; let user; @@ -206,6 +207,7 @@ export const ssoLogin = async (serverUrl: string, serverIdentifier: string, bear dbName: serverUrl, serverUrl, identifier: serverIdentifier, + displayName: serverDisplayName, }, }); await DatabaseManager.setActiveServerDatabase(serverUrl); diff --git a/app/components/app_version/__snapshots__/index.test.tsx.snap b/app/components/app_version/__snapshots__/index.test.tsx.snap index fe8c0f2d7..9331096fe 100644 --- a/app/components/app_version/__snapshots__/index.test.tsx.snap +++ b/app/components/app_version/__snapshots__/index.test.tsx.snap @@ -7,8 +7,10 @@ exports[`@components/app_version should match snapshot 1`] = ` diff --git a/app/components/app_version/index.tsx b/app/components/app_version/index.tsx index d2e96cebe..50db56bca 100644 --- a/app/components/app_version/index.tsx +++ b/app/components/app_version/index.tsx @@ -10,8 +10,10 @@ import {t} from '@i18n'; const style = StyleSheet.create({ info: { - alignItems: 'center', - justifyContent: 'flex-end', + position: 'absolute', + bottom: 0, + marginLeft: 20, + marginBottom: 12, }, version: { fontSize: 12, diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 6e56f65e4..fc3d5abfb 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -4,7 +4,7 @@ // Note: This file has been adapted from the library https://github.com/csath/react-native-reanimated-text-input import {debounce} from 'lodash'; -import React, {useState, useEffect, useRef} from 'react'; +import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef} from 'react'; import {View, TextInput, TouchableWithoutFeedback, Text, Platform, TextStyle, NativeSyntheticEvent, TextInputFocusEventData, TextInputProps, GestureResponderEvent, TargetedEvent} from 'react-native'; import Animated, {useCode, interpolateNode, EasingNode, Value, set, Clock} from 'react-native-reanimated'; @@ -13,12 +13,13 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {timingAnimation} from './animation_utils'; +const DEFAULT_INPUT_HEIGHT = 48; const BORDER_DEFAULT_WIDTH = 1; const BORDER_FOCUSED_WIDTH = 2; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { - height: 48, + height: DEFAULT_INPUT_HEIGHT + (2 * BORDER_DEFAULT_WIDTH), width: '100%', }, errorContainer: { @@ -43,7 +44,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ left: 16, fontFamily: 'OpenSans', fontSize: 16, - zIndex: 1, + zIndex: 10, }, smallLabel: { fontSize: 10, @@ -51,7 +52,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ textInput: { fontFamily: 'OpenSans', fontSize: 16, - lineHeight: 24, paddingTop: 12, paddingBottom: 12, paddingHorizontal: 16, @@ -59,6 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ borderColor: changeOpacity(theme.centerChannelColor, 0.16), borderRadius: 4, borderWidth: BORDER_DEFAULT_WIDTH, + backgroundColor: theme.centerChannelBg, }, })); @@ -85,6 +86,11 @@ const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelSt return [unfocused, focused]; }; +export type FloatingTextInputRef = { + focus: () => void; + isFocused: () => boolean; +} + type FloatingTextInputProps = TextInputProps & { containerStyle?: TextStyle; editable?: boolean; @@ -100,12 +106,12 @@ type FloatingTextInputProps = TextInputProps & { value: string; } -const FloatingTextInput = ({ +const FloatingTextInput = forwardRef(({ error, containerStyle, isKeyboardInput = true, editable = true, - errorIcon = 'BRKN-alert-outline', + errorIcon = 'alert-outline', label = '', onPress = undefined, onFocus, @@ -114,9 +120,9 @@ const FloatingTextInput = ({ theme, value = '', ...props -}: FloatingTextInputProps) => { +}: FloatingTextInputProps, ref) => { const [focusedLabel, setIsFocusLabel] = useState(); - const [focused, setIsFocused] = useState(Boolean(value)); + const [focused, setIsFocused] = useState(Boolean(value) && editable); const inputRef = useRef(null); const [animation] = useState(new Value(focusedLabel ? 1 : 0)); const debouncedOnFocusTextInput = debounce(setIsFocusLabel, 500, {leading: true, trailing: false}); @@ -128,6 +134,11 @@ const FloatingTextInput = ({ to = focusedLabel ? 1 : 0; } + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + isFocused: () => inputRef.current?.isFocused() || false, + }), [inputRef]); + useCode( () => set( animation, @@ -164,6 +175,7 @@ const FloatingTextInput = ({ backgroundColor: ( focusedLabel ? theme.centerChannelBg : 'transparent' ), + paddingHorizontal: focusedLabel ? 4 : 0, color: styles.label.color, }; @@ -200,7 +212,10 @@ const FloatingTextInput = ({ textInputColorStyles = {borderColor: theme.errorTextColor}; } - const textInputBorder = {borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH}; + const textInputBorder = { + borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH, + height: DEFAULT_INPUT_HEIGHT + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2), + }; const combinedTextInputStyle = [styles.textInput, textInputBorder, textInputColorStyles]; const textAnimatedTextStyle = [styles.label, focusStyle, labelColorStyles]; @@ -235,7 +250,7 @@ const FloatingTextInput = ({ ref={inputRef} underlineColorAndroid='transparent' /> - {!focused && error && ( + {error && ( {showErrorIcon && errorIcon && ); -}; +}); export default FloatingTextInput; diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index f4982a165..cccbc6b0a 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -35,13 +35,14 @@ import type {LaunchProps} from '@typings/launch'; interface LoginProps extends LaunchProps { componentId: string; config: ClientConfig; + serverDisplayName: string; license: ClientLicense; theme: Theme; } export const MFA_EXPECTED_ERRORS = ['mfa.validate_token.authenticate.app_error', 'ent.mfa.validate_token.authenticate.app_error']; -const Login: NavigationFunctionComponent = ({config, extra, launchError, launchType, license, serverUrl, theme}: LoginProps) => { +const Login: NavigationFunctionComponent = ({config, serverDisplayName, extra, launchError, launchType, license, serverUrl, theme}: LoginProps) => { const styles = getStyleSheet(theme); const loginRef = useRef(null); @@ -128,7 +129,7 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT }); const signIn = async () => { - const result: LoginActionResponse = await login(serverUrl!, {loginId: loginId.toLowerCase(), password, config, license}); + const result: LoginActionResponse = await login(serverUrl!, {serverDisplayName, loginId: loginId.toLowerCase(), password, config, license}); if (checkLoginResponse(result)) { if (!result.hasTeams && !result.error) { // eslint-disable-next-line no-console @@ -171,7 +172,7 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT const goToMfa = () => { const screen = MFA; const title = intl.formatMessage({id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'}); - goToScreen(screen, title, {goToHome, loginId, password, config, license, serverUrl, theme}); + goToScreen(screen, title, {goToHome, loginId, password, config, serverDisplayName, license, serverUrl, theme}); }; const getLoginErrorMessage = (loginError: string | ClientErrorProps | Error) => { diff --git a/app/screens/login_options/index.tsx b/app/screens/login_options/index.tsx index c475dd586..69cbbfd51 100644 --- a/app/screens/login_options/index.tsx +++ b/app/screens/login_options/index.tsx @@ -26,6 +26,7 @@ import type {LaunchProps} from '@typings/launch'; interface LoginOptionsProps extends LaunchProps { componentId: string; serverUrl: string; + serverDisplayName: string; config: ClientConfig; license: ClientLicense; theme: Theme; @@ -60,7 +61,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const LoginOptions: NavigationFunctionComponent = ({config, extra, launchType, launchError, license, serverUrl, theme}: LoginOptionsProps) => { +const LoginOptions: NavigationFunctionComponent = ({config, extra, launchType, launchError, license, serverDisplayName, serverUrl, theme}: LoginOptionsProps) => { const intl = useIntl(); const styles = getStyles(theme); @@ -68,13 +69,13 @@ const LoginOptions: NavigationFunctionComponent = ({config, extra, launchType, l const screen = LOGIN; const title = intl.formatMessage({id: 'mobile.routes.login', defaultMessage: 'Login'}); - goToScreen(screen, title, {config, extra, launchError, launchType, license, serverUrl, theme}); + goToScreen(screen, title, {config, extra, launchError, launchType, license, serverDisplayName, serverUrl, theme}); }); const displaySSO = preventDoubleTap((ssoType: string) => { const screen = SSO; const title = intl.formatMessage({id: 'mobile.routes.sso', defaultMessage: 'Single Sign-On'}); - goToScreen(screen, title, {config, extra, launchError, launchType, license, theme, ssoType, serverUrl}); + goToScreen(screen, title, {config, extra, launchError, launchType, license, theme, ssoType, serverDisplayName, serverUrl}); }); return ( diff --git a/app/screens/mfa/index.tsx b/app/screens/mfa/index.tsx index 10c65f694..060e243d0 100644 --- a/app/screens/mfa/index.tsx +++ b/app/screens/mfa/index.tsx @@ -31,11 +31,12 @@ type MFAProps = { license: Partial; loginId: string; password: string; + serverDisplayName: string; serverUrl: string; theme: Theme; } -const MFA = ({config, goToHome, license, loginId, password, serverUrl, theme}: MFAProps) => { +const MFA = ({config, goToHome, license, loginId, password, serverDisplayName, serverUrl, theme}: MFAProps) => { const intl = useIntl(); const [token, setToken] = useState(''); const [error, setError] = useState(''); @@ -78,7 +79,7 @@ const MFA = ({config, goToHome, license, loginId, password, serverUrl, theme}: M return; } setIsLoading(true); - const result: LoginActionResponse = await login(serverUrl, {loginId, password, mfaToken: token, config, license}); + const result: LoginActionResponse = await login(serverUrl, {loginId, password, mfaToken: token, config, license, serverDisplayName}); setIsLoading(false); if (result?.error && result.failed) { if (typeof result.error == 'string') { diff --git a/app/screens/mfa/mfa.test.tsx b/app/screens/mfa/mfa.test.tsx index 172978b2b..26503aa40 100644 --- a/app/screens/mfa/mfa.test.tsx +++ b/app/screens/mfa/mfa.test.tsx @@ -23,6 +23,7 @@ describe('*** MFA Screen ***', () => { password: 'passwd', license: {}, serverUrl: 'https://locahost:8065', + serverDisplayName: 'Test Server', theme: Preferences.THEMES.denim, }; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 2079340e9..9411ab9aa 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -12,12 +12,13 @@ import CompassIcon from '@components/compass_icon'; import {Device, Preferences, Screens} from '@constants'; import NavigationConstants from '@constants/navigation'; import EphemeralStore from '@store/ephemeral_store'; -import {changeOpacity} from '@utils/theme'; +import {changeOpacity, setNavigatorStyles} from '@utils/theme'; import type {LaunchProps} from '@typings/launch'; const {MattermostManaged} = NativeModules; const isRunningInSplitView = MattermostManaged.isRunningInSplitView; +const appearanceControlledScreens = [Screens.SERVER, Screens.LOGIN, Screens.LOGIN_OPTIONS, Screens.FORGOT_PASSWORD, Screens.MFA]; Navigation.setDefaultOptions({ layout: { @@ -27,6 +28,19 @@ Navigation.setDefaultOptions({ }, }); +Appearance.addChangeListener(() => { + const theme = getThemeFromState(); + const screens = EphemeralStore.getAllNavigationComponents(); + if (screens.includes(Screens.SERVER)) { + for (const screen of screens) { + if (appearanceControlledScreens.includes(screen)) { + Navigation.updateProps(screen, {theme}); + setNavigatorStyles(screen, theme); + } + } + } +}); + function getThemeFromState() { if (EphemeralStore.theme) { return EphemeralStore.theme; diff --git a/app/screens/server/background.tsx b/app/screens/server/background.tsx new file mode 100644 index 000000000..a9c45385b --- /dev/null +++ b/app/screens/server/background.tsx @@ -0,0 +1,550 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import React from 'react'; +import Svg, { + Path, + G, + Defs, + LinearGradient, + Stop, +} from 'react-native-svg'; + +import {useIsTablet} from '@hooks/device'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + backgroundGraphic: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.06), + position: 'absolute', + flex: 1, + }, +})); + +const Background = ({ + theme, +}: Props) => { + const styles = getStyleSheet(theme); + + const isTablet = useIsTablet(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {isTablet && + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + + + + + + + + + + + + ); +}; + +export default Background; diff --git a/app/screens/server/form.tsx b/app/screens/server/form.tsx new file mode 100644 index 000000000..cde00c41c --- /dev/null +++ b/app/screens/server/form.tsx @@ -0,0 +1,217 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {MutableRefObject, useCallback, useEffect, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {ActivityIndicator, Platform, useWindowDimensions, View} from 'react-native'; +import Button from 'react-native-button'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; + +import FormattedText from '@app/components/formatted_text'; +import FloatingTextInput, {FloatingTextInputRef} from '@components/floating_text_input_label'; +import {useIsTablet} from '@hooks/device'; +import {t} from '@i18n'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + buttonDisabled: boolean; + connecting: boolean; + displayName?: string; + displayNameError?: string; + handleConnect: () => void; + handleDisplayNameTextChanged: (text: string) => void; + handleUrlTextChanged: (text: string) => void; + keyboardAwareRef: MutableRefObject; + theme: Theme; + url?: string; + urlError?: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + formContainer: { + alignItems: 'center', + maxWidth: 600, + width: '100%', + paddingHorizontal: 20, + }, + enterServer: { + marginBottom: 24, + }, + fullWidth: { + width: '100%', + }, + error: { + marginBottom: 18, + }, + chooseText: { + alignSelf: 'flex-start', + color: changeOpacity(theme.centerChannelColor, 0.64), + marginTop: 8, + ...typography('Body', 75, 'Regular'), + }, + connectButton: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + width: '100%', + marginTop: 32, + marginLeft: 20, + marginRight: 20, + padding: 15, + }, + connectingIndicator: { + marginRight: 10, + }, +})); + +const ServerForm = ({ + buttonDisabled, + connecting, + displayName = '', + displayNameError, + handleConnect, + handleDisplayNameTextChanged, + handleUrlTextChanged, + keyboardAwareRef, + theme, + url = '', + urlError, +}: Props) => { + const {formatMessage} = useIntl(); + const isTablet = useIsTablet(); + const dimensions = useWindowDimensions(); + const displayNameRef = useRef(null); + const urlRef = useRef(null); + const styles = getStyleSheet(theme); + + const focus = () => { + if (Platform.OS === 'ios') { + let offsetY = 160; + if (isTablet) { + const {width, height} = dimensions; + const isLandscape = width > height; + offsetY = isLandscape ? 230 : 100; + } + requestAnimationFrame(() => { + keyboardAwareRef.current?.scrollToPosition(0, offsetY); + }); + } + }; + + const onBlur = useCallback(() => { + if (Platform.OS === 'ios' && isTablet && !urlRef.current?.isFocused() && !displayNameRef.current?.isFocused()) { + keyboardAwareRef.current?.scrollToPosition(0, 0); + } + }, []); + + const onFocus = useCallback(() => { + focus(); + }, [dimensions]); + + const onUrlSubmit = useCallback(() => { + displayNameRef.current?.focus(); + }, []); + + useEffect(() => { + if (Platform.OS === 'ios' && isTablet) { + if (urlRef.current?.isFocused() || displayNameRef.current?.isFocused()) { + focus(); + } else { + keyboardAwareRef.current?.scrollToPosition(0, 0); + } + } + }, [dimensions, isTablet]); + + let styleButtonText = buttonTextStyle(theme, 'lg', 'primary', 'default'); + let styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); + + let buttonID = t('mobile.components.select_server_view.connect'); + let buttonText = 'Connect'; + let buttonIcon; + + if (connecting) { + buttonID = t('mobile.components.select_server_view.connecting'); + buttonText = 'Connecting'; + buttonIcon = ( + + ); + } else if (buttonDisabled) { + styleButtonText = buttonTextStyle(theme, 'lg', 'primary', 'disabled'); + styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary', 'disabled'); + } + + return ( + + + + + + + + + + + ); +}; + +export default ServerForm; diff --git a/app/screens/server/header.tsx b/app/screens/server/header.tsx new file mode 100644 index 000000000..f27a44b13 --- /dev/null +++ b/app/screens/server/header.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@app/components/formatted_text'; +import {useIsTablet} from '@hooks/device'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + textContainer: { + marginBottom: 32, + maxWidth: 600, + width: '100%', + paddingHorizontal: 20, + }, + welcome: { + marginTop: 12, + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Heading', 400, 'SemiBold'), + }, + connect: { + width: 270, + letterSpacing: -1, + color: theme.mentionColor, + marginVertical: 12, + ...typography('Heading', 1000, 'SemiBold'), + }, + connectTablet: { + width: undefined, + }, + description: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 100, 'Regular'), + }, +})); + +const ServerHeader = ({theme}: Props) => { + const isTablet = useIsTablet(); + const styles = getStyleSheet(theme); + + return ( + + + + + + ); +}; + +export default ServerHeader; diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 69cdee4b6..23456c8af 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -4,30 +4,30 @@ import {useManagedConfig, ManagedConfig} from '@mattermost/react-native-emm'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import { - ActivityIndicator, EventSubscription, Image, Keyboard, KeyboardAvoidingView, - Platform, StyleSheet, TextInput, TouchableWithoutFeedback, View, -} from 'react-native'; -import Button from 'react-native-button'; -import {Navigation, NavigationFunctionComponent} from 'react-native-navigation'; +import {Alert, Platform, View} from 'react-native'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; +import {Navigation} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; import {doPing} from '@actions/remote/general'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import LocalConfig from '@assets/config.json'; +import ClientError from '@client/rest/error'; import AppVersion from '@components/app_version'; -import ErrorText from '@components/error_text'; -import FormattedText from '@components/formatted_text'; import {Screens} from '@constants'; +import {t} from '@i18n'; import NetworkManager from '@init/network_manager'; import {goToScreen} from '@screens/navigation'; import {DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch'; +import {getErrorMessage} from '@utils/client_error'; import {isMinimumServerVersion} from '@utils/helpers'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {getServerUrlAfterRedirect, isValidUrl, sanitizeUrl} from '@utils/url'; -import type ClientError from '@client/rest/error'; +import Background from './background'; +import ServerForm from './form'; +import ServerHeader from './header'; interface ServerProps extends LaunchProps { componentId: string; @@ -36,24 +36,75 @@ interface ServerProps extends LaunchProps { let cancelPing: undefined | (() => void); -const Server: NavigationFunctionComponent = ({componentId, extra, launchType, launchError, theme}: ServerProps) => { - // TODO: If we have LaunchProps, ensure they get passed along to subsequent screens - // so that they are eventually accessible in the Channel screen. +const defaultServerUrlMessage = { + id: t('mobile.server_url.empty'), + defaultMessage: 'Please enter a valid server URL', +}; +const Server = ({componentId, extra, launchType, launchError, theme}: ServerProps) => { const intl = useIntl(); const managedConfig = useManagedConfig(); - const input = useRef(null); + const keyboardAwareRef = useRef(); const [connecting, setConnecting] = useState(false); - const initialError = launchError && launchType === LaunchType.Notification ? intl.formatMessage({ - id: 'mobile.launchError.notification', - defaultMessage: 'Did not find a server for this notification', - }) : undefined; - const [error, setError] = useState|string|undefined>(initialError); - + const [displayName, setDisplayName] = useState(''); + const [buttonDisabled, setButtonDisabled] = useState(true); const [url, setUrl] = useState(''); + const [urlError, setUrlError] = useState(undefined); const styles = getStyleSheet(theme); const {formatMessage} = intl; + useEffect(() => { + let serverUrl = managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; + let autoconnect = managedConfig?.allowOtherServers === 'false' || LocalConfig.AutoSelectServerUrl; + + if (launchType === LaunchType.DeepLink) { + const deepLinkServerUrl = (extra as DeepLinkWithData).data?.serverUrl; + if (managedConfig) { + autoconnect = (managedConfig.allowOtherServers === 'false' && managedConfig.serverUrl === deepLinkServerUrl); + if (managedConfig.serverUrl !== deepLinkServerUrl || launchError) { + Alert.alert('', intl.formatMessage({ + id: 'mobile.server_url.deeplink.emm.denied', + defaultMessage: 'This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server', + })); + } + } else { + autoconnect = true; + serverUrl = deepLinkServerUrl; + } + } + + if (serverUrl) { + // If a server Url is set by the managed or local configuration, use it. + setUrl(serverUrl); + + if (autoconnect) { + // If no other servers are allowed or the local config for AutoSelectServerUrl is set, attempt to connect + handleConnect(managedConfig?.serverUrl || LocalConfig.DefaultServerUrl); + } + } + }, []); + + useEffect(() => { + if (url && displayName) { + setButtonDisabled(false); + } else { + setButtonDisabled(true); + } + }, [url, displayName]); + + useEffect(() => { + const listener = { + componentDidAppear: () => { + if (url) { + NetworkManager.invalidateClient(url); + } + }, + }; + const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); + + return () => unsubscribe.remove(); + }, [componentId, url]); + const displayLogin = (serverUrl: string, config: ClientConfig, license: ClientLicense) => { const samlEnabled = config.EnableSaml === 'true' && license.IsLicensed === 'true' && license.SAML === 'true'; const gitlabEnabled = config.EnableSignUpWithGitLab === 'true'; @@ -77,6 +128,7 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la const passProps = { config, + displayName, extra, launchError, launchType, @@ -98,40 +150,57 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la setUrl(serverUrl); }; - const handleConnect = preventDoubleTap((manualUrl?: string) => { + const handleConnect = preventDoubleTap(async (manualUrl?: string) => { + if (buttonDisabled) { + return; + } + if (connecting && cancelPing) { cancelPing(); return; } - let serverUrl = typeof manualUrl === 'string' ? manualUrl : url; + const serverUrl = typeof manualUrl === 'string' ? manualUrl : url; if (!serverUrl || serverUrl.trim() === '') { - setError(intl.formatMessage({ - id: 'mobile.server_url.empty', - defaultMessage: 'Please enter a valid server URL', - })); - + setUrlError(formatMessage(defaultServerUrlMessage)); return; } - serverUrl = sanitizeUrl(serverUrl); - if (!isValidUrl(serverUrl)) { - setError(formatMessage({ - id: 'mobile.server_url.invalid_format', - defaultMessage: 'URL must start with http:// or https://', - })); - + if (!isServerUrlValid(serverUrl)) { return; } + // TODO: Query to see if there is a server with the same display name + // Set the error here and return + // Elias to add this change in a Later PR. as soon as this one goes in + pingServer(serverUrl); }); + const handleDisplayNameTextChanged = useCallback((text: string) => { + setDisplayName(text); + }, []); + + const handleUrlTextChanged = useCallback((text: string) => { + setUrlError(undefined); + setUrl(text); + }, []); + + const isServerUrlValid = (serverUrl?: string) => { + const testUrl = sanitizeUrl(serverUrl ?? url); + if (!isValidUrl(testUrl)) { + setUrlError(intl.formatMessage({ + id: 'mobile.server_url.invalid_format', + defaultMessage: 'URL must start with http:// or https://', + })); + return false; + } + return true; + }; + const pingServer = async (pingUrl: string, retryWithHttp = true) => { let canceled = false; setConnecting(true); - setError(undefined); - cancelPing = () => { // We should not need this once we have the cancelable network-client library canceled = true; @@ -151,16 +220,17 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la const nurl = serverUrl.replace('https:', 'http:'); pingServer(nurl, false); } else { - setError(result.error as ClientError); + setUrlError(getErrorMessage(result.error as ClientError, intl)); setConnecting(false); } - + setButtonDisabled(true); return; } const data = await fetchConfigAndLicense(serverUrl, true); if (data.error) { - setError(data.error as ClientError); + setButtonDisabled(true); + setUrlError(getErrorMessage(data.error as ClientError, intl)); setConnecting(false); return; } @@ -168,236 +238,60 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la displayLogin(serverUrl, data.config!, data.license!); }; - const blur = useCallback(() => { - input.current?.blur(); - }, []); - - const handleTextChanged = useCallback((text: string) => { - setUrl(text); - }, []); - - useEffect(() => { - let listener: EventSubscription; - if (Platform.OS === 'android') { - listener = Keyboard.addListener('keyboardDidHide', blur); - } - - return () => listener?.remove(); - }, []); - - useEffect(() => { - let serverUrl = managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; - let autoconnect = managedConfig?.allowOtherServers === 'false' || LocalConfig.AutoSelectServerUrl; - - if (launchType === LaunchType.DeepLink) { - const deepLinkServerUrl = (extra as DeepLinkWithData).data?.serverUrl; - if (managedConfig) { - autoconnect = (managedConfig.allowOtherServers === 'false' && managedConfig.serverUrl === deepLinkServerUrl); - if (managedConfig.serverUrl !== deepLinkServerUrl || launchError) { - setError(intl.formatMessage({ - id: 'mobile.server_url.deeplink.emm.denied', - defaultMessage: 'This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server', - })); - } - } else { - autoconnect = true; - serverUrl = deepLinkServerUrl; - } - } - - if (serverUrl) { - // If a server Url is set by the managed or local configuration, use it. - setUrl(serverUrl); - - if (autoconnect) { - // If no other servers are allowed or the local config for AutoSelectServerUrl is set, attempt to connect - handleConnect(managedConfig?.serverUrl || LocalConfig.DefaultServerUrl); - } - } - }, []); - - useEffect(() => { - const listener = { - componentDidAppear: () => { - if (url) { - NetworkManager.invalidateClient(url); - } - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); - - return () => unsubscribe.remove(); - }, [componentId, url]); - - let buttonIcon; - let buttonText; - - if (connecting) { - buttonIcon = ( - - ); - buttonText = ( - - ); - } else { - buttonText = ( - - ); - } - - const inputDisabled = managedConfig.allowOtherServers === 'false' || connecting; - const inputStyle = [styles.inputBox]; - if (inputDisabled) { - inputStyle.push(styles.disabledInput); - } - return ( - - + + - - - + - - - - - {Boolean(error) && - - - - } - - + // @ts-expect-error legacy ref + ref={keyboardAwareRef} + scrollToOverflowEnabled={true} + style={styles.flex} + > + + + - - + + ); }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ appInfo: { - color: theme.centerChannelColor, - }, - container: { - flex: 1, - backgroundColor: theme.centerChannelBg, + color: changeOpacity(theme.centerChannelColor, 0.56), }, flex: { flex: 1, }, - formContainer: { - flex: 1, - flexDirection: 'column', + scrollContainer: { justifyContent: 'center', alignItems: 'center', - paddingRight: 15, - paddingLeft: 15, - }, - disabledInput: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - }, - connectButton: { - borderRadius: 3, - borderColor: theme.buttonBg, - alignItems: 'center', - borderWidth: 1, - alignSelf: 'stretch', - marginTop: 10, - padding: 15, - }, - connectingIndicator: { - marginRight: 5, - }, - inputBox: { - fontSize: 16, - height: 45, - borderColor: theme.centerChannelColor, - borderWidth: 1, - marginTop: 5, - marginBottom: 5, - paddingLeft: 10, - alignSelf: 'stretch', - borderRadius: 3, - color: theme.centerChannelColor, - }, - logo: { - height: 72, - resizeMode: 'contain', - }, - header: { - color: theme.centerChannelColor, - textAlign: 'center', - marginTop: 15, - marginBottom: 15, - fontSize: 20, - }, - connectText: { - textAlign: 'center', - color: theme.buttonBg, - fontSize: 17, + flex: 1, }, })); diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index e44f70477..ef2cbc267 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -19,10 +19,11 @@ interface SSOProps extends LaunchProps { config: Partial; license: Partial; ssoType: string; + serverDisplayName: string; theme: Partial; } -const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme}: SSOProps) => { +const SSO = ({config, extra, launchError, launchType, serverDisplayName, serverUrl, ssoType, theme}: SSOProps) => { const managedConfig = useManagedConfig(); const [loginError, setLoginError] = useState(''); @@ -73,7 +74,7 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme} }; const doSSOLogin = async (bearerToken: string, csrfToken: string) => { - const result: LoginActionResponse = await ssoLogin(serverUrl!, config.DiagnosticId!, bearerToken, csrfToken); + const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken); if (result?.error && result.failed) { onLoadEndError(result.error); return; diff --git a/app/screens/sso/sso.test.tsx b/app/screens/sso/sso.test.tsx index 4ef96694e..c1872d697 100644 --- a/app/screens/sso/sso.test.tsx +++ b/app/screens/sso/sso.test.tsx @@ -29,6 +29,7 @@ describe('SSO', () => { ssoType: 'GITLAB', theme: Preferences.THEMES.denim, serverUrl: 'https://locahost:8065', + serverDisplayName: 'Test Server', launchType: LaunchType.Normal, }; diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 879ddc95a..b9bbcb68d 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -42,6 +42,8 @@ class EphemeralStore { this.navigationModalStack = []; } + getAllNavigationComponents = () => this.allNavigationComponentIds; + getNavigationTopComponentId = () => { return this.navigationComponentIdStack[0]; } diff --git a/app/utils/buttonStyles.ts b/app/utils/buttonStyles.ts new file mode 100644 index 000000000..21987691b --- /dev/null +++ b/app/utils/buttonStyles.ts @@ -0,0 +1,423 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {StyleProp, StyleSheet, TextStyle, ViewStyle} from 'react-native'; + +import {blendColors, changeOpacity} from '@utils/theme'; + +type ButtonSize = 'xs' | 's' | 'm' | 'lg' +type ButtonEmphasis = 'primary' | 'secondary' | 'tertiary' | 'link' +type ButtonType = 'default' | 'destructive' | 'inverted' | 'disabled' +type ButtonState = 'default' | 'hover' | 'active' | 'focus' + +type ButtonSizes = { + [key in ButtonSize]: ViewStyle +} +type BackgroundStyles = { + [key in ButtonEmphasis]: { + [ke in ButtonType]: { + [k in ButtonState]: ViewStyle + } + } +} + +/** + * Returns the appropriate Style object for + * + * + * @param theme + * @param size + * @param emphasis + * @param type + * @param state + * @returns + */ +export const buttonBackgroundStyle = ( + theme: Theme, + size: ButtonSize = 'm', + emphasis: ButtonEmphasis = 'primary', + type: ButtonType = 'default', + state: ButtonState = 'default', +): StyleProp => { + const styles = StyleSheet.create({ + main: { + flex: 0, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 4, + }, + fullWidth: { + width: '100%', + }, + }); + + const backgroundStyles: BackgroundStyles = { + primary: { + default: { + default: { + backgroundColor: theme.buttonBg, + }, + hover: { + backgroundColor: blendColors(theme.buttonBg, '#000000', 0.08), + }, + active: { + backgroundColor: blendColors(theme.buttonBg, '#000000', 0.16), + }, + focus: { + backgroundColor: theme.buttonBg, + borderColor: changeOpacity('#FFFFFF', 0.32), + borderWidth: 2, + }, + }, + destructive: { + default: { + backgroundColor: theme.errorTextColor, + }, + hover: { + backgroundColor: blendColors(theme.errorTextColor, '#000000', 0.08), + }, + active: { + backgroundColor: blendColors(theme.errorTextColor, '#000000', 0.16), + }, + focus: { + backgroundColor: theme.errorTextColor, + borderColor: changeOpacity('#FFFFFF', 0.32), + borderWidth: 2, + }, + }, + inverted: { + default: { + backgroundColor: theme.buttonColor, + }, + hover: { + backgroundColor: blendColors(theme.buttonColor, '#000000', 0.08), + }, + active: { + backgroundColor: blendColors(theme.buttonColor, '#000000', 0.16), + }, + focus: { + backgroundColor: theme.buttonColor, + borderColor: changeOpacity('#FFFFFF', 0.32), + borderWidth: 2, + }, + }, + disabled: { + default: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + hover: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + }, + active: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + }, + focus: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + }, + }, + }, + secondary: { + default: { + default: { + backgroundColor: changeOpacity(theme.buttonBg, 0), + borderColor: theme.buttonBg, + borderWidth: 1, + }, + hover: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderColor: theme.buttonBg, + borderWidth: 1, + }, + active: { + backgroundColor: changeOpacity(theme.buttonBg, 0.16), + borderColor: theme.buttonBg, + borderWidth: 1, + }, + focus: { + backgroundColor: changeOpacity(theme.buttonBg, 0), + borderColor: theme.sidebarTextActiveBorder, + borderWidth: 2, + }, + }, + destructive: { + default: { + backgroundColor: changeOpacity(theme.errorTextColor, 0), + borderColor: theme.errorTextColor, + borderWidth: 1, + }, + hover: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.08), + borderColor: theme.errorTextColor, + borderWidth: 1, + }, + active: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.16), + borderColor: theme.errorTextColor, + borderWidth: 1, + }, + focus: { + backgroundColor: changeOpacity(theme.errorTextColor, 0), + borderColor: changeOpacity(theme.errorTextColor, 0.68), // @to-do; needs 32% white? + borderWidth: 2, + }, + }, + inverted: { + default: { + backgroundColor: changeOpacity(theme.buttonColor, 0), + borderColor: theme.buttonColor, + borderWidth: 1, + }, + hover: { + backgroundColor: changeOpacity(theme.buttonColor, 0.08), + borderColor: theme.buttonColor, + borderWidth: 1, + }, + active: { + backgroundColor: changeOpacity(theme.buttonColor, 0.16), + borderColor: theme.buttonColor, + borderWidth: 1, + }, + focus: { + backgroundColor: changeOpacity(theme.buttonColor, 0), + borderColor: theme.sidebarTextActiveBorder, + borderWidth: 2, + }, + }, + disabled: { + default: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + borderColor: changeOpacity(theme.centerChannelColor, 0.32), + borderWidth: 1, + }, + hover: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + borderColor: changeOpacity(theme.centerChannelColor, 0.32), + borderWidth: 1, + }, + active: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + borderColor: changeOpacity(theme.centerChannelColor, 0.32), + borderWidth: 1, + }, + focus: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0), + borderColor: changeOpacity(theme.centerChannelColor, 0.32), + borderWidth: 1, + }, + }, + }, + tertiary: { + default: { + default: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + }, + hover: { + backgroundColor: changeOpacity(theme.buttonBg, 0.12), + }, + active: { + backgroundColor: changeOpacity(theme.buttonBg, 0.16), + }, + focus: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderColor: theme.sidebarTextActiveBorder, + borderWidth: 2, + }, + }, + destructive: { + default: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.08), + }, + hover: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.12), + }, + active: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.16), + }, + focus: { + backgroundColor: changeOpacity(theme.errorTextColor, 0.08), + borderColor: changeOpacity(theme.errorTextColor, 0.68), // @to-do; needs 32% white? + borderWidth: 2, + }, + }, + inverted: { + default: { + backgroundColor: changeOpacity('#FFFFFF', 0.12), + }, + hover: { + backgroundColor: changeOpacity('#FFFFFF', 0.16), + }, + active: { + backgroundColor: changeOpacity('#FFFFFF', 0.24), + }, + focus: { + backgroundColor: changeOpacity('#FFFFFF', 0.08), + borderColor: theme.sidebarTextActiveBorder, // @to-do; needs 32% white? + borderWidth: 2, + }, + }, + disabled: { + default: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + hover: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + active: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + focus: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + }, + }, + link: { + default: { + default: { + backgroundColor: 'transparent', + }, + hover: { + backgroundColor: 'transparent', + }, + active: { + backgroundColor: 'transparent', + }, + focus: { + backgroundColor: 'transparent', + }, + }, + inverted: { + default: { + backgroundColor: 'transparent', + }, + hover: { + backgroundColor: 'transparent', + }, + active: { + backgroundColor: 'transparent', + }, + focus: { + backgroundColor: 'transparent', + }, + }, + disabled: { + default: { + backgroundColor: 'transparent', + }, + hover: { + backgroundColor: 'transparent', + }, + active: { + backgroundColor: 'transparent', + }, + focus: { + backgroundColor: 'transparent', + }, + }, + destructive: { + default: { + backgroundColor: 'transparent', + }, + hover: { + backgroundColor: 'transparent', + }, + active: { + backgroundColor: 'transparent', + }, + focus: { + backgroundColor: 'transparent', + }, + }, + }, + }; + + const sizes: ButtonSizes = StyleSheet.create({ + xs: { + height: 24, + paddingVertical: 6, + paddingHorizontal: 10, + }, + s: { + height: 32, + paddingVertical: 10, + paddingHorizontal: 16, + }, + m: { + height: 40, + paddingVertical: 12, + paddingHorizontal: 20, + }, + lg: { + height: 48, + paddingVertical: 14, + paddingHorizontal: 24, + }, + }); + + return StyleSheet.create([styles.main, sizes[size], backgroundStyles[emphasis][type][state]]); +}; + +/** + * Returns the appropriate TextStyle for the object inside the button. + * + * + * @param theme + * @param size + * @param emphasis + * @param type + * @returns + */ +export const buttonTextStyle = ( + theme: Theme, + size: ButtonSize = 'm', + emphasis: ButtonEmphasis = 'primary', + type: ButtonType = 'default', +): StyleProp => { + // Color + let color: string = theme.buttonColor; + + if (type === 'disabled') { + color = changeOpacity(theme.centerChannelColor, 0.32); + } + + if ((type === 'destructive' && emphasis !== 'primary')) { + color = theme.errorTextColor; + } + + if ((type === 'inverted' && emphasis === 'primary') || + (type !== 'inverted' && emphasis !== 'primary')) { + color = theme.buttonBg; + } + + const styles = StyleSheet.create({ + main: { + fontFamily: 'OpenSans-SemiBold', + fontWeight: '600', + }, + underline: { + textDecorationLine: 'underline', + }, + }); + + const sizes = StyleSheet.create({ + xs: { + fontSize: 11, + lineHeight: 10, + letterSpacing: 0.02, + }, + s: { + fontSize: 12, + lineHeight: 11, + }, + m: { + fontSize: 14, + lineHeight: 14, + }, + lg: { + fontSize: 16, + lineHeight: 18, + }, + }); + + return StyleSheet.create([styles.main, sizes[size], {color}]); +}; + diff --git a/app/utils/client_error.ts b/app/utils/client_error.ts index a3bc63ddb..b7103e25f 100644 --- a/app/utils/client_error.ts +++ b/app/utils/client_error.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl'; + import {cleanUrlForLogging} from '@utils/url'; export class ClientError extends Error { @@ -25,3 +27,14 @@ export class ClientError extends Error { Object.defineProperty(this, 'message', {enumerable: true}); } } + +export const getErrorMessage = (error: Error | string, intl: IntlShape) => { + const intlError = error as ClientError; + if (intlError.intl) { + return intl.formatMessage(intlError.intl); + } else if (error instanceof Error) { + return error.message; + } + + return error; +}; diff --git a/app/utils/typography.ts b/app/utils/typography.ts new file mode 100644 index 000000000..812bf5f35 --- /dev/null +++ b/app/utils/typography.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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; + +const fontFamily = StyleSheet.create({ + OpenSans: { + fontFamily: 'OpenSans', + }, + Metropolis: { + fontFamily: 'Metropolis', + }, +}); + +const fontStyle = StyleSheet.create({ + SemiBold: { + fontWeight: '600', + }, + Regular: { + fontWeight: '400', + }, + Light: { + fontWeight: '300', + }, +}); + +const fontSize = StyleSheet.create({ + 1000: { + fontSize: 40, + lineHeight: 48, + letterSpacing: -0.02, + }, + 900: { + fontSize: 36, + lineHeight: 44, + letterSpacing: -0.02, + }, + 800: { + fontSize: 32, + lineHeight: 40, + letterSpacing: -0.01, + }, + 700: { + fontSize: 28, + lineHeight: 36, + }, + 600: { + fontSize: 25, + lineHeight: 30, + }, + 500: { + fontSize: 22, + lineHeight: 28, + }, + 400: { + fontSize: 20, + lineHeight: 28, + }, + 300: { + fontSize: 18, + lineHeight: 24, + }, + 200: { + fontSize: 16, + lineHeight: 24, + }, + 100: { + fontSize: 14, + lineHeight: 20, + }, + 75: { + fontSize: 12, + lineHeight: 16, + }, + 50: { + fontSize: 11, + lineHeight: 16, + }, + 25: { + fontSize: 10, + lineHeight: 16, + }, +}); + +type Typography = Pick + +export const typography = ( + type: FontTypes = 'Body', + size: FontSizes = 100, + style?: FontStyles, +): Typography => { + // Style defaults + if (!style) { + // eslint-disable-next-line no-param-reassign + style = type === 'Heading' ? 'SemiBold' : 'Regular'; + } + + const font = type === 'Heading' && size > 100 ? fontFamily.Metropolis : fontFamily.OpenSans; + + const typeStyle = { + ...font, + ...fontSize[size], + ...fontStyle[style], + }; + + /* + * Use the appropriate font-file (i.e. OpenSans-SemiBold) + * This switch statement can be removed when Android supports font-weight strings + */ + switch (typeStyle.fontWeight) { + case '300': + typeStyle.fontFamily = `${typeStyle.fontFamily}-${style}`; + break; + case '400': + typeStyle.fontFamily = `${typeStyle.fontFamily}`; + break; + case '600': + typeStyle.fontFamily = `${typeStyle.fontFamily}-${style}`; + break; + } + + return typeStyle; +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 9d5461bb0..5de99da65 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -151,10 +151,14 @@ "mobile.add_team.create_team": "Create a New Team", "mobile.add_team.join_team": "Join Another Team", "mobile.components.select_server_view.connect": "Connect", - "mobile.components.select_server_view.connecting": "Connecting...", + "mobile.components.select_server_view.connecting": "Connecting", + "mobile.components.select_server_view.displayHelp": "Choose a display name for your server", + "mobile.components.select_server_view.displayName": "Display Name", "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", + "mobile.components.select_server_view.msg_connect": "Let’s Connect to a Server", + "mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL", + "mobile.components.select_server_view.msg_welcome": "Welcome", "mobile.components.select_server_view.proceed": "Proceed", - "mobile.components.select_server_view.siteUrlPlaceholder": "https://mattermost.example.com", "mobile.custom_status.choose_emoji": "Choose an emoji", "mobile.custom_status.clear_after": "Clear After", "mobile.custom_status.clear_after.title": "Clear Custom Status After", @@ -171,7 +175,6 @@ "mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", "mobile.error_handler.title": "Unexpected error occurred", "mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.", - "mobile.launchError.notification": "Did not find a server for this notification", "mobile.link.error.text": "Unable to open the link.", "mobile.link.error.title": "Error", "mobile.login_options.choose_title": "Choose your login method", @@ -226,6 +229,8 @@ "mobile.routes.user_profile": "Profile", "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", + "mobile.server_ping_failed": "Cannot connect to the server.", + "mobile.server_requires_client_certificate": "Server requires client certificate for authentication.", "mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.", "mobile.server_upgrade.button": "OK", "mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n", diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 3cbe92d41..63e63e23e 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -252,6 +252,7 @@ export type LoginArgs = { loginId: string; mfaToken?: string; password: string; + serverDisplayName: string; }; export type ServerUrlChangedArgs = {