[NEW] Create discussions (#1942)

* [WIP][NEW] Create Discussion

* [FIX] Clear multiselect & Translations

* [NEW] Create Discussion at MessageActions

* [NEW] Disabled Multiselect

* [FIX] Initial channel

* [NEW] Create discussion on MessageBox Actions

* [FIX] Crashing on edit name

* [IMPROVEMENT] New message layout

* [CHORE] Update README

* [NEW] Avatars on MultiSelect

* [FIX] Select Users

* [FIX] Add redirect and Handle tablet

* [IMPROVEMENT] Split CreateDiscussionView

* [FIX] Create a discussion inner discussion

* [FIX] Create a discussion

* [I18N] Add pt-br

* Change icons

* [FIX] Nav to discussion & header title

* Fix header

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Djorkaeff Alexandre 2020-03-30 16:50:27 -03:00 committed by GitHub
parent acdf39b32d
commit 475ccbd9c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 653 additions and 66 deletions

View File

@ -80,7 +80,7 @@ Readme will guide you on how to config.
|--------------------------------------------------------------- |-------- |
| Jitsi Integration | ✅ |
| Federation (Directory) | ✅ |
| Discussions | |
| Discussions | |
| Omnichannel | ❌ |
| Threads | ✅ |
| Record Audio | ✅ |

View File

@ -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,

View File

@ -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
};
}

View File

@ -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 = (
<FastImage

View File

@ -63,6 +63,10 @@ class MessageActions extends React.Component {
this.EDIT_INDEX = this.options.length - 1;
}
// Create Discussion
this.options.push(I18n.t('Create_Discussion'));
this.CREATE_DISCUSSION_INDEX = this.options.length - 1;
// Mark as unread
if (message.u && message.u._id !== user.id) {
this.options.push(I18n.t('Mark_unread'));
@ -371,6 +375,11 @@ class MessageActions extends React.Component {
}
}
handleCreateDiscussion = () => {
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;

View File

@ -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 <CancelEditingButton onPress={editCancel} theme={theme} />;
}
return <FileButton onPress={showFileActions} theme={theme} />;
return <ActionsButton onPress={showMessageBoxActions} theme={theme} />;
});
LeftButtons.propTypes = {
theme: PropTypes.string,
showFileActions: PropTypes.func.isRequired,
showMessageBoxActions: PropTypes.func.isRequired,
editing: PropTypes.bool,
editCancel: PropTypes.func.isRequired
};

View File

@ -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 <SendButton onPress={submit} theme={theme} />;
@ -13,11 +13,11 @@ const RightButtons = React.memo(({
return (
<>
<AudioButton onPress={recordAudioMessage} theme={theme} />
<FileButton onPress={showFileActions} theme={theme} />
<ActionsButton onPress={showMessageBoxActions} theme={theme} />
</>
);
}
return <FileButton onPress={showFileActions} theme={theme} />;
return <ActionsButton onPress={showMessageBoxActions} theme={theme} />;
});
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;

View File

@ -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 }) => (
<BaseButton
onPress={onPress}
testID='messagebox-actions'
@ -13,9 +13,9 @@ const FileButton = React.memo(({ theme, onPress }) => (
/>
));
FileButton.propTypes = {
ActionsButton.propTypes = {
theme: PropTypes.string,
onPress: PropTypes.func.isRequired
};
export default FileButton;
export default ActionsButton;

View File

@ -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
};

View File

@ -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}
/>
</View>
</View>

View File

@ -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 ? <Image style={styles.chipImage} source={{ uri: item.imageUrl }} /> : null}
{item.imageUrl ? <FastImage style={styles.chipImage} source={{ uri: item.imageUrl }} /> : null}
<Text numberOfLines={1} style={[styles.chipText, { color: themes[theme].titleText }]}>{textParser([item.text])}</Text>
<CustomIcon name='cross' size={16} color={themes[theme].auxiliaryText} />
</>

View File

@ -9,12 +9,13 @@ import ActivityIndicator from '../../ActivityIndicator';
import styles from './styles';
const Input = ({
children, open, theme, loading
children, open, theme, loading, inputStyle, disabled
}) => (
<Touchable
onPress={() => open(true)}
style={{ backgroundColor: themes[theme].backgroundColor }}
style={[{ backgroundColor: themes[theme].backgroundColor }, inputStyle]}
background={Touchable.Ripple(themes[theme].bannerBackground)}
disabled={disabled}
>
<View style={[styles.input, { borderColor: themes[theme].separatorColor }]}>
{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
};

View File

@ -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 ? <FastImage style={styles.itemImage} source={{ uri: item.imageUrl }} /> : null}
<Text style={{ color: themes[theme].titleText }}>{textParser([item.text])}</Text>
{selected ? <Check theme={theme} /> : null}
</>

View File

