diff --git a/app/actions/remote/terms_of_service.ts b/app/actions/remote/terms_of_service.ts new file mode 100644 index 0000000000..ff40727067 --- /dev/null +++ b/app/actions/remote/terms_of_service.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; + +import {forceLogoutIfNecessary} from './session'; + +import type ClientError from '@client/rest/error'; + +export async function fetchTermsOfService(serverUrl: string): Promise<{terms?: TermsOfService; error?: any}> { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const terms = await client.getTermsOfService(); + return {terms}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +} + +export async function updateTermsOfServiceStatus(serverUrl: string, id: string, status: boolean): Promise<{resp?: {status: string}; error?: any}> { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const resp = await client.updateMyTermsOfServiceStatus(id, status); + return {resp}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +} diff --git a/app/client/graphQL/entry.ts b/app/client/graphQL/entry.ts index eb1625b930..b83e08d84b 100644 --- a/app/client/graphQL/entry.ts +++ b/app/client/graphQL/entry.ts @@ -184,6 +184,8 @@ query ${QueryNames.QUERY_ENTRY} { createAt expiresAt } + termsOfServiceId + termsOfServiceCreateAt } teamMembers(userId:"me") { deleteAt diff --git a/app/client/rest/tos.ts b/app/client/rest/tos.ts index 6fdebd9fdd..0926ff461a 100644 --- a/app/client/rest/tos.ts +++ b/app/client/rest/tos.ts @@ -2,8 +2,8 @@ // See LICENSE.txt for license information. export interface ClientTosMix { - updateMyTermsOfServiceStatus: (termsOfServiceId: string, accepted: boolean) => Promise; - getTermsOfService: () => Promise; + updateMyTermsOfServiceStatus: (termsOfServiceId: string, accepted: boolean) => Promise<{status: string}>; + getTermsOfService: () => Promise; } const ClientTos = (superclass: any) => class extends superclass { diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 74885588a2..7bbacbc994 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -58,6 +58,7 @@ export const SHARE_FEEDBACK = 'ShareFeedback'; export const SNACK_BAR = 'SnackBar'; export const SSO = 'SSO'; export const TABLE = 'Table'; +export const TERMS_OF_SERVICE = 'TermsOfService'; export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; @@ -121,6 +122,7 @@ export default { SNACK_BAR, SSO, TABLE, + TERMS_OF_SERVICE, THREAD, THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, @@ -154,11 +156,12 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set([ ]); export const OVERLAY_SCREENS = new Set([ - IN_APP_NOTIFICATION, GALLERY, - SNACK_BAR, + IN_APP_NOTIFICATION, REVIEW_APP, SHARE_FEEDBACK, + SNACK_BAR, + TERMS_OF_SERVICE, ]); export const NOT_READY = [ diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 3ff742d003..f633a8edf7 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -14,9 +14,22 @@ const {SERVER: { MY_CHANNEL, TEAM, THREAD, + USER, }} = MM_TABLES; export default schemaMigrations({migrations: [ + { + toVersion: 6, + steps: [ + addColumns({ + table: USER, + columns: [ + {name: 'terms_of_service_id', type: 'string'}, + {name: 'terms_of_service_create_at', type: 'number'}, + ], + }), + ], + }, { toVersion: 5, steps: [ diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index efaca8ef96..1dc48e0f2a 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -121,6 +121,12 @@ export default class UserModel extends Model implements UserModelInterface { /** timezone : The timezone for this user */ @json('timezone', safeParseJSON) timezone!: UserTimezone | null; + /** termsOfServiceId : The id of the last accepted terms of service */ + @field('terms_of_service_id') termsOfServiceId!: string; + + /** termsOfServiceCreateAt : The last time the user accepted the terms of service */ + @field('terms_of_service_create_at') termsOfServiceCreateAt!: number; + /** channelsCreated : All the channels that this user created */ @children(CHANNEL) channelsCreated!: Query; diff --git a/app/database/operator/server_data_operator/transformers/user.ts b/app/database/operator/server_data_operator/transformers/user.ts index 2b84c7ae8d..420abc34f2 100644 --- a/app/database/operator/server_data_operator/transformers/user.ts +++ b/app/database/operator/server_data_operator/transformers/user.ts @@ -42,6 +42,8 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs): user.timezone = raw.timezone || null; user.isBot = raw.is_bot ?? false; user.remoteId = raw?.remote_id ?? null; + user.termsOfServiceId = raw?.terms_of_service_id ?? ''; + user.termsOfServiceCreateAt = raw?.terms_of_service_create_at ?? 0; if (raw.status) { user.status = raw.status; } diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index b6d0d06aae..5cb7a38076 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -38,7 +38,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 5, + version: 6, tables: [ CategorySchema, CategoryChannelSchema, diff --git a/app/database/schema/server/table_schemas/user.ts b/app/database/schema/server/table_schemas/user.ts index 849865f463..24af8ad374 100644 --- a/app/database/schema/server/table_schemas/user.ts +++ b/app/database/schema/server/table_schemas/user.ts @@ -29,5 +29,7 @@ export default tableSchema({ {name: 'timezone', type: 'string'}, {name: 'update_at', type: 'number'}, {name: 'username', type: 'string'}, + {name: 'terms_of_service_id', type: 'string'}, + {name: 'terms_of_service_create_at', type: 'number'}, ], }); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index efa7bb8f97..b4e294029b 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -44,7 +44,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 5, + version: 6, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -603,6 +603,9 @@ describe('*** Test schema for SERVER database ***', () => { timezone: {name: 'timezone', type: 'string'}, update_at: {name: 'update_at', type: 'number'}, username: {name: 'username', type: 'string'}, + terms_of_service_create_at: {name: 'terms_of_service_create_at', type: 'number'}, + terms_of_service_id: {name: 'terms_of_service_id', type: 'string'}, + }, columnArray: [ {name: 'auth_service', type: 'string'}, @@ -624,6 +627,8 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'timezone', type: 'string'}, {name: 'update_at', type: 'number'}, {name: 'username', type: 'string'}, + {name: 'terms_of_service_id', type: 'string'}, + {name: 'terms_of_service_create_at', type: 'number'}, ], }, }, diff --git a/app/hooks/android_back_handler.ts b/app/hooks/android_back_handler.ts index aaed5f004a..6ac3337225 100644 --- a/app/hooks/android_back_handler.ts +++ b/app/hooks/android_back_handler.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 {BackHandler} from 'react-native'; import NavigationStore from '@store/navigation_store'; -const useAndroidHardwareBackHandler = (componentId: string, callback: EffectCallback) => { +const useAndroidHardwareBackHandler = (componentId: string, callback: () => void) => { useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { if (NavigationStore.getNavigationTopComponentId() === componentId) { diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 56c353af31..733d8f1be1 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -16,20 +16,26 @@ import {Navigation as NavigationConstants, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; -import {resetToTeams} from '@screens/navigation'; +import {resetToTeams, openToS} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; +import {tryRunAppReview} from '@utils/reviews'; import {addSentryContext} from '@utils/sentry'; import AdditionalTabletView from './additional_tablet_view'; import CategoriesList from './categories_list'; import Servers from './servers'; +import type {LaunchType} from '@typings/launch'; + type ChannelProps = { channelsCount: number; isCRTEnabled: boolean; teamsCount: number; time?: number; isLicensed: boolean; + showToS: boolean; + launchType: LaunchType; + coldStart?: boolean; }; const edges: Edge[] = ['bottom', 'left', 'right']; @@ -47,6 +53,14 @@ const styles = StyleSheet.create({ let backPressedCount = 0; let backPressTimeout: NodeJS.Timeout|undefined; +// 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; + const ChannelListScreen = (props: ChannelProps) => { const theme = useTheme(); const managedConfig = useManagedConfig(); @@ -123,6 +137,23 @@ const ChannelListScreen = (props: ChannelProps) => { addSentryContext(serverUrl); }, [serverUrl]); + useEffect(() => { + if (props.showToS && !NavigationStore.isToSOpen()) { + openToS(); + } + }, [props.showToS]); + + // Init the rate app. Only run the effect on the first render if ToS is not open + useEffect(() => { + if (hasRendered) { + return; + } + hasRendered = true; + if (!NavigationStore.isToSOpen()) { + tryRunAppReview(props.launchType, props.coldStart); + } + }, []); + return ( diff --git a/app/screens/home/channel_list/index.ts b/app/screens/home/channel_list/index.ts index 00342c881b..4d2a73d692 100644 --- a/app/screens/home/channel_list/index.ts +++ b/app/screens/home/channel_list/index.ts @@ -3,27 +3,59 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {of as of$, combineLatest} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {queryAllMyChannelsForTeam} from '@queries/servers/channel'; -import {observeCurrentTeamId, observeLicense} from '@queries/servers/system'; +import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeCurrentTeamId, observeLicense} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; import {observeIsCRTEnabled} from '@queries/servers/thread'; +import {observeCurrentUser} from '@queries/servers/user'; import ChannelsList from './channel_list'; import type {WithDatabaseArgs} from '@typings/database/database'; -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - isCRTEnabled: observeIsCRTEnabled(database), - teamsCount: queryMyTeams(database).observeCount(false), - channelsCount: observeCurrentTeamId(database).pipe( - switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount() : of$(0))), - ), - isLicensed: observeLicense(database).pipe( +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const isLicensed = observeLicense(database).pipe( switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))), - ), -})); + ); + const currentUser = observeCurrentUser(database); + const customTermsOfServiceEnabled = observeConfigBooleanValue(database, 'EnableCustomTermsOfService'); + const customTermsOfServiceId = observeConfigValue(database, 'CustomTermsOfServiceId'); + const customTermsOfServicePeriod = observeConfigIntValue(database, 'CustomTermsOfServiceReAcceptancePeriod'); + + const showToS = combineLatest([ + isLicensed, + customTermsOfServiceEnabled, + currentUser, + customTermsOfServiceId, + customTermsOfServicePeriod, + ]).pipe( + switchMap(([lcs, cfg, user, id, period]) => { + if (!lcs || !cfg) { + return of$(false); + } + + if (user?.termsOfServiceId !== id) { + return of$(true); + } + + const timeElapsed = Date.now() - (user?.termsOfServiceCreateAt || 0); + return of$(timeElapsed > (period * 24 * 60 * 60 * 1000)); + }), + distinctUntilChanged(), + ); + + return { + isCRTEnabled: observeIsCRTEnabled(database), + teamsCount: queryMyTeams(database).observeCount(false), + channelsCount: observeCurrentTeamId(database).pipe( + switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount() : of$(0))), + ), + isLicensed, + showToS, + }; +}); export default withDatabase(enhanced(ChannelsList)); diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 3bd5faedce..5389ce3f88 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -16,7 +16,6 @@ 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'; @@ -41,14 +40,6 @@ 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(); @@ -105,15 +96,6 @@ 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.TABLE: screen = withServerDatabase(require('@screens/table').default); break; + case Screens.TERMS_OF_SERVICE: + screen = withServerDatabase(require('@screens/terms_of_service').default); + break; case Screens.THREAD: screen = withServerDatabase(require('@screens/thread').default); break; diff --git a/app/screens/integration_selector/channel_list_row/index.test.tsx b/app/screens/integration_selector/channel_list_row/index.test.tsx index 69cc99847e..b915461116 100644 --- a/app/screens/integration_selector/channel_list_row/index.test.tsx +++ b/app/screens/integration_selector/channel_list_row/index.test.tsx @@ -4,7 +4,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; -import {Preferences} from '@app/constants'; +import {Preferences} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/integration_selector/channel_list_row/index.tsx b/app/screens/integration_selector/channel_list_row/index.tsx index a0f0bbe7d9..3aea34f43e 100644 --- a/app/screens/integration_selector/channel_list_row/index.tsx +++ b/app/screens/integration_selector/channel_list_row/index.tsx @@ -7,9 +7,9 @@ import { View, } from 'react-native'; -import {typography} from '@app/utils/typography'; import CompassIcon from '@components/compass_icon'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row'; diff --git a/app/screens/integration_selector/custom_list/index.test.tsx b/app/screens/integration_selector/custom_list/index.test.tsx index 8d2f79e7c4..6542e89a1d 100644 --- a/app/screens/integration_selector/custom_list/index.test.tsx +++ b/app/screens/integration_selector/custom_list/index.test.tsx @@ -5,7 +5,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; import {Text} from 'react-native'; -import {Preferences} from '@app/constants'; +import {Preferences} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/integration_selector/custom_list/index.tsx b/app/screens/integration_selector/custom_list/index.tsx index d9f5671e73..c9a9fcddee 100644 --- a/app/screens/integration_selector/custom_list/index.tsx +++ b/app/screens/integration_selector/custom_list/index.tsx @@ -5,8 +5,8 @@ import { Text, Platform, FlatList, RefreshControl, View, SectionList, } from 'react-native'; -import {typography} from '@app/utils/typography'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; export const FLATLIST = 'flat'; export const SECTIONLIST = 'section'; diff --git a/app/screens/integration_selector/custom_list_row/index.test.tsx b/app/screens/integration_selector/custom_list_row/index.test.tsx index fda5607e5b..5ecab10e9c 100644 --- a/app/screens/integration_selector/custom_list_row/index.test.tsx +++ b/app/screens/integration_selector/custom_list_row/index.test.tsx @@ -5,7 +5,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; import {Text, View} from 'react-native'; -import CompassIcon from '@app/components/compass_icon'; +import CompassIcon from '@components/compass_icon'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/integration_selector/custom_list_row/index.tsx b/app/screens/integration_selector/custom_list_row/index.tsx index 4d8286580a..2184882d16 100644 --- a/app/screens/integration_selector/custom_list_row/index.tsx +++ b/app/screens/integration_selector/custom_list_row/index.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; -import CompassIcon from '@app/components/compass_icon'; +import CompassIcon from '@components/compass_icon'; export type Props = { id: string; diff --git a/app/screens/integration_selector/option_list_row/index.test.tsx b/app/screens/integration_selector/option_list_row/index.test.tsx index 79f622112c..435c00706b 100644 --- a/app/screens/integration_selector/option_list_row/index.test.tsx +++ b/app/screens/integration_selector/option_list_row/index.test.tsx @@ -4,7 +4,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; -import {Preferences} from '@app/constants'; +import {Preferences} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/integration_selector/option_list_row/index.tsx b/app/screens/integration_selector/option_list_row/index.tsx index 3d330c65b4..e20d4992f9 100644 --- a/app/screens/integration_selector/option_list_row/index.tsx +++ b/app/screens/integration_selector/option_list_row/index.tsx @@ -7,8 +7,8 @@ import { View, } from 'react-native'; -import {typography} from '@app/utils/typography'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row'; diff --git a/app/screens/integration_selector/selected_option/index.test.tsx b/app/screens/integration_selector/selected_option/index.test.tsx index 212585953a..24953c8887 100644 --- a/app/screens/integration_selector/selected_option/index.test.tsx +++ b/app/screens/integration_selector/selected_option/index.test.tsx @@ -4,8 +4,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; -import {Preferences} from '@app/constants'; -import {View as ViewConstants} from '@constants'; +import {Preferences, View as ViewConstants} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/integration_selector/selected_option/index.tsx b/app/screens/integration_selector/selected_option/index.tsx index 3bb0f6ac94..f3de838da9 100644 --- a/app/screens/integration_selector/selected_option/index.tsx +++ b/app/screens/integration_selector/selected_option/index.tsx @@ -8,10 +8,10 @@ import { View, } from 'react-native'; -import {typography} from '@app/utils/typography'; import CompassIcon from '@components/compass_icon'; import {View as ViewConstants} from '@constants'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; type Props = { theme: Theme; diff --git a/app/screens/integration_selector/selected_options/index.test.tsx b/app/screens/integration_selector/selected_options/index.test.tsx index 2216489918..cdeaf43dd3 100644 --- a/app/screens/integration_selector/selected_options/index.test.tsx +++ b/app/screens/integration_selector/selected_options/index.test.tsx @@ -4,8 +4,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; -import {Preferences} from '@app/constants'; -import {View as ViewConstants} from '@constants'; +import {Preferences, View as ViewConstants} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 150593c553..383a826bf9 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -182,6 +182,11 @@ function isScreenRegistered(screen: string) { return true; } +export function openToS() { + NavigationStore.setToSOpen(true); + return showOverlay(Screens.TERMS_OF_SERVICE, {}, {overlay: {interceptTouchOutside: true}}); +} + export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}) { const theme = getThemeFromState(); const isDark = tinyColor(theme.sidebarBg).isDark(); diff --git a/app/screens/terms_of_service/index.ts b/app/screens/terms_of_service/index.ts new file mode 100644 index 0000000000..64235d35ff --- /dev/null +++ b/app/screens/terms_of_service/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {observeConfigValue} from '@queries/servers/system'; + +import TermsOfService from './terms_of_service'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + siteName: observeConfigValue(database, 'SiteName'), + }; +}); + +export default withDatabase(enhanced(TermsOfService)); diff --git a/app/screens/terms_of_service/terms_of_service.tsx b/app/screens/terms_of_service/terms_of_service.tsx new file mode 100644 index 0000000000..036cdee12a --- /dev/null +++ b/app/screens/terms_of_service/terms_of_service.tsx @@ -0,0 +1,297 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import { + Alert, + ScrollView, + Text, + View, +} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {logout} from '@actions/remote/session'; +import {fetchTermsOfService, updateTermsOfServiceStatus} from '@actions/remote/terms_of_service'; +import Button from '@components/button'; +import Loading from '@components/loading'; +import Markdown from '@components/markdown'; +import {Screens} from '@constants/index'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {dismissOverlay} from '@screens/navigation'; +import NavigationStore from '@store/navigation_store'; +import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + siteName?: string; + componentId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + root: { + flex: 1, + backgroundColor: changeOpacity('#000000', 0.50), + justifyContent: 'center', + alginIntems: 'center', + }, + baseText: { + color: theme.centerChannelColor, + ...typography('Body', 100, 'Regular'), + }, + container: { + flex: 1, + maxWidth: 680, + alignSelf: 'center', + alignContent: 'center', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + wrapper: { + backgroundColor: theme.centerChannelBg, + borderRadius: 12, + margin: 10, + opacity: 1, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + padding: 24, + }, + scrollView: { + marginBottom: 24, + }, + title: { + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + borderBottomWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + marginBottom: 12, + }, + errorTitle: { + color: theme.centerChannelColor, + ...typography('Heading', 400, 'SemiBold'), + }, + errorDescription: { + marginBottom: 24, + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, + firstButton: { + marginBottom: 10, + }, + loadingContainer: { + justifyContent: 'center', + }, + }; +}); + +const TermsOfService = ({ + siteName = 'Mattermost', + componentId, +}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const insets = useSafeAreaInsets(); + + const [loading, setLoading] = useState(true); + const [getTermsError, setGetTermError] = useState(false); + const [termsId, setTermsId] = useState(''); + const [termsText, setTermsText] = useState(''); + + const getTerms = useCallback(async () => { + setLoading(true); + setGetTermError(false); + + const {terms} = await fetchTermsOfService(serverUrl); + if (terms) { + setLoading(false); + setTermsId(terms.id); + setTermsText(terms.text); + } else { + setLoading(false); + setGetTermError(true); + } + }, [serverUrl]); + + const closeTermsAndLogout = useCallback(() => { + dismissOverlay(componentId); + logout(serverUrl); + }, [serverUrl, componentId]); + + const alertError = useCallback((retry: () => void) => { + Alert.alert( + siteName, + intl.formatMessage({ + id: 'terms_of_service.api_error', + defaultMessage: 'Unable to complete the request. If this issue persists, contact your System Administrator.', + }), + [{ + text: intl.formatMessage({id: 'terms_of_service.alert_cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + onPress: closeTermsAndLogout, + }, { + text: intl.formatMessage({id: 'terms_of_service.alert_retry', defaultMessage: 'Try Again'}), + onPress: retry, + }], + ); + }, [intl, closeTermsAndLogout]); + + const alertDecline = useCallback(() => { + Alert.alert( + intl.formatMessage({ + id: 'terms_of_service.terms_declined.title', + defaultMessage: 'You must accept the terms of service', + }), + intl.formatMessage({ + id: 'terms_of_service.terms_declined.text', + defaultMessage: 'You must accept the terms of service to access this server. Please contact your system administrator for more details. You will now be logged out. Log in again to accept the terms of service.', + }), + [{ + text: intl.formatMessage({id: 'terms_of_service.terms_declined.ok', defaultMessage: 'OK'}), + onPress: closeTermsAndLogout, + }], + ); + }, [intl, siteName, closeTermsAndLogout]); + + const acceptTerms = useCallback(async () => { + setLoading(true); + const {resp} = await updateTermsOfServiceStatus(serverUrl, termsId, true); + if (resp?.status === 'OK') { + dismissOverlay(componentId); + } else { + alertError(acceptTerms); + } + }, [alertError, alertDecline, termsId, serverUrl, componentId]); + + const declineTerms = useCallback(async () => { + setLoading(true); + const {resp} = await updateTermsOfServiceStatus(serverUrl, termsId, false); + if (resp?.status === 'OK') { + alertDecline(); + } else { + alertError(declineTerms); + } + }, [serverUrl, termsId, closeTermsAndLogout]); + + const onPressClose = useCallback(async () => { + if (getTermsError) { + closeTermsAndLogout(); + } else { + declineTerms(); + } + }, [declineTerms, closeTermsAndLogout, getTermsError]); + + useEffect(() => { + getTerms(); + }, []); + + useEffect(() => { + return () => { + NavigationStore.setToSOpen(false); + }; + }); + + useAndroidHardwareBackHandler(componentId, onPressClose); + + const blockStyles = useMemo(() => getMarkdownBlockStyles(theme), [theme]); + const textStyles = useMemo(() => getMarkdownTextStyles(theme), [theme]); + + let content; + if (loading) { + content = ( + + + + ); + } else if (getTermsError) { + content = ( + <> + + {intl.formatMessage({id: 'terms_of_service.error.title', defaultMessage: 'Failed to get the ToS.'})} + + + {intl.formatMessage({id: 'terms_of_service.error.description', defaultMessage: 'It was not possible to get the Terms of Service from the Server.'})} + +