[NEW] Show server history (#2421)

* Add dropdown

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Adding new table to serverSchema

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Saving if not exists

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* list of visited servers finished

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Fix lint

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Rename ServerLinks to ServersHistory

* Refactor

* Save username

* Sort servers desc

* ServerInput

* Item

* Refactor

* Layout tweaks

* Layout

* query by text

* Small refactor

* Redirecting to login

* Save username for oauth

* Fix keyboard persist

* Add tests

* Unnecessary yield

* Stop rendering FlatList logic when there's no servers on history

* Dismiss keyboard and autocomplete when tapped outside server TextInput

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Ezequiel de Oliveira 2020-09-11 14:10:16 -03:00 committed by GitHub
parent 334140df0d
commit d37678b354
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 373 additions and 51 deletions

View File

@ -23,11 +23,13 @@ export function selectServerFailure() {
}; };
} }
export function serverRequest(server, certificate = null) { export function serverRequest(server, certificate = null, username = null, fromServerHistory = false) {
return { return {
type: SERVER.REQUEST, type: SERVER.REQUEST,
server, server,
certificate certificate,
username,
fromServerHistory
}; };
} }

View File

@ -23,14 +23,21 @@ export const FormContainerInner = ({ children }) => (
</View> </View>
); );
const FormContainer = ({ children, theme, testID }) => ( const FormContainer = ({
children, theme, testID, ...props
}) => (
<KeyboardView <KeyboardView
style={{ backgroundColor: themes[theme].backgroundColor }} style={{ backgroundColor: themes[theme].backgroundColor }}
contentContainerStyle={sharedStyles.container} contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128} keyboardVerticalOffset={128}
> >
<StatusBar theme={theme} /> <StatusBar theme={theme} />
<ScrollView {...scrollPersistTaps} style={sharedStyles.container} contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}> <ScrollView
style={sharedStyles.container}
contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}
{...scrollPersistTaps}
{...props}
>
<SafeAreaView testID={testID} theme={theme} style={{ backgroundColor: themes[theme].backgroundColor }}> <SafeAreaView testID={testID} theme={theme} style={{ backgroundColor: themes[theme].backgroundColor }}>
{children} {children}
<AppVersion theme={theme} /> <AppVersion theme={theme} />

View File

@ -16,6 +16,7 @@ import Permission from './model/Permission';
import SlashCommand from './model/SlashCommand'; import SlashCommand from './model/SlashCommand';
import User from './model/User'; import User from './model/User';
import Server from './model/Server'; import Server from './model/Server';
import ServersHistory from './model/ServersHistory';
import serversSchema from './schema/servers'; import serversSchema from './schema/servers';
import appSchema from './schema/app'; import appSchema from './schema/app';
@ -71,7 +72,7 @@ class DB {
schema: serversSchema, schema: serversSchema,
migrations: serversMigrations migrations: serversMigrations
}), }),
modelClasses: [Server, User], modelClasses: [Server, User, ServersHistory],
actionsEnabled: true actionsEnabled: true
}) })
} }

View File

@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export default class ServersHistory extends Model {
static table = 'servers_history';
@field('url') url;
@field('username') username;
@readonly @date('updated_at') updatedAt
}

View File

@ -1,4 +1,4 @@
import { schemaMigrations, addColumns } from '@nozbe/watermelondb/Schema/migrations'; import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
export default schemaMigrations({ export default schemaMigrations({
migrations: [ migrations: [
@ -70,6 +70,19 @@ export default schemaMigrations({
] ]
}) })
] ]
},
{
toVersion: 9,
steps: [
createTable({
name: 'servers_history',
columns: [
{ name: 'url', type: 'string', isIndexed: true },
{ name: 'username', type: 'string', isOptional: true },
{ name: 'updated_at', type: 'number' }
]
})
]
} }
] ]
}); });

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export default appSchema({ export default appSchema({
version: 8, version: 9,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'users', name: 'users',
@ -34,6 +34,14 @@ export default appSchema({
{ name: 'enterprise_modules', type: 'string', isOptional: true }, { name: 'enterprise_modules', type: 'string', isOptional: true },
{ name: 'e2e_enable', type: 'boolean', isOptional: true } { name: 'e2e_enable', type: 'boolean', isOptional: true }
] ]
}),
tableSchema({
name: 'servers_history',
columns: [
{ name: 'url', type: 'string', isIndexed: true },
{ name: 'username', type: 'string', isOptional: true },
{ name: 'updated_at', type: 'number' }
]
}) })
] ]
}); });

