diff --git a/app/actions/server.js b/app/actions/server.js index 8a50fc4d..01fb8c3e 100644 --- a/app/actions/server.js +++ b/app/actions/server.js @@ -23,11 +23,13 @@ export function selectServerFailure() { }; } -export function serverRequest(server, certificate = null) { +export function serverRequest(server, certificate = null, username = null, fromServerHistory = false) { return { type: SERVER.REQUEST, server, - certificate + certificate, + username, + fromServerHistory }; } diff --git a/app/containers/FormContainer.js b/app/containers/FormContainer.js index 19b5c189..46a55054 100644 --- a/app/containers/FormContainer.js +++ b/app/containers/FormContainer.js @@ -23,14 +23,21 @@ export const FormContainerInner = ({ children }) => ( ); -const FormContainer = ({ children, theme, testID }) => ( +const FormContainer = ({ + children, theme, testID, ...props +}) => ( - + {children} diff --git a/app/lib/database/index.js b/app/lib/database/index.js index bf5a164b..d0de06f9 100644 --- a/app/lib/database/index.js +++ b/app/lib/database/index.js @@ -16,6 +16,7 @@ import Permission from './model/Permission'; import SlashCommand from './model/SlashCommand'; import User from './model/User'; import Server from './model/Server'; +import ServersHistory from './model/ServersHistory'; import serversSchema from './schema/servers'; import appSchema from './schema/app'; @@ -71,7 +72,7 @@ class DB { schema: serversSchema, migrations: serversMigrations }), - modelClasses: [Server, User], + modelClasses: [Server, User, ServersHistory], actionsEnabled: true }) } diff --git a/app/lib/database/model/ServersHistory.js b/app/lib/database/model/ServersHistory.js new file mode 100644 index 00000000..469775f1 --- /dev/null +++ b/app/lib/database/model/ServersHistory.js @@ -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 +} diff --git a/app/lib/database/model/serversMigrations.js b/app/lib/database/model/serversMigrations.js index f6a80bed..418a61dd 100644 --- a/app/lib/database/model/serversMigrations.js +++ b/app/lib/database/model/serversMigrations.js @@ -1,4 +1,4 @@ -import { schemaMigrations, addColumns } from '@nozbe/watermelondb/Schema/migrations'; +import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations'; export default schemaMigrations({ 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' } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/servers.js b/app/lib/database/schema/servers.js index bee3f8da..bd480664 100644 --- a/app/lib/database/schema/servers.js +++ b/app/lib/database/schema/servers.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 8, + version: 9, tables: [ tableSchema({ name: 'users', @@ -34,6 +34,14 @@ export default appSchema({ { name: 'enterprise_modules', type: 'string', 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' } + ] }) ] }); diff --git a/app/sagas/login.js b/app/sagas/login.js index e175c21f..7b64de0b 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -4,6 +4,7 @@ import { import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import moment from 'moment'; import 'moment/min/locales'; +import { Q } from '@nozbe/watermelondb'; import * as types from '../actions/actionsTypes'; import { @@ -52,6 +53,26 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE } else { const server = yield select(getServer); 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)); } } catch (e) { diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index 87f1bdbf..61b94750 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -1,6 +1,7 @@ import { put, takeLatest } from 'redux-saga/effects'; import { Alert } from 'react-native'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { Q } from '@nozbe/watermelondb'; import semver from 'semver'; 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 { if (certificate) { yield UserPreferences.setMapAsync(extractHostname(server), certificate); } const serverInfo = yield getServerInfo({ server }); + const serversDB = database.servers; + const serversHistoryCollection = serversDB.collections.get('servers_history'); if (serverInfo) { yield RocketChat.getLoginServices(server); yield RocketChat.getLoginSettings({ server }); 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)); } } catch (e) { diff --git a/app/views/LoginView.js b/app/views/LoginView.js index 95d8a90d..f96adbee 100644 --- a/app/views/LoginView.js +++ b/app/views/LoginView.js @@ -56,6 +56,7 @@ class LoginView extends React.Component { static propTypes = { navigation: PropTypes.object, + route: PropTypes.object, Site_Name: PropTypes.string, Accounts_RegistrationForm: PropTypes.string, Accounts_RegistrationForm_LinkReplacementText: PropTypes.string, @@ -74,7 +75,7 @@ class LoginView extends React.Component { constructor(props) { super(props); this.state = { - user: '', + user: props.route.params?.username ?? '', password: '' }; } @@ -123,6 +124,7 @@ class LoginView extends React.Component { } renderUserForm = () => { + const { user } = this.state; const { Accounts_EmailOrUsernamePlaceholder, Accounts_PasswordPlaceholder, Accounts_PasswordReset, Accounts_RegistrationForm_LinkReplacementText, isFetching, theme, Accounts_ShowFormLogin } = this.props; @@ -146,6 +148,7 @@ class LoginView extends React.Component { textContentType='username' autoCompleteType='username' theme={theme} + value={user} /> ( + onPress(item.url)} theme={theme} testID={`server-history-${ item.url }`}> + + {item.url} + {item.username} + + onDelete(item)} testID={`server-history-delete-${ item.url }`}> + + + +); + +Item.propTypes = { + item: PropTypes.object, + theme: PropTypes.string, + onPress: PropTypes.func, + onDelete: PropTypes.func +}; + +export default Item; diff --git a/app/views/NewServerView/ServerInput/index.js b/app/views/NewServerView/ServerInput/index.js new file mode 100644 index 00000000..73e85b91 --- /dev/null +++ b/app/views/NewServerView/ServerInput/index.js @@ -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 ( + + setFocused(true)} + onBlur={() => setFocused(false)} + /> + { + focused && serversHistory?.length + ? ( + + onPressServerHistory(item)} onDelete={onDelete} />} + ItemSeparatorComponent={() => } + keyExtractor={item => item.id} + /> + + ) : null + } + + ); +}; + +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; diff --git a/app/views/NewServerView.js b/app/views/NewServerView/index.js similarity index 70% rename from app/views/NewServerView.js rename to app/views/NewServerView/index.js index 8ddebdd6..8b2047ac 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView/index.js @@ -1,42 +1,41 @@ import React from 'react'; import PropTypes from 'prop-types'; import { - Text, Keyboard, StyleSheet, TouchableOpacity, View, Alert, BackHandler + Text, Keyboard, StyleSheet, View, Alert, BackHandler } from 'react-native'; import { connect } from 'react-redux'; import * as FileSystem from 'expo-file-system'; import DocumentPicker from 'react-native-document-picker'; import { Base64 } from 'js-base64'; import parse from 'url-parse'; +import { Q } from '@nozbe/watermelondb'; -import UserPreferences from '../lib/userPreferences'; -import EventEmitter from '../utils/events'; -import { selectServerRequest, serverRequest } from '../actions/server'; -import { inviteLinksClear as inviteLinksClearAction } from '../actions/inviteLinks'; -import sharedStyles from './Styles'; -import Button from '../containers/Button'; -import TextInput from '../containers/TextInput'; -import OrSeparator from '../containers/OrSeparator'; -import FormContainer, { FormContainerInner } from '../containers/FormContainer'; -import I18n from '../i18n'; -import { isIOS } from '../utils/deviceInfo'; -import { themes } from '../constants/colors'; -import log, { logEvent, events } from '../utils/log'; -import { animateNextTransition } from '../utils/layoutAnimation'; -import { withTheme } from '../theme'; -import { setBasicAuth, BASIC_AUTH_KEY } from '../utils/fetch'; -import { CloseModalButton } from '../containers/HeaderButton'; -import { showConfirmationAlert } from '../utils/info'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import UserPreferences from '../../lib/userPreferences'; +import EventEmitter from '../../utils/events'; +import { selectServerRequest, serverRequest } from '../../actions/server'; +import { inviteLinksClear as inviteLinksClearAction } from '../../actions/inviteLinks'; +import sharedStyles from '../Styles'; +import Button from '../../containers/Button'; +import OrSeparator from '../../containers/OrSeparator'; +import FormContainer, { FormContainerInner } from '../../containers/FormContainer'; +import I18n from '../../i18n'; +import { isIOS } from '../../utils/deviceInfo'; +import { themes } from '../../constants/colors'; +import log, { logEvent, events } from '../../utils/log'; +import { animateNextTransition } from '../../utils/layoutAnimation'; +import { withTheme } from '../../theme'; +import { setBasicAuth, BASIC_AUTH_KEY } from '../../utils/fetch'; +import { CloseModalButton } from '../../containers/HeaderButton'; +import { showConfirmationAlert } from '../../utils/info'; +import database from '../../lib/database'; +import ServerInput from './ServerInput'; const styles = StyleSheet.create({ title: { ...sharedStyles.textBold, fontSize: 22 }, - inputContainer: { - marginTop: 24, - marginBottom: 32 - }, certificatePicker: { marginBottom: 32, alignItems: 'center', @@ -84,12 +83,17 @@ class NewServerView extends React.Component { this.state = { text: '', connectingOpen: false, - certificate: null + certificate: null, + serversHistory: [] }; EventEmitter.addEventListener('NewServer', this.handleNewServerEvent); BackHandler.addEventListener('hardwareBackPress', this.handleBackPress); } + componentDidMount() { + this.queryServerHistory(); + } + componentDidUpdate(prevProps) { const { adding } = this.props; if (prevProps.adding !== adding) { @@ -122,6 +126,29 @@ class NewServerView extends React.Component { onChangeText = (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 = () => { @@ -138,7 +165,11 @@ class NewServerView extends React.Component { 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); const { text, certificate } = this.state; const { connectServer } = this.props; @@ -164,7 +195,11 @@ class NewServerView extends React.Component { Keyboard.dismiss(); const server = this.completeUrl(text); await this.basicAuth(server, text); - connectServer(server, cert); + if (fromServerHistory) { + connectServer(server, cert, username, true); + } else { + connectServer(server, cert); + } } } @@ -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 = () => { const { certificate } = this.state; const { theme } = this.props; @@ -283,24 +331,25 @@ class NewServerView extends React.Component { render() { const { connecting, theme } = this.props; - const { text, connectingOpen } = this.state; + const { + text, connectingOpen, serversHistory + } = this.state; return ( - + {I18n.t('Join_your_workspace')} -