@ -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 (
<View style={[styles.modal, { backgroundColor: themes[theme].backgroundColor }]}>
<View style={[styles.content, { backgroundColor: themes[theme].backgroundColor }]}>
<TextInput
onChangeText={onSearchChange}
placeholder={placeholder.text}
onChangeText={onSearch || onSearchChange}
placeholder={I18n.t('Search')}
theme={theme}
/>
<Items items={items} selected={selected} onSelect={onSelect} theme={theme} />
@ -124,19 +136,24 @@ export const MultiSelect = React.memo(({
open={onShow}
theme={theme}
loading={loading}
disabled={disabled}
inputStyle={inputStyle}
>
<Text style={[styles.pickerText, { color: themes[theme].auxiliaryText }]}>{currentValue}</Text>
<Text style={[styles.pickerText, { color: currentValue ? themes[theme].titleText : themes[theme].auxiliaryText }]}>{currentValue || placeholder.text}</Text>
</Input>
);
if (context === BLOCK_CONTEXT.FORM) {
const items = options.filter(option => selected.includes(option.value));
button = (
<Input
open={onShow}
theme={theme}
loading={loading}
disabled={disabled}
inputStyle={inputStyle}
>
<Chips items={options.filter(option => selected.includes(option.value))} onSelect={onSelect} theme={theme} />
{items.length ? <Chips items={items} onSelect={onSelect} theme={theme} /> : <Text style={[styles.pickerText, { color: themes[theme].auxiliaryText }]}>{placeholder.text}</Text>}
</Input>
);
}
@ -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: () => {}
};

View File

@ -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
}
});

View File

@ -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}}.',

View File

@ -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}}',

View File

@ -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
},
{

View File

@ -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

View File

@ -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;
}
}

View File

@ -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
});

View File

@ -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;

View File

@ -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()
]);
};

View File

@ -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({

27
app/utils/avatar.js Normal file
View File

@ -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;
};

View File

@ -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 (
<>
<Text style={styles.label}>{I18n.t('Parent_channel_or_group')}</Text>
<MultiSelect
theme={theme}
inputStyle={styles.inputStyle}
onChange={onChannelSelect}
onSearch={getChannels}
value={initial && [initial]}
disabled={initial}
options={channels.map(channel => ({
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;

View File

@ -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 (
<>
<Text style={styles.label}>{I18n.t('Invite_users')}</Text>
<MultiSelect
theme={theme}
inputStyle={styles.inputStyle}
onSearch={getUsers}
onChange={onUserSelect}
options={users.map(user => ({
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;

View File

@ -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
? (
<CustomHeaderButtons>
<Item title={I18n.t('Create')} onPress={submit} testID='create-discussion-submit' />
</CustomHeaderButtons>
)
: null
),
headerLeft: <CloseModalButton navigation={navigation} />
};
}
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 (
<KeyboardView
style={{ backgroundColor: themes[theme].auxiliaryBackground }}
contentContainerStyle={styles.container}
keyboardVerticalOffset={128}
>
<StatusBar theme={theme} />
<SafeAreaView testID='create-discussion-view' style={styles.container} forceInset={{ vertical: 'never' }}>
<ScrollView {...scrollPersistTaps}>
<Text style={[styles.description, { color: themes[theme].auxiliaryText }]}>{I18n.t('Discussion_Desc')}</Text>
<SelectChannel
server={server}
userId={user.id}
token={user.token}
initial={this.channel && { text: RocketChat.getRoomTitle(this.channel) }}
onChannelSelect={({ value }) => this.setState({ channel: { rid: value } })}
theme={theme}
/>
<TextInput
label={I18n.t('Discussion_name')}
placeholder={I18n.t('A_meaningful_name_for_the_discussion_room')}
containerStyle={styles.inputStyle}
defaultValue={name}
onChangeText={text => this.setState({ name: text })}
/>
<SelectUsers
server={server}
userId={user.id}
token={user.token}
selected={users}
onUserSelect={({ value }) => this.setState({ users: value })}
theme={theme}
/>
<TextInput
multiline
label={I18n.t('Your_message')}
inputStyle={styles.multiline}
theme={theme}
placeholder={I18n.t('Usually_a_discussion_starts_with_a_question_like_How_do_I_upload_a_picture')}
onChangeText={text => this.setState({ reply: text })}
/>
<Loading visible={loading} />
</ScrollView>
</SafeAreaView>
</KeyboardView>
);
}
}
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));

View File

@ -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
}
});

View File

@ -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}
>
<View style={[sharedStyles.separatorVertical, styles.createChannelContainer, { borderColor: themes[theme].separatorColor }]}>
<CustomIcon style={[styles.createChannelIcon, { color: themes[theme].tintColor }]} size={24} name='plus' />
<CustomIcon style={[styles.createChannelIcon, { color: themes[theme].tintColor }]} size={24} name='hashtag' />
<Text style={[styles.createChannelText, { color: themes[theme].tintColor }]}>{I18n.t('Create_Channel')}</Text>
</View>
</Touch>
<Touch
onPress={this.createDiscussion}
style={[styles.createDiscussionButton, { backgroundColor: themes[theme].backgroundColor }]}
testID='new-message-view-create-discussion'
theme={theme}
>
<View style={[sharedStyles.separatorBottom, styles.createChannelContainer, { borderColor: themes[theme].separatorColor }]}>
<CustomIcon style={[styles.createChannelIcon, { color: themes[theme].tintColor }]} size={24} name='chat' />
<Text style={[styles.createChannelText, { color: themes[theme].tintColor }]}>{I18n.t('Create_Discussion')}</Text>
</View>
</Touch>
</View>
);
}