View File

@ -4,6 +4,7 @@ import {
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import moment from 'moment'; import moment from 'moment';
import 'moment/min/locales'; import 'moment/min/locales';
import { Q } from '@nozbe/watermelondb';
import * as types from '../actions/actionsTypes'; import * as types from '../actions/actionsTypes';
import { import {
@ -52,6 +53,26 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE
} else { } else {
const server = yield select(getServer); const server = yield select(getServer);
yield localAuthenticate(server); yield localAuthenticate(server);
// Saves username on server history
const serversDB = database.servers;
const serversHistoryCollection = serversDB.collections.get('servers_history');
yield serversDB.action(async() => {
try {
const serversHistory = await serversHistoryCollection.query(Q.where('url', server)).fetch();
if (serversHistory?.length) {
const serverHistoryRecord = serversHistory[0];
// this is updating on every login just to save `updated_at`
// keeping this server as the most recent on autocomplete order
await serverHistoryRecord.update((s) => {
s.username = result.username;
});
}
} catch (e) {
log(e);
}
});
yield put(loginSuccess(result)); yield put(loginSuccess(result));
} }
} catch (e) { } catch (e) {

View File

@ -1,6 +1,7 @@
import { put, takeLatest } from 'redux-saga/effects'; import { put, takeLatest } from 'redux-saga/effects';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { Q } from '@nozbe/watermelondb';
import semver from 'semver'; import semver from 'semver';
import Navigation from '../lib/Navigation'; import Navigation from '../lib/Navigation';
@ -131,18 +132,39 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
} }
}; };
const handleServerRequest = function* handleServerRequest({ server, certificate }) { const handleServerRequest = function* handleServerRequest({
server, certificate, username, fromServerHistory
}) {
try { try {
if (certificate) { if (certificate) {
yield UserPreferences.setMapAsync(extractHostname(server), certificate); yield UserPreferences.setMapAsync(extractHostname(server), certificate);
} }
const serverInfo = yield getServerInfo({ server }); const serverInfo = yield getServerInfo({ server });
const serversDB = database.servers;
const serversHistoryCollection = serversDB.collections.get('servers_history');
if (serverInfo) { if (serverInfo) {
yield RocketChat.getLoginServices(server); yield RocketChat.getLoginServices(server);
yield RocketChat.getLoginSettings({ server }); yield RocketChat.getLoginSettings({ server });
Navigation.navigate('WorkspaceView'); Navigation.navigate('WorkspaceView');
if (fromServerHistory) {
Navigation.navigate('LoginView', { username });
}
yield serversDB.action(async() => {
try {
const serversHistory = await serversHistoryCollection.query(Q.where('url', server)).fetch();
if (!serversHistory?.length) {
await serversHistoryCollection.create((s) => {
s.url = server;
});
}
} catch (e) {
log(e);
}
});
yield put(selectServerRequest(server, serverInfo.version, false)); yield put(selectServerRequest(server, serverInfo.version, false));
} }
} catch (e) { } catch (e) {

View File

@ -56,6 +56,7 @@ class LoginView extends React.Component {
static propTypes = { static propTypes = {
navigation: PropTypes.object, navigation: PropTypes.object,
route: PropTypes.object,
Site_Name: PropTypes.string, Site_Name: PropTypes.string,
Accounts_RegistrationForm: PropTypes.string, Accounts_RegistrationForm: PropTypes.string,
Accounts_RegistrationForm_LinkReplacementText: PropTypes.string, Accounts_RegistrationForm_LinkReplacementText: PropTypes.string,
@ -74,7 +75,7 @@ class LoginView extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
user: '', user: props.route.params?.username ?? '',
password: '' password: ''
}; };
} }
@ -123,6 +124,7 @@ class LoginView extends React.Component {
} }
renderUserForm = () => { renderUserForm = () => {
const { user } = this.state;
const { const {
Accounts_EmailOrUsernamePlaceholder, Accounts_PasswordPlaceholder, Accounts_PasswordReset, Accounts_RegistrationForm_LinkReplacementText, isFetching, theme, Accounts_ShowFormLogin Accounts_EmailOrUsernamePlaceholder, Accounts_PasswordPlaceholder, Accounts_PasswordReset, Accounts_RegistrationForm_LinkReplacementText, isFetching, theme, Accounts_ShowFormLogin
} = this.props; } = this.props;
@ -146,6 +148,7 @@ class LoginView extends React.Component {
textContentType='username' textContentType='username'
autoCompleteType='username' autoCompleteType='username'
theme={theme} theme={theme}
value={user}
/> />
<TextInput <TextInput
label='Password' label='Password'

View File

@ -0,0 +1,53 @@
import React from 'react';
import {
View, StyleSheet, Text
} from 'react-native';
import PropTypes from 'prop-types';
import { BorderlessButton } from 'react-native-gesture-handler';
import { themes } from '../../../constants/colors';
import { CustomIcon } from '../../../lib/Icons';
import sharedStyles from '../../Styles';
import Touch from '../../../utils/touch';
const styles = StyleSheet.create({
container: {
height: 56,
paddingHorizontal: 15,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
},
content: {
flex: 1,
flexDirection: 'column'
},
server: {
...sharedStyles.textMedium,
fontSize: 16
}
});
const Item = ({
item, theme, onPress, onDelete
}) => (
<Touch style={styles.container} onPress={() => onPress(item.url)} theme={theme} testID={`server-history-${ item.url }`}>
<View style={styles.content}>
<Text style={[styles.server, { color: themes[theme].bodyText }]}>{item.url}</Text>
<Text style={[styles.username, { color: themes[theme].auxiliaryText }]}>{item.username}</Text>
</View>
<BorderlessButton onPress={() => onDelete(item)} testID={`server-history-delete-${ item.url }`}>
<CustomIcon name='delete' size={24} color={themes[theme].auxiliaryText} />
</BorderlessButton>
</Touch>
);
Item.propTypes = {
item: PropTypes.object,
theme: PropTypes.string,
onPress: PropTypes.func,
onDelete: PropTypes.func
};
export default Item;

View File

@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { View, FlatList, StyleSheet } from 'react-native';
import PropTypes from 'prop-types';
import TextInput from '../../../containers/TextInput';
import { themes } from '../../../constants/colors';
import Item from './Item';
import Separator from '../../../containers/Separator';
const styles = StyleSheet.create({
container: {
zIndex: 1,
marginTop: 24,
marginBottom: 32
},
inputContainer: {
marginTop: 0,
marginBottom: 0
},
serverHistory: {
maxHeight: 180,
width: '100%',
top: '100%',
zIndex: 1,
position: 'absolute',
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 2,
borderTopWidth: 0
}
});
const ServerInput = ({
text,
theme,
serversHistory,
onChangeText,
onSubmit,
onDelete,
onPressServerHistory
}) => {
const [focused, setFocused] = useState(false);
return (
<View style={styles.container}>
<TextInput
label='Enter workspace URL'
placeholder='Ex. your-company.rocket.chat'
containerStyle={styles.inputContainer}
value={text}
returnKeyType='send'
onChangeText={onChangeText}
testID='new-server-view-input'
onSubmitEditing={onSubmit}
clearButtonMode='while-editing'
keyboardType='url'
textContentType='URL'
theme={theme}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
{
focused && serversHistory?.length
? (
<View style={[styles.serverHistory, { backgroundColor: themes[theme].backgroundColor, borderColor: themes[theme].separatorColor }]}>
<FlatList
data={serversHistory}
renderItem={({ item }) => <Item item={item} theme={theme} onPress={() => onPressServerHistory(item)} onDelete={onDelete} />}
ItemSeparatorComponent={() => <Separator theme={theme} />}
keyExtractor={item => item.id}
/>
</View>
) : null
}
</View>
);
};
ServerInput.propTypes = {
text: PropTypes.string,
theme: PropTypes.string,
serversHistory: PropTypes.array,
onChangeText: PropTypes.func,
onSubmit: PropTypes.func,
onDelete: PropTypes.func,
onPressServerHistory: PropTypes.func
};
export default ServerInput;

View File

@ -1,42 +1,41 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
Text, Keyboard, StyleSheet, TouchableOpacity, View, Alert, BackHandler Text, Keyboard, StyleSheet, View, Alert, BackHandler
} from 'react-native'; } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import DocumentPicker from 'react-native-document-picker'; import DocumentPicker from 'react-native-document-picker';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import parse from 'url-parse'; import parse from 'url-parse';
import { Q } from '@nozbe/watermelondb';
import UserPreferences from '../lib/userPreferences'; import { TouchableOpacity } from 'react-native-gesture-handler';
import EventEmitter from '../utils/events'; import UserPreferences from '../../lib/userPreferences';
import { selectServerRequest, serverRequest } from '../actions/server'; import EventEmitter from '../../utils/events';
import { inviteLinksClear as inviteLinksClearAction } from '../actions/inviteLinks'; import { selectServerRequest, serverRequest } from '../../actions/server';
import sharedStyles from './Styles'; import { inviteLinksClear as inviteLinksClearAction } from '../../actions/inviteLinks';
import Button from '../containers/Button'; import sharedStyles from '../Styles';
import TextInput from '../containers/TextInput'; import Button from '../../containers/Button';
import OrSeparator from '../containers/OrSeparator'; import OrSeparator from '../../containers/OrSeparator';
import FormContainer, { FormContainerInner } from '../containers/FormContainer'; import FormContainer, { FormContainerInner } from '../../containers/FormContainer';
import I18n from '../i18n'; import I18n from '../../i18n';
import { isIOS } from '../utils/deviceInfo'; import { isIOS } from '../../utils/deviceInfo';
import { themes } from '../constants/colors'; import { themes } from '../../constants/colors';
import log, { logEvent, events } from '../utils/log'; import log, { logEvent, events } from '../../utils/log';
import { animateNextTransition } from '../utils/layoutAnimation'; import { animateNextTransition } from '../../utils/layoutAnimation';
import { withTheme } from '../theme'; import { withTheme } from '../../theme';
import { setBasicAuth, BASIC_AUTH_KEY } from '../utils/fetch'; import { setBasicAuth, BASIC_AUTH_KEY } from '../../utils/fetch';
import { CloseModalButton } from '../containers/HeaderButton'; import { CloseModalButton } from '../../containers/HeaderButton';
import { showConfirmationAlert } from '../utils/info'; import { showConfirmationAlert } from '../../utils/info';
import database from '../../lib/database';
import ServerInput from './ServerInput';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
...sharedStyles.textBold, ...sharedStyles.textBold,
fontSize: 22 fontSize: 22
}, },
inputContainer: {
marginTop: 24,
marginBottom: 32
},
certificatePicker: { certificatePicker: {
marginBottom: 32, marginBottom: 32,
alignItems: 'center', alignItems: 'center',
@ -84,12 +83,17 @@ class NewServerView extends React.Component {
this.state = { this.state = {
text: '', text: '',
connectingOpen: false, connectingOpen: false,
certificate: null certificate: null,
serversHistory: []
}; };
EventEmitter.addEventListener('NewServer', this.handleNewServerEvent); EventEmitter.addEventListener('NewServer', this.handleNewServerEvent);
BackHandler.addEventListener('hardwareBackPress', this.handleBackPress); BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
} }
componentDidMount() {
this.queryServerHistory();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { adding } = this.props; const { adding } = this.props;
if (prevProps.adding !== adding) { if (prevProps.adding !== adding) {
@ -122,6 +126,29 @@ class NewServerView extends React.Component {
onChangeText = (text) => { onChangeText = (text) => {
this.setState({ text }); this.setState({ text });
this.queryServerHistory(text);
}
queryServerHistory = async(text) => {
const db = database.servers;
try {
const serversHistoryCollection = db.collections.get('servers_history');
let whereClause = [
Q.where('username', Q.notEq(null)),
Q.experimentalSortBy('updated_at', Q.desc),
Q.experimentalTake(3)
];
if (text) {
whereClause = [
...whereClause,
Q.where('url', Q.like(`%${ Q.sanitizeLikeString(text) }%`))
];
}
const serversHistory = await serversHistoryCollection.query(...whereClause).fetch();
this.setState({ serversHistory });
} catch {
// Do nothing
}
} }
close = () => { close = () => {
@ -138,7 +165,11 @@ class NewServerView extends React.Component {
connectServer(server); connectServer(server);
} }
submit = async() => { onPressServerHistory = (serverHistory) => {
this.setState({ text: serverHistory?.url }, () => this.submit({ fromServerHistory: true, username: serverHistory?.username }));
}
submit = async({ fromServerHistory = false, username }) => {
logEvent(events.NEWSERVER_CONNECT_TO_WORKSPACE); logEvent(events.NEWSERVER_CONNECT_TO_WORKSPACE);
const { text, certificate } = this.state; const { text, certificate } = this.state;
const { connectServer } = this.props; const { connectServer } = this.props;
@ -164,9 +195,13 @@ class NewServerView extends React.Component {
Keyboard.dismiss(); Keyboard.dismiss();
const server = this.completeUrl(text); const server = this.completeUrl(text);
await this.basicAuth(server, text); await this.basicAuth(server, text);
if (fromServerHistory) {
connectServer(server, cert, username, true);
} else {
connectServer(server, cert); connectServer(server, cert);
} }
} }
}
connectOpen = () => { connectOpen = () => {
logEvent(events.NEWSERVER_JOIN_OPEN_WORKSPACE); logEvent(events.NEWSERVER_JOIN_OPEN_WORKSPACE);
@ -251,6 +286,19 @@ class NewServerView extends React.Component {
}); });
} }
deleteServerHistory = async(item) => {
const { serversHistory } = this.state;
const db = database.servers;
try {
await db.action(async() => {
await item.destroyPermanently();
});
this.setState({ serversHistory: serversHistory.filter(server => server.id !== item.id) });
} catch {
// Nothing
}
}
renderCertificatePicker = () => { renderCertificatePicker = () => {
const { certificate } = this.state; const { certificate } = this.state;
const { theme } = this.props; const { theme } = this.props;
@ -283,24 +331,25 @@ class NewServerView extends React.Component {
render() { render() {
const { connecting, theme } = this.props; const { connecting, theme } = this.props;
const { text, connectingOpen } = this.state; const {
text, connectingOpen, serversHistory
} = this.state;
return ( return (
<FormContainer theme={theme} testID='new-server-view'> <FormContainer
theme={theme}
testID='new-server-view'
keyboardShouldPersistTaps='never'
>
<FormContainerInner> <FormContainerInner>
<Text style={[styles.title, { color: themes[theme].titleText }]}>{I18n.t('Join_your_workspace')}</Text> <Text style={[styles.title, { color: themes[theme].titleText }]}>{I18n.t('Join_your_workspace')}</Text>
<TextInput <ServerInput
label='Enter workspace URL' text={text}
placeholder='Ex. your-company.rocket.chat'
containerStyle={styles.inputContainer}
value={text}
returnKeyType='send'
onChangeText={this.onChangeText}
testID='new-server-view-input'
onSubmitEditing={this.submit}
clearButtonMode='while-editing'
keyboardType='url'
textContentType='URL'
theme={theme} theme={theme}
serversHistory={serversHistory}
onChangeText={this.onChangeText}
onSubmit={this.submit}
onDelete={this.deleteServerHistory}
onPressServerHistory={this.onPressServerHistory}
/> />
<Button <Button
title={I18n.t('Connect')} title={I18n.t('Connect')}
@ -338,7 +387,7 @@ const mapStateToProps = state => ({
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
connectServer: (server, certificate) => dispatch(serverRequest(server, certificate)), connectServer: (server, certificate, username, fromServerHistory) => dispatch(serverRequest(server, certificate, username, fromServerHistory)),
selectServer: server => dispatch(selectServerRequest(server)), selectServer: server => dispatch(selectServerRequest(server)),
inviteLinksClear: () => dispatch(inviteLinksClearAction()) inviteLinksClear: () => dispatch(inviteLinksClearAction())
}); });

View File

@ -0,0 +1,44 @@
const {
device, expect, element, by, waitFor
} = require('detox');
const { login, navigateToLogin, logout, tapBack } = require('../../helpers/app');
const data = require('../../data');
describe('Server history', () => {
before(async() => {
await device.launchApp({ permissions: { notifications: 'YES' }, delete: true });
});
describe('Usage', () => {
it('should login, save server as history and logout', async() => {
await navigateToLogin();
await login(data.users.regular.username, data.users.regular.password);
await logout();
await element(by.id('join-workspace')).tap();
await waitFor(element(by.id('new-server-view'))).toBeVisible().withTimeout(60000);
})
it('should show servers history', async() => {
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeVisible().withTimeout(2000);
});
it('should tap on a server history and navigate to login', async() => {
await element(by.id(`server-history-${ data.server }`)).tap();
await waitFor(element(by.id('login-view'))).toBeVisible().withTimeout(2000);
await expect(element(by.id('login-view-email'))).toHaveText(data.users.regular.username);
});
it('should delete server from history', async() => {
await tapBack();
await waitFor(element(by.id('workspace-view'))).toBeVisible().withTimeout(2000);
await tapBack();
await waitFor(element(by.id('new-server-view'))).toBeVisible().withTimeout(2000);
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeVisible().withTimeout(2000);
await element(by.id(`server-history-delete-${ data.server }`)).tap();
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeNotVisible().withTimeout(2000);
});
});
});