diff --git a/app/definitions/ILoggedUser.ts b/app/definitions/ILoggedUser.ts index 9b7193e77..92bff8252 100644 --- a/app/definitions/ILoggedUser.ts +++ b/app/definitions/ILoggedUser.ts @@ -22,6 +22,8 @@ export interface ILoggedUser { isFromWebView?: boolean; enableMessageParserEarlyAdoption: boolean; alsoSendThreadToChannel: 'default' | 'always' | 'never'; + bio?: string; + nickname?: string; } export interface ILoggedUserResultFromServer diff --git a/app/definitions/IProfile.ts b/app/definitions/IProfile.ts index 0e4889736..bbd01b61c 100644 --- a/app/definitions/IProfile.ts +++ b/app/definitions/IProfile.ts @@ -7,6 +7,8 @@ export interface IProfileParams { email: string | null; newPassword: string; currentPassword: string; + bio?: string; + nickname?: string; } export interface IAvatarButton { diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 655499cd9..7cc46dbad 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -726,6 +726,8 @@ "Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.", "Learn_more": "Learn more", "and_N_more": "and {{count}} more", + "Nickname": "Nickname", + "Bio":"Bio", "decline": "Decline", "accept": "Accept", "Incoming_call_from": "Incoming call from", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 5495105b1..bf584460b 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -711,6 +711,8 @@ "Discard_changes_description": "Todas as alterações serão perdidas, se você sair sem salvar.", "Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente", "Presence_Cap_Warning_Description": "O limite de conexões ativas para a workspace foi atingido, por isso o serviço responsável pela presença dos usuários está temporariamente desabilitado. Ele pode ser reabilitado manualmente nas configurações da workspace.", + "Nickname": "Apelido", + "Bio": "Biografia", "decline": "Recusar", "accept": "Aceitar", "Incoming_call_from": "Chamada recebida de", diff --git a/app/lib/database/model/servers/User.js b/app/lib/database/model/servers/User.js index 5d3438237..7fbbf4b96 100644 --- a/app/lib/database/model/servers/User.js +++ b/app/lib/database/model/servers/User.js @@ -29,4 +29,8 @@ export default class User extends Model { @field('is_from_webview') isFromWebView; @field('enable_message_parser_early_adoption') enableMessageParserEarlyAdoption; + + @field('nickname') nickname; + + @field('bio') bio; } diff --git a/app/lib/database/model/servers/migrations.js b/app/lib/database/model/servers/migrations.js index d1f24f125..d78e5b146 100644 --- a/app/lib/database/model/servers/migrations.js +++ b/app/lib/database/model/servers/migrations.js @@ -103,6 +103,18 @@ export default schemaMigrations({ columns: [{ name: 'enable_message_parser_early_adoption', type: 'boolean', isOptional: true }] }) ] + }, + { + toVersion: 13, + steps: [ + addColumns({ + table: 'users', + columns: [ + { name: 'nickname', type: 'string', isOptional: true }, + { name: 'bio', type: 'string', isOptional: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/servers.js b/app/lib/database/schema/servers.js index 68eddb036..b39c5880e 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: 12, + version: 13, tables: [ tableSchema({ name: 'users', @@ -17,7 +17,9 @@ export default appSchema({ { name: 'show_message_in_main_thread', type: 'boolean', isOptional: true }, { name: 'avatar_etag', type: 'string', isOptional: true }, { name: 'is_from_webview', type: 'boolean', isOptional: true }, - { name: 'enable_message_parser_early_adoption', type: 'boolean', isOptional: true } + { name: 'enable_message_parser_early_adoption', type: 'boolean', isOptional: true }, + { name: 'nickname', type: 'string', isOptional: true }, + { name: 'bio', type: 'string', isOptional: true } ] }), tableSchema({ diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index c62dcbe76..fb7b6ec8f 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -309,6 +309,12 @@ export default function subscribeRooms() { if (unset?.avatarETag) { store.dispatch(setUser({ avatarETag: '' })); } + if (diff?.bio) { + store.dispatch(setUser({ bio: diff.bio })); + } + if (diff?.nickname) { + store.dispatch(setUser({ nickname: diff.nickname })); + } } if (/subscriptions/.test(ev)) { if (type === 'removed') { diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index d64069f49..649982776 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -304,7 +304,9 @@ async function login(credentials: ICredentials, isFromWebView = false): Promise< isFromWebView, showMessageInMainThread, enableMessageParserEarlyAdoption, - alsoSendThreadToChannel: result.me.settings?.preferences?.alsoSendThreadToChannel + alsoSendThreadToChannel: result.me.settings?.preferences?.alsoSendThreadToChannel, + bio: result.me.bio, + nickname: result.me.nickname }; return user; } diff --git a/app/sagas/login.js b/app/sagas/login.js index 6e5b2a437..94138f451 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -171,7 +171,9 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { roles: user.roles, isFromWebView: user.isFromWebView, showMessageInMainThread: user.showMessageInMainThread, - avatarETag: user.avatarETag + avatarETag: user.avatarETag, + bio: user.bio, + nickname: user.nickname }; yield serversDB.action(async () => { try { diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index a989ca03b..8af61d060 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -94,7 +94,9 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch status: userRecord.status, statusText: userRecord.statusText, roles: userRecord.roles, - avatarETag: userRecord.avatarETag + avatarETag: userRecord.avatarETag, + bio: userRecord.bio, + nickname: userRecord.nickname }; } catch { // search credentials on shared credentials (Experimental/Official) diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 7ee61bd52..190b5b3a9 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -11,7 +11,7 @@ import Touch from '../../containers/Touch'; import KeyboardView from '../../containers/KeyboardView'; import sharedStyles from '../Styles'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; -import { showErrorAlert, showConfirmationAlert } from '../../lib/methods/helpers'; +import { showErrorAlert, showConfirmationAlert, compareServerVersion } from '../../lib/methods/helpers'; import { LISTENER } from '../../containers/Toast'; import EventEmitter from '../../lib/methods/helpers/events'; import { FormTextInput } from '../../containers/TextInput'; @@ -36,6 +36,10 @@ import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSh import { DeleteAccountActionSheetContent } from './components/DeleteAccountActionSheetContent'; import ActionSheetContentWithInputAndSubmit from '../../containers/ActionSheet/ActionSheetContentWithInputAndSubmit'; +// https://github.com/RocketChat/Rocket.Chat/blob/174c28d40b3d5a52023ee2dca2e81dd77ff33fa5/apps/meteor/app/lib/server/functions/saveUser.js#L24-L25 +const MAX_BIO_LENGTH = 260; +const MAX_NICKNAME_LENGTH = 120; + interface IProfileViewProps extends IActionSheetProvider, IBaseScreen { user: IUser; baseUrl: string; @@ -48,6 +52,7 @@ interface IProfileViewProps extends IActionSheetProvider, IBaseScreen { private name?: TextInput | null; private username?: TextInput | null; - private email?: TextInput; - private avatarUrl?: TextInput; - private newPassword?: TextInput; + private email?: TextInput | null; + private avatarUrl?: TextInput | null; + private newPassword?: TextInput | null; + private nickname?: TextInput | null; + private bio?: TextInput | null; setHeader = () => { const { navigation, isMasterDetail } = this.props; @@ -98,6 +107,8 @@ class ProfileView extends React.Component name: '', username: '', email: '', + bio: '', + nickname: '', newPassword: '', currentPassword: '', customFields: {}, @@ -123,7 +134,7 @@ class ProfileView extends React.Component init = (user?: IUser) => { const { user: userProps } = this.props; - const { name, username, emails, customFields } = user || userProps; + const { name, username, emails, customFields, bio, nickname } = user || userProps; this.setState({ name: name as string, @@ -131,12 +142,14 @@ class ProfileView extends React.Component email: emails ? emails[0].address : null, newPassword: null, currentPassword: null, - customFields: customFields || {} + customFields: customFields || {}, + bio, + nickname }); }; formIsChanged = () => { - const { name, username, email, newPassword, customFields } = this.state; + const { name, username, email, newPassword, customFields, bio, nickname } = this.state; const { user } = this.props; let customFieldsChanged = false; @@ -152,6 +165,8 @@ class ProfileView extends React.Component return !( user.name === name && user.username === username && + user.bio === bio && + user.nickname === nickname && !newPassword && user.emails && user.emails[0].address === email && @@ -168,7 +183,7 @@ class ProfileView extends React.Component this.setState({ saving: true }); - const { name, username, email, newPassword, currentPassword, customFields, twoFactorCode } = this.state; + const { name, username, email, newPassword, currentPassword, customFields, twoFactorCode, bio, nickname } = this.state; const { user, dispatch } = this.props; const params = {} as IProfileParams; @@ -187,6 +202,14 @@ class ProfileView extends React.Component params.email = email; } + if (user.bio !== bio) { + params.bio = bio; + } + + if (user.nickname !== nickname) { + params.nickname = nickname; + } + // newPassword if (newPassword) { params.newPassword = newPassword; @@ -400,7 +423,7 @@ class ProfileView extends React.Component }; render() { - const { name, username, email, newPassword, customFields, saving } = this.state; + const { name, username, email, newPassword, customFields, saving, nickname, bio } = this.state; const { user, theme, @@ -410,7 +433,8 @@ class ProfileView extends React.Component Accounts_AllowUserAvatarChange, Accounts_AllowUsernameChange, Accounts_CustomFields, - Accounts_AllowDeleteOwnAccount + Accounts_AllowDeleteOwnAccount, + serverVersion } = this.props; return ( @@ -431,9 +455,7 @@ class ProfileView extends React.Component { - this.name = e; - }} + inputRef={e => (this.name = e)} label={I18n.t('Name')} placeholder={I18n.t('Name')} value={name} @@ -446,9 +468,7 @@ class ProfileView extends React.Component { - this.username = e; - }} + inputRef={e => (this.username = e)} label={I18n.t('Username')} placeholder={I18n.t('Username')} value={username} @@ -461,28 +481,48 @@ class ProfileView extends React.Component { - if (e) { - this.email = e; - } - }} + inputRef={e => (this.email = e)} label={I18n.t('Email')} placeholder={I18n.t('Email')} value={email || undefined} onChangeText={value => this.setState({ email: value })} onSubmitEditing={() => { - this.newPassword?.focus(); + this.nickname?.focus(); }} testID='profile-view-email' /> + {compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.5.0') ? ( + (this.nickname = e)} + label={I18n.t('Nickname')} + value={nickname} + onChangeText={value => this.setState({ nickname: value })} + onSubmitEditing={() => { + this.bio?.focus(); + }} + testID='profile-view-nickname' + maxLength={MAX_NICKNAME_LENGTH} + /> + ) : null} + {compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.1.0') ? ( + (this.bio = e)} + label={I18n.t('Bio')} + inputStyle={styles.inputBio} + multiline + maxLength={MAX_BIO_LENGTH} + value={bio} + onChangeText={value => this.setState({ bio: value })} + onSubmitEditing={() => { + this.newPassword?.focus(); + }} + testID='profile-view-bio' + /> + ) : null} { - if (e) { - this.newPassword = e; - } - }} + inputRef={e => (this.newPassword = e)} label={I18n.t('New_Password')} placeholder={I18n.t('New_Password')} value={newPassword || undefined} @@ -539,6 +579,7 @@ const mapStateToProps = (state: IApplicationState) => ({ Accounts_AllowUsernameChange: state.settings.Accounts_AllowUsernameChange as boolean, Accounts_CustomFields: state.settings.Accounts_CustomFields as string, baseUrl: state.server.server, + serverVersion: state.server.version, Accounts_AllowDeleteOwnAccount: state.settings.Accounts_AllowDeleteOwnAccount as boolean }); diff --git a/app/views/ProfileView/styles.ts b/app/views/ProfileView/styles.ts index f9db43c85..1d23428ba 100644 --- a/app/views/ProfileView/styles.ts +++ b/app/views/ProfileView/styles.ts @@ -22,5 +22,9 @@ export default StyleSheet.create({ marginRight: 15, marginBottom: 15, borderRadius: 4 + }, + inputBio: { + height: 100, + textAlignVertical: 'top' } }); diff --git a/e2e/tests/assorted/03-profile.spec.ts b/e2e/tests/assorted/03-profile.spec.ts index 221ebf020..33ce053f8 100644 --- a/e2e/tests/assorted/03-profile.spec.ts +++ b/e2e/tests/assorted/03-profile.spec.ts @@ -10,6 +10,12 @@ async function waitForToast() { await sleep(600); } +async function dismissKeyboardAndScrollUp() { + await element(by.id('profile-view-list')).swipe('down'); + await sleep(300); + await element(by.id('profile-view-list')).swipe('up'); +} + describe('Profile screen', () => { let textMatcher: TTextMatcher; let user: ITestUser; @@ -74,23 +80,27 @@ describe('Profile screen', () => { it('should change name and username', async () => { await element(by.id('profile-view-name')).replaceText(`${user.username}new`); await element(by.id('profile-view-username')).replaceText(`${user.username}new`); - // dismiss keyboard - await element(by.id('profile-view-list')).swipe('down'); + await dismissKeyboardAndScrollUp(); + await element(by.id('profile-view-submit')).tap(); + await waitForToast(); + }); + + it('should change nickname and bio', async () => { + await element(by.id('profile-view-nickname')).replaceText(`nickname-${user.username}`); + await element(by.id('profile-view-bio')).replaceText(`bio-${user.username}`); + await dismissKeyboardAndScrollUp(); await element(by.id('profile-view-submit')).tap(); await waitForToast(); }); it('should change email and password', async () => { - await element(by.id('profile-view-list')).swipe('up'); + await element(by.id('profile-view-list')).swipe('down'); await waitFor(element(by.id('profile-view-email'))) .toBeVisible() .withTimeout(2000); await element(by.id('profile-view-email')).replaceText(`mobile+profileChangesNew${random()}@rocket.chat`); - // dismiss keyboard - await element(by.id('profile-view-list')).swipe('down'); + await dismissKeyboardAndScrollUp(); await element(by.id('profile-view-new-password')).replaceText(`${user.password}new`); - // dismiss keyboard - await element(by.id('profile-view-list')).swipe('down'); await waitFor(element(by.id('profile-view-submit'))) .toExist() .withTimeout(2000);