From a5254992d31d458b40bb56a70809aab6809b3734 Mon Sep 17 00:00:00 2001 From: Jason Frerich Date: Fri, 29 Oct 2021 17:14:55 -0500 Subject: [PATCH] Draft - Add Server Screen Gekidou Updates (#5663) * initial checkin * all text is added * add fonts * wip * get fonts from differnt site suggestion from matt * use actual apostrophe * add react-native-paper dependency * wip * add project info for metropolis fonts * correct the display name help text alignment with stretch use paper theme to color and make the the placeholder opaque remove placeholder prop because spec wants the box to be empty on focus * fix Metropolis font reference * Add more styling. Clean up code later * input box text theme color * separate padding for two textinput boxes share the input theme * Use ActivityIndicator from react-native-paper * wrap formatText components in a view so they scroll up inline with the text inputs and button * clean up styles section. no empty lines and no comments * Use Colors library instead of harcoding #FFFFFF * Move error message into helper directly under the server url input box * need to handle the error so it can be presented in the modal * set state for button is disabled or not reset errors before every connect attempt * when url is not valid show the error message and disable the connect button until some text in display or the url input changes * s/generic/default/ * group formatted messages together * Add icon to the error message fix clipped helper messages * when urlError is not '', make the input border red after connecting creates an error, typing in the input boxes will * enable the connect button * reset the error messages * change outline from red to blue * After connecting and the server url has an error, allow the user to click the display input and keep the url border red. The only time the red border and error reset is when the user types in that field again * remove duplicate button component. only get the prop values and construct after all values defined * use correct icon and add spacing between icon and text * fix styling for Android New styling from figma * initial add of background svg. RHS is off a bit * sync with gekidou changes * sync with gekidou * sync with gekidou * sync with gekidou * sync with gekidou * use new FloatingTextInput component * Added widgets/text_settings * wip * wip * Update index.tsx * work in progress * fix text settings component * fix text settings component * crash fix * code clean up * Update index.tsx * Fix testSettings * Wrap with View so the text moves with the text inputs when keyboard appears * update styling when user starts typing in server url after a failed connect, dismiss the error * use utils/buttonStyles * leave as it was to minimize irrelevant PR diffs * nit * Align all components Add vertical margin to styles Add subcontainer and container props to text_setting component Use FormattedText for choosing a display name text instead of text_setting helper text * revert midnight change * reset the connecing error when a user starts modifying the server url * formatting remove svg repalce with * fix lint * remove svg file * Listening for appearance to set the theme for non connected screens * Pass the theme as a prop for TextSettings * Fix Server screen layout and pass the displayName for DB creation * Tablet layout * Persist keyboard on tap * Change position & opacity for app version * added background SVG * Fix non theme control screens status bar color * Split Server screen into smaller components * fixed svg background layout for tablet * Bring back form & header and remove some extra background color and styles * Remove duplicate font reference in Info.plist * Final UI tweaks * Fix error display on url field * margin bottom to app version * Add ClientError utility to extract error * update snapshot Co-authored-by: Elias Nahum Co-authored-by: Avinash Lingaloo Co-authored-by: Matthew Birtch --- android/app/src/main/AndroidManifest.xml | 2 +- app/actions/remote/general.ts | 18 +- app/actions/remote/session.ts | 6 +- .../__snapshots__/index.test.tsx.snap | 6 +- app/components/app_version/index.tsx | 6 +- .../floating_text_input_label/index.tsx | 37 +- app/screens/login/index.tsx | 7 +- app/screens/login_options/index.tsx | 7 +- app/screens/mfa/index.tsx | 5 +- app/screens/mfa/mfa.test.tsx | 1 + app/screens/navigation.ts | 16 +- app/screens/server/background.tsx | 550 ++++++++++++++++++ app/screens/server/form.tsx | 217 +++++++ app/screens/server/header.tsx | 72 +++ app/screens/server/index.tsx | 398 +++++-------- app/screens/sso/index.tsx | 5 +- app/screens/sso/sso.test.tsx | 1 + app/store/ephemeral_store.ts | 2 + app/utils/buttonStyles.ts | 423 ++++++++++++++ app/utils/client_error.ts | 13 + app/utils/typography.ts | 128 ++++ assets/base/i18n/en.json | 11 +- types/database/database.d.ts | 1 + 23 files changed, 1642 insertions(+), 290 deletions(-) create mode 100644 app/screens/server/background.tsx create mode 100644 app/screens/server/form.tsx create mode 100644 app/screens/server/header.tsx create mode 100644 app/utils/buttonStyles.ts create mode 100644 app/utils/typography.ts 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 = {