From fe52fcaab66b7d17157d698f2537843a62a3efeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 24 Nov 2022 19:58:56 +0100 Subject: [PATCH] Add terms of service (#6777) * Add terms of service * Add i18n * Fix test * Address feedback * Address ux feedback * Update texts * Avoid Review to show on top of ToS Co-authored-by: Daniel Espino --- app/actions/remote/terms_of_service.ts | 42 +++ app/client/graphQL/entry.ts | 2 + app/client/rest/tos.ts | 4 +- app/constants/screens.ts | 7 +- app/database/migration/server/index.ts | 13 + app/database/models/server/user.ts | 6 + .../server_data_operator/transformers/user.ts | 2 + app/database/schema/server/index.ts | 2 +- .../schema/server/table_schemas/user.ts | 2 + app/database/schema/server/test.ts | 7 +- app/hooks/android_back_handler.ts | 4 +- .../home/channel_list/channel_list.tsx | 33 +- app/screens/home/channel_list/index.ts | 56 +++- app/screens/home/index.tsx | 18 -- app/screens/index.tsx | 3 + .../channel_list_row/index.test.tsx | 2 +- .../channel_list_row/index.tsx | 2 +- .../custom_list/index.test.tsx | 2 +- .../custom_list/index.tsx | 2 +- .../custom_list_row/index.test.tsx | 2 +- .../custom_list_row/index.tsx | 2 +- .../option_list_row/index.test.tsx | 2 +- .../option_list_row/index.tsx | 2 +- .../selected_option/index.test.tsx | 3 +- .../selected_option/index.tsx | 2 +- .../selected_options/index.test.tsx | 3 +- app/screens/navigation.ts | 5 + app/screens/terms_of_service/index.ts | 19 ++ .../terms_of_service/terms_of_service.tsx | 297 ++++++++++++++++++ app/store/navigation_store.ts | 9 + app/utils/graphql.ts | 6 +- assets/base/i18n/en.json | 15 + types/api/graphql.d.ts | 2 + types/api/terms_of_service.d.ts | 9 + types/database/models/servers/user.ts | 6 + 35 files changed, 538 insertions(+), 55 deletions(-) create mode 100644 app/actions/remote/terms_of_service.ts create mode 100644 app/screens/terms_of_service/index.ts create mode 100644 app/screens/terms_of_service/terms_of_service.tsx create mode 100644 types/api/terms_of_service.d.ts diff --git a/app/actions/remote/terms_of_service.ts b/app/actions/remote/terms_of_service.ts new file mode 100644 index 000000000..ff4072706 --- /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 eb1625b93..b83e08d84 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 6fdebd9fd..0926ff461 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 74885588a..7bbacbc99 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 3ff742d00..f633a8edf 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 efaca8ef9..1dc48e0f2 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 2b84c7ae8..420abc34f 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 b6d0d06aa..5cb7a3807 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 849865f46..24af8ad37 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 efa7bb8f9..b4e294029 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 aaed5f004..6ac333722 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 56c353af3..733d8f1be 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 00342c881..4d2a73d69 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 3bd5faedc..5389ce3f8 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 69cc99847..b91546111 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 a0f0bbe7d..3aea34f43 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 8d2f79e7c..6542e89a1 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 d9f5671e7..c9a9fcdde 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 fda5607e5..5ecab10e9 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 4d8286580..2184882d1 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 79f622112..435c00706 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 3d330c65b..e20d4992f 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 212585953..24953c888 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 3bb0f6ac9..f3de838da 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 221648991..cdeaf43dd 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 150593c55..383a826bf 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 000000000..64235d35f --- /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 000000000..036cdee12 --- /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.'})} + +