From 5b9492356bea5e75047f0f78f87934b5feb68cba Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 17 Feb 2022 10:42:06 -0300 Subject: [PATCH] Gekidou MultiServers second part (#5963) * Edit Server display name * Lock iPhone to portrait and iPad to landscape * Create actions for app global to store device token and multi server tutorial * Add MutliServer tutorial on first use * WebSocket reconnection priority * have isRecordGlobalEqualToRaw to not check for value * Return early on edit server if error is found * Prepopulate server screen with last logged out server address and name * Add CompassIcon to circleCI asset generation --- .circleci/config.yml | 7 + app/actions/app/global.ts | 31 +++ app/actions/remote/session.ts | 4 +- app/components/tutorial_highlight/index.tsx | 57 +++++ app/components/tutorial_highlight/item.tsx | 57 +++++ .../tutorial_highlight/swipe_left.tsx | 62 ++++++ .../tutorial_highlight/swipe_left_hand.tsx | 38 ++++ app/constants/database.ts | 1 + app/constants/screens.ts | 2 + app/context/theme/index.tsx | 22 +- app/database/manager/__mocks__/index.ts | 10 + app/database/manager/index.ts | 16 +- .../app_data_operator/comparator/index.ts | 2 +- app/database/subscription/servers.ts | 2 +- app/init/global_event_handler.ts | 23 +- app/init/push_notifications.ts | 15 +- app/init/websocket_manager.ts | 70 +++++-- app/screens/edit_server/form.tsx | 198 ++++++++++++++++++ app/screens/edit_server/header.tsx | 55 +++++ app/screens/edit_server/index.tsx | 137 ++++++++++++ .../servers/servers_list/index.tsx | 3 +- .../servers/servers_list/server_item/index.ts | 33 +++ .../{index.tsx => server_item.tsx} | 80 +++++-- app/screens/index.tsx | 154 ++------------ app/screens/navigation.ts | 6 +- app/screens/server/index.tsx | 4 +- app/utils/server/index.ts | 74 ++++--- app/utils/svg/index.ts | 31 +++ assets/base/i18n/en.json | 8 +- ios/Mattermost/Info.plist | 4 +- ios/Podfile.lock | 6 + package-lock.json | 29 ++- package.json | 2 + types/components/tutorial_highlight.d.ts | 9 + 34 files changed, 1012 insertions(+), 240 deletions(-) create mode 100644 app/actions/app/global.ts create mode 100644 app/components/tutorial_highlight/index.tsx create mode 100644 app/components/tutorial_highlight/item.tsx create mode 100644 app/components/tutorial_highlight/swipe_left.tsx create mode 100644 app/components/tutorial_highlight/swipe_left_hand.tsx create mode 100644 app/screens/edit_server/form.tsx create mode 100644 app/screens/edit_server/header.tsx create mode 100644 app/screens/edit_server/index.tsx create mode 100644 app/screens/home/channel_list/servers/servers_list/server_item/index.ts rename app/screens/home/channel_list/servers/servers_list/server_item/{index.tsx => server_item.tsx} (79%) create mode 100644 app/utils/svg/index.ts create mode 100644 types/components/tutorial_highlight.d.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index b753e3dcf..292774343 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,6 +83,13 @@ commands: - run: name: Generate assets command: node ./scripts/generate-assets.js + - run: + name: Compass Icons + environment: + COMPASS_ICONS: "node_modules/@mattermost/compass-icons/font/compass-icons.ttf" + command: | + cp "$COMPASS_ICONS" "assets/fonts/" + cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts" - save_cache: name: Save assets cache key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }} diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts new file mode 100644 index 000000000..81d83458b --- /dev/null +++ b/app/actions/app/global.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GLOBAL_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +export const storeDeviceToken = async (token: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.appDatabase?.operator; + + if (!operator) { + return {error: 'No App database found'}; + } + + return operator.handleGlobal({ + global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: token}], + prepareRecordsOnly, + }); +}; + +export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => { + const operator = DatabaseManager.appDatabase?.operator; + + if (!operator) { + return {error: 'No App database found'}; + } + + return operator.handleGlobal({ + global: [{id: GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, value: 'true'}], + prepareRecordsOnly, + }); +}; diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index afb5febd6..b5ddabbf1 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -153,7 +153,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo } }; -export const logout = async (serverUrl: string, skipServerLogout = false) => { +export const logout = async (serverUrl: string, skipServerLogout = false, removeServer = false) => { if (!skipServerLogout) { try { const client = NetworkManager.getClient(serverUrl); @@ -165,7 +165,7 @@ export const logout = async (serverUrl: string, skipServerLogout = false) => { } } - DeviceEventEmitter.emit(Events.SERVER_LOGOUT, serverUrl); + DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer}); }; export const sendPasswordResetEmail = async (serverUrl: string, email: string) => { diff --git a/app/components/tutorial_highlight/index.tsx b/app/components/tutorial_highlight/index.tsx new file mode 100644 index 000000000..5dc9775a8 --- /dev/null +++ b/app/components/tutorial_highlight/index.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {Modal, StyleSheet, useWindowDimensions, View} from 'react-native'; + +import HighlightItem from './item'; + +type Props = { + children?: React.ReactNode; + itemBounds: TutorialItemBounds; + itemBorderRadius?: number; + onDismiss: () => void; + onShow: () => void; +} + +const TutorialHighlight = ({children, itemBounds, itemBorderRadius, onDismiss, onShow}: Props) => { + const {width, height} = useWindowDimensions(); + const [visible, setIsVisible] = useState(false); + const isLandscape = width > height; + const supportedOrientations = isLandscape ? 'landscape' : 'portrait'; + + useEffect(() => { + const t = setTimeout(() => { + setIsVisible(true); + }, 500); + + return () => clearTimeout(t); + }, []); + + return ( + + + + {children} + + ); +}; + +export default TutorialHighlight; diff --git a/app/components/tutorial_highlight/item.tsx b/app/components/tutorial_highlight/item.tsx new file mode 100644 index 000000000..de98bb12a --- /dev/null +++ b/app/components/tutorial_highlight/item.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet} from 'react-native'; +import Svg, {ClipPath, Defs, G, Path, Rect} from 'react-native-svg'; +import tinyColor from 'tinycolor2'; + +import {useTheme} from '@context/theme'; +import {constructRectangularPathWithBorderRadius} from '@utils/svg'; + +type Props = { + borderRadius?: number; + height: number; + itemBounds: TutorialItemBounds; + onDismiss: () => void; + width: number; +} + +const HighlightItem = ({height, itemBounds, onDismiss, borderRadius = 0, width}: Props) => { + const theme = useTheme(); + const isDark = tinyColor(theme.centerChannelBg).isDark(); + + const pathD = useMemo(() => { + const parent = {startX: 0, startY: 0, endX: width, endY: height}; + return constructRectangularPathWithBorderRadius(parent, itemBounds, borderRadius); + }, [borderRadius, itemBounds, width]); + + return ( + + + + + + + + + + + ); +}; + +export default HighlightItem; diff --git a/app/components/tutorial_highlight/swipe_left.tsx b/app/components/tutorial_highlight/swipe_left.tsx new file mode 100644 index 000000000..505116f6a --- /dev/null +++ b/app/components/tutorial_highlight/swipe_left.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import HandSwipeLeft from './swipe_left_hand'; + +type Props = { + containerStyle?: StyleProp; + message: string; + style?: StyleProp; + textStyles?: StyleProp; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + view: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderRadius: 8, + height: 136, + padding: 16, + width: 247, + }, + text: { + ...typography('Heading', 200), + color: theme.centerChannelColor, + marginTop: 8, + paddingHorizontal: 12, + textAlign: 'center', + }, +})); + +const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + + + {message} + + + + ); +}; + +export default TutorialSwipeLeft; diff --git a/app/components/tutorial_highlight/swipe_left_hand.tsx b/app/components/tutorial_highlight/swipe_left_hand.tsx new file mode 100644 index 000000000..4030ca6f3 --- /dev/null +++ b/app/components/tutorial_highlight/swipe_left_hand.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +type Props = { + fillColor: string; +} + +function SwipeLeftHand({fillColor, ...props}: Props) { + return ( + + + + + + ); +} + +export default SwipeLeftHand; + diff --git a/app/constants/database.ts b/app/constants/database.ts index 8a3e63f0d..07e867f87 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -66,6 +66,7 @@ export const SYSTEM_IDENTIFIERS = { export const GLOBAL_IDENTIFIERS = { DEVICE_TOKEN: 'deviceToken', + MULTI_SERVER_TUTORIAL: 'multiServerTutorial', }; export default { diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 553bf17e2..d1b235161 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -14,6 +14,7 @@ export const CHANNEL_EDIT = 'ChannelEdit'; export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; export const CUSTOM_STATUS = 'CustomStatus'; export const EDIT_PROFILE = 'EditProfile'; +export const EDIT_SERVER = 'EditServer'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; @@ -44,6 +45,7 @@ export default { CUSTOM_STATUS_CLEAR_AFTER, CUSTOM_STATUS, EDIT_PROFILE, + EDIT_SERVER, FORGOT_PASSWORD, HOME, INTEGRATION_SELECTOR, diff --git a/app/context/theme/index.tsx b/app/context/theme/index.tsx index c0cdf7daf..e45e02b73 100644 --- a/app/context/theme/index.tsx +++ b/app/context/theme/index.tsx @@ -38,6 +38,15 @@ export function getDefaultThemeByAppearance(): Theme { export const ThemeContext = createContext(getDefaultThemeByAppearance()); const {Consumer, Provider} = ThemeContext; +const updateThemeIfNeeded = (theme: Theme) => { + if (theme !== EphemeralStore.theme) { + EphemeralStore.theme = theme; + requestAnimationFrame(() => { + setNavigationStackStyles(theme); + }); + } +}; + const ThemeProvider = ({currentTeamId, children, themes}: Props) => { const getTheme = (): Theme => { if (currentTeamId) { @@ -45,12 +54,8 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => { if (teamTheme?.value) { try { const theme = setThemeDefaults(JSON.parse(teamTheme.value) as Theme); - if (theme !== EphemeralStore.theme) { - EphemeralStore.theme = theme; - requestAnimationFrame(() => { - setNavigationStackStyles(theme); - }); - } + updateThemeIfNeeded(theme); + return theme; } catch { // no theme change @@ -59,10 +64,7 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => { } const defaultTheme = getDefaultThemeByAppearance(); - EphemeralStore.theme = defaultTheme; - requestAnimationFrame(() => { - setNavigationStackStyles(defaultTheme); - }); + updateThemeIfNeeded(defaultTheme); return defaultTheme; }; diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 7c1cb1528..3af7e380f 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -208,6 +208,16 @@ class DatabaseManager { return null; }; + public getActiveServerDisplayName = async (): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryActiveServer(database); + return server?.displayName; + } + + return null; + }; + public getServerUrlFromIdentifier = async (identifier: string): Promise => { const database = this.appDatabase?.database; if (database) { diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index e2c1ebb04..6dd5a6281 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -252,7 +252,7 @@ class DatabaseManager { }; /** - * getActiveServerUrl: Get the record for active server database. + * getActiveServerUrl: Get the server url for active server database. * @returns {Promise} */ public getActiveServerUrl = async (): Promise => { @@ -265,6 +265,20 @@ class DatabaseManager { return null; }; + /** + * getActiveServerDisplayName: Get the server display name for active server database. + * @returns {Promise} + */ + public getActiveServerDisplayName = async (): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryActiveServer(database); + return server?.displayName; + } + + return null; + }; + public getServerUrlFromIdentifier = async (identifier: string): Promise => { const database = this.appDatabase?.database; if (database) { diff --git a/app/database/operator/app_data_operator/comparator/index.ts b/app/database/operator/app_data_operator/comparator/index.ts index 686d470b4..8cc261af7 100644 --- a/app/database/operator/app_data_operator/comparator/index.ts +++ b/app/database/operator/app_data_operator/comparator/index.ts @@ -9,5 +9,5 @@ export const isRecordInfoEqualToRaw = (record: InfoModel, raw: AppInfo) => { }; export const isRecordGlobalEqualToRaw = (record: GlobalModel, raw: IdValue) => { - return raw.id === record.id && raw.value === record.value; + return raw.id === record.id; }; diff --git a/app/database/subscription/servers.ts b/app/database/subscription/servers.ts index c95cbbdb4..7064add6a 100644 --- a/app/database/subscription/servers.ts +++ b/app/database/subscription/servers.ts @@ -15,7 +15,7 @@ export const subscribeActiveServers = (observer: (servers: ServersModel[]) => vo return db?. get(SERVERS). query(Q.where('identifier', Q.notEq(''))). - observeWithColumns(['last_active_at']). + observeWithColumns(['display_name', 'last_active_at']). subscribe(observer); }; diff --git a/app/init/global_event_handler.ts b/app/init/global_event_handler.ts index 5c32e20a7..034b7a486 100644 --- a/app/init/global_event_handler.ts +++ b/app/init/global_event_handler.ts @@ -23,6 +23,11 @@ import {deleteFileCache} from '@utils/file'; type LinkingCallbackArg = {url: string}; +type LogoutCallbackArg = { + serverUrl: string; + removeServer: boolean; +} + class GlobalEventHandler { JavascriptAndNativeErrorHandler: jsAndNativeErrorHandler | undefined; @@ -83,7 +88,7 @@ class GlobalEventHandler { } }; - onLogout = async (serverUrl: string) => { + onLogout = async ({serverUrl, removeServer}: LogoutCallbackArg) => { await removeServerCredentials(serverUrl); const channelIds = await selectAllMyChannelIds(serverUrl); PushNotifications.cancelChannelsNotifications(channelIds); @@ -92,7 +97,12 @@ class GlobalEventHandler { WebsocketManager.invalidateClient(serverUrl); const activeServerUrl = await DatabaseManager.getActiveServerUrl(); - await DatabaseManager.deleteServerDatabase(serverUrl); + const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName(); + if (removeServer) { + await DatabaseManager.destroyServerDatabase(serverUrl); + } else { + await DatabaseManager.deleteServerDatabase(serverUrl); + } const analyticsClient = analytics.get(serverUrl); if (analyticsClient) { @@ -105,13 +115,18 @@ class GlobalEventHandler { deleteFileCache(serverUrl); if (activeServerUrl === serverUrl) { + let displayName = ''; let launchType: LaunchType = LaunchType.AddServer; if (!Object.keys(DatabaseManager.serverDatabases).length) { EphemeralStore.theme = undefined; launchType = LaunchType.Normal; + + if (activeServerDisplayName) { + displayName = activeServerDisplayName; + } } - relaunchApp({launchType}, true); + relaunchApp({launchType, serverUrl, displayName}, true); } }; @@ -154,7 +169,7 @@ class GlobalEventHandler { const credentials = await getServerCredentials(serverUrl); if (credentials) { - this.onLogout(serverUrl); + this.onLogout({serverUrl, removeServer: false}); } }; } diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 0c2ce6d31..21932bce6 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -14,10 +14,10 @@ import { Registered, } from 'react-native-notifications'; +import {storeDeviceToken} from '@actions/app/global'; import {markChannelAsViewed} from '@actions/local/channel'; import {backgroundNotification, openNotification} from '@actions/remote/notifications'; import {Device, Events, Navigation, Screens} from '@constants'; -import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import NativeNotifications from '@notifications'; @@ -187,7 +187,7 @@ class PushNotifications { const serverUrl = await this.getServerUrlFromNotification(notification); if (serverUrl) { - DeviceEventEmitter.emit(Events.SERVER_LOGOUT, serverUrl); + DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl}); } }; @@ -257,16 +257,7 @@ class PushNotifications { prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE; } - const operator = DatabaseManager.appDatabase?.operator; - - if (!operator) { - return {error: 'No App database found'}; - } - - operator.handleGlobal({ - global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: `${prefix}:${deviceToken}`}], - prepareRecordsOnly: false, - }); + storeDeviceToken(`${prefix}:${deviceToken}`); // Store the device token in the default database this.requestNotificationReplyPermissions(); diff --git a/app/init/websocket_manager.ts b/app/init/websocket_manager.ts index cf2448813..149a52028 100644 --- a/app/init/websocket_manager.ts +++ b/app/init/websocket_manager.ts @@ -2,7 +2,9 @@ // See LICENSE.txt for license information. import NetInfo, {NetInfoState} from '@react-native-community/netinfo'; -import {AppState, AppStateStatus, DeviceEventEmitter, Platform} from 'react-native'; +import {debounce, DebouncedFunc} from 'lodash'; +import {AppState, AppStateStatus} from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; import {setCurrentUserStatusOffline} from '@actions/local/user'; import {fetchStatusByIds} from '@actions/remote/user'; @@ -15,11 +17,16 @@ import {queryAllUsers} from '@queries/servers/user'; import type {ServerCredential} from '@typings/credentials'; +const WAIT_TO_CLOSE = 15 * 1000; +const WAIT_UNTIL_NEXT = 5 * 1000; + class WebsocketManager { private clients: Record = {}; - private statusUpdatesIntervalIDs: Record = {}; - private previousAppState: AppStateStatus; + private connectionTimerIDs: Record void>> = {}; + private isBackgroundTimerRunning = false; private netConnected = false; + private previousAppState: AppStateStatus; + private statusUpdatesIntervalIDs: Record = {}; constructor() { this.previousAppState = AppState.currentState; @@ -46,17 +53,14 @@ class WebsocketManager { AppState.addEventListener('change', this.onAppStateChange); NetInfo.addEventListener(this.onNetStateChange); - - if (Platform.OS === 'android') { - DeviceEventEmitter.addListener('windowFocusChanged', ({appState}: {appState: AppStateStatus}) => { - this.onAppStateChange(appState); - }); - } }; public invalidateClient = (serverUrl: string) => { this.clients[serverUrl]?.close(); this.clients[serverUrl]?.invalidate(); + if (this.connectionTimerIDs[serverUrl]) { + this.connectionTimerIDs[serverUrl].cancel(); + } delete this.clients[serverUrl]; }; @@ -80,14 +84,21 @@ class WebsocketManager { public closeAll = () => { for (const client of Object.values(this.clients)) { - client.close(true); + if (client.isConnected()) { + client.close(true); + } } }; - public openAll = () => { - for (const client of Object.values(this.clients)) { - if (!client.isConnected()) { - client.initialize(); + public openAll = async () => { + for await (const clientUrl of Object.keys(this.clients)) { + const activeServerUrl = await DatabaseManager.getActiveServerUrl(); + if (clientUrl === activeServerUrl) { + this.initializeClient(clientUrl); + } else { + const bounce = debounce(this.initializeClient.bind(this, clientUrl), WAIT_UNTIL_NEXT); + this.connectionTimerIDs[clientUrl] = bounce; + bounce(); } } }; @@ -96,6 +107,24 @@ class WebsocketManager { return this.clients[serverUrl]?.isConnected(); }; + private cancelAllConnections = () => { + for (const url in this.connectionTimerIDs) { + if (this.connectionTimerIDs[url]) { + this.connectionTimerIDs[url].cancel(); + delete this.connectionTimerIDs[url]; + } + } + }; + + private initializeClient = (serverUrl: string) => { + const client: WebSocketClient = this.clients[serverUrl]; + if (!client?.isConnected()) { + client.initialize(); + } + this.connectionTimerIDs[serverUrl]?.cancel(); + delete this.connectionTimerIDs[serverUrl]; + }; + private onFirstConnect = (serverUrl: string) => { this.startPeriodicStatusUpdates(serverUrl); handleFirstConnect(serverUrl); @@ -156,13 +185,22 @@ class WebsocketManager { return; } - if (appState !== 'active') { - this.closeAll(); + this.cancelAllConnections(); + if (appState === 'background' && !this.isBackgroundTimerRunning) { + this.isBackgroundTimerRunning = true; + this.cancelAllConnections(); + BackgroundTimer.runBackgroundTimer(() => { + this.closeAll(); + BackgroundTimer.stopBackgroundTimer(); + this.isBackgroundTimerRunning = false; + }, WAIT_TO_CLOSE); this.previousAppState = appState; return; } if (appState === 'active' && this.netConnected) { // Reopen the websockets only if there is connection + BackgroundTimer.stopBackgroundTimer(); + this.isBackgroundTimerRunning = false; this.openAll(); this.previousAppState = appState; return; diff --git a/app/screens/edit_server/form.tsx b/app/screens/edit_server/form.tsx new file mode 100644 index 000000000..1a5c83317 --- /dev/null +++ b/app/screens/edit_server/form.tsx @@ -0,0 +1,198 @@ +// 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 {Keyboard, Platform, useWindowDimensions, View} from 'react-native'; +import Button from 'react-native-button'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; + +import FloatingTextInput, {FloatingTextInputRef} from '@components/floating_text_input_label'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +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'; +import {removeProtocol, stripTrailingSlashes} from '@utils/url'; + +type Props = { + buttonDisabled: boolean; + connecting: boolean; + displayName?: string; + displayNameError?: string; + handleUpdate: () => void; + handleDisplayNameTextChanged: (text: string) => void; + keyboardAwareRef: MutableRefObject; + serverUrl: string; + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + formContainer: { + alignItems: 'center', + maxWidth: 600, + width: '100%', + paddingHorizontal: 20, + }, + 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, + }, + loadingContainerStyle: { + marginRight: 10, + padding: 0, + top: -2, + }, + loading: { + height: 20, + width: 20, + }, +})); + +const EditServerForm = ({ + buttonDisabled, + connecting, + displayName = '', + displayNameError, + handleUpdate, + handleDisplayNameTextChanged, + keyboardAwareRef, + serverUrl, + theme, +}: Props) => { + const {formatMessage} = useIntl(); + const isTablet = useIsTablet(); + const dimensions = useWindowDimensions(); + const displayNameRef = useRef(null); + const styles = getStyleSheet(theme); + + const onBlur = useCallback(() => { + if (Platform.OS === 'ios') { + const reset = !displayNameRef.current?.isFocused(); + if (reset) { + keyboardAwareRef.current?.scrollToPosition(0, 0); + } + } + }, []); + + const onUpdate = useCallback(() => { + Keyboard.dismiss(); + handleUpdate(); + }, [handleUpdate]); + + const onFocus = useCallback(() => { + // For iOS we set the position of the input instead of + // having the KeyboardAwareScrollView figure it out by itself + // on Android KeyboardAwareScrollView does nothing as is handled + // by the OS + 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); + }); + } + }, [dimensions, isTablet]); + + useEffect(() => { + if (Platform.OS === 'ios' && isTablet) { + if (displayNameRef.current?.isFocused()) { + onFocus(); + } else { + keyboardAwareRef.current?.scrollToPosition(0, 0); + } + } + }, [onFocus]); + + const buttonType = buttonDisabled ? 'disabled' : 'default'; + const styleButtonText = buttonTextStyle(theme, 'lg', 'primary', buttonType); + const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary', buttonType); + + let buttonID = t('edit_server.save'); + let buttonText = 'Save'; + let buttonIcon; + + if (connecting) { + buttonID = t('edit_server.saving'); + buttonText = 'Saving'; + buttonIcon = ( + + ); + } + + return ( + + + + + {!displayNameError && + + } + + + ); +}; + +export default EditServerForm; diff --git a/app/screens/edit_server/header.tsx b/app/screens/edit_server/header.tsx new file mode 100644 index 000000000..ce81ac2ba --- /dev/null +++ b/app/screens/edit_server/header.tsx @@ -0,0 +1,55 @@ +// 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 '@components/formatted_text'; +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, + }, + title: { + letterSpacing: -1, + color: theme.centerChannelColor, + marginVertical: 12, + ...typography('Heading', 1000, 'SemiBold'), + }, + description: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 200, 'Regular'), + }, +})); + +const EditServerHeader = ({theme}: Props) => { + const styles = getStyleSheet(theme); + + return ( + + + + + ); +}; + +export default EditServerHeader; diff --git a/app/screens/edit_server/index.tsx b/app/screens/edit_server/index.tsx new file mode 100644 index 000000000..036cd01ed --- /dev/null +++ b/app/screens/edit_server/index.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {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 DatabaseManager from '@database/manager'; +import {queryServerByDisplayName} from '@queries/app/servers'; +import Background from '@screens/background'; +import {dismissModal} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Form from './form'; +import Header from './header'; + +import type ServersModel from '@typings/database/models/app/servers'; + +interface ServerProps { + closeButtonId?: string; + componentId: string; + server: ServersModel; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + appInfo: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + flex: { + flex: 1, + }, + scrollContainer: { + alignItems: 'center', + height: '100%', + justifyContent: 'center', + }, +})); + +const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) => { + const {formatMessage} = useIntl(); + const keyboardAwareRef = useRef(); + const [saving, setSaving] = useState(false); + const [displayName, setDisplayName] = useState(server.displayName); + const [buttonDisabled, setButtonDisabled] = useState(true); + const [displayNameError, setDisplayNameError] = useState(); + const styles = getStyleSheet(theme); + + useEffect(() => { + setButtonDisabled(Boolean(displayName && displayName !== server.displayName)); + }, [displayName]); + + useEffect(() => { + const navigationEvents = Navigation.events().registerNavigationButtonPressedListener(({buttonId}) => { + if (closeButtonId && buttonId === closeButtonId) { + dismissModal({componentId}); + } + }); + + return () => navigationEvents.remove(); + }, []); + + const handleUpdate = useCallback(async () => { + if (buttonDisabled) { + return; + } + + if (displayNameError) { + setDisplayNameError(undefined); + } + + setSaving(true); + const knownServer = await queryServerByDisplayName(DatabaseManager.appDatabase!.database, displayName); + if (knownServer && knownServer.lastActiveAt > 0 && knownServer.url !== server.url) { + setButtonDisabled(true); + setDisplayNameError(formatMessage({ + id: 'mobile.server_name.exists', + defaultMessage: 'You are using this name for another server.', + })); + setSaving(false); + return; + } + + await DatabaseManager.updateServerDisplayName(server.url, displayName); + dismissModal({componentId}); + }, [!buttonDisabled && displayName, !buttonDisabled && displayNameError]); + + const handleDisplayNameTextChanged = useCallback((text: string) => { + setDisplayName(text); + setDisplayNameError(undefined); + }, []); + + return ( + + + + +
+
+ + + + ); +}; + +export default EditServer; diff --git a/app/screens/home/channel_list/servers/servers_list/index.tsx b/app/screens/home/channel_list/servers/servers_list/index.tsx index 76730ab77..715129c7a 100644 --- a/app/screens/home/channel_list/servers/servers_list/index.tsx +++ b/app/screens/home/channel_list/servers/servers_list/index.tsx @@ -41,9 +41,10 @@ const ServerList = ({servers}: Props) => { addNewServer(theme); }, [servers]); - const renderServer = useCallback(({item: t}: ListRenderItemInfo) => { + const renderServer = useCallback(({item: t, index}: ListRenderItemInfo) => { return ( diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/index.ts b/app/screens/home/channel_list/servers/servers_list/server_item/index.ts new file mode 100644 index 000000000..3c09e0d80 --- /dev/null +++ b/app/screens/home/channel_list/servers/servers_list/server_item/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; + +import {GLOBAL_IDENTIFIERS, MM_TABLES} from '@constants/database'; + +import ServerItem from './server_item'; + +import type GlobalModel from '@typings/database/models/app/global'; +import type ServersModel from '@typings/database/models/app/servers'; + +const {MULTI_SERVER_TUTORIAL} = GLOBAL_IDENTIFIERS; +const {APP: {GLOBAL}} = MM_TABLES; + +const enhance = withObservables(['highlight'], ({highlight, server}: {highlight: boolean; server: ServersModel}) => { + let tutorialWatched = of$(false); + if (highlight) { + tutorialWatched = server.collections.get(GLOBAL).findAndObserve(MULTI_SERVER_TUTORIAL).pipe( + switchMap(({value}) => of$(Boolean(value))), + catchError(() => of$(false)), + ); + } + + return { + server: server.observe(), + tutorialWatched, + }; +}); + +export default enhance(ServerItem); diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/index.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx similarity index 79% rename from app/screens/home/channel_list/servers/servers_list/server_item/index.tsx rename to app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index c4d78f285..bd99497f7 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/index.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -7,17 +7,21 @@ import {DeviceEventEmitter, Text, View} from 'react-native'; import {RectButton} from 'react-native-gesture-handler'; import Swipeable from 'react-native-gesture-handler/Swipeable'; +import {storeMultiServerTutorial} from '@actions/app/global'; import {appEntry} from '@actions/remote/entry'; import {logout} from '@actions/remote/session'; import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; import ServerIcon from '@components/server_icon'; +import TutorialHighlight from '@components/tutorial_highlight'; +import TutorialSwipeLeft from '@components/tutorial_highlight/swipe_left'; import {Events} from '@constants'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import {subscribeServerUnreadAndMentions} from '@database/subscription/unreads'; +import {useIsTablet} from '@hooks/device'; import {dismissBottomSheet} from '@screens/navigation'; -import {addNewServer, alertServerLogout, alertServerRemove} from '@utils/server'; +import {addNewServer, alertServerLogout, alertServerRemove, editServer} from '@utils/server'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {removeProtocol, stripTrailingSlashes} from '@utils/url'; @@ -30,8 +34,10 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type {Subscription} from 'rxjs'; type Props = { + highlight: boolean; isActive: boolean; server: ServersModel; + tutorialWatched: boolean; } type BadgeValues = { @@ -103,16 +109,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ width: 40, justifyContent: 'center', }, + tutorial: { + top: -30, + }, + tutorialTablet: { + top: -80, + }, })); -const ServerItem = ({isActive, server}: Props) => { +const ServerItem = ({highlight, isActive, server, tutorialWatched}: Props) => { const intl = useIntl(); const theme = useTheme(); + const isTablet = useIsTablet(); const [switching, setSwitching] = useState(false); const [badge, setBadge] = useState({isUnread: false, mentions: 0}); const styles = getStyleSheet(theme); const swipeable = useRef(); const subscription = useRef(); + const viewRef = useRef(null); + const [showTutorial, setShowTutorial] = useState(false); + const [itemBounds, setItemBounds] = useState({startX: 0, startY: 0, endX: 0, endY: 0}); const database = DatabaseManager.serverDatabases[server.url]?.database; let displayName = server.displayName; @@ -142,17 +158,22 @@ const ServerItem = ({isActive, server}: Props) => { }; const removeServer = async () => { - if (server.lastActiveAt > 0) { - await logout(server.url); - } + const skipLogoutFromServer = server.lastActiveAt === 0; + await dismissBottomSheet(); + await logout(server.url, skipLogoutFromServer, true); + }; - if (isActive) { - dismissBottomSheet(); - } else { - DeviceEventEmitter.emit(Events.SWIPEABLE, ''); - } - - await DatabaseManager.destroyServerDatabase(server.url); + const startTutorial = () => { + viewRef.current?.measureInWindow((x, y, w, h) => { + const bounds: TutorialItemBounds = { + startX: x - 20, + startY: y - 5, + endX: x + w + 20, + endY: y + h + 5, + }; + setShowTutorial(true); + setItemBounds(bounds); + }); }; const containerStyle = useMemo(() => { @@ -177,9 +198,15 @@ const ServerItem = ({isActive, server}: Props) => { addNewServer(theme, server.url, displayName); }, [server, theme, intl]); + const handleDismissTutorial = useCallback(() => { + swipeable.current?.close(); + setShowTutorial(false); + storeMultiServerTutorial(); + }, []); + const handleEdit = useCallback(() => { - // eslint-disable-next-line no-console - console.log('ON EDIT'); + DeviceEventEmitter.emit(Events.SWIPEABLE, ''); + editServer(theme, server); }, [server]); const handleLogout = useCallback(async () => { @@ -190,6 +217,10 @@ const ServerItem = ({isActive, server}: Props) => { alertServerRemove(server.displayName, removeServer, intl); }, [isActive, server, intl]); + const handleShowTutorial = useCallback(() => { + swipeable.current?.openRight(); + }, []); + const onServerPressed = useCallback(async () => { if (isActive) { dismissBottomSheet(); @@ -251,6 +282,14 @@ const ServerItem = ({isActive, server}: Props) => { }; }, [server.lastActiveAt, isActive]); + useEffect(() => { + let time: NodeJS.Timeout; + if (highlight && !tutorialWatched) { + time = setTimeout(startTutorial, 300); + } + return () => clearTimeout(time); + }, [highlight, tutorialWatched]); + return ( <> { > { database={database} /> } + {showTutorial && + + + + } ); }; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 37e299506..357c5e854 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -57,72 +57,30 @@ Navigation.setLazyComponentRegistrator((screenName) => { let screen: any|undefined; let extraStyles: StyleProp; switch (screenName) { - case Screens.ABOUT: + case Screens.ABOUT: screen = withServerDatabase(require('@screens/about').default); break; - // case 'AdvancedSettings': - // screen = require('@screens/settings/advanced_settings').default; - // break; - case Screens.BOTTOM_SHEET: + case Screens.BOTTOM_SHEET: screen = withServerDatabase(require('@screens/bottom_sheet').default); break; - case Screens.CHANNEL: + case Screens.CHANNEL: screen = withServerDatabase(require('@screens/channel').default); break; - case Screens.CUSTOM_STATUS: + case Screens.CUSTOM_STATUS: screen = withServerDatabase(require('@screens/custom_status').default); break; - case Screens.CUSTOM_STATUS_CLEAR_AFTER: + case Screens.CUSTOM_STATUS_CLEAR_AFTER: screen = withServerDatabase(require('@screens/custom_status_clear_after').default); break; - case Screens.EMOJI_PICKER: + case Screens.EMOJI_PICKER: screen = withServerDatabase(require('@screens/emoji_picker').default); break; - // case 'ChannelAddMembers': - // screen = require('@screens/channel_add_members').default; - // break; - // case 'ChannelInfo': - // screen = require('@screens/channel_info').default; - // break; - // case 'ChannelMembers': - // screen = require('@screens/channel_members').default; - // break; - // case 'ChannelNotificationPreference': - // screen = require('@screens/channel_notification_preference').default; - // break; - // case 'ClientUpgrade': - // screen = require('@screens/client_upgrade').default; - // break; - // case 'ClockDisplaySettings': - // screen = require('@screens/settings/clock_display').default; - // break; - // case 'Code': - // screen = require('@screens/code').default; - // break; - // case 'CreateChannel': - // screen = require('@screens/create_channel').default; - // break; - // case 'DisplaySettings': - // screen = require('@screens/settings/display_settings').default; - // break; - // case 'EditChannel': - // screen = require('@screens/edit_channel').default; - // break; - // case 'EditPost': - // screen = require('@screens/edit_post').default; - // break; case Screens.EDIT_PROFILE: screen = withServerDatabase((require('@screens/edit_profile').default)); break; - // case 'ErrorTeamsList': - // screen = require('@screens/error_teams_list').default; - // break; - // case 'ExpandedAnnouncementBanner': - // screen = require('@screens/expanded_announcement_banner').default; - // break; - // case 'FlaggedPosts': - // screen = require('@screens/flagged_posts').default; - // break; + case Screens.EDIT_SERVER: + screen = withIntl(require('@screens/edit_server').default); + break; case Screens.FORGOT_PASSWORD: screen = withIntl(require('@screens/forgot_password').default); break; @@ -134,109 +92,21 @@ Navigation.setLazyComponentRegistrator((screenName) => { })); return; } - // case 'Gallery': - // screen = require('@screens/gallery').default; - // break; - // case 'InteractiveDialog': - // screen = require('@screens/interactive_dialog').default; - // break; case Screens.LOGIN: screen = withIntl(require('@screens/login').default); break; - // case 'LongPost': - // screen = require('@screens/long_post').default; - // break; - // case 'MainSidebar': - // screen = require('app/components/sidebars/main').default; - // break; case Screens.MFA: screen = withIntl(require('@screens/mfa').default); break; case Screens.BROWSE_CHANNELS: screen = withServerDatabase(require('@screens/browse_channels').default); break; - // case 'MoreDirectMessages': - // screen = require('@screens/more_dms').default; - // break; - // case 'Notification': - // extraStyles = Platform.select({android: {flex: undefined, height: 100}}); - // screen = require('@screens/notification/index.tsx').default; - // break; - // case 'NotificationSettings': - // screen = require('@screens/settings/notification_settings').default; - // break; - // case 'NotificationSettingsAutoResponder': - // screen = require('@screens/settings/notification_settings_auto_responder').default; - // break; - // case 'NotificationSettingsEmail': - // screen = require('@screens/settings/notification_settings_email').default; - // break; - // case 'NotificationSettingsMentions': - // screen = require('@screens/settings/notification_settings_mentions').default; - // break; - // case 'NotificationSettingsMentionsKeywords': - // screen = require('@screens/settings/notification_settings_mentions_keywords').default; - // break; - // case 'NotificationSettingsMobile': - // screen = require('@screens/settings/notification_settings_mobile').default; - // break; - // case 'Permalink': - // screen = require('@screens/permalink').default; - // break; - // case 'PinnedPosts': - // screen = require('@screens/pinned_posts').default; - // break; case Screens.POST_OPTIONS: screen = withServerDatabase(require('@screens/post_options').default); break; - // case 'ReactionList': - // screen = require('@screens/reaction_list').default; - // break; - // case 'RecentMentions': - // screen = require('@screens/recent_mentions').default; - // break; - // case 'Search': - // screen = require('@screens/search').default; - // break; - // case 'SelectorScreen': - // screen = require('@screens/selector_screen').default; - // break; - // case 'SelectTeam': - // screen = require('@screens/select_team').default; - // break; - // case 'SelectTimezone': - // screen = require('@screens/settings/timezone/select_timezone').default; - // break; - // case 'Settings': - // screen = require('@screens/settings/general').default; - // break; - // case 'SettingsSidebar': - // screen = require('app/components/sidebars/settings').default; - // break; - // case 'SidebarSettings': - // screen = require('@screens/settings/sidebar').default; - // break; - case Screens.SSO: - screen = withIntl(require('@screens/sso').default); - break; - // case 'Table': - // screen = require('@screens/table').default; - // break; - // case 'TermsOfService': - // screen = require('@screens/terms_of_service').default; - // break; - // case 'ThemeSettings': - // screen = require('@screens/settings/theme').default; - // break; - // case 'Thread': - // screen = require('@screens/thread').default; - // break; - // case 'TimezoneSettings': - // screen = require('@screens/settings/timezone').default; - // break; - // case 'UserProfile': - // screen = require('@screens/user_profile').default; - // break; + case Screens.SSO: + screen = withIntl(require('@screens/sso').default); + break; } if (screen) { diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index c979a2984..24bbeb8de 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -99,11 +99,11 @@ export const bottomSheetModalOptions = (theme: Theme, closeButtonId: string) => }; }; +// This locks phones to portrait for all screens while keeps +// all orientations available for Tablets. Navigation.setDefaultOptions({ layout: { - - //@ts-expect-error all not defined in type definition - orientation: [Device.IS_TABLET ? 'all' : 'portrait'], + orientation: Device.IS_TABLET ? undefined : ['portrait'], }, }); diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 45d8135ba..c6af68160 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -70,8 +70,8 @@ const Server = ({ const {formatMessage} = intl; useEffect(() => { - let serverName = managedConfig?.serverName || LocalConfig.DefaultServerName; - let serverUrl = managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; + let serverName = defaultDisplayName || managedConfig?.serverName || LocalConfig.DefaultServerName; + let serverUrl = defaultServerUrl || managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; let autoconnect = managedConfig?.allowOtherServers === 'false' || LocalConfig.AutoSelectServerUrl; if (launchType === LaunchType.DeepLink) { diff --git a/app/utils/server/index.ts b/app/utils/server/index.ts index 64a9e5f46..7417f1a96 100644 --- a/app/utils/server/index.ts +++ b/app/utils/server/index.ts @@ -11,6 +11,8 @@ import {LaunchType} from '@typings/launch'; import {changeOpacity} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; +import type ServersModel from '@typings/database/models/app/servers'; + export function unsupportedServer(isSystemAdmin: boolean, intl: IntlShape) { if (isSystemAdmin) { return unsupportedServerAdminAlert(intl); @@ -34,7 +36,6 @@ export function semverFromServerVersion(value: string) { export async function addNewServer(theme: Theme, serverUrl?: string, displayName?: string) { await dismissBottomSheet(); - const closeButton = CompassIcon.getImageSourceSync('close', 24, changeOpacity(theme.centerChannelColor, 0.56)); const closeButtonId = 'close-server'; const props = { closeButtonId, @@ -43,37 +44,23 @@ export async function addNewServer(theme: Theme, serverUrl?: string, displayName serverUrl, theme, }; - const options = { - layout: { - backgroundColor: theme.centerChannelBg, - componentBackgroundColor: theme.centerChannelBg, - }, - modal: {swipeToDismiss: false}, - topBar: { - visible: true, - drawBehind: true, - translucient: true, - noBorder: true, - elevation: 0, - background: {color: 'transparent'}, - leftButtons: [{ - id: closeButtonId, - icon: closeButton, - testID: 'close.server.button', - }], - leftButtonColor: undefined, - title: {color: theme.sidebarHeaderTextColor}, - scrollEdgeAppearance: { - active: true, - noBorder: true, - translucid: true, - }, - }, - }; + const options = buildServerModalOptions(theme, closeButtonId); showModal(Screens.SERVER, '', props, options); } +export async function editServer(theme: Theme, server: ServersModel) { + const closeButtonId = 'close-server-edit'; + const props = { + closeButtonId, + server, + theme, + }; + const options = buildServerModalOptions(theme, closeButtonId); + + showModal(Screens.EDIT_SERVER, '', props, options); +} + export async function alertServerLogout(displayName: string, onPress: () => void, intl: IntlShape) { Alert.alert( intl.formatMessage({ @@ -168,3 +155,34 @@ function unsupportedServerAlert(intl: IntlShape) { Alert.alert(title, message, buttons, options); } + +function buildServerModalOptions(theme: Theme, closeButtonId: string) { + const closeButton = CompassIcon.getImageSourceSync('close', 24, changeOpacity(theme.centerChannelColor, 0.56)); + return { + layout: { + backgroundColor: theme.centerChannelBg, + componentBackgroundColor: theme.centerChannelBg, + }, + modal: {swipeToDismiss: false}, + topBar: { + visible: true, + drawBehind: true, + translucient: true, + noBorder: true, + elevation: 0, + background: {color: 'transparent'}, + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: closeButtonId, + }], + leftButtonColor: undefined, + title: {color: theme.sidebarHeaderTextColor}, + scrollEdgeAppearance: { + active: true, + noBorder: true, + translucid: true, + }, + }, + }; +} diff --git a/app/utils/svg/index.ts b/app/utils/svg/index.ts new file mode 100644 index 000000000..622d79b6e --- /dev/null +++ b/app/utils/svg/index.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const svgM = (x: number, y: number) => `M ${x} ${y}`; +export const svgL = (x: number, y: number) => `L ${x} ${y}`; +export const svgArc = (toX: number, toY: number, radius: number) => `A ${radius},${radius} 0 0 0 ${toX},${toY}`; +export const z = 'z'; + +export const constructRectangularPathWithBorderRadius = ( + parentBounds: TutorialItemBounds, + itemBounds: TutorialItemBounds, + borderRadius = 0, +): string => { + const {startX, startY, endX, endY} = itemBounds; + return [ + svgM(parentBounds.startX, parentBounds.startY), + svgL(parentBounds.startX, parentBounds.endY), + svgL(parentBounds.endX, parentBounds.endY), + svgL(parentBounds.endX, parentBounds.startY), + z, + svgM(startX, startY + borderRadius), + svgL(startX, endY - borderRadius), + svgArc(startX + borderRadius, endY, borderRadius), + svgL(endX - borderRadius, endY), + svgArc(endX, endY - borderRadius, borderRadius), + svgL(endX, startY + borderRadius), + svgArc(endX - borderRadius, startY, borderRadius), + svgL(startX + borderRadius, startY), + svgArc(startX, startY + borderRadius, borderRadius), + ].join(' '); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2f06323ae..828012ded 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -101,6 +101,11 @@ "custom_status.suggestions.working_from_home": "Working from home", "date_separator.today": "Today", "date_separator.yesterday": "Yesterday", + "edit_server.description": "Specify a display name for this server", + "edit_server.display_help": "Server: {url}", + "edit_server.save": "Save", + "edit_server.saving": "Saving", + "edit_server.title": "Edit server name", "emoji_picker.activities": "Activities", "emoji_picker.animals-nature": "Animals & Nature", "emoji_picker.custom": "Custom", @@ -120,8 +125,8 @@ "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", "file_upload.fileAbove": "Files must be less than {max}", - "home.header.plus_menu": "Options", "get_post_link_modal.title": "Copy Link", + "home.header.plus_menu": "Options", "intro.add_people": "Add People", "intro.channel_details": "Details", "intro.created_by": "created by {creator} on {date}.", @@ -397,6 +402,7 @@ "server.logout.alert_title": "Are you sure you want to log out of {displayName}?", "server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed", "server.remove.alert_title": "Are you sure you want to remove {displayName}?", + "server.tutorial.swipe": "Swipe left on a server to see more actions", "server.websocket.unreachable": "Server is unreachable.", "servers.create_button": "Add a server", "servers.default": "Default Server", diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index c0d89541c..b166ce4fd 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -114,9 +114,11 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortraitUpsideDown UIViewControllerBasedStatusBarAppearance diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 224e06a39..940b0120d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -260,6 +260,8 @@ PODS: - React-jsinspector (0.67.2) - React-logger (0.67.2): - glog + - react-native-background-timer (2.4.1): + - React-Core - react-native-cameraroll (4.1.2): - React-Core - react-native-cookies (6.0.11): @@ -498,6 +500,7 @@ DEPENDENCIES: - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`) - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - react-native-background-timer (from `../node_modules/react-native-background-timer`) - "react-native-cameraroll (from `../node_modules/@react-native-community/cameraroll`)" - "react-native-cookies (from `../node_modules/@react-native-cookies/cookies`)" - react-native-document-picker (from `../node_modules/react-native-document-picker`) @@ -627,6 +630,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/jsinspector" React-logger: :path: "../node_modules/react-native/ReactCommon/logger" + react-native-background-timer: + :path: "../node_modules/react-native-background-timer" react-native-cameraroll: :path: "../node_modules/@react-native-community/cameraroll" react-native-cookies: @@ -773,6 +778,7 @@ SPEC CHECKSUMS: React-jsiexecutor: 52beb652bbc61201bd70cbe4f0b8edb607e8da4f React-jsinspector: 595f76eba2176ebd8817a1fffd47b84fbdab9383 React-logger: 23de8ea0f44fa00ee77e96060273225607fd4d78 + react-native-background-timer: 17ea5e06803401a379ebf1f20505b793ac44d0fe react-native-cameraroll: 2957f2bce63ae896a848fbe0d5352c1bd4d20866 react-native-cookies: cd92f3824ed1e32a20802e8185101e14bb5b76da react-native-document-picker: 429972f7ece4463aa5bcdd789622b3a674a3c5d1 diff --git a/package-lock.json b/package-lock.json index e07fa2df9..e9f3e3db8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "mattermost-mobile", "version": "2.0.0", "hasInstallScript": true, "license": "Apache 2.0", @@ -52,6 +51,7 @@ "react-intl": "5.24.4", "react-native": "0.67.2", "react-native-android-open-settings": "1.3.0", + "react-native-background-timer": "2.4.1", "react-native-button": "3.0.1", "react-native-calendars": "1.1276.0", "react-native-device-info": "8.4.8", @@ -114,6 +114,7 @@ "@types/lodash": "4.14.178", "@types/react": "17.0.38", "@types/react-native": "0.66.15", + "@types/react-native-background-timer": "2.0.0", "@types/react-native-button": "3.0.1", "@types/react-native-share": "3.3.3", "@types/react-native-video": "5.0.12", @@ -5874,6 +5875,12 @@ "@types/react": "*" } }, + "node_modules/@types/react-native-background-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/react-native-background-timer/-/react-native-background-timer-2.0.0.tgz", + "integrity": "sha512-y5VW82dL/ESOLg+5QQHyBdsFVA4ZklENxmOyxv8o06T+3HBG2JOSuz/CIPz1vKdB7dmWDGPZNuPosdtnp+xv2A==", + "dev": true + }, "node_modules/@types/react-native-button": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/react-native-button/-/react-native-button-3.0.1.tgz", @@ -19917,6 +19924,14 @@ "resolved": "https://registry.npmjs.org/react-native-android-open-settings/-/react-native-android-open-settings-1.3.0.tgz", "integrity": "sha512-h4FTWRtTLRVNS7RK4Bg2gAXe8G5bFZL8Jtmfk2rG2a/N/wJR+v1rAY2BxRkC48lQTqwQCIAQRbWgLVkJ8XmaLQ==" }, + "node_modules/react-native-background-timer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-native-background-timer/-/react-native-background-timer-2.4.1.tgz", + "integrity": "sha512-TE4Kiy7jUyv+hugxDxitzu38sW1NqjCk4uE5IgU2WevLv7sZacaBc6PZKOShNRPGirLl1NWkaG3LDEkdb9Um5g==", + "peerDependencies": { + "react-native": ">=0.47.0" + } + }, "node_modules/react-native-button": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/react-native-button/-/react-native-button-3.0.1.tgz", @@ -28981,6 +28996,12 @@ "@types/react": "*" } }, + "@types/react-native-background-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/react-native-background-timer/-/react-native-background-timer-2.0.0.tgz", + "integrity": "sha512-y5VW82dL/ESOLg+5QQHyBdsFVA4ZklENxmOyxv8o06T+3HBG2JOSuz/CIPz1vKdB7dmWDGPZNuPosdtnp+xv2A==", + "dev": true + }, "@types/react-native-button": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/react-native-button/-/react-native-button-3.0.1.tgz", @@ -40101,6 +40122,12 @@ "resolved": "https://registry.npmjs.org/react-native-android-open-settings/-/react-native-android-open-settings-1.3.0.tgz", "integrity": "sha512-h4FTWRtTLRVNS7RK4Bg2gAXe8G5bFZL8Jtmfk2rG2a/N/wJR+v1rAY2BxRkC48lQTqwQCIAQRbWgLVkJ8XmaLQ==" }, + "react-native-background-timer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-native-background-timer/-/react-native-background-timer-2.4.1.tgz", + "integrity": "sha512-TE4Kiy7jUyv+hugxDxitzu38sW1NqjCk4uE5IgU2WevLv7sZacaBc6PZKOShNRPGirLl1NWkaG3LDEkdb9Um5g==", + "requires": {} + }, "react-native-button": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/react-native-button/-/react-native-button-3.0.1.tgz", diff --git a/package.json b/package.json index 0d2276789..17d472911 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-intl": "5.24.4", "react-native": "0.67.2", "react-native-android-open-settings": "1.3.0", + "react-native-background-timer": "2.4.1", "react-native-button": "3.0.1", "react-native-calendars": "1.1276.0", "react-native-device-info": "8.4.8", @@ -111,6 +112,7 @@ "@types/lodash": "4.14.178", "@types/react": "17.0.38", "@types/react-native": "0.66.15", + "@types/react-native-background-timer": "2.0.0", "@types/react-native-button": "3.0.1", "@types/react-native-share": "3.3.3", "@types/react-native-video": "5.0.12", diff --git a/types/components/tutorial_highlight.d.ts b/types/components/tutorial_highlight.d.ts new file mode 100644 index 000000000..df302b0a3 --- /dev/null +++ b/types/components/tutorial_highlight.d.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type TutorialItemBounds = { + startX: number; + startY: number; + endX: number; + endY: number; +}