forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
5fae120826
commit
fe52fcaab6
42
app/actions/remote/terms_of_service.ts
Normal file
42
app/actions/remote/terms_of_service.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,8 @@ query ${QueryNames.QUERY_ENTRY} {
|
||||
createAt
|
||||
expiresAt
|
||||
}
|
||||
termsOfServiceId
|
||||
termsOfServiceCreateAt
|
||||
}
|
||||
teamMembers(userId:"me") {
|
||||
deleteAt
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from './table_schemas';
|
||||
|
||||
export const serverSchema: AppSchema = appSchema({
|
||||
version: 5,
|
||||
version: 6,
|
||||
tables: [
|
||||
CategorySchema,
|
||||
CategoryChannelSchema,
|
||||
|
||||
@@ -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'},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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'},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
19
app/screens/terms_of_service/index.ts
Normal file
19
app/screens/terms_of_service/index.ts
Normal 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));
|
||||
297
app/screens/terms_of_service/terms_of_service.tsx
Normal file
297
app/screens/terms_of_service/terms_of_service.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
types/api/graphql.d.ts
vendored
2
types/api/graphql.d.ts
vendored
@@ -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
9
types/api/terms_of_service.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user