From 9e89316e2a14b1b66f5fa6577783ca29258af855 Mon Sep 17 00:00:00 2001 From: Djorkaeff Alexandre Date: Fri, 8 May 2020 14:36:10 -0300 Subject: [PATCH] [NEW] Livechat (#2004) * [WIP][NEW] Livechat info/actions * [IMPROVEMENT] RoomActionsView * [NEW] Visitor Navigation * [NEW] Get Department REST * [FIX] Borders * [IMPROVEMENT] Refactor RoomInfo View * [FIX] Error while navigate from mention -> roomInfo * [NEW] Livechat Fields * [NEW] Close Livechat * [WIP] Forward livechat * [NEW] Return inquiry * [WIP] Comment when close livechat * [WIP] Improve roomInfo * [IMPROVEMENT] Forward room * [FIX] Department picker * [FIX] Picker without results * [FIX] Superfluous argument * [FIX] Check permissions on RoomActionsView * [FIX] Livechat permissions * [WIP] Show edit to livechat * [I18N] Add pt-br translations * [WIP] Livechat Info * [IMPROVEMENT] Livechat info * [WIP] Livechat Edit * [WIP] Livechat edit * [WIP] Livechat Edit * [WIP] Livechat edit scroll * [FIX] Edit customFields * [FIX] Clean livechat customField * [FIX] Visitor Navigation * [NEW] Next input logic LivechatEdit * [FIX] Add livechat data to subscription * [FIX] Revert change * [NEW] Livechat user Status * [WIP] Livechat tags * [NEW] Edit livechat tags * [FIX] Prevent some crashes * [FIX] Forward * [FIX] Return Livechat error * [FIX] Prevent livechat info crash * [IMPROVEMENT] Use input style on forward chat * OnboardingSeparator -> OrSeparator * [FIX] Go to next input * [NEW] Added some icons * [NEW] Livechat close * [NEW] Forward Room Action * [FIX] Livechat edit style * [FIX] Change status logic * [CHORE] Remove unnecessary logic * [CHORE] Remove unnecessary code * [CHORE] Remove unecessary case * [FIX] Superfluous argument * [IMPROVEMENT] Submit livechat edit * [CHORE] Remove textInput type * [FIX] Livechat edit * [FIX] Livechat Edit * [FIX] Use same effect * [IMPROVEMENT] Tags input * [FIX] Add empty tag * Fix minor issues * Fix typo * insert livechat room data to our room object * review * add method calls server version Co-authored-by: Diego Mello --- app/actions/actionsTypes.js | 2 +- app/actions/room.js | 15 + app/constants/settings.js | 3 + app/containers/LoginServices.js | 6 +- ...{OnboardingSeparator.js => OrSeparator.js} | 6 +- app/containers/RoomTypeIcon.js | 7 +- app/containers/TextInput.js | 20 +- app/containers/UIKit/MultiSelect/Chips.js | 14 +- app/containers/UIKit/MultiSelect/Input.js | 11 +- app/containers/UIKit/MultiSelect/index.js | 4 +- app/containers/message/User.js | 1 - app/i18n/locales/en.js | 25 ++ app/i18n/locales/pt-BR.js | 20 ++ app/index.js | 9 + app/lib/database/model/Room.js | 10 + app/lib/database/model/Subscription.js | 10 + app/lib/database/model/migrations.js | 17 +- app/lib/database/schema/app.js | 14 +- .../methods/helpers/findSubscriptionsRooms.js | 14 +- .../helpers/mergeSubscriptionsRooms.js | 15 + app/lib/methods/subscriptions/rooms.js | 18 +- app/lib/rocketchat.js | 53 +++ app/presentation/RoomItem/TypeIcon.js | 2 +- app/presentation/RoomItem/index.js | 22 +- app/reducers/room.js | 12 + app/sagas/room.js | 64 +++- app/views/ForwardLivechatView.js | 150 ++++++++ app/views/LivechatEditView.js | 285 +++++++++++++++ app/views/NewServerView.js | 4 +- app/views/PickerView.js | 93 +++-- app/views/RoomActionsView/index.js | 101 +++++- app/views/RoomInfoView/Channel.js | 39 ++ app/views/RoomInfoView/CustomFields.js | 31 ++ app/views/RoomInfoView/Direct.js | 42 +++ app/views/RoomInfoView/Item.js | 27 ++ app/views/RoomInfoView/Livechat.js | 140 ++++++++ app/views/RoomInfoView/Timezone.js | 26 ++ app/views/RoomInfoView/index.js | 337 ++++++++---------- app/views/RoomView/Header/Icon.js | 2 +- app/views/RoomView/Header/index.js | 19 +- app/views/RoomView/index.js | 10 +- app/views/RoomsListView/index.js | 5 +- app/views/VisitorNavigationView.js | 98 +++++ package.json | 1 + yarn.lock | 11 +- 45 files changed, 1534 insertions(+), 281 deletions(-) rename app/containers/{OnboardingSeparator.js => OrSeparator.js} (89%) create mode 100644 app/views/ForwardLivechatView.js create mode 100644 app/views/LivechatEditView.js create mode 100644 app/views/RoomInfoView/Channel.js create mode 100644 app/views/RoomInfoView/CustomFields.js create mode 100644 app/views/RoomInfoView/Direct.js create mode 100644 app/views/RoomInfoView/Item.js create mode 100644 app/views/RoomInfoView/Livechat.js create mode 100644 app/views/RoomInfoView/Timezone.js create mode 100644 app/views/VisitorNavigationView.js diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 1dae08222..69b3f916e 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -31,7 +31,7 @@ export const ROOMS = createRequestTypes('ROOMS', [ 'OPEN_SEARCH_HEADER', 'CLOSE_SEARCH_HEADER' ]); -export const ROOM = createRequestTypes('ROOM', ['SUBSCRIBE', 'UNSUBSCRIBE', 'LEAVE', 'DELETE', 'REMOVED', 'USER_TYPING']); +export const ROOM = createRequestTypes('ROOM', ['SUBSCRIBE', 'UNSUBSCRIBE', 'LEAVE', 'DELETE', 'REMOVED', 'CLOSE', 'FORWARD', 'USER_TYPING']); export const APP = createRequestTypes('APP', ['START', 'READY', 'INIT', 'INIT_LOCAL_SETTINGS']); export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']); export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]); diff --git a/app/actions/room.js b/app/actions/room.js index 3b3b213f2..59916bec0 100644 --- a/app/actions/room.js +++ b/app/actions/room.js @@ -30,6 +30,21 @@ export function deleteRoom(rid, t) { }; } +export function closeRoom(rid) { + return { + type: types.ROOM.CLOSE, + rid + }; +} + +export function forwardRoom(rid, transferData) { + return { + type: types.ROOM.FORWARD, + transferData, + rid + }; +} + export function removedRoom() { return { type: types.ROOM.REMOVED diff --git a/app/constants/settings.js b/app/constants/settings.js index ace1e91a9..ba63d4cab 100644 --- a/app/constants/settings.js +++ b/app/constants/settings.js @@ -68,6 +68,9 @@ export default { LDAP_Enable: { type: 'valueAsBoolean' }, + Livechat_request_comment_when_closing_conversation: { + type: 'valueAsBoolean' + }, Jitsi_Enabled: { type: 'valueAsBoolean' }, diff --git a/app/containers/LoginServices.js b/app/containers/LoginServices.js index e6e685b34..2cf06d327 100644 --- a/app/containers/LoginServices.js +++ b/app/containers/LoginServices.js @@ -12,7 +12,7 @@ import sharedStyles from '../views/Styles'; import { themes } from '../constants/colors'; import { loginRequest as loginRequestAction } from '../actions/login'; import Button from './Button'; -import OnboardingSeparator from './OnboardingSeparator'; +import OrSeparator from './OrSeparator'; import Touch from '../utils/touch'; import I18n from '../i18n'; import random from '../utils/random'; @@ -252,12 +252,12 @@ class LoginServices extends React.PureComponent { style={styles.options} color={themes[theme].actionTintColor} /> - + ); } if (length > 0 && separator) { - return ; + return ; } return null; } diff --git a/app/containers/OnboardingSeparator.js b/app/containers/OrSeparator.js similarity index 89% rename from app/containers/OnboardingSeparator.js rename to app/containers/OrSeparator.js index ff18bf6a9..665c14538 100644 --- a/app/containers/OnboardingSeparator.js +++ b/app/containers/OrSeparator.js @@ -24,7 +24,7 @@ const styles = StyleSheet.create({ } }); -const DateSeparator = React.memo(({ theme }) => { +const OrSeparator = React.memo(({ theme }) => { const line = { backgroundColor: themes[theme].borderColor }; const text = { color: themes[theme].auxiliaryText }; return ( @@ -36,8 +36,8 @@ const DateSeparator = React.memo(({ theme }) => { ); }); -DateSeparator.propTypes = { +OrSeparator.propTypes = { theme: PropTypes.string }; -export default DateSeparator; +export default OrSeparator; diff --git a/app/containers/RoomTypeIcon.js b/app/containers/RoomTypeIcon.js index 1055a0b21..b35d83a60 100644 --- a/app/containers/RoomTypeIcon.js +++ b/app/containers/RoomTypeIcon.js @@ -2,7 +2,7 @@ import React from 'react'; import { Image, StyleSheet } from 'react-native'; import PropTypes from 'prop-types'; import { CustomIcon } from '../lib/Icons'; -import { themes } from '../constants/colors'; +import { STATUS_COLORS, themes } from '../constants/colors'; const styles = StyleSheet.create({ style: { @@ -15,7 +15,7 @@ const styles = StyleSheet.create({ }); const RoomTypeIcon = React.memo(({ - type, size, isGroupChat, style, theme + type, size, isGroupChat, status, style, theme }) => { if (!type) { return null; @@ -36,7 +36,7 @@ const RoomTypeIcon = React.memo(({ } return ; } if (type === 'l') { - return ; + return ; } return ; }); @@ -45,6 +45,7 @@ RoomTypeIcon.propTypes = { theme: PropTypes.string, type: PropTypes.string, isGroupChat: PropTypes.bool, + status: PropTypes.string, size: PropTypes.number, style: PropTypes.object }; diff --git a/app/containers/TextInput.js b/app/containers/TextInput.js index dc071971f..a7c7d473c 100644 --- a/app/containers/TextInput.js +++ b/app/containers/TextInput.js @@ -64,8 +64,10 @@ export default class RCTextInput extends React.PureComponent { inputRef: PropTypes.func, testID: PropTypes.string, iconLeft: PropTypes.string, + iconRight: PropTypes.string, placeholder: PropTypes.string, left: PropTypes.element, + onIconRightPress: PropTypes.func, theme: PropTypes.string } @@ -90,6 +92,19 @@ export default class RCTextInput extends React.PureComponent { ); } + get iconRight() { + const { iconRight, onIconRightPress, theme } = this.props; + return ( + + + + ); + } + get iconPassword() { const { showPassword } = this.state; const { testID, theme } = this.props; @@ -117,7 +132,7 @@ export default class RCTextInput extends React.PureComponent { render() { const { showPassword } = this.state; const { - label, left, error, loading, secureTextEntry, containerStyle, inputRef, iconLeft, inputStyle, testID, placeholder, theme, ...inputProps + label, left, error, loading, secureTextEntry, containerStyle, inputRef, iconLeft, iconRight, inputStyle, testID, placeholder, theme, ...inputProps } = this.props; const { dangerColor } = themes[theme]; return ( @@ -140,7 +155,7 @@ export default class RCTextInput extends React.PureComponent { style={[ styles.input, iconLeft && styles.inputIconLeft, - secureTextEntry && styles.inputIconRight, + (secureTextEntry || iconRight) && styles.inputIconRight, { backgroundColor: themes[theme].backgroundColor, borderColor: themes[theme].separatorColor, @@ -165,6 +180,7 @@ export default class RCTextInput extends React.PureComponent { {...inputProps} /> {iconLeft ? this.iconLeft : null} + {iconRight ? this.iconRight : null} {secureTextEntry ? this.iconPassword : null} {loading ? this.loading : null} {left} diff --git a/app/containers/UIKit/MultiSelect/Chips.js b/app/containers/UIKit/MultiSelect/Chips.js index 59264db0a..496ba2261 100644 --- a/app/containers/UIKit/MultiSelect/Chips.js +++ b/app/containers/UIKit/MultiSelect/Chips.js @@ -12,11 +12,13 @@ import styles from './styles'; const keyExtractor = item => item.value.toString(); -const Chip = ({ item, onSelect, theme }) => ( +const Chip = ({ + item, onSelect, style, theme +}) => ( onSelect(item)} - style={[styles.chip, { backgroundColor: themes[theme].auxiliaryBackground }]} + style={[styles.chip, { backgroundColor: themes[theme].auxiliaryBackground }, style]} background={Touchable.Ripple(themes[theme].bannerBackground)} > <> @@ -29,17 +31,21 @@ const Chip = ({ item, onSelect, theme }) => ( Chip.propTypes = { item: PropTypes.object, onSelect: PropTypes.func, + style: PropTypes.object, theme: PropTypes.string }; -const Chips = ({ items, onSelect, theme }) => ( +const Chips = ({ + items, onSelect, style, theme +}) => ( - {items.map(item => )} + {items.map(item => )} ); Chips.propTypes = { items: PropTypes.array, onSelect: PropTypes.func, + style: PropTypes.object, theme: PropTypes.string }; diff --git a/app/containers/UIKit/MultiSelect/Input.js b/app/containers/UIKit/MultiSelect/Input.js index d4de6917c..6a7085fae 100644 --- a/app/containers/UIKit/MultiSelect/Input.js +++ b/app/containers/UIKit/MultiSelect/Input.js @@ -1,5 +1,5 @@ import React from 'react'; -import { View } from 'react-native'; +import { View, Text } from 'react-native'; import PropTypes from 'prop-types'; import Touchable from 'react-native-platform-touchable'; @@ -9,16 +9,16 @@ import ActivityIndicator from '../../ActivityIndicator'; import styles from './styles'; const Input = ({ - children, open, theme, loading, inputStyle, disabled + children, onPress, theme, loading, inputStyle, placeholder, disabled }) => ( open(true)} + onPress={onPress} style={[{ backgroundColor: themes[theme].backgroundColor }, inputStyle]} background={Touchable.Ripple(themes[theme].bannerBackground)} disabled={disabled} > - {children} + {placeholder ? {placeholder} : children} { loading ? @@ -29,10 +29,11 @@ const Input = ({ ); Input.propTypes = { children: PropTypes.node, - open: PropTypes.func, + onPress: PropTypes.func, theme: PropTypes.string, inputStyle: PropTypes.object, disabled: PropTypes.bool, + placeholder: PropTypes.string, loading: PropTypes.bool }; diff --git a/app/containers/UIKit/MultiSelect/index.js b/app/containers/UIKit/MultiSelect/index.js index 6cd584b1b..b88330e4e 100644 --- a/app/containers/UIKit/MultiSelect/index.js +++ b/app/containers/UIKit/MultiSelect/index.js @@ -136,7 +136,7 @@ export const MultiSelect = React.memo(({ /> ) : ( selected.includes(option.value)); button = ( navToRoomInfo(navParam)} - style={styles.titleContainer} disabled={author._id === user.id} > diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index f795354d5..4d1a88a2c 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -85,6 +85,7 @@ export default { Add_Server: 'Add Server', Add_users: 'Add users', Admin_Panel: 'Admin Panel', + Agent: 'Agent', Alert: 'Alert', alert: 'alert', alerts: 'alerts', @@ -133,7 +134,9 @@ export default { Click_to_join: 'Click to Join!', Close: 'Close', Close_emoji_selector: 'Close emoji selector', + Closing_chat: 'Closing chat', Change_language_loading: 'Changing language.', + Chat_closed_by_agent: 'Chat closed by agent', Choose: 'Choose', Choose_from_library: 'Choose from library', Choose_file: 'Choose file', @@ -151,6 +154,7 @@ export default { Continue_with: 'Continue with', Copied_to_clipboard: 'Copied to clipboard!', Copy: 'Copy', + Conversation: 'Conversation', Permalink: 'Permalink', Certificate_password: 'Certificate Password', Clear_cache: 'Clear local server cache', @@ -169,6 +173,7 @@ export default { Default: 'Default', Default_browser: 'Default browser', Delete_Room_Warning: 'Deleting a room will delete all messages posted within the room. This cannot be undone.', + Department: 'Department', delete: 'delete', Delete: 'Delete', DELETE: 'DELETE', @@ -196,6 +201,7 @@ export default { Email: 'Email', EMAIL: 'EMAIL', email: 'e-mail', + Empty_title: 'Empty title', Enable_Auto_Translate: 'Enable Auto-Translate', Enable_notifications: 'Enable notifications', Everyone_can_access_this_channel: 'Everyone can access this channel', @@ -212,6 +218,10 @@ export default { Forgot_password_If_this_email_is_registered: 'If this email is registered, we\'ll send instructions on how to reset your password. If you do not receive an email shortly, please come back and try again.', Forgot_password: 'Forgot your password?', Forgot_Password: 'Forgot Password', + Forward: 'Forward', + Forward_Chat: 'Forward Chat', + Forward_to_department: 'Forward to department', + Forward_to_user: 'Forward to user', Full_table: 'Click to see full table', Generate_New_Link: 'Generate New Link', Group_by_favorites: 'Group favorites', @@ -235,6 +245,7 @@ export default { Message_HideType_subscription_role_removed: 'Role No Longer Defined', Message_HideType_room_archived: 'Room Archived', Message_HideType_room_unarchived: 'Room Unarchived', + IP: 'IP', In_app: 'In-app', IN_APP_AND_DESKTOP: 'IN-APP AND DESKTOP', In_App_and_Desktop_Alert_info: 'Displays a banner at the top of the screen when app is open, and displays a notification on desktop', @@ -260,6 +271,7 @@ export default { Light: 'Light', License: 'License', Livechat: 'Livechat', + Livechat_edit: 'Livechat edit', Login: 'Login', Login_error: 'Your credentials were rejected! Please try again.', Login_with: 'Login with', @@ -292,6 +304,7 @@ export default { N_users: '{{n}} users', name: 'name', Name: 'Name', + Navigation_history: 'Navigation history', Never: 'Never', New_Message: 'New Message', New_Password: 'New Password', @@ -318,6 +331,7 @@ export default { Notifications: 'Notifications', Notification_Duration: 'Notification Duration', Notification_Preferences: 'Notification Preferences', + No_available_agents_to_transfer: 'No available agents to transfer', Offline: 'Offline', Oops: 'Oops!', Onboarding_description: 'A workspace is your team or organization’s space to collaborate. Ask the workspace admin for address to join or create one for your team.', @@ -334,14 +348,17 @@ export default { Open_Source_Communication: 'Open Source Communication', Open_your_authentication_app_and_enter_the_code: 'Open your authentication app and enter the code.', OR: 'OR', + OS: 'OS', Overwrites_the_server_configuration_and_use_room_config: 'Overwrites the server configuration and use room config', Password: 'Password', Parent_channel_or_group: 'Parent channel or group', Permalink_copied_to_clipboard: 'Permalink copied to clipboard!', + Phone: 'Phone', Pin: 'Pin', Pinned_Messages: 'Pinned Messages', pinned: 'pinned', Pinned: 'Pinned', + Please_add_a_comment: 'Please add a comment', Please_enter_your_password: 'Please enter your password', Please_wait: 'Please wait.', Preferences: 'Preferences', @@ -380,6 +397,7 @@ export default { Reset_password: 'Reset password', resetting_password: 'resetting password', RESET: 'RESET', + Return: 'Return', Review_app_title: 'Are you enjoying this app?', Review_app_desc: 'Give us 5 stars on {{store}}', Review_app_yes: 'Sure!', @@ -401,6 +419,7 @@ export default { SAVE: 'SAVE', Save_Changes: 'Save Changes', Save: 'Save', + Saved: 'Saved', saving_preferences: 'saving preferences', saving_profile: 'saving profile', saving_settings: 'saving settings', @@ -415,7 +434,9 @@ export default { Select_Server: 'Select Server', Select_Users: 'Select Users', Select_a_Channel: 'Select a Channel', + Select_a_Department: 'Select a Department', Select_an_option: 'Select an option', + Select_a_User: 'Select a User', Send: 'Send', Send_audio_message: 'Send audio message', Send_crash_report: 'Send crash report', @@ -453,6 +474,7 @@ export default { Started_call: 'Call started by {{userBy}}', Submit: 'Submit', Table: 'Table', + Tags: 'Tags', Take_a_photo: 'Take a photo', Take_a_video: 'Take a video', tap_to_change_status: 'tap to change status', @@ -488,6 +510,7 @@ export default { Updating: 'Updating...', Uploading: 'Uploading', Upload_file_question_mark: 'Upload file?', + User: 'User', Users: 'Users', User_added_by: 'User {{userAdded}} added by {{userBy}}', User_Info: 'User Info', @@ -518,8 +541,10 @@ export default { Whats_your_2fa: 'What\'s your 2FA code?', Without_Servers: 'Without Servers', Workspaces: 'Workspaces', + Would_you_like_to_return_the_inquiry: 'Would you like to return the inquiry?', Write_External_Permission_Message: 'Rocket Chat needs access to your gallery so you can save images.', Write_External_Permission: 'Gallery Permission', + Yes: 'Yes', Yes_action_it: 'Yes, {{action}} it!', Yesterday: 'Yesterday', You_are_in_preview_mode: 'You are in preview mode', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index 139c0b510..6c7e06b50 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -89,6 +89,7 @@ export default { Add_Reaction: 'Reagir', Add_Server: 'Adicionar servidor', Add_users: 'Adicionar usuário', + Agent: 'Agente', Alert: 'Alerta', alert: 'alerta', alerts: 'alertas', @@ -135,7 +136,9 @@ export default { Click_to_join: 'Clique para participar!', Close: 'Fechar', Close_emoji_selector: 'Fechar seletor de emojis', + Closing_chat: 'Fechando conversa', Choose: 'Escolher', + Chat_closed_by_agent: 'Conversa fechada por agente', Choose_from_library: 'Escolha da biblioteca', Choose_file: 'Enviar arquivo', Choose_where_you_want_links_be_opened: 'Escolha onde deseja que os links sejam abertos', @@ -145,6 +148,7 @@ export default { Confirm: 'Confirmar', Connect: 'Conectar', Connected: 'Conectado', + Conversation: 'Conversação', connecting_server: 'conectando no servidor', Connecting: 'Conectando...', Continue_with: 'Entrar com', @@ -187,6 +191,7 @@ export default { Email_or_password_field_is_empty: 'Email ou senha estão vazios', Email: 'Email', email: 'e-mail', + Empty_title: 'Título vazio', Enable_notifications: 'Habilitar notificações', Everyone_can_access_this_channel: 'Todos podem acessar este canal', Error_uploading: 'Erro subindo', @@ -201,6 +206,10 @@ export default { Forgot_password_If_this_email_is_registered: 'Se este e-mail estiver cadastrado, enviaremos instruções sobre como redefinir sua senha. Se você não receber um e-mail em breve, volte e tente novamente.', Forgot_password: 'Esqueceu sua senha?', Forgot_Password: 'Esqueci minha senha', + Forward: 'Encaminhar', + Forward_Chat: 'Encaminhar Conversa', + Forward_to_department: 'Encaminhar para departamento', + Forward_to_user: 'Encaminhar para usuário', Full_table: 'Clique para ver a tabela completa', Generate_New_Link: 'Gerar novo convite', Group_by_favorites: 'Agrupar favoritos', @@ -223,6 +232,7 @@ export default { Message_HideType_subscription_role_removed: 'Papel removido', Message_HideType_room_archived: 'Sala arquivada', Message_HideType_room_unarchived: 'Sala desarquivada', + IP: 'IP', In_app: 'No app', Invisible: 'Invisível', Invite: 'Convidar', @@ -269,6 +279,7 @@ export default { N_users: '{{n}} usuários', name: 'nome', Name: 'Nome', + Navigation_history: 'Histórico de navegação', Never: 'Nunca', New_in_RocketChat_question_mark: 'Novo no Rocket.Chat?', New_Message: 'Nova Mensagem', @@ -289,6 +300,7 @@ export default { Notify_active_in_this_room: 'Notificar usuários ativos nesta sala', Notify_all_in_this_room: 'Notificar todos nesta sala', Not_RC_Server: 'Este não é um servidor Rocket.Chat.\n{{contact}}', + No_available_agents_to_transfer: 'Nenhum agente disponível para transferência', Offline: 'Offline', Oops: 'Ops!', Onboarding_description: 'Workspace é o espaço de colaboração do seu time ou organização. Peça um convite ou o endereço ao seu administrador ou crie uma workspace para o seu time.', @@ -305,6 +317,7 @@ export default { Open_Source_Communication: 'Comunicação Open Source', Open_your_authentication_app_and_enter_the_code: 'Abra seu aplicativo de autenticação e digite o código.', OR: 'OU', + OS: 'SO', Overwrites_the_server_configuration_and_use_room_config: 'Substituir a configuração do servidor e usar a configuração da sala', Password: 'Senha', Parent_channel_or_group: 'Canal ou grupo pai', @@ -315,6 +328,7 @@ export default { Pinned: 'Mensagens Fixadas', Please_wait: 'Por favor, aguarde.', Please_enter_your_password: 'Por favor, digite sua senha', + Please_add_a_comment: 'Por favor, adicione um comentário', Preferences: 'Preferências', Preferences_saved: 'Preferências salvas!', Privacy_Policy: ' Política de Privacidade', @@ -343,6 +357,7 @@ export default { Reset_password: 'Resetar senha', resetting_password: 'redefinindo senha', RESET: 'RESETAR', + Return: 'Retornar', Review_app_title: 'Você está gostando do app?', Review_app_desc: 'Nos dê 5 estrelas na {{store}}', Review_app_yes: 'Claro!', @@ -377,7 +392,9 @@ export default { Select_Server: 'Selecionar Servidor', Select_Users: 'Selecionar Usuários', Select_a_Channel: 'Selecione um canal', + Select_a_Department: 'Selecione um Departamento', Select_an_option: 'Selecione uma opção', + Select_a_User: 'Selecione um Usuário', Send: 'Enviar', Send_audio_message: 'Enviar mensagem de áudio', Send_message: 'Enviar mensagem', @@ -436,6 +453,7 @@ export default { Updating: 'Atualizando...', Uploading: 'Subindo arquivo', Upload_file_question_mark: 'Enviar arquivo?', + User: 'Usuário', Users: 'Usuários', User_added_by: 'Usuário {{userAdded}} adicionado por {{userBy}}', User_has_been_key: 'Usuário foi {{key}}!', @@ -479,8 +497,10 @@ export default { Your_invite_link_will_never_expire: 'Seu link de convite nunca irá vencer.', Your_workspace: 'Sua workspace', You_will_not_be_able_to_recover_this_message: 'Você não será capaz de recuperar essa mensagem!', + Would_you_like_to_return_the_inquiry: 'Deseja retornar a consulta?', Write_External_Permission_Message: 'Rocket Chat precisa de acesso à sua galeria para salvar imagens', Write_External_Permission: 'Acesso à Galeria', + Yes: 'Sim', Crash_report_disclaimer: 'Nós não rastreamos o conteúdo das suas conversas. O relatório de erros apenas contém informações relevantes para identificarmos problemas e corrigí-los.', Type_message: 'Digitar mensagem', Room_search: 'Busca de sala', diff --git a/app/index.js b/app/index.js index ad738c323..824172707 100644 --- a/app/index.js +++ b/app/index.js @@ -168,6 +168,15 @@ const ChatsStack = createStackNavigator({ NotificationPrefView: { getScreen: () => require('./views/NotificationPreferencesView').default }, + VisitorNavigationView: { + getScreen: () => require('./views/VisitorNavigationView').default + }, + ForwardLivechatView: { + getScreen: () => require('./views/ForwardLivechatView').default + }, + LivechatEditView: { + getScreen: () => require('./views/LivechatEditView').default + }, PickerView: { getScreen: () => require('./views/PickerView').default }, diff --git a/app/lib/database/model/Room.js b/app/lib/database/model/Room.js index 4a1097a0e..0a8beab12 100644 --- a/app/lib/database/model/Room.js +++ b/app/lib/database/model/Room.js @@ -13,4 +13,14 @@ export default class Room extends Model { @field('encrypted') encrypted; @field('ro') ro; + + @json('v', sanitizer) v; + + @json('served_by', sanitizer) servedBy; + + @field('department_id') departmentId; + + @json('livechat_data', sanitizer) livechatData; + + @json('tags', sanitizer) tags; } diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index ba928da6e..77efd3783 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -97,4 +97,14 @@ export default class Subscription extends Model { @json('uids', sanitizer) uids; @json('usernames', sanitizer) usernames; + + @json('visitor', sanitizer) visitor; + + @field('department_id') departmentId; + + @json('served_by', sanitizer) servedBy; + + @json('livechat_data', sanitizer) livechatData; + + @json('tags', sanitizer) tags; } diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index ab5b14848..260209427 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -99,7 +99,22 @@ export default schemaMigrations({ addColumns({ table: 'subscriptions', columns: [ - { name: 'banner_closed', type: 'boolean', isOptional: true } + { name: 'banner_closed', type: 'boolean', isOptional: true }, + { name: 'visitor', type: 'string', isOptional: true }, + { name: 'department_id', type: 'string', isOptional: true }, + { name: 'served_by', type: 'string', isOptional: true }, + { name: 'livechat_data', type: 'string', isOptional: true }, + { name: 'tags', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'rooms', + columns: [ + { name: 'v', type: 'string', isOptional: true }, + { name: 'department_id', type: 'string', isOptional: true }, + { name: 'served_by', type: 'string', isOptional: true }, + { name: 'livechat_data', type: 'string', isOptional: true }, + { name: 'tags', type: 'string', isOptional: true } ] }) ] diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index f740501bd..97784b3f9 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -43,7 +43,12 @@ export default appSchema({ { name: 'hide_unread_status', type: 'boolean', isOptional: true }, { name: 'sys_mes', type: 'string', isOptional: true }, { name: 'uids', type: 'string', isOptional: true }, - { name: 'usernames', type: 'string', isOptional: true } + { name: 'usernames', type: 'string', isOptional: true }, + { name: 'visitor', type: 'string', isOptional: true }, + { name: 'department_id', type: 'string', isOptional: true }, + { name: 'served_by', type: 'string', isOptional: true }, + { name: 'livechat_data', type: 'string', isOptional: true }, + { name: 'tags', type: 'string', isOptional: true } ] }), tableSchema({ @@ -52,7 +57,12 @@ export default appSchema({ { name: 'custom_fields', type: 'string' }, { name: 'broadcast', type: 'boolean' }, { name: 'encrypted', type: 'boolean' }, - { name: 'ro', type: 'boolean' } + { name: 'ro', type: 'boolean' }, + { name: 'v', type: 'string', isOptional: true }, + { name: 'department_id', type: 'string', isOptional: true }, + { name: 'served_by', type: 'string', isOptional: true }, + { name: 'livechat_data', type: 'string', isOptional: true }, + { name: 'tags', type: 'string', isOptional: true } ] }), tableSchema({ diff --git a/app/lib/methods/helpers/findSubscriptionsRooms.js b/app/lib/methods/helpers/findSubscriptionsRooms.js index a0a9c5c22..457fc3b5f 100644 --- a/app/lib/methods/helpers/findSubscriptionsRooms.js +++ b/app/lib/methods/helpers/findSubscriptionsRooms.js @@ -44,7 +44,12 @@ export default async(subscriptions = [], rooms = []) => { autoTranslateLanguage: s.autoTranslateLanguage, lastMessage: s.lastMessage, usernames: s.usernames, - uids: s.uids + uids: s.uids, + visitor: s.visitor, + departmentId: s.departmentId, + servedBy: s.servedBy, + livechatData: s.livechatData, + tags: s.tags })); subscriptions = subscriptions.concat(existingSubs); @@ -65,7 +70,12 @@ export default async(subscriptions = [], rooms = []) => { ro: r.ro, broadcast: r.broadcast, muted: r.muted, - sysMes: r.sysMes + sysMes: r.sysMes, + v: r.v, + departmentId: r.departmentId, + servedBy: r.servedBy, + livechatData: r.livechatData, + tags: r.tags })); rooms = rooms.concat(existingRooms); } catch { diff --git a/app/lib/methods/helpers/mergeSubscriptionsRooms.js b/app/lib/methods/helpers/mergeSubscriptionsRooms.js index 2ab12f652..6d9f6624d 100644 --- a/app/lib/methods/helpers/mergeSubscriptionsRooms.js +++ b/app/lib/methods/helpers/mergeSubscriptionsRooms.js @@ -35,6 +35,21 @@ export const merge = (subscription, room) => { } else { subscription.muted = []; } + if (room.v) { + subscription.visitor = room.v; + } + if (room.departmentId) { + subscription.departmentId = room.departmentId; + } + if (room.servedBy) { + subscription.servedBy = room.servedBy; + } + if (room.livechatData) { + subscription.livechatData = room.livechatData; + } + if (room.tags) { + subscription.tags = room.tags; + } subscription.sysMes = room.sysMes; } diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index da1b50d95..d6ea0f7b6 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -75,7 +75,12 @@ const createOrUpdateSubscription = async(subscription, room) => { lastMessage: s.lastMessage, roles: s.roles, usernames: s.usernames, - uids: s.uids + uids: s.uids, + visitor: s.visitor, + departmentId: s.departmentId, + servedBy: s.servedBy, + livechatData: s.livechatData, + tags: s.tags }; } catch (error) { try { @@ -98,10 +103,15 @@ const createOrUpdateSubscription = async(subscription, room) => { // We have to create a plain obj so we can manipulate it on `merge` // Can we do it in a better way? room = { - customFields: r.customFields, - broadcast: r.broadcast, + v: r.v, + ro: r.ro, + tags: r.tags, + servedBy: r.servedBy, encrypted: r.encrypted, - ro: r.ro + broadcast: r.broadcast, + customFields: r.customFields, + departmentId: r.departmentId, + livechatData: r.livechatData }; } catch (error) { // Do nothing diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index e9306358e..036604072 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -757,6 +757,59 @@ const RocketChat = { return this.sdk.get('rooms.info', { roomId }); }, + getVisitorInfo(visitorId) { + // RC 2.3.0 + return this.sdk.get('livechat/visitors.info', { visitorId }); + }, + closeLivechat(rid, comment) { + // RC 0.29.0 + return this.methodCall('livechat:closeRoom', rid, comment, { clientAction: true }); + }, + editLivechat(userData, roomData) { + // RC 0.55.0 + return this.methodCall('livechat:saveInfo', userData, roomData); + }, + returnLivechat(rid) { + // RC 0.72.0 + return this.methodCall('livechat:returnAsInquiry', rid); + }, + forwardLivechat(transferData) { + // RC 0.36.0 + return this.methodCall('livechat:transfer', transferData); + }, + getPagesLivechat(rid, offset) { + // RC 2.3.0 + return this.sdk.get(`livechat/visitors.pagesVisited/${ rid }?count=50&offset=${ offset }`); + }, + getDepartmentInfo(departmentId) { + // RC 2.2.0 + return this.sdk.get(`livechat/department/${ departmentId }?includeAgents=false`); + }, + getDepartments() { + // RC 2.2.0 + return this.sdk.get('livechat/department'); + }, + usersAutoComplete(selector) { + // RC 2.4.0 + return this.sdk.get('users.autocomplete', { selector }); + }, + getRoutingConfig() { + // RC 2.0.0 + return this.methodCall('livechat:getRoutingConfig'); + }, + getTagsList() { + // RC 2.0.0 + return this.methodCall('livechat:getTagsList'); + }, + getAgentDepartments(uid) { + // RC 2.4.0 + return this.sdk.get(`livechat/agents/${ uid }/departments`); + }, + getCustomFields() { + // RC 2.2.0 + return this.sdk.get('livechat/custom-fields'); + }, + getUidDirectMessage(room) { const { id: userId } = reduxStore.getState().login.user; diff --git a/app/presentation/RoomItem/TypeIcon.js b/app/presentation/RoomItem/TypeIcon.js index 19537c07e..d42e31a26 100644 --- a/app/presentation/RoomItem/TypeIcon.js +++ b/app/presentation/RoomItem/TypeIcon.js @@ -11,7 +11,7 @@ const TypeIcon = React.memo(({ if (type === 'd' && !isGroupChat) { return ; } - return ; + return ; }); TypeIcon.propTypes = { diff --git a/app/presentation/RoomItem/index.js b/app/presentation/RoomItem/index.js index dc39fe303..9251717cb 100644 --- a/app/presentation/RoomItem/index.js +++ b/app/presentation/RoomItem/index.js @@ -209,12 +209,20 @@ RoomItem.defaultProps = { getUserPresence: () => {} }; -const mapStateToProps = (state, ownProps) => ({ - connected: state.meteor.connected, - status: - state.meteor.connected && ownProps.type === 'd' - ? state.activeUsers[ownProps.id] && state.activeUsers[ownProps.id].status - : 'offline' -}); +const mapStateToProps = (state, ownProps) => { + let status = 'offline'; + const { id, type, visitor = {} } = ownProps; + if (state.meteor.connected) { + if (type === 'd') { + status = state.activeUsers[id]?.status || 'offline'; + } else if (type === 'l' && visitor?.status) { + ({ status } = visitor); + } + } + return { + connected: state.meteor.connected, + status + }; +}; export default connect(mapStateToProps)(RoomItem); diff --git a/app/reducers/room.js b/app/reducers/room.js index 762905fea..2b5cfcb8a 100644 --- a/app/reducers/room.js +++ b/app/reducers/room.js @@ -31,6 +31,18 @@ export default function(state = initialState, action) { rid: action.rid, isDeleting: true }; + case ROOM.CLOSE: + return { + ...state, + rid: action.rid, + isDeleting: true + }; + case ROOM.FORWARD: + return { + ...state, + rid: action.rid, + isDeleting: true + }; case ROOM.REMOVED: return { ...state, diff --git a/app/sagas/room.js b/app/sagas/room.js index a5cd48b46..9fab31386 100644 --- a/app/sagas/room.js +++ b/app/sagas/room.js @@ -1,4 +1,5 @@ import { Alert } from 'react-native'; +import prompt from 'react-native-prompt-android'; import { takeLatest, take, select, delay, race, put } from 'redux-saga/effects'; @@ -9,6 +10,7 @@ import { removedRoom } from '../actions/room'; import RocketChat from '../lib/rocketchat'; import log from '../utils/log'; import I18n from '../i18n'; +import { showErrorAlert } from '../utils/info'; const watchUserTyping = function* watchUserTyping({ rid, status }) { const auth = yield select(state => state.login.isAuthenticated); @@ -28,10 +30,8 @@ const watchUserTyping = function* watchUserTyping({ rid, status }) { } }; -const handleRemovedRoom = function* handleLeaveRoom({ result }) { - if (result.success) { - yield Navigation.navigate('RoomsListView'); - } +const handleRemovedRoom = function* handleRemovedRoom() { + yield Navigation.navigate('RoomsListView'); // types.ROOM.REMOVE is triggered by `subscriptions-changed` with `removed` arg const { timeout } = yield race({ deleteFinished: take(types.ROOM.REMOVED), @@ -45,7 +45,9 @@ const handleRemovedRoom = function* handleLeaveRoom({ result }) { const handleLeaveRoom = function* handleLeaveRoom({ rid, t }) { try { const result = yield RocketChat.leaveRoom(rid, t); - yield handleRemovedRoom({ result }); + if (result.success) { + yield handleRemovedRoom(); + } } catch (e) { if (e.data && e.data.errorType === 'error-you-are-last-owner') { Alert.alert(I18n.t('Oops'), I18n.t(e.data.errorType)); @@ -58,15 +60,65 @@ const handleLeaveRoom = function* handleLeaveRoom({ rid, t }) { const handleDeleteRoom = function* handleDeleteRoom({ rid, t }) { try { const result = yield RocketChat.deleteRoom(rid, t); - yield handleRemovedRoom({ result }); + if (result.success) { + yield handleRemovedRoom(); + } } catch (e) { Alert.alert(I18n.t('Oops'), I18n.t('There_was_an_error_while_action', { action: I18n.t('deleting_room') })); } }; +const handleCloseRoom = function* handleCloseRoom({ rid }) { + const requestComment = yield select(state => state.settings.Livechat_request_comment_when_closing_conversation); + + const closeRoom = async(comment = '') => { + try { + await RocketChat.closeLivechat(rid, comment); + Navigation.navigate('RoomsListView'); + } catch { + // do nothing + } + }; + + if (!requestComment) { + const comment = I18n.t('Chat_closed_by_agent'); + return closeRoom(comment); + } + + prompt( + I18n.t('Closing_chat'), + I18n.t('Please_add_a_comment'), + [ + { text: I18n.t('Cancel'), onPress: () => { }, style: 'cancel' }, + { + text: I18n.t('Submit'), + onPress: comment => closeRoom(comment) + } + ], + { + cancelable: true + } + ); +}; + +const handleForwardRoom = function* handleForwardRoom({ transferData }) { + try { + const result = yield RocketChat.forwardLivechat(transferData); + if (result === true) { + Navigation.navigate('RoomsListView'); + } else { + showErrorAlert(I18n.t('No_available_agents_to_transfer'), I18n.t('Oops')); + } + } catch (e) { + showErrorAlert(e.reason, I18n.t('Oops')); + } +}; + const root = function* root() { yield takeLatest(types.ROOM.USER_TYPING, watchUserTyping); yield takeLatest(types.ROOM.LEAVE, handleLeaveRoom); yield takeLatest(types.ROOM.DELETE, handleDeleteRoom); + yield takeLatest(types.ROOM.CLOSE, handleCloseRoom); + yield takeLatest(types.ROOM.FORWARD, handleForwardRoom); }; export default root; diff --git a/app/views/ForwardLivechatView.js b/app/views/ForwardLivechatView.js new file mode 100644 index 000000000..d9c97b8bb --- /dev/null +++ b/app/views/ForwardLivechatView.js @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet } from 'react-native'; +import { connect } from 'react-redux'; + +import I18n from '../i18n'; +import { withTheme } from '../theme'; +import { themes } from '../constants/colors'; +import RocketChat from '../lib/rocketchat'; +import OrSeparator from '../containers/OrSeparator'; +import Input from '../containers/UIKit/MultiSelect/Input'; +import { forwardRoom as forwardRoomAction } from '../actions/room'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16 + } +}); + +const ForwardLivechatView = ({ forwardRoom, navigation, theme }) => { + const [departments, setDepartments] = useState([]); + const [departmentId, setDepartment] = useState(); + const [users, setUsers] = useState([]); + const [userId, setUser] = useState(); + const [room, setRoom] = useState(); + + const rid = navigation.getParam('rid'); + + const getDepartments = async() => { + try { + const result = await RocketChat.getDepartments(); + if (result.success) { + setDepartments(result.departments.map(department => ({ label: department.name, value: department._id }))); + } + } catch { + // do nothing + } + }; + + const getUsers = async(term = '') => { + try { + const { servedBy: { _id: agentId } = {} } = room; + const _id = agentId && { $ne: agentId }; + const result = await RocketChat.usersAutoComplete({ conditions: { _id, status: { $ne: 'offline' }, statusLivechat: 'available' }, term }); + if (result.success) { + const parsedUsers = result.items.map(user => ({ label: user.username, value: user._id })); + setUsers(parsedUsers); + return parsedUsers; + } + } catch { + // do nothing + } + return []; + }; + + const getRoom = async() => { + try { + const result = await RocketChat.getRoomInfo(rid); + if (result.success) { + setRoom(result.room); + } + } catch { + // do nothing + } + }; + + const submit = () => { + const transferData = { roomId: rid }; + + if (!departmentId && !userId) { + return; + } + + if (userId) { + transferData.userId = userId; + } else { + transferData.departmentId = departmentId; + } + + forwardRoom(rid, transferData); + }; + + useEffect(() => { + getRoom(); + }, []); + + useEffect(() => { + if (room) { + getUsers(); + getDepartments(); + } + }, [room]); + + useEffect(() => { + if (departmentId || userId) { + submit(); + } + }, [departmentId, userId]); + + const onPressDepartment = () => { + navigation.navigate('PickerView', { + title: I18n.t('Forward_to_department'), + value: room?.departmentId, + data: departments, + onChangeValue: setDepartment, + goBack: false + }); + }; + + const onPressUser = () => { + navigation.navigate('PickerView', { + title: I18n.t('Forward_to_user'), + data: users, + onChangeValue: setUser, + onChangeText: getUsers, + goBack: false + }); + }; + + return ( + + + + + + ); +}; +ForwardLivechatView.propTypes = { + forwardRoom: PropTypes.func, + navigation: PropTypes.object, + theme: PropTypes.string +}; +ForwardLivechatView.navigationOptions = { + title: I18n.t('Forward_Chat') +}; + +const mapDispatchToProps = dispatch => ({ + forwardRoom: (rid, transferData) => dispatch(forwardRoomAction(rid, transferData)) +}); + +export default connect(null, mapDispatchToProps)(withTheme(ForwardLivechatView)); diff --git a/app/views/LivechatEditView.js b/app/views/LivechatEditView.js new file mode 100644 index 000000000..5ac1692e1 --- /dev/null +++ b/app/views/LivechatEditView.js @@ -0,0 +1,285 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Text, StyleSheet, ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-navigation'; +import { connect } from 'react-redux'; + +import { withTheme } from '../theme'; +import { themes } from '../constants/colors'; +import TextInput from '../containers/TextInput'; +import KeyboardView from '../presentation/KeyboardView'; +import RocketChat from '../lib/rocketchat'; +import I18n from '../i18n'; + +import sharedStyles from './Styles'; +import { LISTENER } from '../containers/Toast'; +import EventEmitter from '../utils/events'; +import scrollPersistTaps from '../utils/scrollPersistTaps'; +import { getUserSelector } from '../selectors/login'; +import Chips from '../containers/UIKit/MultiSelect/Chips'; +import Button from '../containers/Button'; + +const styles = StyleSheet.create({ + container: { + padding: 16 + }, + title: { + fontSize: 20, + paddingVertical: 10, + ...sharedStyles.textMedium + } +}); + +const Title = ({ title, theme }) => (title ? {title} : null); +Title.propTypes = { + title: PropTypes.string, + theme: PropTypes.string +}; + +const LivechatEditView = ({ user, navigation, theme }) => { + const [customFields, setCustomFields] = useState({}); + const [availableUserTags, setAvailableUserTags] = useState([]); + + const params = {}; + const inputs = {}; + + const livechat = navigation.getParam('room', {}); + const visitor = navigation.getParam('roomUser', {}); + + const getCustomFields = async() => { + const result = await RocketChat.getCustomFields(); + if (result.success && result.customFields?.length) { + const visitorCustomFields = result.customFields + .filter(field => field.visibility !== 'hidden' && field.scope === 'visitor') + .map(field => ({ [field._id]: (visitor.livechatData && visitor.livechatData[field._id]) || '' })) + .reduce((ret, field) => ({ [field]: field, ...ret })); + + const livechatCustomFields = result.customFields + .filter(field => field.visibility !== 'hidden' && field.scope === 'room') + .map(field => ({ [field._id]: (livechat.livechatData && livechat.livechatData[field._id]) || '' })) + .reduce((ret, field) => ({ [field]: field, ...ret })); + + return setCustomFields({ visitor: visitorCustomFields, livechat: livechatCustomFields }); + } + }; + + const [tagParam, setTags] = useState(livechat?.tags || []); + + useEffect(() => { + setTags([...tagParam, ...availableUserTags]); + }, [availableUserTags]); + + const getTagsList = async(agentDepartments) => { + const tags = await RocketChat.getTagsList(); + const isAdmin = ['admin', 'livechat-manager'].find(role => user.roles.includes(role)); + const availableTags = tags + .filter(({ departments }) => isAdmin || (departments.length === 0 || departments.some(i => agentDepartments.indexOf(i) > -1))) + .map(({ name }) => name); + setAvailableUserTags(availableTags); + }; + + const getAgentDepartments = async() => { + const result = await RocketChat.getAgentDepartments(visitor?._id); + if (result.success) { + const agentDepartments = result.departments.map(dept => dept.departmentId); + getTagsList(agentDepartments); + } + }; + + const submit = async() => { + const userData = { _id: visitor?._id }; + + const { rid, sms } = livechat; + const roomData = { _id: rid }; + + if (params.name) { + userData.name = params.name; + } + if (params.email) { + userData.email = params.email; + } + if (params.phone) { + userData.phone = params.phone; + } + + userData.livechatData = {}; + Object.entries(customFields?.visitor || {}).forEach(([key]) => { + if (params[key] || params[key] === '') { + userData.livechatData[key] = params[key]; + } + }); + + if (params.topic) { + roomData.topic = params.topic; + } + + roomData.tags = tagParam; + + roomData.livechatData = {}; + Object.entries(customFields?.livechat || {}).forEach(([key]) => { + if (params[key] || params[key] === '') { + roomData.livechatData[key] = params[key]; + } + }); + + if (sms) { + delete userData.phone; + } + + const { error } = await RocketChat.editLivechat(userData, roomData); + if (error) { + EventEmitter.emit(LISTENER, { message: error }); + } else { + EventEmitter.emit(LISTENER, { message: I18n.t('Saved') }); + navigation.goBack(); + } + }; + + const onChangeText = (key, text) => { params[key] = text; }; + + useEffect(() => { + getAgentDepartments(); + getCustomFields(); + }, []); + + return ( + + + + + <TextInput + label={I18n.t('Name')} + defaultValue={visitor?.name} + onChangeText={text => onChangeText('name', text)} + onSubmitEditing={() => { inputs.name.focus(); }} + theme={theme} + /> + <TextInput + label={I18n.t('Email')} + inputRef={(e) => { inputs.name = e; }} + defaultValue={visitor?.visitorEmails && visitor?.visitorEmails[0]?.address} + onChangeText={text => onChangeText('email', text)} + onSubmitEditing={() => { inputs.phone.focus(); }} + theme={theme} + /> + <TextInput + label={I18n.t('Phone')} + inputRef={(e) => { inputs.phone = e; }} + defaultValue={visitor?.phone && visitor?.phone[0]?.phoneNumber} + onChangeText={text => onChangeText('phone', text)} + onSubmitEditing={() => { + const keys = Object.keys(customFields?.visitor || {}); + if (keys.length > 0) { + const key = keys.pop(); + inputs[key].focus(); + } else { + inputs.topic.focus(); + } + }} + theme={theme} + /> + {Object.entries(customFields?.visitor || {}).map(([key, value], index, array) => ( + <TextInput + label={key} + defaultValue={value} + inputRef={(e) => { inputs[key] = e; }} + onChangeText={text => onChangeText(key, text)} + onSubmitEditing={() => { + if (array.length - 1 > index) { + return inputs[array[index + 1]].focus(); + } + inputs.topic.focus(); + }} + theme={theme} + /> + ))} + <Title + title={I18n.t('Conversation')} + theme={theme} + /> + <TextInput + label={I18n.t('Topic')} + inputRef={(e) => { inputs.topic = e; }} + defaultValue={livechat?.topic} + onChangeText={text => onChangeText('topic', text)} + onSubmitEditing={() => inputs.tags.focus()} + theme={theme} + /> + + <TextInput + inputRef={(e) => { inputs.tags = e; }} + label={I18n.t('Tags')} + iconRight='plus' + onIconRightPress={() => { + const lastText = inputs.tags._lastNativeText || ''; + if (lastText.length) { + setTags([...tagParam.filter(t => t !== lastText), lastText]); + inputs.tags.clear(); + } + }} + onSubmitEditing={() => { + const keys = Object.keys(customFields?.livechat || {}); + if (keys.length > 0) { + const key = keys.pop(); + inputs[key].focus(); + } else { + submit(); + } + }} + theme={theme} + /> + <Chips + items={tagParam.map(tag => ({ text: { text: tag }, value: tag }))} + onSelect={tag => setTags(tagParam.filter(t => t !== tag.value) || [])} + style={{ backgroundColor: themes[theme].backgroundColor }} + theme={theme} + /> + + {Object.entries(customFields?.livechat || {}).map(([key, value], index, array) => ( + <TextInput + label={key} + defaultValue={value} + inputRef={(e) => { inputs[key] = e; }} + onChangeText={text => onChangeText(key, text)} + onSubmitEditing={() => { + if (array.length - 1 > index) { + return inputs[array[index + 1]].focus(); + } + submit(); + }} + theme={theme} + /> + ))} + + <Button + title={I18n.t('Save')} + onPress={submit} + theme={theme} + /> + </SafeAreaView> + </ScrollView> + </KeyboardView> + ); +}; +LivechatEditView.propTypes = { + user: PropTypes.object, + navigation: PropTypes.object, + theme: PropTypes.string +}; +LivechatEditView.navigationOptions = ({ + title: I18n.t('Livechat_edit') +}); + +const mapStateToProps = state => ({ + server: state.server.server, + user: getUserSelector(state) +}); + +export default connect(mapStateToProps)(withTheme(LivechatEditView)); diff --git a/app/views/NewServerView.js b/app/views/NewServerView.js index edbbd15f0..efea48c1d 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView.js @@ -19,7 +19,7 @@ import { appStart as appStartAction } from '../actions'; import sharedStyles from './Styles'; import Button from '../containers/Button'; import TextInput from '../containers/TextInput'; -import OnboardingSeparator from '../containers/OnboardingSeparator'; +import OrSeparator from '../containers/OrSeparator'; import FormContainer, { FormContainerInner } from '../containers/FormContainer'; import I18n from '../i18n'; import { isIOS } from '../utils/deviceInfo'; @@ -324,7 +324,7 @@ class NewServerView extends React.Component { testID='new-server-view-button' theme={theme} /> - <OnboardingSeparator theme={theme} /> + <OrSeparator theme={theme} /> <Text style={[styles.description, { color: themes[theme].auxiliaryText }]}>{I18n.t('Onboarding_join_open_description')}</Text> <Button title={I18n.t('Join_our_open_workspace')} diff --git a/app/views/PickerView.js b/app/views/PickerView.js index 5a8c73f2a..69275d30d 100644 --- a/app/views/PickerView.js +++ b/app/views/PickerView.js @@ -1,20 +1,38 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { FlatList, StyleSheet } from 'react-native'; +import { + View, FlatList, StyleSheet, Text +} from 'react-native'; import I18n from '../i18n'; import { themedHeader } from '../utils/navigation'; import { withTheme } from '../theme'; import { themes } from '../constants/colors'; +import debounce from '../utils/debounce'; import sharedStyles from './Styles'; import ListItem from '../containers/ListItem'; import Check from '../containers/Check'; import Separator from '../containers/Separator'; +import SearchBox from '../containers/SearchBox'; const styles = StyleSheet.create({ check: { marginHorizontal: 0 + }, + search: { + width: '100%', + height: 56 + }, + noResult: { + fontSize: 16, + paddingVertical: 56, + ...sharedStyles.textAlignCenter, + ...sharedStyles.textSemibold + }, + withoutBorder: { + borderBottomWidth: 0, + borderTopWidth: 0 } }); @@ -54,13 +72,37 @@ class PickerView extends React.PureComponent { const data = props.navigation.getParam('data', []); const value = props.navigation.getParam('value'); this.state = { data, value }; + + this.onSearch = props.navigation.getParam('onChangeText'); } onChangeValue = (value) => { const { navigation } = this.props; + const goBack = navigation.getParam('goBack', true); const onChange = navigation.getParam('onChangeValue', () => {}); onChange(value); - navigation.goBack(); + if (goBack) { + navigation.goBack(); + } + } + + onChangeText = debounce(async(text) => { + if (this.onSearch) { + const data = await this.onSearch(text); + this.setState({ data }); + } + }, 300, true) + + renderSearch() { + if (!this.onSearch) { + return null; + } + + return ( + <View style={styles.search}> + <SearchBox onChangeText={this.onChangeText} /> + </View> + ); } render() { @@ -68,27 +110,32 @@ class PickerView extends React.PureComponent { const { theme } = this.props; return ( - <FlatList - data={data} - keyExtractor={item => item.value} - renderItem={({ item }) => ( - <Item - item={item} - theme={theme} - selected={(value || data[0]?.value) === item.value} - onItemPress={() => this.onChangeValue(item.value)} - /> - )} - ItemSeparatorComponent={() => <Separator theme={theme} />} - contentContainerStyle={[ - sharedStyles.listContentContainer, - { - backgroundColor: themes[theme].auxiliaryBackground, - borderColor: themes[theme].separatorColor - } - ]} - style={{ backgroundColor: themes[theme].auxiliaryBackground }} - /> + <> + {this.renderSearch()} + <FlatList + data={data} + keyExtractor={item => item.value} + renderItem={({ item }) => ( + <Item + item={item} + theme={theme} + selected={!this.onSearch && (value || data[0]?.value) === item.value} + onItemPress={() => this.onChangeValue(item.value)} + /> + )} + ItemSeparatorComponent={() => <Separator theme={theme} />} + ListEmptyComponent={() => <Text style={[styles.noResult, { color: themes[theme].titleText }]}>{I18n.t('No_results_found')}</Text>} + contentContainerStyle={[ + sharedStyles.listContentContainer, + { + backgroundColor: themes[theme].auxiliaryBackground, + borderColor: themes[theme].separatorColor + }, + !data.length && styles.withoutBorder + ]} + style={{ backgroundColor: themes[theme].auxiliaryBackground }} + /> + </> ); } } diff --git a/app/views/RoomActionsView/index.js b/app/views/RoomActionsView/index.js index 869c8faca..626feb7a7 100644 --- a/app/views/RoomActionsView/index.js +++ b/app/views/RoomActionsView/index.js @@ -9,7 +9,7 @@ import _ from 'lodash'; import Touch from '../../utils/touch'; import { setLoading as setLoadingAction } from '../../actions/selectedUsers'; -import { leaveRoom as leaveRoomAction } from '../../actions/room'; +import { leaveRoom as leaveRoomAction, closeRoom as closeRoomAction } from '../../actions/room'; import styles from './styles'; import sharedStyles from '../Styles'; import Avatar from '../../containers/Avatar'; @@ -28,6 +28,7 @@ import { themedHeader } from '../../utils/navigation'; import { CloseModalButton } from '../../containers/HeaderButton'; import { getUserSelector } from '../../selectors/login'; import Markdown from '../../containers/markdown'; +import { showConfirmationAlert, showErrorAlert } from '../../utils/info'; class RoomActionsView extends React.Component { static navigationOptions = ({ navigation, screenProps }) => { @@ -51,6 +52,7 @@ class RoomActionsView extends React.Component { leaveRoom: PropTypes.func, jitsiEnabled: PropTypes.bool, setLoadingInvite: PropTypes.func, + closeRoom: PropTypes.func, theme: PropTypes.string } @@ -69,7 +71,9 @@ class RoomActionsView extends React.Component { canViewMembers: false, canAutoTranslate: false, canAddUser: false, - canInviteUser: false + canInviteUser: false, + canForwardGuest: false, + canReturnQueue: false }; if (room && room.observe && room.rid) { this.roomObservable = room.observe(); @@ -117,6 +121,12 @@ class RoomActionsView extends React.Component { this.canAddUser(); this.canInviteUser(); + + // livechat permissions + if (room.t === 'l') { + this.canForwardGuest(); + this.canReturnQueue(); + } } } @@ -186,9 +196,32 @@ class RoomActionsView extends React.Component { return result; } + canForwardGuest = async() => { + const { room } = this.state; + const { rid } = room; + let result = true; + + const transferLivechatGuest = 'transfer-livechat-guest'; + const permissions = await RocketChat.hasPermission([transferLivechatGuest], rid); + if (!permissions[transferLivechatGuest]) { + result = false; + } + + this.setState({ canForwardGuest: result }); + } + + canReturnQueue = async() => { + try { + const { returnQueue } = await RocketChat.getRoutingConfig(); + this.setState({ canReturnQueue: returnQueue }); + } catch { + // do nothing + } + } + get sections() { const { - room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate + room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue } = this.state; const { jitsiEnabled } = this.props; const { @@ -373,7 +406,42 @@ class RoomActionsView extends React.Component { }); } } else if (t === 'l') { - sections[2].data = [notificationsAction]; + sections[2].data = []; + + sections[2].data.push({ + icon: 'circle-cross', + name: I18n.t('Close'), + event: this.closeLivechat + }); + + if (canForwardGuest) { + sections[2].data.push({ + icon: 'reply', + name: I18n.t('Forward'), + route: 'ForwardLivechatView', + params: { rid } + }); + } + + if (canReturnQueue) { + sections[2].data.push({ + icon: 'back', + name: I18n.t('Return'), + event: this.returnLivechat + }); + } + + sections[2].data.push({ + icon: 'reload', + name: I18n.t('Navigation_history'), + route: 'VisitorNavigationView', + params: { rid } + }); + + sections.push({ + data: [notificationsAction], + renderItem: this.renderItem + }); } return sections; @@ -384,6 +452,28 @@ class RoomActionsView extends React.Component { return <View style={[styles.separator, { backgroundColor: themes[theme].separatorColor }]} />; } + closeLivechat = () => { + const { room: { rid } } = this.state; + const { closeRoom } = this.props; + + closeRoom(rid); + } + + returnLivechat = () => { + const { room: { rid } } = this.state; + showConfirmationAlert({ + message: I18n.t('Would_you_like_to_return_the_inquiry'), + callToAction: I18n.t('Yes'), + onPress: async() => { + try { + await RocketChat.returnLivechat(rid); + } catch (e) { + showErrorAlert(e.reason, I18n.t('Oops')); + } + } + }); + } + updateRoomMember = async() => { const { room } = this.state; @@ -485,7 +575,7 @@ class RoomActionsView extends React.Component { ? <Text style={[styles.roomTitle, { color: themes[theme].titleText }]} numberOfLines={1}>{room.fname}</Text> : ( <View style={styles.roomTitleRow}> - <RoomTypeIcon type={room.prid ? 'discussion' : room.t} theme={theme} /> + <RoomTypeIcon type={room.prid ? 'discussion' : room.t} status={room.visitor?.status} theme={theme} /> <Text style={[styles.roomTitle, { color: themes[theme].titleText }]} numberOfLines={1}>{RocketChat.getRoomTitle(room)}</Text> </View> ) @@ -583,6 +673,7 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ leaveRoom: (rid, t) => dispatch(leaveRoomAction(rid, t)), + closeRoom: rid => dispatch(closeRoomAction(rid)), setLoadingInvite: loading => dispatch(setLoadingAction(loading)) }); diff --git a/app/views/RoomInfoView/Channel.js b/app/views/RoomInfoView/Channel.js new file mode 100644 index 000000000..81ae70c61 --- /dev/null +++ b/app/views/RoomInfoView/Channel.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import I18n from '../../i18n'; +import Item from './Item'; + +const Channel = ({ room, theme }) => { + const { description, topic, announcement } = room; + return ( + <> + <Item + label={I18n.t('Description')} + content={description || `__${ I18n.t('No_label_provided', { label: 'description' }) }__`} + theme={theme} + /> + <Item + label={I18n.t('Topic')} + content={topic || `__${ I18n.t('No_label_provided', { label: 'topic' }) }__`} + theme={theme} + /> + <Item + label={I18n.t('Announcement')} + content={announcement || `__${ I18n.t('No_label_provided', { label: 'announcement' }) }__`} + theme={theme} + /> + <Item + label={I18n.t('Broadcast_Channel')} + content={room.broadcast && I18n.t('Broadcast_channel_Description')} + theme={theme} + /> + </> + ); +}; +Channel.propTypes = { + room: PropTypes.object, + theme: PropTypes.string +}; + +export default Channel; diff --git a/app/views/RoomInfoView/CustomFields.js b/app/views/RoomInfoView/CustomFields.js new file mode 100644 index 000000000..b58c7af18 --- /dev/null +++ b/app/views/RoomInfoView/CustomFields.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Item from './Item'; + +const CustomFields = ({ customFields, theme }) => { + if (customFields) { + return ( + Object.keys(customFields).map((title) => { + if (!customFields[title]) { + return; + } + return ( + <Item + label={title} + content={customFields[title]} + theme={theme} + /> + ); + }) + ); + } + + return null; +}; +CustomFields.propTypes = { + customFields: PropTypes.object, + theme: PropTypes.string +}; + +export default CustomFields; diff --git a/app/views/RoomInfoView/Direct.js b/app/views/RoomInfoView/Direct.js new file mode 100644 index 000000000..7587bda6f --- /dev/null +++ b/app/views/RoomInfoView/Direct.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import PropTypes from 'prop-types'; + +import { themes } from '../../constants/colors'; +import I18n from '../../i18n'; + +import Timezone from './Timezone'; +import CustomFields from './CustomFields'; + +import styles from './styles'; + +const Roles = ({ roles, theme }) => (roles && roles.length ? ( + <View style={styles.item}> + <Text style={[styles.itemLabel, { color: themes[theme].titleText }]}>{I18n.t('Roles')}</Text> + <View style={styles.rolesContainer}> + {roles.map(role => (role ? ( + <View style={[styles.roleBadge, { backgroundColor: themes[theme].auxiliaryBackground }]} key={role}> + <Text style={styles.role}>{role}</Text> + </View> + ) : null))} + </View> + </View> +) : null); +Roles.propTypes = { + roles: PropTypes.array, + theme: PropTypes.string +}; + +const Direct = ({ roomUser, theme }) => ( + <> + <Roles roles={roomUser.parsedRoles} theme={theme} /> + <Timezone utcOffset={roomUser.utcOffset} theme={theme} /> + <CustomFields customFields={roomUser.customFields} theme={theme} /> + </> +); +Direct.propTypes = { + roomUser: PropTypes.object, + theme: PropTypes.string +}; + +export default Direct; diff --git a/app/views/RoomInfoView/Item.js b/app/views/RoomInfoView/Item.js new file mode 100644 index 000000000..d282ebd2f --- /dev/null +++ b/app/views/RoomInfoView/Item.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import PropTypes from 'prop-types'; + +import styles from './styles'; +import Markdown from '../../containers/markdown'; +import { themes } from '../../constants/colors'; + +const Item = ({ label, content, theme }) => ( + content ? ( + <View style={styles.item}> + <Text accessibilityLabel={label} style={[styles.itemLabel, { color: themes[theme].titleText }]}>{label}</Text> + <Markdown + style={[styles.itemContent, { color: themes[theme].auxiliaryText }]} + msg={content} + theme={theme} + /> + </View> + ) : null +); +Item.propTypes = { + label: PropTypes.string, + content: PropTypes.string, + theme: PropTypes.string +}; + +export default Item; diff --git a/app/views/RoomInfoView/Livechat.js b/app/views/RoomInfoView/Livechat.js new file mode 100644 index 000000000..a93ea1714 --- /dev/null +++ b/app/views/RoomInfoView/Livechat.js @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import { Text, StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; + +import RocketChat from '../../lib/rocketchat'; +import { withTheme } from '../../theme'; +import CustomFields from './CustomFields'; +import Item from './Item'; +import Timezone from './Timezone'; +import sharedStyles from '../Styles'; +import { themes } from '../../constants/colors'; +import I18n from '../../i18n'; + +const styles = StyleSheet.create({ + title: { + fontSize: 16, + paddingHorizontal: 20, + ...sharedStyles.textMedium + } +}); + +const Title = ({ title, theme }) => <Text style={[styles.title, { color: themes[theme].titleText }]}>{title}</Text>; +Title.propTypes = { + title: PropTypes.string, + theme: PropTypes.string +}; + +const Livechat = ({ room, roomUser, theme }) => { + const [department, setDepartment] = useState({}); + + + const getDepartment = async(id) => { + if (id) { + const result = await RocketChat.getDepartmentInfo(id); + if (result.success) { + setDepartment(result.department); + } + } + }; + + const getRoom = () => { + if (room.departmentId) { + getDepartment(room.departmentId); + } + }; + + useEffect(() => { getRoom(); }, []); + + return ( + <> + <Title + title={I18n.t('User')} + theme={theme} + /> + <Timezone + utcOffset={roomUser.utc} + theme={theme} + /> + <Item + label={I18n.t('Username')} + content={roomUser.username} + theme={theme} + /> + <Item + label={I18n.t('Email')} + content={roomUser.visitorEmails?.map(email => email.address).reduce((ret, item) => `${ ret }${ item }\n`)} + theme={theme} + /> + <Item + label={I18n.t('Phone')} + content={roomUser.phone?.map(phone => phone.phoneNumber).reduce((ret, item) => `${ ret }${ item }\n`)} + theme={theme} + /> + <Item + label={I18n.t('IP')} + content={roomUser.ip} + theme={theme} + /> + <Item + label={I18n.t('OS')} + content={roomUser.os} + theme={theme} + /> + <Item + label={I18n.t('Browser')} + content={roomUser.browser} + theme={theme} + /> + <CustomFields + customFields={roomUser.livechatData} + theme={theme} + /> + <Title + title={I18n.t('Conversation')} + theme={theme} + /> + <Item + label={I18n.t('Agent')} + content={room.servedBy?.username} + theme={theme} + /> + <Item + label={I18n.t('Facebook')} + content={room.facebook?.page.name} + theme={theme} + /> + <Item + label={I18n.t('SMS')} + content={room.sms && 'SMS Enabled'} + theme={theme} + /> + <Item + label={I18n.t('Topic')} + content={room.topic} + theme={theme} + /> + <Item + label={I18n.t('Tags')} + content={room.tags?.join(', ')} + theme={theme} + /> + <Item + label={I18n.t('Department')} + content={department.name} + theme={theme} + /> + <CustomFields + customFields={room.livechatData} + theme={theme} + /> + </> + ); +}; +Livechat.propTypes = { + room: PropTypes.object, + roomUser: PropTypes.object, + theme: PropTypes.string +}; + +export default withTheme(Livechat); diff --git a/app/views/RoomInfoView/Timezone.js b/app/views/RoomInfoView/Timezone.js new file mode 100644 index 000000000..f07433967 --- /dev/null +++ b/app/views/RoomInfoView/Timezone.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import moment from 'moment'; + +import I18n from '../../i18n'; +import Item from './Item'; + +const Timezone = ({ utcOffset, Message_TimeFormat, theme }) => (utcOffset ? ( + <Item + label={I18n.t('Timezone')} + content={`${ moment().utcOffset(utcOffset).format(Message_TimeFormat) } (UTC ${ utcOffset })`} + theme={theme} + /> +) : null); +Timezone.propTypes = { + utcOffset: PropTypes.number, + Message_TimeFormat: PropTypes.string, + theme: PropTypes.string +}; + +const mapStateToProps = state => ({ + Message_TimeFormat: state.settings.Message_TimeFormat +}); + +export default connect(mapStateToProps)(Timezone); diff --git a/app/views/RoomInfoView/index.js b/app/views/RoomInfoView/index.js index 1e1139f9a..273c5c215 100644 --- a/app/views/RoomInfoView/index.js +++ b/app/views/RoomInfoView/index.js @@ -3,19 +3,20 @@ import PropTypes from 'prop-types'; import { View, Text, ScrollView } from 'react-native'; import { BorderlessButton } from 'react-native-gesture-handler'; import { connect } from 'react-redux'; -import moment from 'moment'; -import _ from 'lodash'; import { SafeAreaView } from 'react-navigation'; +import UAParser from 'ua-parser-js'; +import _ from 'lodash'; + +import database from '../../lib/database'; import { CustomIcon } from '../../lib/Icons'; import Status from '../../containers/Status'; import Avatar from '../../containers/Avatar'; import styles from './styles'; import sharedStyles from '../Styles'; -import database from '../../lib/database'; import RocketChat from '../../lib/rocketchat'; import RoomTypeIcon from '../../containers/RoomTypeIcon'; import I18n from '../../i18n'; -import { CustomHeaderButtons, Item } from '../../containers/HeaderButton'; +import { CustomHeaderButtons } from '../../containers/HeaderButton'; import StatusBar from '../../containers/StatusBar'; import log from '../../utils/log'; import { themes } from '../../constants/colors'; @@ -24,6 +25,11 @@ import { themedHeader } from '../../utils/navigation'; import { getUserSelector } from '../../selectors/login'; import Markdown from '../../containers/markdown'; +import Livechat from './Livechat'; +import Channel from './Channel'; +import Item from './Item'; +import Direct from './Direct'; + const PERMISSION_EDIT_ROOM = 'edit-room'; const getRoomTitle = (room, type, name, username, statusText, theme) => (type === 'd' ? ( @@ -35,7 +41,7 @@ const getRoomTitle = (room, type, name, username, statusText, theme) => (type == ) : ( <View style={styles.roomTitleRow}> - <RoomTypeIcon type={room.prid ? 'discussion' : room.t} key='room-info-type' theme={theme} /> + <RoomTypeIcon type={room.prid ? 'discussion' : room.t} key='room-info-type' status={room.visitor?.status} theme={theme} /> <Text testID='room-info-view-name' style={[styles.roomTitle, { color: themes[theme].titleText }]} key='room-info-name'>{RocketChat.getRoomTitle(room)}</Text> </View> ) @@ -43,16 +49,22 @@ const getRoomTitle = (room, type, name, username, statusText, theme) => (type == class RoomInfoView extends React.Component { static navigationOptions = ({ navigation, screenProps }) => { - const showEdit = navigation.getParam('showEdit'); - const rid = navigation.getParam('rid'); const t = navigation.getParam('t'); + const rid = navigation.getParam('rid'); + const room = navigation.getParam('room'); + const roomUser = navigation.getParam('roomUser'); + const showEdit = navigation.getParam('showEdit', t === 'l'); return { title: t === 'd' ? I18n.t('User_Info') : I18n.t('Room_Info'), ...themedHeader(screenProps.theme), headerRight: showEdit ? ( <CustomHeaderButtons> - <Item iconName='edit' onPress={() => navigation.navigate('RoomInfoEditView', { rid })} testID='room-info-view-edit-button' /> + <Item + iconName='edit' + onPress={() => navigation.navigate(t === 'l' ? 'LivechatEditView' : 'RoomInfoEditView', { rid, room, roomUser })} + testID='room-info-view-edit-button' + /> </CustomHeaderButtons> ) : null @@ -66,7 +78,6 @@ class RoomInfoView extends React.Component { token: PropTypes.string }), baseUrl: PropTypes.string, - Message_TimeFormat: PropTypes.string, theme: PropTypes.string } @@ -78,68 +89,42 @@ class RoomInfoView extends React.Component { this.t = props.navigation.getParam('t'); this.state = { room: room || { rid: this.rid, t: this.t }, - roomUser: roomUser || {}, - parsedRoles: [] + roomUser: roomUser || {} }; } - async componentDidMount() { - const { roomUser, room: roomState } = this.state; - if (this.t === 'd' && !_.isEmpty(roomUser)) { - return; - } - - if (this.t === 'd') { - try { - const roomUserId = RocketChat.getUidDirectMessage(roomState); - const result = await RocketChat.getUserInfo(roomUserId); - if (result.success) { - const { roles } = result.user; - let parsedRoles = []; - if (roles && roles.length) { - parsedRoles = await Promise.all(roles.map(async(role) => { - const description = await this.getRoleDescription(role); - return description; - })); - } - this.setState({ roomUser: result.user, parsedRoles }); - } - } catch (e) { - log(e); - } - return; + componentDidMount() { + if (this.isDirect) { + this.loadUser(); + } else { + this.loadRoom(); } const { navigation } = this.props; - let room = navigation.getParam('room'); - if (room && room.observe) { - this.roomObservable = room.observe(); - this.subscription = this.roomObservable - .subscribe((changes) => { - this.setState({ room: changes }); - }); - } else { - try { - const result = await RocketChat.getRoomInfo(this.rid); - if (result.success) { - // eslint-disable-next-line prefer-destructuring - room = result.room; - this.setState({ room }); - } - } catch (e) { - log(e); + this.willFocusListener = navigation.addListener('willFocus', () => { + if (this.isLivechat) { + this.loadVisitor(); } - } - const permissions = await RocketChat.hasPermission([PERMISSION_EDIT_ROOM], room.rid); - if (permissions[PERMISSION_EDIT_ROOM] && !room.prid && this.t !== 'l') { - navigation.setParams({ showEdit: true }); - } + }); } componentWillUnmount() { if (this.subscription && this.subscription.unsubscribe) { this.subscription.unsubscribe(); } + if (this.willFocusListener && this.willFocusListener.remove) { + this.willFocusListener.remove(); + } + } + + get isDirect() { + const { room } = this.state; + return room.t === 'd'; + } + + get isLivechat() { + const { room } = this.state; + return room.t === 'l'; } getRoleDescription = async(id) => { @@ -154,86 +139,112 @@ class RoomInfoView extends React.Component { } catch (e) { return null; } + }; + + loadVisitor = async() => { + const { room } = this.state; + const { navigation } = this.props; + + try { + const result = await RocketChat.getVisitorInfo(room?.visitor?._id); + if (result.success) { + const { visitor } = result; + if (visitor.userAgent) { + const ua = new UAParser(); + ua.setUA(visitor.userAgent); + visitor.os = `${ ua.getOS().name } ${ ua.getOS().version }`; + visitor.browser = `${ ua.getBrowser().name } ${ ua.getBrowser().version }`; + } + this.setState({ roomUser: visitor }); + navigation.setParams({ roomUser: visitor }); + } + } catch (error) { + // Do nothing + } } - goRoom = async() => { - const { roomUser } = this.state; - const { username } = roomUser; + loadUser = async() => { + const { room: roomState, roomUser } = this.state; + + if (_.isEmpty(roomUser)) { + try { + const roomUserId = RocketChat.getUidDirectMessage(roomState); + const result = await RocketChat.getUserInfo(roomUserId); + if (result.success) { + const { user } = result; + const { roles } = user; + if (roles && roles.length) { + user.parsedRoles = await Promise.all(roles.map(async(role) => { + const description = await this.getRoleDescription(role); + return description; + })); + } + + const room = await this.getDirect(user.username); + + this.setState({ roomUser: user, room: { ...roomState, rid: room.rid } }); + } + } catch { + // do nothing + } + } + } + + loadRoom = async() => { const { navigation } = this.props; + let room = navigation.getParam('room'); + if (room && room.observe) { + this.roomObservable = room.observe(); + this.subscription = this.roomObservable + .subscribe((changes) => { + this.setState({ room: changes }); + navigation.setParams({ room: changes }); + }); + } else { + try { + const result = await RocketChat.getRoomInfo(this.rid); + if (result.success) { + ({ room } = result); + this.setState({ room }); + } + } catch (e) { + log(e); + } + } + + const permissions = await RocketChat.hasPermission([PERMISSION_EDIT_ROOM], room.rid); + if (permissions[PERMISSION_EDIT_ROOM] && !room.prid) { + navigation.setParams({ showEdit: true }); + } + } + + getDirect = async(username) => { try { const result = await RocketChat.createDirectMessage(username); if (result.success) { + return result.room; + } + } catch { + // do nothing + } + } + + goRoom = async() => { + const { roomUser, room } = this.state; + const { navigation } = this.props; + try { + if (room.rid) { await navigation.navigate('RoomsListView'); - const rid = result.room._id; - navigation.navigate('RoomView', { rid, name: RocketChat.getRoomTitle(roomUser), t: 'd' }); + navigation.navigate('RoomView', { rid: room.rid, name: RocketChat.getRoomTitle(roomUser), t: 'd' }); } } catch (e) { // do nothing } } - videoCall = () => RocketChat.callJitsi(this.rid) - - isDirect = () => this.t === 'd' - - renderItem = ({ label, content }) => { - const { theme } = this.props; - return ( - <View style={styles.item}> - <Text accessibilityLabel={label} style={[styles.itemLabel, { color: themes[theme].titleText }]}>{label}</Text> - <Markdown - style={[styles.itemContent, { color: themes[theme].auxiliaryText }]} - msg={content || `__${ I18n.t('No_label_provided', { label: label.toLowerCase() }) }__`} - theme={theme} - /> - </View> - ); - } - - renderRole = (description) => { - const { theme } = this.props; - if (description) { - return ( - <View style={[styles.roleBadge, { backgroundColor: themes[theme].auxiliaryBackground }]} key={description}> - <Text style={styles.role}>{ description }</Text> - </View> - ); - } - return null; - } - - renderRoles = () => { - const { parsedRoles } = this.state; - const { theme } = this.props; - if (parsedRoles && parsedRoles.length) { - return ( - <View style={styles.item}> - <Text style={[styles.itemLabel, { color: themes[theme].titleText }]}>{I18n.t('Roles')}</Text> - <View style={styles.rolesContainer}> - {parsedRoles.map(role => this.renderRole(role))} - </View> - </View> - ); - } - return null; - } - - renderTimezone = () => { - const { roomUser } = this.state; - const { Message_TimeFormat } = this.props; - - if (roomUser) { - const { utcOffset } = roomUser; - - if (!utcOffset) { - return null; - } - return this.renderItem({ - label: I18n.t('Timezone'), - content: `${ moment().utcOffset(utcOffset).format(Message_TimeFormat) } (UTC ${ utcOffset })` - }); - } - return null; + videoCall = () => { + const { room } = this.state; + RocketChat.callJitsi(room.rid); } renderAvatar = (room, roomUser) => { @@ -254,37 +265,6 @@ class RoomInfoView extends React.Component { ); } - renderBroadcast = () => this.renderItem({ - label: I18n.t('Broadcast_Channel'), - content: I18n.t('Broadcast_channel_Description') - }); - - renderCustomFields = () => { - const { roomUser } = this.state; - if (roomUser) { - const { customFields } = roomUser; - - if (!roomUser.customFields) { - return null; - } - - return ( - Object.keys(customFields).map((title) => { - if (!customFields[title]) { - return; - } - return ( - <View style={styles.item} key={title}> - <Text style={styles.itemLabel}>{title}</Text> - <Text style={styles.itemContent}>{customFields[title]}</Text> - </View> - ); - }) - ); - } - return null; - } - renderButton = (onPress, iconName, text) => { const { theme } = this.props; return ( @@ -309,37 +289,21 @@ class RoomInfoView extends React.Component { </View> ) - renderChannel = () => { - const { room } = this.state; - const { description, topic, announcement } = room; - return ( - <> - {this.renderItem({ label: I18n.t('Description'), content: description })} - {this.renderItem({ label: I18n.t('Topic'), content: topic })} - {this.renderItem({ label: I18n.t('Announcement'), content: announcement })} - {room.broadcast ? this.renderBroadcast() : null} - </> - ); - } + renderContent = () => { + const { room, roomUser } = this.state; + const { theme } = this.props; - renderDirect = () => { - const { roomUser } = this.state; - return ( - <> - {this.renderRoles()} - {this.renderTimezone()} - {this.renderCustomFields(roomUser._id)} - </> - ); + if (this.isDirect) { + return <Direct roomUser={roomUser} theme={theme} />; + } else if (this.t === 'l') { + return <Livechat room={room} roomUser={roomUser} theme={theme} />; + } + return <Channel room={room} theme={theme} />; } render() { const { room, roomUser } = this.state; const { theme } = this.props; - const isDirect = this.isDirect(); - if (!room) { - return <View />; - } return ( <ScrollView style={[styles.scroll, { backgroundColor: themes[theme].backgroundColor }]}> <StatusBar theme={theme} /> @@ -348,12 +312,12 @@ class RoomInfoView extends React.Component { forceInset={{ vertical: 'never' }} testID='room-info-view' > - <View style={[styles.avatarContainer, isDirect && styles.avatarContainerDirectRoom, { backgroundColor: themes[theme].auxiliaryBackground }]}> + <View style={[styles.avatarContainer, this.isDirect && styles.avatarContainerDirectRoom, { backgroundColor: themes[theme].auxiliaryBackground }]}> {this.renderAvatar(room, roomUser)} - <View style={styles.roomTitleContainer}>{ getRoomTitle(room, this.t, roomUser && roomUser.name, roomUser && roomUser.username, roomUser && roomUser.statusText, theme) }</View> - {isDirect ? this.renderButtons() : null} + <View style={styles.roomTitleContainer}>{ getRoomTitle(room, this.t, roomUser?.name, roomUser?.username, roomUser?.statusText, theme) }</View> + {this.isDirect ? this.renderButtons() : null} </View> - {isDirect ? this.renderDirect() : this.renderChannel()} + {this.renderContent()} </SafeAreaView> </ScrollView> ); @@ -362,8 +326,7 @@ class RoomInfoView extends React.Component { const mapStateToProps = state => ({ baseUrl: state.server.server, - user: getUserSelector(state), - Message_TimeFormat: state.settings.Message_TimeFormat + user: getUserSelector(state) }); export default connect(mapStateToProps)(withTheme(RoomInfoView)); diff --git a/app/views/RoomView/Header/Icon.js b/app/views/RoomView/Header/Icon.js index 12ab5572e..d47f75603 100644 --- a/app/views/RoomView/Header/Icon.js +++ b/app/views/RoomView/Header/Icon.js @@ -29,7 +29,7 @@ const Icon = React.memo(({ } let colorStyle = {}; - if (type === 'd' && roomUserId) { + if (type === 'l') { colorStyle = { color: STATUS_COLORS[status] }; } else { colorStyle = { color: isAndroid && theme === 'light' ? themes[theme].buttonText : themes[theme].auxiliaryText }; diff --git a/app/views/RoomView/Header/index.js b/app/views/RoomView/Header/index.js index 5d3dc2592..8b1bec52f 100644 --- a/app/views/RoomView/Header/index.js +++ b/app/views/RoomView/Header/index.js @@ -8,7 +8,6 @@ import Header from './Header'; import RightButtons from './RightButtons'; import { withTheme } from '../../../theme'; import RoomHeaderLeft from './RoomHeaderLeft'; -import { getUserSelector } from '../../../selectors/login'; class RoomHeaderView extends Component { static propTypes = { @@ -95,17 +94,15 @@ class RoomHeaderView extends Component { } const mapStateToProps = (state, ownProps) => { - let status; let statusText; - const { roomUserId, type } = ownProps; - if (type === 'd') { - const user = getUserSelector(state); - if (user.id) { - if (state.activeUsers[roomUserId] && state.meteor.connected) { - ({ status, statusText } = state.activeUsers[roomUserId]); - } else { - status = 'offline'; - } + let status = 'offline'; + const { roomUserId, type, visitor = {} } = ownProps; + + if (state.meteor.connected) { + if (type === 'd' && state.activeUsers[roomUserId]) { + ({ status, statusText } = state.activeUsers[roomUserId]); + } else if (type === 'l' && visitor?.status) { + ({ status } = visitor); } } diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 345709b7f..dc8317fea 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -69,7 +69,7 @@ const stateAttrsUpdate = [ 'readOnly', 'member' ]; -const roomAttrsUpdate = ['f', 'ro', 'blocked', 'blocker', 'archived', 'muted', 'jitsiTimeout', 'announcement', 'sysMes', 'topic', 'name', 'fname', 'roles', 'bannerClosed']; +const roomAttrsUpdate = ['f', 'ro', 'blocked', 'blocker', 'archived', 'muted', 'jitsiTimeout', 'announcement', 'sysMes', 'topic', 'name', 'fname', 'roles', 'bannerClosed', 'visitor']; class RoomView extends React.Component { static navigationOptions = ({ navigation, screenProps }) => { @@ -87,6 +87,7 @@ class RoomView extends React.Component { const goRoomActionsView = navigation.getParam('goRoomActionsView', () => {}); const unreadsCount = navigation.getParam('unreadsCount', null); const roomUserId = navigation.getParam('roomUserId'); + const visitor = navigation.getParam('visitor'); if (!rid) { return { ...themedHeader(screenProps.theme) @@ -104,6 +105,7 @@ class RoomView extends React.Component { type={t} widthOffset={tmid ? 95 : 130} roomUserId={roomUserId} + visitor={visitor} goRoomActionsView={goRoomActionsView} /> ), @@ -291,6 +293,12 @@ class RoomView extends React.Component { this.setReadOnly(); } } + // If it's a livechat room + if (this.t === 'l') { + if (!isEqual(prevState.roomUpdate.visitor, roomUpdate.visitor)) { + navigation.setParams({ visitor: roomUpdate.visitor }); + } + } if (((roomUpdate.fname !== prevState.roomUpdate.fname) || (roomUpdate.name !== prevState.roomUpdate.name)) && !this.tmid) { navigation.setParams({ name: RocketChat.getRoomTitle(room) }); } diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index 454734300..699bd3b9b 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -421,7 +421,8 @@ class RoomsListView extends React.Component { type: item.t, prid: item.prid, uids: item.uids, - usernames: item.usernames + usernames: item.usernames, + visitor: item.visitor })); // unread @@ -548,6 +549,7 @@ class RoomsListView extends React.Component { prid: item.prid, room: item, search: item.search, + visitor: item.visitor, roomUserId: this.getUidDirectMessage(item) }); } @@ -816,6 +818,7 @@ class RoomsListView extends React.Component { useRealName={useRealName} getUserPresence={this.getUserPresence} isGroupChat={isGroupChat} + visitor={item.visitor} /> ); }; diff --git a/app/views/VisitorNavigationView.js b/app/views/VisitorNavigationView.js new file mode 100644 index 000000000..4cf4f66e2 --- /dev/null +++ b/app/views/VisitorNavigationView.js @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import { FlatList, StyleSheet, Text } from 'react-native'; +import PropTypes from 'prop-types'; + +import { withTheme } from '../theme'; +import RocketChat from '../lib/rocketchat'; +import { themes } from '../constants/colors'; +import Separator from '../containers/Separator'; +import openLink from '../utils/openLink'; +import I18n from '../i18n'; +import debounce from '../utils/debounce'; +import sharedStyles from './Styles'; +import ListItem from '../containers/ListItem'; + +const styles = StyleSheet.create({ + noResult: { + fontSize: 16, + paddingVertical: 56, + ...sharedStyles.textAlignCenter, + ...sharedStyles.textSemibold + }, + withoutBorder: { + borderBottomWidth: 0, + borderTopWidth: 0 + } +}); + +const Item = ({ item, theme }) => ( + <ListItem + title={item.navigation?.page?.title || I18n.t('Empty_title')} + onPress={() => openLink(item.navigation?.page?.location?.href)} + theme={theme} + /> +); +Item.propTypes = { + item: PropTypes.object, + theme: PropTypes.string +}; + +const VisitorNavigationView = ({ navigation, theme }) => { + let offset; + let total = 0; + const [pages, setPages] = useState([]); + + const getPages = async() => { + const rid = navigation.getParam('rid'); + if (rid) { + try { + const result = await RocketChat.getPagesLivechat(rid, offset); + if (result.success) { + setPages(result.pages); + offset = result.pages.length; + ({ total } = result); + } + } catch { + // do nothig + } + } + }; + + useEffect(() => { getPages(); }, []); + + const onEndReached = debounce(() => { + if (pages.length <= total) { + getPages(); + } + }, 300); + + return ( + <FlatList + data={pages} + renderItem={({ item }) => <Item item={item} theme={theme} />} + ItemSeparatorComponent={() => <Separator theme={theme} />} + contentContainerStyle={[ + sharedStyles.listContentContainer, + { + backgroundColor: themes[theme].auxiliaryBackground, + borderColor: themes[theme].separatorColor + }, + !pages.length && styles.withoutBorder + ]} + style={{ backgroundColor: themes[theme].auxiliaryBackground }} + ListEmptyComponent={() => <Text style={[styles.noResult, { color: themes[theme].titleText }]}>{I18n.t('No_results_found')}</Text>} + keyExtractor={item => item} + onEndReached={onEndReached} + onEndReachedThreshold={5} + /> + ); +}; +VisitorNavigationView.propTypes = { + theme: PropTypes.string, + navigation: PropTypes.object +}; +VisitorNavigationView.navigationOptions = { + title: I18n.t('Navigation_history') +}; + +export default withTheme(VisitorNavigationView); diff --git a/package.json b/package.json index 37f4077d1..88e602b5a 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "rn-root-view": "^1.0.3", "rn-user-defaults": "^1.8.1", "semver": "7.3.2", + "ua-parser-js": "^0.7.21", "url-parse": "^1.4.7", "use-deep-compare-effect": "^1.3.1" }, diff --git a/yarn.lock b/yarn.lock index 1aa317fbd..449fa8e0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2461,9 +2461,9 @@ prop-types "^15.7.2" "@react-native-community/async-storage@^1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@react-native-community/async-storage/-/async-storage-1.9.0.tgz#af26a8879bd2987970fbbe81a9623851d29a56f1" - integrity sha512-TlGMr02JcmY4huH1P7Mt7p6wJecosPpW+09+CwCFLn875IhpRqU2XiVA+BQppZOYfQdHUfUzIKyCBeXOlCEbEg== + version "1.10.0" + resolved "https://registry.yarnpkg.com/@react-native-community/async-storage/-/async-storage-1.10.0.tgz#fd6a9737f3c227ef4e28858b8201ad793a022296" + integrity sha512-kPJwhUpBKLXGrBnUjx0JVSJvSEl5nPO+puJ3Uy9pMvika9uWeniRGZPQjUWWQimU5M7xhQ41d5I1OP82Q3Xx9A== dependencies: deep-assign "^3.0.0" @@ -15862,6 +15862,11 @@ ua-parser-js@^0.7.18: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b" integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ== +ua-parser-js@^0.7.21: + version "0.7.21" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" + integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== + uglify-es@^3.1.9: version "3.3.9" resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"