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 <danielespino@MacBook-Pro-de-Daniel.local>
This commit is contained in:
Daniel Espino García
2022-11-24 19:58:56 +01:00
committed by GitHub
parent 5fae120826
commit fe52fcaab6
35 changed files with 538 additions and 55 deletions

View File

@@ -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};
}
}

View File

@@ -184,6 +184,8 @@ query ${QueryNames.QUERY_ENTRY} {
createAt
expiresAt
}
termsOfServiceId
termsOfServiceCreateAt
}
teamMembers(userId:"me") {
deleteAt

View File

@@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
export interface ClientTosMix {
updateMyTermsOfServiceStatus: (termsOfServiceId: string, accepted: boolean) => Promise<any>;
getTermsOfService: () => Promise<any>;
updateMyTermsOfServiceStatus: (termsOfServiceId: string, accepted: boolean) => Promise<{status: string}>;
getTermsOfService: () => Promise<TermsOfService>;
}
const ClientTos = (superclass: any) => class extends superclass {

View File

@@ -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<string>([
]);
export const OVERLAY_SCREENS = new Set<string>([
IN_APP_NOTIFICATION,
GALLERY,
SNACK_BAR,
IN_APP_NOTIFICATION,
REVIEW_APP,
SHARE_FEEDBACK,
SNACK_BAR,
TERMS_OF_SERVICE,
]);
export const NOT_READY = [

View File

@@ -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: [

View File

@@ -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<ChannelModel>;

View File

@@ -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;
}

View File

@@ -38,7 +38,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 5,
version: 6,
tables: [
CategorySchema,
CategoryChannelSchema,

View File

@@ -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'},
],
});

View File

@@ -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'},
],
},
},

View File

@@ -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) {

View File

@@ -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<ManagedConfig>();
@@ -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 (
<FreezeScreen freeze={!isFocused}>
<Animated.View style={top}/>

View File

@@ -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));

View File

@@ -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 (
<>
<NavigationContainer

View File

@@ -219,6 +219,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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();

View File

@@ -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));

View File

@@ -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 = (
<View style={styles.loadingContainer}>
<Loading color={theme.centerChannelColor}/>
</View>
);
} else if (getTermsError) {
content = (
<>
<Text style={styles.errorTitle}>
{intl.formatMessage({id: 'terms_of_service.error.title', defaultMessage: 'Failed to get the ToS.'})}
</Text>
<Text style={styles.errorDescription}>
{intl.formatMessage({id: 'terms_of_service.error.description', defaultMessage: 'It was not possible to get the Terms of Service from the Server.'})}
</Text>
<Button
onPress={getTerms}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.error.retry', defaultMessage: 'Retry'})}
size={'lg'}
backgroundStyle={styles.firstButton}
/>
<Button
onPress={onPressClose}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.error.logout', defaultMessage: 'Logout'})}
size={'lg'}
emphasis={'link'}
/>
</>
);
} else {
content = (
<>
<ScrollView
style={styles.scrollView}
>
<Markdown
baseTextStyle={styles.baseText}
textStyles={textStyles}
blockStyles={blockStyles}
value={termsText}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
location={Screens.TERMS_OF_SERVICE}
theme={theme}
/>
</ScrollView>
<Button
onPress={acceptTerms}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.acceptButton', defaultMessage: 'Accept'})}
size={'lg'}
backgroundStyle={styles.firstButton}
/>
<Button
onPress={onPressClose}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.decline', defaultMessage: 'Decline'})}
size={'lg'}
emphasis={'link'}
/>
</>
);
}
const containerStyle = useMemo(() => {
return [{
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop: insets.top,
}, styles.container];
}, [styles, insets]);
return (
<View style={styles.root}>
<View style={containerStyle}>
<View style={styles.wrapper}>
<Text style={styles.title}>{intl.formatMessage({id: 'terms_of_service.title', defaultMessage: 'Terms of Service'})}</Text>
{content}
</View>
</View>
</View>
);
};
export default TermsOfService;

View File

@@ -6,6 +6,15 @@ class NavigationStore {
navigationComponentIdStack: string[] = [];
navigationModalStack: string[] = [];
visibleTab = 'Home';
tosOpen = false;
setToSOpen = (open: boolean) => {
this.tosOpen = open;
};
isToSOpen = () => {
return this.tosOpen;
};
addNavigationComponentId = (componentId: string) => {
this.addToNavigationComponentIdStack(componentId);

View File

@@ -43,10 +43,10 @@ export const gqlToClientUser = (u: Partial<GQLUser>): UserProfile => {
bot_description: u.botDescription,
bot_last_icon_update: u.botLastIconUpdate,
auth_data: '',
terms_of_service_id: '',
terms_of_service_create_at: 0,
terms_of_service_id: u.termsOfServiceId || '',
terms_of_service_create_at: u.termsOfServiceCreateAt || 0,
auth_data: '',
};
};

View File

@@ -26,6 +26,8 @@
"alert.removed_from_channel.title": "Removed from channel",
"alert.removed_from_team.description": "You have been removed from team {displayName}.",
"alert.removed_from_team.title": "Removed from team",
"announcment_banner.dismiss": "Dismiss announcement",
"announcment_banner.okay": "Okay",
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
@@ -356,6 +358,7 @@
"mobile.android.back_handler_exit": "Press back again to exit",
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.announcement_banner.title": "Announcement",
"mobile.calls_call_ended": "Call ended",
"mobile.calls_call_screen": "Call",
"mobile.calls_chat_thread": "Chat thread",
@@ -826,6 +829,18 @@
"suggestion.search.public": "Public Channels",
"team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.",
"team_list.no_other_teams.title": "No additional teams to join",
"terms_of_service.agreeButton": "I Agree",
"terms_of_service.alert_cancel": "Cancel",
"terms_of_service.alert_retry": "Try Again",
"terms_of_service.api_error": "Unable to complete the request. If this issue persists, contact your System Administrator.",
"terms_of_service.error.description": "It was not possible to get the Terms of Service from the Server.",
"terms_of_service.error.logout": "Logout",
"terms_of_service.error.retry": "Retry",
"terms_of_service.error.title": "Failed to get the ToS.",
"terms_of_service.reject": "Reject",
"terms_of_service.terms_rejected": "You must agree to the terms of service before accessing {siteName}. Please contact your System Administrator for more details.",
"terms_of_service.terms_rejected.ok": "OK",
"terms_of_service.title": "Terms of Service",
"thread.header.thread": "Thread",
"thread.header.thread_in": "in {channelName}",
"thread.noReplies": "No replies yet",

View File

@@ -44,6 +44,8 @@ type GQLUser = {
remoteId: string;
botDescription: string;
botLastIconUpdate: number;
termsOfServiceId: string;
termsOfServiceCreateAt: number;
roles: Array<Partial<GQLRole>>;
customStatus: Partial<GQLUserCustomStatus>;

9
types/api/terms_of_service.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type TermsOfService = {
create_at: number;
id: string;
text: string;
user_id: string;
}

View File

@@ -112,6 +112,12 @@ declare class UserModel extends Model {
/* user mentions keys always excluding @channel, @all, @here */
userMentionKeys: UserMentionKey[];
/** termsOfServiceId : The id of the last accepted terms of service */
termsOfServiceId: string;
/** termsOfServiceCreateAt : The last time the user accepted the terms of service */
termsOfServiceCreateAt: number;
}
export default UserModel;