From 5fae1208267c267e615b5649fca49d1c2b810dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 24 Nov 2022 18:52:15 +0100 Subject: [PATCH] Add support for review app (#6772) * Add app review * Use overlay instead of modal * Add fixes for ios * i18n-extract * Add to milliseconds function * Address review feedback * Add try to queryGlobalValue * added app review illustration * add feedback illustration * Add animations and feedback bot message * Restrict reviews to build environment variable * Fix bug with "dont ask anymore" * Add check for only supported servers * Add missing change * Use for await Co-authored-by: Daniel Espino Co-authored-by: Matthew Birtch --- app/actions/app/global.ts | 40 +-- app/actions/remote/channel.ts | 23 ++ app/actions/remote/general.ts | 1 + app/actions/remote/nps.ts | 30 +++ app/client/rest/base.ts | 8 +- app/client/rest/files.ts | 4 +- app/client/rest/index.ts | 5 +- app/client/rest/nps.ts | 19 ++ app/components/button/index.tsx | 61 +++++ .../formatted_relative_time/index.tsx | 7 +- app/components/illustrations/review_app.tsx | 197 +++++++++++++++ .../illustrations/share_feedback.tsx | 49 ++++ app/constants/calls.ts | 4 +- app/constants/database.ts | 3 + app/constants/files.ts | 4 +- app/constants/general.ts | 6 + app/constants/network.ts | 3 +- app/constants/post.ts | 6 +- app/constants/screens.ts | 6 + app/hooks/navigate_back.ts | 4 +- app/init/launch.ts | 20 +- app/init/managed_app.ts | 3 +- app/managers/websocket_manager.ts | 5 +- .../calls/components/call_duration.tsx | 7 +- app/products/calls/connection/simple-peer.ts | 6 +- app/queries/app/global.ts | 46 +++- app/queries/app/servers.ts | 34 +++ .../servers/servers_list/server_item/index.ts | 2 +- app/screens/home/index.tsx | 18 ++ app/screens/index.tsx | 6 + app/screens/navigation.ts | 24 +- app/screens/post_options/index.ts | 3 +- app/screens/review_app/index.tsx | 230 ++++++++++++++++++ app/screens/share_feedback/index.tsx | 187 ++++++++++++++ app/utils/buttonStyles.ts | 16 -- app/utils/datetime.ts | 7 + app/utils/navigation/index.ts | 10 +- app/utils/post_list/index.ts | 5 +- app/utils/reviews.ts | 57 +++++ assets/base/config.json | 3 +- assets/base/i18n/en.json | 11 + fastlane/Fastfile | 5 + ios/Podfile.lock | 6 + package-lock.json | 11 + package.json | 1 + types/components/button.d.ts | 18 ++ types/launch/index.ts | 1 + 47 files changed, 1144 insertions(+), 78 deletions(-) create mode 100644 app/actions/remote/nps.ts create mode 100644 app/client/rest/nps.ts create mode 100644 app/components/button/index.tsx create mode 100644 app/components/illustrations/review_app.tsx create mode 100644 app/components/illustrations/share_feedback.tsx create mode 100644 app/screens/review_app/index.tsx create mode 100644 app/screens/share_feedback/index.tsx create mode 100644 app/utils/reviews.ts create mode 100644 types/components/button.d.ts diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index 09706cfc22..bf1aa1e21e 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -4,11 +4,11 @@ import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; -export const storeDeviceToken = async (token: string, prepareRecordsOnly = false) => { +export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly = false) => { try { const {operator} = DatabaseManager.getAppDatabaseAndOperator(); return operator.handleGlobal({ - globals: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: token}], + globals: [{id, value}], prepareRecordsOnly, }); } catch (error) { @@ -16,26 +16,26 @@ export const storeDeviceToken = async (token: string, prepareRecordsOnly = false } }; +export const storeDeviceToken = async (token: string, prepareRecordsOnly = false) => { + return storeGlobal(GLOBAL_IDENTIFIERS.DEVICE_TOKEN, token, prepareRecordsOnly); +}; + export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => { - try { - const {operator} = DatabaseManager.getAppDatabaseAndOperator(); - return operator.handleGlobal({ - globals: [{id: GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, value: 'true'}], - prepareRecordsOnly, - }); - } catch (error) { - return {error}; - } + return storeGlobal(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, 'true', prepareRecordsOnly); }; export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => { - try { - const {operator} = DatabaseManager.getAppDatabaseAndOperator(); - return operator.handleGlobal({ - globals: [{id: GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, value: 'true'}], - prepareRecordsOnly, - }); - } catch (error) { - return {error}; - } + return storeGlobal(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, 'true', prepareRecordsOnly); +}; + +export const storeDontAskForReview = async (prepareRecordsOnly = false) => { + return storeGlobal(GLOBAL_IDENTIFIERS.DONT_ASK_FOR_REVIEW, 'true', prepareRecordsOnly); +}; + +export const storeLastAskForReview = async (prepareRecordsOnly = false) => { + return storeGlobal(GLOBAL_IDENTIFIERS.LAST_ASK_FOR_REVIEW, Date.now(), prepareRecordsOnly); +}; + +export const storeFirstLaunch = async (prepareRecordsOnly = false) => { + return storeGlobal(GLOBAL_IDENTIFIERS.FIRST_LAUNCH, Date.now(), prepareRecordsOnly); }; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 73e65ac220..2f5beb170a 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -709,6 +709,29 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri } } +export async function goToNPSChannel(serverUrl: string) { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const user = await client.getUserByUsername(General.NPS_PLUGIN_BOT_USERNAME); + const {data, error} = await createDirectChannel(serverUrl, user.id); + if (error || !data) { + throw error || new Error('channel not found'); + } + await switchToChannelById(serverUrl, data.id, data.team_id); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } + + return {}; +} + export async function createDirectChannel(serverUrl: string, userId: string, displayName = '') { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index a3c816446c..e624ccee51 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -124,3 +124,4 @@ export const getRedirectLocation = async (serverUrl: string, link: string) => { return {error}; } }; + diff --git a/app/actions/remote/nps.ts b/app/actions/remote/nps.ts new file mode 100644 index 0000000000..2b7da4ea1c --- /dev/null +++ b/app/actions/remote/nps.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {General} from '@constants'; +import NetworkManager from '@managers/network_manager'; + +export const isNPSEnabled = async (serverUrl: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const manifests = await client.getPluginsManifests(); + for (const v of manifests) { + if (v.id === General.NPS_PLUGIN_ID) { + return true; + } + } + return false; + } catch (error) { + return false; + } +}; + +export const giveFeedbackAction = async (serverUrl: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const post = await client.npsGiveFeedbackAction(); + return {post}; + } catch (error) { + return {error}; + } +}; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index cafe5f88da..573f09aa29 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -205,12 +205,16 @@ export default class ClientBase { return `${this.urlVersion}/plugins`; } + getPluginRoute(id: string) { + return `/plugins/${id}`; + } + getAppsProxyRoute() { - return '/plugins/com.mattermost.apps'; + return this.getPluginRoute('com.mattermost.apps'); } getCallsRoute() { - return `/plugins/${Calls.PluginId}`; + return this.getPluginRoute(Calls.PluginId); } doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => { diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index c4a3f62726..0a1f8a3d6e 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -3,6 +3,8 @@ import {ClientResponse, ClientResponseError, ProgressPromise, UploadRequestOptions} from '@mattermost/react-native-network-client'; +import {toMilliseconds} from '@utils/datetime'; + export interface ClientFilesMix { getFileUrl: (fileId: string, timestamp: number) => string; getFileThumbnailUrl: (fileId: string, timestamp: number) => string; @@ -72,7 +74,7 @@ const ClientFiles = (superclass: any) => class extends superclass { channel_id: channelId, }, }, - timeoutInterval: 3 * 60 * 1000, // 3 minutes + timeoutInterval: toMilliseconds({minutes: 3}), }; const promise = this.apiClient.upload(url, file.localPath, options) as ProgressPromise; promise.progress!(onProgress).then(onComplete).catch(onError); diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index 9c4ada9014..4922d26b42 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -15,6 +15,7 @@ import ClientFiles, {ClientFilesMix} from './files'; import ClientGeneral, {ClientGeneralMix} from './general'; import ClientGroups, {ClientGroupsMix} from './groups'; import ClientIntegrations, {ClientIntegrationsMix} from './integrations'; +import ClientNPS, {ClientNPSMix} from './nps'; import ClientPosts, {ClientPostsMix} from './posts'; import ClientPreferences, {ClientPreferencesMix} from './preferences'; import ClientTeams, {ClientTeamsMix} from './teams'; @@ -40,7 +41,8 @@ interface Client extends ClientBase, ClientTosMix, ClientUsersMix, ClientCallsMix, - ClientPluginsMix + ClientPluginsMix, + ClientNPSMix {} class Client extends mix(ClientBase).with( @@ -60,6 +62,7 @@ class Client extends mix(ClientBase).with( ClientUsers, ClientCalls, ClientPlugins, + ClientNPS, ) { // eslint-disable-next-line no-useless-constructor constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) { diff --git a/app/client/rest/nps.ts b/app/client/rest/nps.ts new file mode 100644 index 0000000000..c0c7c68bb9 --- /dev/null +++ b/app/client/rest/nps.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {General} from '@constants'; + +export interface ClientNPSMix { + npsGiveFeedbackAction: () => Promise; +} + +const ClientNPS = (superclass: any) => class extends superclass { + npsGiveFeedbackAction = async () => { + return this.doFetch( + `${this.getPluginRoute(General.NPS_PLUGIN_ID)}/api/v1/give_feedback`, + {method: 'post'}, + ); + }; +}; + +export default ClientNPS; diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx new file mode 100644 index 0000000000..24c4c0a172 --- /dev/null +++ b/app/components/button/index.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; +import RNButton from 'react-native-button'; + +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; + +type Props = { + theme: Theme; + backgroundStyle?: StyleProp; + textStyle?: StyleProp; + size?: ButtonSize; + emphasis?: ButtonEmphasis; + buttonType?: ButtonType; + buttonState?: ButtonState; + testID?: string; + onPress: () => void; + text: string; +} + +const Button = ({ + theme, + backgroundStyle, + textStyle, + size, + emphasis, + buttonType, + buttonState, + onPress, + text, + testID, +}: Props) => { + const bgStyle = useMemo(() => [ + buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState), + backgroundStyle, + ], [theme, backgroundStyle, size, emphasis, buttonType, buttonState]); + + const txtStyle = useMemo(() => [ + buttonTextStyle(theme, size, emphasis, buttonType), + textStyle, + ], [theme, textStyle, size, emphasis, buttonType]); + + return ( + + + {text} + + + ); +}; + +export default Button; diff --git a/app/components/formatted_relative_time/index.tsx b/app/components/formatted_relative_time/index.tsx index 4c6573dd8e..fe800f3d4b 100644 --- a/app/components/formatted_relative_time/index.tsx +++ b/app/components/formatted_relative_time/index.tsx @@ -5,6 +5,8 @@ import moment from 'moment-timezone'; import React, {useEffect, useState} from 'react'; import {Text, TextProps} from 'react-native'; +import {toMilliseconds} from '@utils/datetime'; + type FormattedRelativeTimeProps = TextProps & { timezone?: UserTimezone | string; value: number | string | Date; @@ -24,7 +26,10 @@ const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...pro const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime); useEffect(() => { if (updateIntervalInSeconds) { - const interval = setInterval(() => setFormattedTime(getFormattedRelativeTime()), updateIntervalInSeconds * 1000); + const interval = setInterval( + () => setFormattedTime(getFormattedRelativeTime()), + toMilliseconds({seconds: updateIntervalInSeconds}), + ); return function cleanup() { return clearInterval(interval); }; diff --git a/app/components/illustrations/review_app.tsx b/app/components/illustrations/review_app.tsx new file mode 100644 index 0000000000..5ebaf5a7d6 --- /dev/null +++ b/app/components/illustrations/review_app.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + Path, + Mask, + G, + Rect, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function ReviewAppIllustration({theme}: Props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ReviewAppIllustration; diff --git a/app/components/illustrations/share_feedback.tsx b/app/components/illustrations/share_feedback.tsx new file mode 100644 index 0000000000..a46f7b2add --- /dev/null +++ b/app/components/illustrations/share_feedback.tsx @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + Path, + Ellipse, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function ShareFeedbackIllustration({theme}: Props) { + return ( + + + + + + + + ); +} + +export default ShareFeedbackIllustration; diff --git a/app/constants/calls.ts b/app/constants/calls.ts index ec17fde4d4..c11a7c8116 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -const RefreshConfigMillis = 20 * 60 * 1000; // Refresh config after 20 minutes +import {toMilliseconds} from '@utils/datetime'; + +const RefreshConfigMillis = toMilliseconds({minutes: 20}); const RequiredServer = { FULL_VERSION: '6.3.0', diff --git a/app/constants/database.ts b/app/constants/database.ts index 4660e48120..7f2e85e2fc 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -73,6 +73,9 @@ export const SYSTEM_IDENTIFIERS = { export const GLOBAL_IDENTIFIERS = { DEVICE_TOKEN: 'deviceToken', + DONT_ASK_FOR_REVIEW: 'dontAskForReview', + FIRST_LAUNCH: 'firstLaunch', + LAST_ASK_FOR_REVIEW: 'lastAskForReview', MULTI_SERVER_TUTORIAL: 'multiServerTutorial', PROFILE_LONG_PRESS_TUTORIAL: 'profileLongPressTutorial', }; diff --git a/app/constants/files.ts b/app/constants/files.ts index 84762ecf51..0a90dbcd75 100644 --- a/app/constants/files.ts +++ b/app/constants/files.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {toMilliseconds} from '@utils/datetime'; + export const VALID_IMAGE_MIME_TYPES = [ 'image/jpeg', 'image/jpeg', @@ -43,6 +45,6 @@ export const Files: Record = { }; Files.DOCUMENT_TYPES = Files.WORD_TYPES.concat(Files.PDF_TYPES, Files.TEXT_TYPES); -export const PROGRESS_TIME_TO_STORE = 60000; // 60 * 1000 (60s) +export const PROGRESS_TIME_TO_STORE = toMilliseconds({seconds: 60}); export default Files; diff --git a/app/constants/general.ts b/app/constants/general.ts index c201822c06..1be49a7013 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {toMilliseconds} from '@utils/datetime'; + export default { PAGE_SIZE_DEFAULT: 60, POST_CHUNK_SIZE: 60, @@ -36,4 +38,8 @@ export default { AUTOCOMPLETE_SPLIT_CHARACTERS: ['.', '-', '_'], CHANNEL_USER_ROLE: 'channel_user', RESTRICT_DIRECT_MESSAGE_ANY: 'any', + TIME_TO_FIRST_REVIEW: toMilliseconds({days: 14}), + TIME_TO_NEXT_REVIEW: toMilliseconds({days: 90}), + NPS_PLUGIN_ID: 'com.mattermost.nps', + NPS_PLUGIN_BOT_USERNAME: 'feedbackbot', }; diff --git a/app/constants/network.ts b/app/constants/network.ts index 1b67499d0c..4fdf7329d0 100644 --- a/app/constants/network.ts +++ b/app/constants/network.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {toMilliseconds} from '@utils/datetime'; import keyMirror from '@utils/key_mirror'; export const CERTIFICATE_ERRORS = keyMirror({ @@ -8,7 +9,7 @@ export const CERTIFICATE_ERRORS = keyMirror({ CLIENT_CERTIFICATE_MISSING: null, }); -export const DOWNLOAD_TIMEOUT = (1000 * 60) * 10; // 10 mins +export const DOWNLOAD_TIMEOUT = toMilliseconds({minutes: 10}); export default { CERTIFICATE_ERRORS, diff --git a/app/constants/post.ts b/app/constants/post.ts index 99a6083dca..4eee747e82 100644 --- a/app/constants/post.ts +++ b/app/constants/post.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {toMilliseconds} from '@utils/datetime'; + export const PostTypes: Record = { CHANNEL_DELETED: 'system_channel_deleted', CHANNEL_UNARCHIVED: 'system_channel_restored', @@ -45,10 +47,10 @@ export enum PostPriorityType { IMPORTANT = 'important', } -export const POST_TIME_TO_FAIL = 10000; +export const POST_TIME_TO_FAIL = toMilliseconds({seconds: 10}); export default { - POST_COLLAPSE_TIMEOUT: 1000 * 60 * 5, + POST_COLLAPSE_TIMEOUT: toMilliseconds({minutes: 5}), POST_TYPES: PostTypes, USER_ACTIVITY_POST_TYPES: [ PostTypes.ADD_TO_CHANNEL, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 8c60dfb5f6..74885588a2 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -37,6 +37,7 @@ export const PERMALINK = 'Permalink'; export const PINNED_MESSAGES = 'PinnedMessages'; export const POST_OPTIONS = 'PostOptions'; export const REACTIONS = 'Reactions'; +export const REVIEW_APP = 'ReviewApp'; export const SAVED_MESSAGES = 'SavedMessages'; export const SEARCH = 'Search'; export const SELECT_TEAM = 'SelectTeam'; @@ -53,6 +54,7 @@ export const SETTINGS_NOTIFICATION_AUTO_RESPONDER = 'SettingsNotificationAutoRes export const SETTINGS_NOTIFICATION_EMAIL = 'SettingsNotificationEmail'; export const SETTINGS_NOTIFICATION_MENTION = 'SettingsNotificationMention'; export const SETTINGS_NOTIFICATION_PUSH = 'SettingsNotificationPush'; +export const SHARE_FEEDBACK = 'ShareFeedback'; export const SNACK_BAR = 'SnackBar'; export const SSO = 'SSO'; export const TABLE = 'Table'; @@ -98,6 +100,7 @@ export default { PINNED_MESSAGES, POST_OPTIONS, REACTIONS, + REVIEW_APP, SAVED_MESSAGES, SEARCH, SELECT_TEAM, @@ -114,6 +117,7 @@ export default { SETTINGS_NOTIFICATION_EMAIL, SETTINGS_NOTIFICATION_MENTION, SETTINGS_NOTIFICATION_PUSH, + SHARE_FEEDBACK, SNACK_BAR, SSO, TABLE, @@ -153,6 +157,8 @@ export const OVERLAY_SCREENS = new Set([ IN_APP_NOTIFICATION, GALLERY, SNACK_BAR, + REVIEW_APP, + SHARE_FEEDBACK, ]); export const NOT_READY = [ diff --git a/app/hooks/navigate_back.ts b/app/hooks/navigate_back.ts index 8b595cfba7..65185a30a6 100644 --- a/app/hooks/navigate_back.ts +++ b/app/hooks/navigate_back.ts @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {EffectCallback, useEffect} from 'react'; +import {useEffect} from 'react'; import {Navigation} from 'react-native-navigation'; const BACK_BUTTON = 'RNN.back'; -const useBackNavigation = (callback: EffectCallback) => { +const useBackNavigation = (callback: () => void) => { useEffect(() => { const backListener = Navigation.events().registerNavigationButtonPressedListener(({buttonId}) => { if (buttonId === BACK_BUTTON) { diff --git a/app/init/launch.ts b/app/init/launch.ts index 04aa737a03..ddcb307f34 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -25,7 +25,7 @@ const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, Pu export const initialLaunch = async () => { const deepLinkUrl = await Linking.getInitialURL(); if (deepLinkUrl) { - launchAppFromDeepLink(deepLinkUrl); + launchAppFromDeepLink(deepLinkUrl, true); return; } @@ -41,20 +41,20 @@ export const initialLaunch = async () => { tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null; } if (initialNotificationTypes.includes(notification?.payload?.type) && tapped) { - launchAppFromNotification(convertToNotificationData(notification!)); + launchAppFromNotification(convertToNotificationData(notification!), true); return; } - launchApp({launchType: Launch.Normal}); + launchApp({launchType: Launch.Normal, coldStart: true}); }; -const launchAppFromDeepLink = (deepLinkUrl: string) => { - const props = getLaunchPropsFromDeepLink(deepLinkUrl); +const launchAppFromDeepLink = (deepLinkUrl: string, coldStart = false) => { + const props = getLaunchPropsFromDeepLink(deepLinkUrl, coldStart); launchApp(props); }; -const launchAppFromNotification = async (notification: NotificationWithData) => { - const props = await getLaunchPropsFromNotification(notification); +const launchAppFromNotification = async (notification: NotificationWithData, coldStart = false) => { + const props = await getLaunchPropsFromNotification(notification, coldStart); launchApp(props); }; @@ -184,10 +184,11 @@ export const relaunchApp = (props: LaunchProps, resetNavigation = false) => { return launchApp(props, resetNavigation); }; -export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps => { +export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => { const parsed = parseDeepLink(deepLinkUrl); const launchProps: LaunchProps = { launchType: Launch.DeepLink, + coldStart, }; switch (parsed.type) { @@ -219,9 +220,10 @@ export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps => return launchProps; }; -export const getLaunchPropsFromNotification = async (notification: NotificationWithData): Promise => { +export const getLaunchPropsFromNotification = async (notification: NotificationWithData, coldStart = false): Promise => { const launchProps: LaunchProps = { launchType: Launch.Notification, + coldStart, }; const {payload} = notification; diff --git a/app/init/managed_app.ts b/app/init/managed_app.ts index ffe6d6197c..86c15f29b0 100644 --- a/app/init/managed_app.ts +++ b/app/init/managed_app.ts @@ -7,9 +7,10 @@ import JailMonkey from 'jail-monkey'; import {Alert, AlertButton, AppState, AppStateStatus, Platform} from 'react-native'; import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; +import {toMilliseconds} from '@utils/datetime'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; -const PROMPT_IN_APP_PIN_CODE_AFTER = 5 * 60 * 1000; +const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5}); class ManagedApp { backgroundSince = 0; diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index e19446eaa5..3ac1ea269e 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -14,10 +14,11 @@ import {General} from '@constants'; import DatabaseManager from '@database/manager'; import {getCurrentUserId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; +import {toMilliseconds} from '@utils/datetime'; import {logError} from '@utils/log'; -const WAIT_TO_CLOSE = 15 * 1000; -const WAIT_UNTIL_NEXT = 20 * 1000; +const WAIT_TO_CLOSE = toMilliseconds({seconds: 15}); +const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 20}); class WebsocketManager { private clients: Record = {}; diff --git a/app/products/calls/components/call_duration.tsx b/app/products/calls/components/call_duration.tsx index bfc3c06a17..f878285d74 100644 --- a/app/products/calls/components/call_duration.tsx +++ b/app/products/calls/components/call_duration.tsx @@ -5,6 +5,8 @@ import moment from 'moment-timezone'; import React, {useEffect, useState} from 'react'; import {Text, StyleProp, TextStyle} from 'react-native'; +import {toMilliseconds} from '@utils/datetime'; + type CallDurationProps = { style: StyleProp; value: number; @@ -34,7 +36,10 @@ const CallDuration = ({value, style, updateIntervalInSeconds}: CallDurationProps const [formattedTime, setFormattedTime] = useState(() => getCallDuration()); useEffect(() => { if (updateIntervalInSeconds) { - const interval = setInterval(() => setFormattedTime(getCallDuration()), updateIntervalInSeconds * 1000); + const interval = setInterval( + () => setFormattedTime(getCallDuration()), + toMilliseconds({seconds: updateIntervalInSeconds}), + ); return function cleanup() { clearInterval(interval); }; diff --git a/app/products/calls/connection/simple-peer.ts b/app/products/calls/connection/simple-peer.ts index 8a7f51cb22..6af6c1e78b 100644 --- a/app/products/calls/connection/simple-peer.ts +++ b/app/products/calls/connection/simple-peer.ts @@ -21,6 +21,8 @@ import { } from 'react-native-webrtc'; import stream from 'readable-stream'; +import {toMilliseconds} from '@utils/datetime'; + import type {ICEServersConfigs} from '@calls/types/calls'; const queueMicrotask = (callback: any) => { @@ -55,8 +57,8 @@ function generateId(): string { } const MAX_BUFFERED_AMOUNT = 64 * 1024; -const ICECOMPLETE_TIMEOUT = 5 * 1000; -const CHANNEL_CLOSING_TIMEOUT = 5 * 1000; +const ICECOMPLETE_TIMEOUT = toMilliseconds({seconds: 5}); +const CHANNEL_CLOSING_TIMEOUT = toMilliseconds({seconds: 5}); /** * WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods. diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index 0626804587..c72840486a 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -21,20 +21,56 @@ export const getDeviceToken = async (appDatabase: Database): Promise => } }; -export const observeMultiServerTutorial = (appDatabase: Database) => { - return appDatabase.get(GLOBAL).query(Q.where('id', GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL), Q.take(1)).observe().pipe( +export const queryGlobalValue = (key: string) => { + try { + const {database} = DatabaseManager.getAppDatabaseAndOperator(); + return database.get(GLOBAL).query(Q.where('id', key), Q.take(1)); + } catch { + return undefined; + } +}; + +export const observeMultiServerTutorial = () => { + const query = queryGlobalValue(GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL); + if (!query) { + return of$(false); + } + return query.observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$(false))), switchMap((v) => of$(Boolean(v))), ); }; export const observeProfileLongPresTutorial = () => { - const appDatabase = DatabaseManager.appDatabase?.database; - if (!appDatabase) { + const query = queryGlobalValue(GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL); + if (!query) { return of$(false); } - return appDatabase.get(GLOBAL).query(Q.where('id', GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL), Q.take(1)).observe().pipe( + return query.observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$(false))), switchMap((v) => of$(Boolean(v))), ); }; + +export const getLastAskedForReview = async () => { + const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LAST_ASK_FOR_REVIEW)?.fetch(); + if (!records?.[0]?.value) { + return 0; + } + + return records[0].value; +}; + +export const getDontAskForReview = async () => { + const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.DONT_ASK_FOR_REVIEW)?.fetch(); + return Boolean(records?.[0]?.value); +}; + +export const getFirstLaunch = async () => { + const records = await queryGlobalValue(GLOBAL_IDENTIFIERS.FIRST_LAUNCH)?.fetch(); + if (!records?.[0]?.value) { + return 0; + } + + return records[0].value; +}; diff --git a/app/queries/app/servers.ts b/app/queries/app/servers.ts index f1ca4d36d3..a1d28a6ddb 100644 --- a/app/queries/app/servers.ts +++ b/app/queries/app/servers.ts @@ -3,7 +3,11 @@ import {Database, Q} from '@nozbe/watermelondb'; +import {SupportedServer} from '@constants'; import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {getConfigValue} from '@queries/servers/system'; +import {isMinimumServerVersion} from '@utils/helpers'; import type ServerModel from '@typings/database/models/app/servers'; @@ -56,3 +60,33 @@ export const queryServerName = async (appDatabase: Database, serverUrl: string) return serverUrl; } }; + +export const areAllServersSupported = async () => { + let appDatabase; + try { + const databaseAndOperator = DatabaseManager.getAppDatabaseAndOperator(); + appDatabase = databaseAndOperator.database; + } catch { + return false; + } + + const servers = await queryAllServers(appDatabase); + for await (const s of servers) { + if (s.lastActiveAt) { + try { + const {database: serverDatabase} = DatabaseManager.getServerDatabaseAndOperator(s.url); + const version = await getConfigValue(serverDatabase, 'Version'); + + const {MAJOR_VERSION, MIN_VERSION, PATCH_VERSION} = SupportedServer; + const isSupportedServer = isMinimumServerVersion(version || '', MAJOR_VERSION, MIN_VERSION, PATCH_VERSION); + if (!isSupportedServer) { + return false; + } + } catch { + continue; + } + } + } + + return true; +}; 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 index 7b9dc91cde..7c7583302f 100644 --- 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 @@ -16,7 +16,7 @@ import type ServersModel from '@typings/database/models/app/servers'; const enhance = withObservables(['highlight'], ({highlight, server}: {highlight: boolean; server: ServersModel}) => { let tutorialWatched = of$(false); if (highlight) { - tutorialWatched = observeMultiServerTutorial(server.database); + tutorialWatched = observeMultiServerTutorial(); } const serverDatabase = DatabaseManager.serverDatabases[server.url]?.database; diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 5389ce3f88..3bd5faedce 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -16,6 +16,7 @@ import {findChannels, popToRoot} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation'; import {notificationError} from '@utils/notification'; +import {tryRunAppReview} from '@utils/reviews'; import Account from './account'; import ChannelList from './channel_list'; @@ -40,6 +41,14 @@ type HomeProps = LaunchProps & { const Tab = createBottomTabNavigator(); +// This is needed since the Database Provider is recreating this component +// when the database is changed (couldn't find exactly why), re-triggering +// the effect. This makes sure the rate logic is only handle on the first +// run. Most of the normal users won't see this issue, but on edge times +// (near the time you will see the rate dialog) will show when switching +// servers. +let hasRendered = false; + export default function HomeScreen(props: HomeProps) { const theme = useTheme(); const intl = useIntl(); @@ -96,6 +105,15 @@ export default function HomeScreen(props: HomeProps) { }; }, [intl.locale]); + // Init the rate app. Only run the effect on the first render + useEffect(() => { + if (hasRendered) { + return; + } + hasRendered = true; + tryRunAppReview(props.launchType, props.coldStart); + }, []); + return ( <> { case Screens.REACTIONS: screen = withServerDatabase(require('@screens/reactions').default); break; + case Screens.REVIEW_APP: + screen = withServerDatabase(require('@screens/review_app').default); + break; case Screens.SETTINGS: screen = withServerDatabase(require('@screens/settings').default); break; @@ -197,6 +200,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.SETTINGS_NOTIFICATION_PUSH: screen = withServerDatabase(require('@screens/settings/notification_push').default); break; + case Screens.SHARE_FEEDBACK: + screen = withServerDatabase(require('@screens/share_feedback').default); + break; case Screens.SNACK_BAR: { const snackBarScreen = withServerDatabase(require('@screens/snack_bar').default); Navigation.registerComponent(Screens.SNACK_BAR, () => diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 7eb8fda449..150593c553 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -77,7 +77,7 @@ export const loginAnimationOptions = () => { }; }; -export const bottomSheetModalOptions = (theme: Theme, closeButtonId?: string) => { +export const bottomSheetModalOptions = (theme: Theme, closeButtonId?: string): Options => { if (closeButtonId) { const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor); const closeButtonTestId = `${closeButtonId.replace('close-', 'close.').replace(/-/g, '_')}.button`; @@ -432,7 +432,7 @@ export async function dismissAllModalsAndPopToScreen(screenId: string, title: st } } -export function showModal(name: string, title: string, passProps = {}, options = {}) { +export function showModal(name: string, title: string, passProps = {}, options: Options = {}) { if (!isScreenRegistered(name)) { return; } @@ -485,7 +485,7 @@ export function showModal(name: string, title: string, passProps = {}, options = }); } -export function showModalOverCurrentContext(name: string, passProps = {}, options = {}) { +export function showModalOverCurrentContext(name: string, passProps = {}, options: Options = {}) { const title = ''; let animations; switch (Platform.OS) { @@ -599,7 +599,7 @@ export function setButtons(componentId: string, buttons: NavButtons = {leftButto mergeNavigationOptions(componentId, options); } -export function showOverlay(name: string, passProps = {}, options = {}) { +export function showOverlay(name: string, passProps = {}, options: Options = {}) { if (!isScreenRegistered(name)) { return; } @@ -694,6 +694,22 @@ export const showAppForm = async (form: AppForm) => { showModal(Screens.APPS_FORM, form.title || '', passProps); }; +export const showReviewOverlay = (hasAskedBefore: boolean) => { + showOverlay( + Screens.REVIEW_APP, + {hasAskedBefore}, + {overlay: {interceptTouchOutside: true}}, + ); +}; + +export const showShareFeedbackOverlay = () => { + showOverlay( + Screens.SHARE_FEEDBACK, + {}, + {overlay: {interceptTouchOutside: true}}, + ); +}; + export async function findChannels(title: string, theme: Theme) { const options: Options = {}; const closeButtonId = 'close-find-channels'; diff --git a/app/screens/post_options/index.ts b/app/screens/post_options/index.ts index cf33174ca2..78253bb9c0 100644 --- a/app/screens/post_options/index.ts +++ b/app/screens/post_options/index.ts @@ -13,6 +13,7 @@ import {observePermissionForChannel, observePermissionForPost} from '@queries/se import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system'; import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; +import {toMilliseconds} from '@utils/datetime'; import {isMinimumServerVersion} from '@utils/helpers'; import {isSystemMessage} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; @@ -40,7 +41,7 @@ const observeCanEditPost = (database: Database, isOwner: boolean, post: PostMode } if (isLicensed && postEditTimeLimit !== -1) { - const timeLeft = (post.createAt + (postEditTimeLimit * 1000)) - Date.now(); + const timeLeft = (post.createAt + toMilliseconds({seconds: postEditTimeLimit})) - Date.now(); if (timeLeft <= 0) { return of$(false); } diff --git a/app/screens/review_app/index.tsx b/app/screens/review_app/index.tsx new file mode 100644 index 0000000000..9013cd8ed4 --- /dev/null +++ b/app/screens/review_app/index.tsx @@ -0,0 +1,230 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {TouchableWithoutFeedback, View, Text, Alert, TouchableOpacity} from 'react-native'; +import InAppReview from 'react-native-in-app-review'; +import Animated, {runOnJS, SlideInDown, SlideOutDown} from 'react-native-reanimated'; + +import {storeDontAskForReview, storeLastAskForReview} from '@actions/app/global'; +import {isNPSEnabled} from '@actions/remote/nps'; +import Button from '@components/button'; +import CompassIcon from '@components/compass_icon'; +import ReviewAppIllustration from '@components/illustrations/review_app'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useBackNavigation from '@hooks/navigate_back'; +import {dismissOverlay, showShareFeedbackOverlay} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + hasAskedBefore: boolean; + componentId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + root: { + flex: 1, + backgroundColor: changeOpacity('#000000', 0.50), + justifyContent: 'center', + alignItems: 'center', + }, + container: { + flex: 1, + maxWidth: 680, + alignSelf: 'center', + alignContet: 'center', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + flexDirection: 'row', + }, + wrapper: { + backgroundColor: theme.centerChannelBg, + borderRadius: 12, + flex: 1, + margin: 10, + opacity: 1, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + + }, + content: { + marginHorizontal: 24, + marginBottom: 24, + alignItems: 'center', + }, + buttonsWrapper: { + flexDirection: 'row', + width: '100%', + }, + close: { + justifyContent: 'center', + height: 44, + width: 40, + paddingLeft: 16, + paddingTop: 16, + }, + title: { + ...typography('Heading', 600, 'SemiBold'), + color: theme.centerChannelColor, + marginTop: 0, + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + ...typography('Body', 200, 'Regular'), + color: changeOpacity(theme.centerChannelColor, 0.72), + marginBottom: 24, + textAlign: 'center', + }, + leftButton: { + flex: 1, + marginRight: 5, + }, + rightButton: { + flex: 1, + marginLeft: 5, + }, + dontAsk: { + ...typography('Body', 75, 'SemiBold'), + color: theme.buttonBg, + marginTop: 24, + }, +})); + +const ReviewApp = ({ + hasAskedBefore, + componentId, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const serverUrl = useServerUrl(); + + const [show, setShow] = useState(true); + + const executeAfterDone = useRef<() => void>(() => dismissOverlay(componentId)); + + const close = useCallback((afterDone: () => void) => { + executeAfterDone.current = afterDone; + storeLastAskForReview(); + setShow(false); + }, []); + + const onPressYes = useCallback(async () => { + close(async () => { + await dismissOverlay(componentId); + try { + // eslint-disable-next-line new-cap + await InAppReview.RequestInAppReview(); + } catch (error) { + Alert.alert( + intl.formatMessage({id: 'rate.error.title', defaultMessage: 'Error'}), + intl.formatMessage({id: 'rate.error.text', defaultMessage: 'There has been an error while opening the review modal.'}), + ); + } + }); + }, [close, intl, componentId]); + + const onPressNeedsWork = useCallback(async () => { + close(async () => { + await dismissOverlay(componentId); + if (await isNPSEnabled(serverUrl)) { + showShareFeedbackOverlay(); + } + }); + }, [close, componentId, serverUrl]); + + const onPressDontAsk = useCallback(() => { + storeDontAskForReview(); + close(async () => { + await dismissOverlay(componentId); + }); + }, [close, intl, componentId]); + + const onPressClose = useCallback(() => { + close(async () => { + await dismissOverlay(componentId); + }); + }, [close, componentId]); + + useBackNavigation(onPressClose); + + const doAfterAnimation = useCallback(() => { + executeAfterDone.current(); + }, []); + + const slideOut = useMemo(() => SlideOutDown.withCallback((finished: boolean) => { + 'worklet'; + if (finished) { + runOnJS(doAfterAnimation)(); + } + }), []); + + return ( + + + {show && + + + + + + + + {intl.formatMessage({id: 'rate.title', defaultMessage: 'Enjoying Mattermost?'})} + + + {intl.formatMessage({id: 'rate.subtitle', defaultMessage: 'Let us know what you think.'})} + + +