diff --git a/README.md b/README.md index d720598b28..bcae9f99d1 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Readme will guide you on how to config. |--------------------------------------------------------------- |-------- | | Jitsi Integration | ✅ | | Federation (Directory) | ✅ | -| Discussions | ❌ | +| Discussions | ✅ | | Omnichannel | ❌ | | Threads | ✅ | | Record Audio | ✅ | diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 786c411fae..478903e093 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -35,6 +35,7 @@ export const ROOM = createRequestTypes('ROOM', ['LEAVE', 'DELETE', 'REMOVED', 'U 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]); +export const CREATE_DISCUSSION = createRequestTypes('CREATE_DISCUSSION', [...defaultTypes]); export const SELECTED_USERS = createRequestTypes('SELECTED_USERS', ['ADD_USER', 'REMOVE_USER', 'RESET', 'SET_LOADING']); export const SERVER = createRequestTypes('SERVER', [ ...defaultTypes, diff --git a/app/actions/createDiscussion.js b/app/actions/createDiscussion.js new file mode 100644 index 0000000000..5b6faa851c --- /dev/null +++ b/app/actions/createDiscussion.js @@ -0,0 +1,22 @@ +import * as types from './actionsTypes'; + +export function createDiscussionRequest(data) { + return { + type: types.CREATE_DISCUSSION.REQUEST, + data + }; +} + +export function createDiscussionSuccess(data) { + return { + type: types.CREATE_DISCUSSION.SUCCESS, + data + }; +} + +export function createDiscussionFailure(err) { + return { + type: types.CREATE_DISCUSSION.FAILURE, + err + }; +} diff --git a/app/containers/Avatar.js b/app/containers/Avatar.js index 11a6d6afe3..2e4aa5353f 100644 --- a/app/containers/Avatar.js +++ b/app/containers/Avatar.js @@ -4,10 +4,7 @@ import { View } from 'react-native'; import FastImage from 'react-native-fast-image'; import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import Touch from '../utils/touch'; - -const formatUrl = (url, baseUrl, uriSize, avatarAuthURLFragment) => ( - `${ baseUrl }${ url }?format=png&size=${ uriSize }&${ avatarAuthURLFragment }` -); +import { avatarURL } from '../utils/avatar'; const Avatar = React.memo(({ text, size, baseUrl, borderRadius, style, avatar, type, children, userId, token, onPress, theme @@ -22,24 +19,9 @@ const Avatar = React.memo(({ return null; } - const room = type === 'd' ? text : `@${ text }`; - - // Avoid requesting several sizes by having only two sizes on cache - const uriSize = size === 100 ? 100 : 50; - - let avatarAuthURLFragment = ''; - if (userId && token) { - avatarAuthURLFragment = `&rc_token=${ token }&rc_uid=${ userId }`; - } - - - let uri; - if (avatar) { - uri = avatar.includes('http') ? avatar : formatUrl(avatar, baseUrl, uriSize, avatarAuthURLFragment); - } else { - uri = formatUrl(`/avatar/${ room }`, baseUrl, uriSize, avatarAuthURLFragment); - } - + const uri = avatarURL({ + type, text, size, userId, token, avatar, baseUrl + }); let image = ( { + const { message, room: channel } = this.props; + Navigation.navigate('CreateDiscussionView', { message, channel }); + } + handleActionPress = (actionIndex) => { if (actionIndex) { switch (actionIndex) { @@ -413,6 +422,9 @@ class MessageActions extends React.Component { case this.READ_RECEIPT_INDEX: this.handleReadReceipt(); break; + case this.CREATE_DISCUSSION_INDEX: + this.handleCreateDiscussion(); + break; case this.TOGGLE_TRANSLATION_INDEX: this.handleToggleTranslation(); break; diff --git a/app/containers/MessageBox/LeftButtons.ios.js b/app/containers/MessageBox/LeftButtons.ios.js index ab08af7d79..e00d1b8db3 100644 --- a/app/containers/MessageBox/LeftButtons.ios.js +++ b/app/containers/MessageBox/LeftButtons.ios.js @@ -1,20 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { CancelEditingButton, FileButton } from './buttons'; +import { CancelEditingButton, ActionsButton } from './buttons'; const LeftButtons = React.memo(({ - theme, showFileActions, editing, editCancel + theme, showMessageBoxActions, editing, editCancel }) => { if (editing) { return ; } - return ; + return ; }); LeftButtons.propTypes = { theme: PropTypes.string, - showFileActions: PropTypes.func.isRequired, + showMessageBoxActions: PropTypes.func.isRequired, editing: PropTypes.bool, editCancel: PropTypes.func.isRequired }; diff --git a/app/containers/MessageBox/RightButtons.android.js b/app/containers/MessageBox/RightButtons.android.js index f05c1ede8e..b351dde75e 100644 --- a/app/containers/MessageBox/RightButtons.android.js +++ b/app/containers/MessageBox/RightButtons.android.js @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { SendButton, AudioButton, FileButton } from './buttons'; +import { SendButton, AudioButton, ActionsButton } from './buttons'; const RightButtons = React.memo(({ - theme, showSend, submit, recordAudioMessage, recordAudioMessageEnabled, showFileActions + theme, showSend, submit, recordAudioMessage, recordAudioMessageEnabled, showMessageBoxActions }) => { if (showSend) { return ; @@ -13,11 +13,11 @@ const RightButtons = React.memo(({ return ( <> - + ); } - return ; + return ; }); RightButtons.propTypes = { @@ -26,7 +26,7 @@ RightButtons.propTypes = { submit: PropTypes.func.isRequired, recordAudioMessage: PropTypes.func.isRequired, recordAudioMessageEnabled: PropTypes.bool, - showFileActions: PropTypes.func.isRequired + showMessageBoxActions: PropTypes.func.isRequired }; export default RightButtons; diff --git a/app/containers/MessageBox/buttons/FileButton.js b/app/containers/MessageBox/buttons/ActionsButton.js similarity index 72% rename from app/containers/MessageBox/buttons/FileButton.js rename to app/containers/MessageBox/buttons/ActionsButton.js index 21ba4ffbe7..ddfdb94e6c 100644 --- a/app/containers/MessageBox/buttons/FileButton.js +++ b/app/containers/MessageBox/buttons/ActionsButton.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import BaseButton from './BaseButton'; -const FileButton = React.memo(({ theme, onPress }) => ( +const ActionsButton = React.memo(({ theme, onPress }) => ( ( /> )); -FileButton.propTypes = { +ActionsButton.propTypes = { theme: PropTypes.string, onPress: PropTypes.func.isRequired }; -export default FileButton; +export default ActionsButton; diff --git a/app/containers/MessageBox/buttons/index.js b/app/containers/MessageBox/buttons/index.js index 5046ca50e8..15375b9054 100644 --- a/app/containers/MessageBox/buttons/index.js +++ b/app/containers/MessageBox/buttons/index.js @@ -2,12 +2,12 @@ import CancelEditingButton from './CancelEditingButton'; import ToggleEmojiButton from './ToggleEmojiButton'; import SendButton from './SendButton'; import AudioButton from './AudioButton'; -import FileButton from './FileButton'; +import ActionsButton from './ActionsButton'; export { CancelEditingButton, ToggleEmojiButton, SendButton, AudioButton, - FileButton + ActionsButton }; diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index 14decbbf9d..19141af376 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -45,6 +45,7 @@ import { import CommandsPreview from './CommandsPreview'; import { Review } from '../../utils/review'; import { getUserSelector } from '../../selectors/login'; +import Navigation from '../../lib/Navigation'; const imagePickerConfig = { cropping: true, @@ -65,6 +66,7 @@ const FILE_PHOTO_INDEX = 1; const FILE_VIDEO_INDEX = 2; const FILE_LIBRARY_INDEX = 3; const FILE_DOCUMENT_INDEX = 4; +const CREATE_DISCUSSION_INDEX = 5; class MessageBox extends Component { static propTypes = { @@ -113,12 +115,13 @@ class MessageBox extends Component { }; this.text = ''; this.focused = false; - this.fileOptions = [ + this.messageBoxActions = [ I18n.t('Cancel'), I18n.t('Take_a_photo'), I18n.t('Take_a_video'), I18n.t('Choose_from_library'), - I18n.t('Choose_file') + I18n.t('Choose_file'), + I18n.t('Create_Discussion') ]; const libPickerLabels = { cropperChooseText: I18n.t('Choose'), @@ -157,8 +160,8 @@ class MessageBox extends Component { } } else { try { - const room = await subsCollection.find(rid); - msg = room.draftMessage; + this.room = await subsCollection.find(rid); + msg = this.room.draftMessage; } catch (error) { console.log('Messagebox.didMount: Room not found'); } @@ -588,20 +591,24 @@ class MessageBox extends Component { } } + createDiscussion = () => { + Navigation.navigate('CreateDiscussionView', { channel: this.room }); + } + showUploadModal = (file) => { this.setState({ file: { ...file, isVisible: true } }); } - showFileActions = () => { + showMessageBoxActions = () => { ActionSheet.showActionSheetWithOptions({ - options: this.fileOptions, + options: this.messageBoxActions, cancelButtonIndex: FILE_CANCEL_INDEX }, (actionIndex) => { - this.handleFileActionPress(actionIndex); + this.handleMessageBoxActions(actionIndex); }); } - handleFileActionPress = (actionIndex) => { + handleMessageBoxActions = (actionIndex) => { switch (actionIndex) { case FILE_PHOTO_INDEX: this.takePhoto(); @@ -615,6 +622,9 @@ class MessageBox extends Component { case FILE_DOCUMENT_INDEX: this.chooseFile(); break; + case CREATE_DISCUSSION_INDEX: + this.createDiscussion(); + break; default: break; } @@ -783,7 +793,7 @@ class MessageBox extends Component { } else if (handleCommandSubmit(event)) { this.submit(); } else if (handleCommandShowUpload(event)) { - this.showFileActions(); + this.showMessageBoxActions(); } } @@ -828,7 +838,7 @@ class MessageBox extends Component { theme={theme} showEmojiKeyboard={showEmojiKeyboard} editing={editing} - showFileActions={this.showFileActions} + showMessageBoxActions={this.showMessageBoxActions} editCancel={this.editCancel} openEmoji={this.openEmoji} closeEmoji={this.closeEmoji} @@ -854,7 +864,7 @@ class MessageBox extends Component { submit={this.submit} recordAudioMessage={this.recordAudioMessage} recordAudioMessageEnabled={Message_AudioRecorderEnabled} - showFileActions={this.showFileActions} + showMessageBoxActions={this.showMessageBoxActions} /> diff --git a/app/containers/UIKit/MultiSelect/Chips.js b/app/containers/UIKit/MultiSelect/Chips.js index dae9c797e2..59264db0ad 100644 --- a/app/containers/UIKit/MultiSelect/Chips.js +++ b/app/containers/UIKit/MultiSelect/Chips.js @@ -1,7 +1,8 @@ import React from 'react'; -import { Text, View, Image } from 'react-native'; +import { Text, View } from 'react-native'; import PropTypes from 'prop-types'; import Touchable from 'react-native-platform-touchable'; +import FastImage from 'react-native-fast-image'; import { themes } from '../../../constants/colors'; import { textParser } from '../utils'; @@ -19,7 +20,7 @@ const Chip = ({ item, onSelect, theme }) => ( background={Touchable.Ripple(themes[theme].bannerBackground)} > <> - {item.imageUrl ? : null} + {item.imageUrl ? : null} {textParser([item.text])} diff --git a/app/containers/UIKit/MultiSelect/Input.js b/app/containers/UIKit/MultiSelect/Input.js index 5be39bad55..d4de6917cc 100644 --- a/app/containers/UIKit/MultiSelect/Input.js +++ b/app/containers/UIKit/MultiSelect/Input.js @@ -9,12 +9,13 @@ import ActivityIndicator from '../../ActivityIndicator'; import styles from './styles'; const Input = ({ - children, open, theme, loading + children, open, theme, loading, inputStyle, disabled }) => ( open(true)} - style={{ backgroundColor: themes[theme].backgroundColor }} + style={[{ backgroundColor: themes[theme].backgroundColor }, inputStyle]} background={Touchable.Ripple(themes[theme].bannerBackground)} + disabled={disabled} > {children} @@ -30,6 +31,8 @@ Input.propTypes = { children: PropTypes.node, open: PropTypes.func, theme: PropTypes.string, + inputStyle: PropTypes.object, + disabled: PropTypes.bool, loading: PropTypes.bool }; diff --git a/app/containers/UIKit/MultiSelect/Items.js b/app/containers/UIKit/MultiSelect/Items.js index 17b30a52d1..7a460ac475 100644 --- a/app/containers/UIKit/MultiSelect/Items.js +++ b/app/containers/UIKit/MultiSelect/Items.js @@ -2,6 +2,7 @@ import React from 'react'; import { Text, FlatList } from 'react-native'; import PropTypes from 'prop-types'; import Touchable from 'react-native-platform-touchable'; +import FastImage from 'react-native-fast-image'; import Separator from '../../Separator'; import Check from '../../Check'; @@ -26,6 +27,7 @@ const Item = ({ ]} > <> + {item.imageUrl ? : null} {textParser([item.text])} {selected ? : null} diff --git a/app/containers/UIKit/MultiSelect/index.js b/app/containers/UIKit/MultiSelect/index.js index 553e92c61d..6617ecb7b6 100644 --- a/app/containers/UIKit/MultiSelect/index.js +++ b/app/containers/UIKit/MultiSelect/index.js @@ -10,6 +10,7 @@ import TextInput from '../../TextInput'; import { textParser } from '../utils'; import { themes } from '../../../constants/colors'; +import I18n from '../../../i18n'; import Chips from './Chips'; import Items from './Items'; @@ -33,6 +34,10 @@ export const MultiSelect = React.memo(({ loading, value: values, multiselect = false, + onSearch, + onClose, + disabled, + inputStyle, theme }) => { const [selected, select] = useState(values || []); @@ -51,6 +56,12 @@ export const MultiSelect = React.memo(({ setOpen(showContent); }, [showContent]); + useEffect(() => { + if (values && values.length && !multiselect) { + setCurrentValue(values[0].text); + } + }, []); + const onShow = () => { Animated.timing( animatedValue, @@ -63,6 +74,7 @@ export const MultiSelect = React.memo(({ }; const onHide = () => { + onClose(); Animated.timing( animatedValue, { @@ -73,7 +85,7 @@ export const MultiSelect = React.memo(({ }; const onSelect = (item) => { - const { value } = item; + const { value, text: { text } } = item; if (multiselect) { let newSelect = []; if (!selected.includes(value)) { @@ -85,20 +97,20 @@ export const MultiSelect = React.memo(({ onChange({ value: newSelect }); } else { onChange({ value }); - setCurrentValue(value); - setOpen(false); + setCurrentValue(text); + onHide(); } }; const renderContent = () => { - const items = options.filter(option => textParser([option.text]).toLowerCase().includes(search.toLowerCase())); + const items = onSearch ? options : options.filter(option => textParser([option.text]).toLowerCase().includes(search.toLowerCase())); return ( @@ -124,19 +136,24 @@ export const MultiSelect = React.memo(({ open={onShow} theme={theme} loading={loading} + disabled={disabled} + inputStyle={inputStyle} > - {currentValue} + {currentValue || placeholder.text} ); if (context === BLOCK_CONTEXT.FORM) { + const items = options.filter(option => selected.includes(option.value)); button = ( - selected.includes(option.value))} onSelect={onSelect} theme={theme} /> + {items.length ? : {placeholder.text}} ); } @@ -172,6 +189,13 @@ MultiSelect.propTypes = { context: PropTypes.number, loading: PropTypes.bool, multiselect: PropTypes.bool, + onSearch: PropTypes.func, + onClose: PropTypes.func, + inputStyle: PropTypes.object, value: PropTypes.array, + disabled: PropTypes.bool, theme: PropTypes.string }; +MultiSelect.defaultProps = { + onClose: () => {} +}; diff --git a/app/containers/UIKit/MultiSelect/styles.js b/app/containers/UIKit/MultiSelect/styles.js index bac42a168a..f261932c34 100644 --- a/app/containers/UIKit/MultiSelect/styles.js +++ b/app/containers/UIKit/MultiSelect/styles.js @@ -30,6 +30,7 @@ export default StyleSheet.create({ }, pickerText: { ...sharedStyles.textRegular, + paddingLeft: 6, fontSize: 16 }, item: { @@ -40,7 +41,7 @@ export default StyleSheet.create({ }, input: { minHeight: 48, - padding: 8, + paddingHorizontal: 8, paddingBottom: 0, borderWidth: StyleSheet.hairlineWidth, borderRadius: 2, @@ -58,6 +59,7 @@ export default StyleSheet.create({ height: 226 }, chips: { + paddingTop: 8, flexDirection: 'row', flexWrap: 'wrap', marginRight: 50 @@ -82,5 +84,11 @@ export default StyleSheet.create({ borderRadius: 2, width: 20, height: 20 + }, + itemImage: { + marginRight: 8, + borderRadius: 2, + width: 24, + height: 24 } }); diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 0949dc0dea..e2d0471431 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -87,6 +87,7 @@ export default { alert: 'alert', alerts: 'alerts', All_users_in_the_channel_can_write_new_messages: 'All users in the channel can write new messages', + A_meaningful_name_for_the_discussion_room: 'A meaningful name for the discussion room', All: 'All', All_Messages: 'All Messages', Allow_Reactions: 'Allow Reactions', @@ -154,6 +155,7 @@ export default { Whats_the_password_for_your_certificate: 'What\'s the password for your certificate?', Create_account: 'Create an account', Create_Channel: 'Create Channel', + Create_Discussion: 'Create Discussion', Created_snippet: 'Created a snippet', Create_a_new_workspace: 'Create a new workspace', Create: 'Create', @@ -173,6 +175,8 @@ export default { Direct_Messages: 'Direct Messages', Disable_notifications: 'Disable notifications', Discussions: 'Discussions', + Discussion_Desc: 'Help keeping an overview about what\'s going on! By creating a discussion, a sub-channel of the one you selected is created and both are linked.', + Discussion_name: 'Discussion name', Dont_Have_An_Account: 'Don\'t you have an account?', Do_you_have_an_account: 'Do you have an account?', Do_you_have_a_certificate: 'Do you have a certificate?', @@ -323,6 +327,7 @@ export default { OR: 'OR', 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!', Pin: 'Pin', Pinned_Messages: 'Pinned Messages', @@ -400,6 +405,7 @@ export default { Select_Avatar: 'Select Avatar', Select_Server: 'Select Server', Select_Users: 'Select Users', + Select_a_Channel: 'Select a Channel', Send: 'Send', Send_audio_message: 'Send audio message', Send_crash_report: 'Send crash report', @@ -482,6 +488,7 @@ export default { Username: 'Username', Username_or_email: 'Username or email', Uses_server_configuration: 'Uses server configuration', + Usually_a_discussion_starts_with_a_question_like_How_do_I_upload_a_picture: 'Usually, a discussion starts with a question, like "How do I upload a picture?"', Validating: 'Validating', Verify_email_title: 'Registration Succeeded!', Verify_email_desc: 'We have sent you an email to confirm your registration. If you do not receive an email shortly, please come back and try again.', @@ -508,6 +515,7 @@ export default { Logged_out_by_server: 'You\'ve been logged out by the server. Please log in again.', You_need_to_access_at_least_one_RocketChat_server_to_share_something: 'You need to access at least one Rocket.Chat server to share something.', Your_certificate: 'Your Certificate', + Your_message: 'Your message', Your_invite_link_will_expire_after__usesLeft__uses: 'Your invite link will expire after {{usesLeft}} uses.', Your_invite_link_will_expire_on__date__or_after__usesLeft__uses: 'Your invite link will expire on {{date}} or after {{usesLeft}} uses.', Your_invite_link_will_expire_on__date__: 'Your invite link will expire on {{date}}.', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index bf1c1ba163..806cac9db9 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -93,6 +93,7 @@ export default { alert: 'alerta', alerts: 'alertas', All_users_in_the_channel_can_write_new_messages: 'Todos usuários no canal podem enviar mensagens novas', + A_meaningful_name_for_the_discussion_room: 'Um nome significativo para o canal de discussão', All: 'Todos', Allow_Reactions: 'Permitir reagir', Alphabetical: 'Alfabético', @@ -152,6 +153,7 @@ export default { Permalink: 'Link-Permanente', Create_account: 'Criar conta', Create_Channel: 'Criar Canal', + Create_Discussion: 'Criar Discussão', Created_snippet: 'Criou um snippet', Create_a_new_workspace: 'Criar nova área de trabalho', Create: 'Criar', @@ -169,6 +171,8 @@ export default { Description: 'Descrição', Disable_notifications: 'Desabilitar notificações', Discussions: 'Discussões', + Discussion_Desc: 'Ajude a manter uma visão geral sobre o que está acontecendo! Ao criar uma discussão, um sub-canal do que você selecionou é criado e os dois são vinculados.', + Discussion_name: 'Nome da discussão', Dont_Have_An_Account: 'Não tem uma conta?', Do_you_have_an_account: 'Você tem uma conta?', Do_you_really_want_to_key_this_room_question_mark: 'Você quer realmente {{key}} esta sala?', @@ -297,6 +301,7 @@ export default { OR: 'OU', 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', Permalink_copied_to_clipboard: 'Link-permanente copiado para a área de transferência!', Pin: 'Fixar', Pinned_Messages: 'Mensagens Fixadas', @@ -365,6 +370,7 @@ export default { Select_Avatar: 'Selecionar Avatar', Select_Server: 'Selecionar Servidor', Select_Users: 'Selecionar Usuários', + Select_a_Channel: 'Selecione um canal', Send: 'Enviar', Send_audio_message: 'Enviar mensagem de áudio', Send_message: 'Enviar mensagem', @@ -435,6 +441,7 @@ export default { Username: 'Usuário', Username_or_email: 'Usuário ou email', Uses_server_configuration: 'Usar configuração do servidor', + Usually_a_discussion_starts_with_a_question_like_How_do_I_upload_a_picture: 'Normalmente, uma discussão começa com uma pergunta como: Como faço para enviar uma foto?', Verify_email_title: 'Registrado com sucesso!', Verify_email_desc: 'Nós lhe enviamos um e-mail para confirmar o seu registro. Se você não receber um e-mail em breve, por favor retorne e tente novamente.', Video_call: 'Chamada de vídeo', @@ -449,6 +456,7 @@ export default { You_are_in_preview_mode: 'Está é uma prévia do canal', You_are_offline: 'Você está offline', You_can_search_using_RegExp_eg: 'Você pode usar expressões regulares, por exemplo `/^text$/i`', + Your_message: 'Sua mensagem', You_colon: 'Você: ', you_were_mentioned: 'você foi mencionado', You_were_removed_from_channel: 'Você foi removido de {{channel}}', diff --git a/app/index.js b/app/index.js index a2c8b13c60..b18fba4671 100644 --- a/app/index.js +++ b/app/index.js @@ -289,11 +289,21 @@ const ModalBlockStack = createStackNavigator({ cardStyle }); +const CreateDiscussionStack = createStackNavigator({ + CreateDiscussionView: { + getScreen: () => require('./views/CreateDiscussionView').default + } +}, { + defaultNavigationOptions: defaultHeader, + cardStyle +}); + const InsideStackModal = createStackNavigator({ Main: ChatsDrawer, NewMessageStack, AttachmentStack, ModalBlockStack, + CreateDiscussionStack, JitsiMeetView: { getScreen: () => require('./views/JitsiMeetView').default } @@ -438,6 +448,7 @@ const ModalSwitch = createSwitchNavigator({ RoomActionsStack, SettingsStack, ModalBlockStack, + CreateDiscussionStack, AuthLoading: () => null }, { diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 226ffbbb86..1bb3791ff6 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -605,6 +605,16 @@ const RocketChat = { // RC 0.59.0 return this.sdk.post('im.create', { username }); }, + + createDiscussion({ + prid, pmid, t_name, reply, users + }) { + // RC 1.0.0 + return this.sdk.post('rooms.createDiscussion', { + prid, pmid, t_name, reply, users + }); + }, + joinRoom(roomId, type) { // TODO: join code // RC 0.48.0 diff --git a/app/reducers/createDiscussion.js b/app/reducers/createDiscussion.js new file mode 100644 index 0000000000..582164f711 --- /dev/null +++ b/app/reducers/createDiscussion.js @@ -0,0 +1,36 @@ +import { CREATE_DISCUSSION } from '../actions/actionsTypes'; + +const initialState = { + isFetching: false, + failure: false, + result: {}, + error: {} +}; + +export default function(state = initialState, action) { + switch (action.type) { + case CREATE_DISCUSSION.REQUEST: + return { + ...state, + isFetching: true, + failure: false, + error: {} + }; + case CREATE_DISCUSSION.SUCCESS: + return { + ...state, + isFetching: false, + failure: false, + result: action.data + }; + case CREATE_DISCUSSION.FAILURE: + return { + ...state, + isFetching: false, + failure: true, + error: action.err + }; + default: + return state; + } +} diff --git a/app/reducers/index.js b/app/reducers/index.js index c156fb7861..9f94eb7a47 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -16,6 +16,7 @@ import customEmojis from './customEmojis'; import activeUsers from './activeUsers'; import usersTyping from './usersTyping'; import inviteLinks from './inviteLinks'; +import createDiscussion from './createDiscussion'; export default combineReducers({ settings, @@ -34,5 +35,6 @@ export default combineReducers({ customEmojis, activeUsers, usersTyping, - inviteLinks + inviteLinks, + createDiscussion }); diff --git a/app/sagas/createDiscussion.js b/app/sagas/createDiscussion.js new file mode 100644 index 0000000000..1e53e57d4b --- /dev/null +++ b/app/sagas/createDiscussion.js @@ -0,0 +1,52 @@ +import { + select, put, call, take, takeLatest +} from 'redux-saga/effects'; +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; + +import { CREATE_DISCUSSION, LOGIN } from '../actions/actionsTypes'; +import { createDiscussionSuccess, createDiscussionFailure } from '../actions/createDiscussion'; +import RocketChat from '../lib/rocketchat'; +import database from '../lib/database'; + +const create = function* create(data) { + return yield RocketChat.createDiscussion(data); +}; + +const handleRequest = function* handleRequest({ data }) { + try { + const auth = yield select(state => state.login.isAuthenticated); + if (!auth) { + yield take(LOGIN.SUCCESS); + } + const result = yield call(create, data); + + if (result.success) { + const { discussion: sub } = result; + + try { + const db = database.active; + const subCollection = db.collections.get('subscriptions'); + yield db.action(async() => { + await subCollection.create((s) => { + s._raw = sanitizedRaw({ id: sub.rid }, subCollection.schema); + Object.assign(s, sub); + }); + }); + } catch { + // do nothing + } + + yield put(createDiscussionSuccess(sub)); + } else { + yield put(createDiscussionFailure(result)); + } + } catch (err) { + yield put(createDiscussionFailure(err)); + } +}; + +const root = function* root() { + yield takeLatest(CREATE_DISCUSSION.REQUEST, handleRequest); +}; + +export default root; diff --git a/app/sagas/index.js b/app/sagas/index.js index 38c1843528..27886be830 100644 --- a/app/sagas/index.js +++ b/app/sagas/index.js @@ -9,6 +9,7 @@ import init from './init'; import state from './state'; import deepLinking from './deepLinking'; import inviteLinks from './inviteLinks'; +import createDiscussion from './createDiscussion'; const root = function* root() { yield all([ @@ -21,7 +22,8 @@ const root = function* root() { selectServer(), state(), deepLinking(), - inviteLinks() + inviteLinks(), + createDiscussion() ]); }; diff --git a/app/tablet.js b/app/tablet.js index c81249b42d..e530c74b76 100644 --- a/app/tablet.js +++ b/app/tablet.js @@ -117,6 +117,11 @@ export const initTabletNav = (setState) => { setState({ showModal: true }); return null; } + if (routeName === 'CreateDiscussionView') { + modalRef.dispatch(NavigationActions.navigate({ routeName, params })); + setState({ showModal: true }); + return null; + } if (routeName === 'RoomView') { const resetAction = StackActions.reset({ diff --git a/app/utils/avatar.js b/app/utils/avatar.js new file mode 100644 index 0000000000..cf61daee1a --- /dev/null +++ b/app/utils/avatar.js @@ -0,0 +1,27 @@ +const formatUrl = (url, baseUrl, uriSize, avatarAuthURLFragment) => ( + `${ baseUrl }${ url }?format=png&size=${ uriSize }&${ avatarAuthURLFragment }` +); + +export const avatarURL = ({ + type, text, size, userId, token, avatar, baseUrl +}) => { + const room = type === 'd' ? text : `@${ text }`; + + // Avoid requesting several sizes by having only two sizes on cache + const uriSize = size === 100 ? 100 : 50; + + let avatarAuthURLFragment = ''; + if (userId && token) { + avatarAuthURLFragment = `&rc_token=${ token }&rc_uid=${ userId }`; + } + + + let uri; + if (avatar) { + uri = avatar.includes('http') ? avatar : formatUrl(avatar, baseUrl, uriSize, avatarAuthURLFragment); + } else { + uri = formatUrl(`/avatar/${ room }`, baseUrl, uriSize, avatarAuthURLFragment); + } + + return uri; +}; diff --git a/app/views/CreateDiscussionView/SelectChannel.js b/app/views/CreateDiscussionView/SelectChannel.js new file mode 100644 index 0000000000..aef2618a66 --- /dev/null +++ b/app/views/CreateDiscussionView/SelectChannel.js @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Text } from 'react-native'; +import PropTypes from 'prop-types'; + +import debounce from '../../utils/debounce'; +import { avatarURL } from '../../utils/avatar'; +import RocketChat from '../../lib/rocketchat'; +import I18n from '../../i18n'; +import { MultiSelect } from '../../containers/UIKit/MultiSelect'; + +import styles from './styles'; + +const SelectChannel = ({ + server, token, userId, onChannelSelect, initial, theme +}) => { + const [channels, setChannels] = useState([]); + + const getChannels = debounce(async(keyword = '') => { + try { + const res = await RocketChat.search({ text: keyword, filterUsers: false }); + setChannels(res); + } catch { + // do nothing + } + }, 300); + + const getAvatar = (text, type) => avatarURL({ + text, type, userId, token, baseUrl: server + }); + + return ( + <> + {I18n.t('Parent_channel_or_group')} + ({ + value: channel.rid, + text: { text: RocketChat.getRoomTitle(channel) }, + imageUrl: getAvatar(RocketChat.getRoomAvatar(channel), channel.t) + }))} + onClose={() => setChannels([])} + placeholder={{ text: `${ I18n.t('Select_a_Channel') }...` }} + /> + + ); +}; +SelectChannel.propTypes = { + server: PropTypes.string, + token: PropTypes.string, + userId: PropTypes.string, + initial: PropTypes.object, + onChannelSelect: PropTypes.func, + theme: PropTypes.string +}; + +export default SelectChannel; diff --git a/app/views/CreateDiscussionView/SelectUsers.js b/app/views/CreateDiscussionView/SelectUsers.js new file mode 100644 index 0000000000..6de24bda94 --- /dev/null +++ b/app/views/CreateDiscussionView/SelectUsers.js @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Text } from 'react-native'; +import PropTypes from 'prop-types'; +import { BLOCK_CONTEXT } from '@rocket.chat/ui-kit'; + +import debounce from '../../utils/debounce'; +import { avatarURL } from '../../utils/avatar'; +import RocketChat from '../../lib/rocketchat'; +import I18n from '../../i18n'; +import { MultiSelect } from '../../containers/UIKit/MultiSelect'; + +import styles from './styles'; + +const SelectUsers = ({ + server, token, userId, selected, onUserSelect, theme +}) => { + const [users, setUsers] = useState([]); + + const getUsers = debounce(async(keyword = '') => { + try { + const res = await RocketChat.search({ text: keyword, filterRooms: false }); + setUsers([...users.filter(u => selected.includes(u.name)), ...res.filter(r => !users.find(u => u.name === r.name))]); + } catch { + // do nothing + } + }, 300); + + const getAvatar = text => avatarURL({ + text, type: 'd', userId, token, baseUrl: server + }); + + return ( + <> + {I18n.t('Invite_users')} + ({ + value: user.name, + text: { text: RocketChat.getRoomTitle(user) }, + imageUrl: getAvatar(RocketChat.getRoomAvatar(user)) + }))} + onClose={() => setUsers(users.filter(u => selected.includes(u.name)))} + placeholder={{ text: `${ I18n.t('Select_Users') }...` }} + context={BLOCK_CONTEXT.FORM} + multiselect + /> + + ); +}; +SelectUsers.propTypes = { + server: PropTypes.string, + token: PropTypes.string, + userId: PropTypes.string, + selected: PropTypes.array, + onUserSelect: PropTypes.func, + theme: PropTypes.string +}; + +export default SelectUsers; diff --git a/app/views/CreateDiscussionView/index.js b/app/views/CreateDiscussionView/index.js new file mode 100644 index 0000000000..439e591503 --- /dev/null +++ b/app/views/CreateDiscussionView/index.js @@ -0,0 +1,195 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { ScrollView, Text } from 'react-native'; +import { SafeAreaView } from 'react-navigation'; +import isEqual from 'lodash/isEqual'; + +import Loading from '../../containers/Loading'; +import KeyboardView from '../../presentation/KeyboardView'; +import scrollPersistTaps from '../../utils/scrollPersistTaps'; +import I18n from '../../i18n'; +import { CustomHeaderButtons, Item, CloseModalButton } from '../../containers/HeaderButton'; +import StatusBar from '../../containers/StatusBar'; +import { themes } from '../../constants/colors'; +import { withTheme } from '../../theme'; +import { themedHeader } from '../../utils/navigation'; +import { getUserSelector } from '../../selectors/login'; +import TextInput from '../../containers/TextInput'; +import RocketChat from '../../lib/rocketchat'; +import Navigation from '../../lib/Navigation'; +import { createDiscussionRequest } from '../../actions/createDiscussion'; +import { showErrorAlert } from '../../utils/info'; + +import SelectChannel from './SelectChannel'; +import SelectUsers from './SelectUsers'; + +import styles from './styles'; + +class CreateChannelView extends React.Component { + static navigationOptions = ({ navigation, screenProps }) => { + const submit = navigation.getParam('submit', () => {}); + const showSubmit = navigation.getParam('showSubmit', navigation.getParam('message')); + return { + ...themedHeader(screenProps.theme), + title: I18n.t('Create_Discussion'), + headerRight: ( + showSubmit + ? ( + + + + ) + : null + ), + headerLeft: + }; + } + + propTypes = { + navigation: PropTypes.object, + server: PropTypes.string, + user: PropTypes.object, + create: PropTypes.func, + loading: PropTypes.bool, + result: PropTypes.object, + failure: PropTypes.bool, + error: PropTypes.object, + theme: PropTypes.string + } + + constructor(props) { + super(props); + const { navigation } = props; + navigation.setParams({ submit: this.submit }); + this.channel = navigation.getParam('channel'); + const message = navigation.getParam('message', {}); + this.state = { + channel: this.channel, + message, + name: message.msg || '', + users: [], + reply: '' + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + loading, failure, error, result, navigation + } = this.props; + + if (!isEqual(this.state, prevState)) { + navigation.setParams({ showSubmit: this.valid() }); + } + + if (!loading && loading !== prevProps.loading) { + setTimeout(() => { + if (failure) { + const msg = error.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_channel') }); + showErrorAlert(msg); + } else { + const { rid, t, prid } = result; + if (this.channel) { + Navigation.navigate('RoomsListView'); + } + Navigation.navigate('RoomView', { + rid, name: RocketChat.getRoomTitle(result), t, prid + }); + } + }, 300); + } + } + + submit = () => { + const { + name: t_name, channel: { prid, rid }, message: { id: pmid }, reply, users + } = this.state; + const { create } = this.props; + + // create discussion + create({ + prid: prid || rid, pmid, t_name, reply, users + }); + }; + + valid = () => { + const { + channel, name + } = this.state; + + return ( + channel + && channel.rid + && channel.rid.trim().length + && name.trim().length + ); + }; + + render() { + const { name, users } = this.state; + const { + server, user, loading, theme + } = this.props; + return ( + + + + + {I18n.t('Discussion_Desc')} + this.setState({ channel: { rid: value } })} + theme={theme} + /> + this.setState({ name: text })} + /> + this.setState({ users: value })} + theme={theme} + /> + this.setState({ reply: text })} + /> + + + + + ); + } +} + +const mapStateToProps = state => ({ + user: getUserSelector(state), + server: state.server.server, + error: state.createDiscussion.error, + failure: state.createDiscussion.failure, + loading: state.createDiscussion.isFetching, + result: state.createDiscussion.result +}); + +const mapDispatchToProps = dispatch => ({ + create: data => dispatch(createDiscussionRequest(data)) +}); + +export default connect(mapStateToProps, mapDispatchToProps)(withTheme(CreateChannelView)); diff --git a/app/views/CreateDiscussionView/styles.js b/app/views/CreateDiscussionView/styles.js new file mode 100644 index 0000000000..aa13621bba --- /dev/null +++ b/app/views/CreateDiscussionView/styles.js @@ -0,0 +1,24 @@ +import { StyleSheet } from 'react-native'; + +import sharedStyles from '../Styles'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 8 + }, + multiline: { + height: 130 + }, + label: { + marginBottom: 10, + fontSize: 14, + ...sharedStyles.textSemibold + }, + inputStyle: { + marginBottom: 16 + }, + description: { + paddingBottom: 16 + } +}); diff --git a/app/views/NewMessageView.js b/app/views/NewMessageView.js index 2140df18b0..69f61f1d66 100644 --- a/app/views/NewMessageView.js +++ b/app/views/NewMessageView.js @@ -24,6 +24,7 @@ import { themes } from '../constants/colors'; import { withTheme } from '../theme'; import { themedHeader } from '../utils/navigation'; import { getUserSelector } from '../selectors/login'; +import Navigation from '../lib/Navigation'; const styles = StyleSheet.create({ safeAreaView: { @@ -33,7 +34,10 @@ const styles = StyleSheet.create({ marginLeft: 60 }, createChannelButton: { - marginVertical: 25 + marginTop: 25 + }, + createDiscussionButton: { + marginBottom: 25 }, createChannelContainer: { height: 46, @@ -42,7 +46,7 @@ const styles = StyleSheet.create({ }, createChannelIcon: { marginLeft: 18, - marginRight: 15 + marginRight: 16 }, createChannelText: { fontSize: 17, @@ -142,6 +146,10 @@ class NewMessageView extends React.Component { navigation.navigate('SelectedUsersViewCreateChannel', { nextActionID: 'CREATE_CHANNEL', title: I18n.t('Select_Users') }); } + createDiscussion = () => { + Navigation.navigate('CreateDiscussionView'); + } + renderHeader = () => { const { theme } = this.props; return ( @@ -154,10 +162,21 @@ class NewMessageView extends React.Component { theme={theme} > - + {I18n.t('Create_Channel')} + + + + {I18n.t('Create_Discussion')} + + ); }