From 061c313e3f3bec0768795c12771f5f2b4417b593 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 24 May 2018 17:17:45 -0300 Subject: [PATCH] [NEW] Broadcast channels (#301) * Broadcast channels * e2e tests --- app/actions/actionsTypes.js | 3 +- app/actions/messages.js | 7 + app/containers/RoomTypeIcon.js | 6 +- app/containers/message/index.js | 70 ++++++---- app/containers/message/styles.js | 13 ++ .../helpers/mergeSubscriptionsRooms.js | 7 +- app/lib/methods/subscriptions/rooms.js | 20 ++- app/lib/realm.js | 9 +- app/lib/rocketchat.js | 6 +- app/sagas/messages.js | 29 ++++- app/views/CreateChannelView.js | 123 +++++++++++------- app/views/RoomActionsView/index.js | 44 +++++-- app/views/RoomInfoEditView/index.js | 10 +- app/views/RoomInfoEditView/styles.js | 4 + app/views/RoomInfoView/index.js | 21 ++- app/views/RoomInfoView/styles.js | 6 +- app/views/RoomView/index.js | 26 +++- app/views/RoomView/styles.js | 3 + app/views/Styles.js | 10 +- e2e/06-createroom.spec.js | 44 ++++--- e2e/07-room.spec.js | 71 +++++----- e2e/11-broadcast.spec.js | 101 ++++++++++++++ e2e/data.js | 6 +- 23 files changed, 471 insertions(+), 168 deletions(-) create mode 100644 e2e/11-broadcast.spec.js diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 985fde88..f2d87dd2 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -71,7 +71,8 @@ export const MESSAGES = createRequestTypes('MESSAGES', [ 'TOGGLE_PIN_FAILURE', 'SET_INPUT', 'CLEAR_INPUT', - 'TOGGLE_REACTION_PICKER' + 'TOGGLE_REACTION_PICKER', + 'REPLY_BROADCAST' ]); export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]); export const SELECTED_USERS = createRequestTypes('SELECTED_USERS', ['ADD_USER', 'REMOVE_USER', 'RESET', 'SET_LOADING']); diff --git a/app/actions/messages.js b/app/actions/messages.js index cfedf300..edaaefb1 100644 --- a/app/actions/messages.js +++ b/app/actions/messages.js @@ -183,3 +183,10 @@ export function toggleReactionPicker(message) { message }; } + +export function replyBroadcast(message) { + return { + type: types.MESSAGES.REPLY_BROADCAST, + message + }; +} diff --git a/app/containers/RoomTypeIcon.js b/app/containers/RoomTypeIcon.js index 4450594c..269d4294 100644 --- a/app/containers/RoomTypeIcon.js +++ b/app/containers/RoomTypeIcon.js @@ -11,6 +11,10 @@ const styles = StyleSheet.create({ }); const RoomTypeIcon = ({ type, size }) => { + if (!type) { + return null; + } + const icon = { c: 'pound', p: 'lock', @@ -21,7 +25,7 @@ const RoomTypeIcon = ({ type, size }) => { }; RoomTypeIcon.propTypes = { - type: PropTypes.string.isRequired, + type: PropTypes.string, size: PropTypes.number }; diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 4254a4cf..3aac53d5 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -18,10 +18,28 @@ import Reply from './Reply'; import ReactionsModal from './ReactionsModal'; import Emoji from './Emoji'; import styles from './styles'; -import { actionsShow, errorActionsShow, toggleReactionPicker } from '../../actions/messages'; +import { actionsShow, errorActionsShow, toggleReactionPicker, replyBroadcast } from '../../actions/messages'; import messagesStatus from '../../constants/messagesStatus'; import Touch from '../../utils/touch'; +const SYSTEM_MESSAGES = [ + 'r', + 'au', + 'ru', + 'ul', + 'uj', + 'rm', + 'user-muted', + 'user-unmuted', + 'message_pinned', + 'subscription-role-added', + 'subscription-role-removed', + 'room_changed_description', + 'room_changed_announcement', + 'room_changed_topic', + 'room_changed_privacy' +]; + const getInfoMessage = ({ t, role, msg, u }) => { @@ -68,7 +86,8 @@ const getInfoMessage = ({ }), dispatch => ({ actionsShow: actionMessage => dispatch(actionsShow(actionMessage)), errorActionsShow: actionMessage => dispatch(errorActionsShow(actionMessage)), - toggleReactionPicker: message => dispatch(toggleReactionPicker(message)) + toggleReactionPicker: message => dispatch(toggleReactionPicker(message)), + replyBroadcast: message => dispatch(replyBroadcast(message)) })) export default class Message extends React.Component { static propTypes = { @@ -83,17 +102,20 @@ export default class Message extends React.Component { editing: PropTypes.bool, errorActionsShow: PropTypes.func, toggleReactionPicker: PropTypes.func, + replyBroadcast: PropTypes.func, onReactionPress: PropTypes.func, style: ViewPropTypes.style, onLongPress: PropTypes.func, _updatedAt: PropTypes.instanceOf(Date), - archived: PropTypes.bool + archived: PropTypes.bool, + broadcast: PropTypes.bool } static defaultProps = { onLongPress: () => {}, _updatedAt: new Date(), - archived: false + archived: false, + broadcast: false } constructor(props) { @@ -116,6 +138,9 @@ export default class Message extends React.Component { if (!equal(this.props.reactions, nextProps.reactions)) { return true; } + if (this.props.broadcast !== nextProps.broadcast) { + return true; + } return this.props._updatedAt.toGMTString() !== nextProps._updatedAt.toGMTString(); } @@ -150,25 +175,11 @@ export default class Message extends React.Component { parseMessage = () => JSON.parse(JSON.stringify(this.props.item)); isInfoMessage() { - return [ - 'r', - 'au', - 'ru', - 'ul', - 'uj', - 'rm', - 'user-muted', - 'user-unmuted', - 'message_pinned', - 'subscription-role-added', - 'subscription-role-removed', - 'room_changed_description', - 'room_changed_announcement', - 'room_changed_topic', - 'room_changed_privacy' - ].includes(this.props.item.t); + return SYSTEM_MESSAGES.includes(this.props.item.t); } + isOwn = () => this.props.item.u && this.props.item.u._id === this.props.user.id; + isDeleted() { return this.props.item.t === 'rm'; } @@ -187,7 +198,7 @@ export default class Message extends React.Component { if (previousItem && ( (previousItem.ts.toDateString() === item.ts.toDateString()) && (previousItem.u.username === item.u.username) && - !(previousItem.groupable === false || item.groupable === false) && + !(previousItem.groupable === false || item.groupable === false || this.props.broadcast === true) && (previousItem.status === item.status) && (item.ts - previousItem.ts < this.props.Message_GroupingPeriod * 1000) )) { @@ -303,6 +314,20 @@ export default class Message extends React.Component { ); } + renderBroadcastReply() { + if (!this.props.broadcast || this.isOwn()) { + return null; + } + return ( + this.props.replyBroadcast(this.parseMessage())} + > + Reply + + ); + } + render() { const { item, message, editing, style, archived @@ -329,6 +354,7 @@ export default class Message extends React.Component { {this.renderAttachment()} {this.renderUrl()} {this.renderReactions()} + {this.renderBroadcastReply()} {this.state.reactionsModal && diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index 45a7a8f4..8421b09e 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -84,5 +84,18 @@ export default StyleSheet.create({ padding: 10, paddingRight: 12, paddingLeft: 0 + }, + broadcastButton: { + borderColor: '#1d74f5', + borderWidth: 2, + borderRadius: 2, + paddingVertical: 10, + width: 100, + alignItems: 'center', + justifyContent: 'center', + marginTop: 6 + }, + broadcastButtonText: { + color: '#1d74f5' } }); diff --git a/app/lib/methods/helpers/mergeSubscriptionsRooms.js b/app/lib/methods/helpers/mergeSubscriptionsRooms.js index 4289fe5d..e5d4340d 100644 --- a/app/lib/methods/helpers/mergeSubscriptionsRooms.js +++ b/app/lib/methods/helpers/mergeSubscriptionsRooms.js @@ -4,6 +4,9 @@ import normalizeMessage from './normalizeMessage'; export const merge = (subscription, room) => { subscription.muted = []; if (room) { + if (room.rid) { + subscription.rid = room.rid; + } subscription.roomUpdatedAt = room._updatedAt; subscription.lastMessage = normalizeMessage(room.lastMessage); subscription.ro = room.ro; @@ -13,6 +16,7 @@ export const merge = (subscription, room) => { subscription.reactWhenReadOnly = room.reactWhenReadOnly; subscription.archived = room.archived; subscription.joinCodeRequired = room.joinCodeRequired; + subscription.broadcast = room.broadcast; if (room.muted && room.muted.length) { subscription.muted = room.muted.filter(user => user).map(user => ({ value: user })); @@ -28,7 +32,8 @@ export const merge = (subscription, room) => { subscription.notifications = false; } - subscription.blocked = !!subscription.blocker; + subscription.blocker = !!subscription.blocker; + subscription.blocked = !!subscription.blocked; return subscription; }; diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index 279eb1c4..cf7270dd 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -51,16 +51,24 @@ export default async function subscribeRooms(id) { const [type, data] = ddpMessage.fields.args; const [, ev] = ddpMessage.fields.eventName.split('/'); if (/subscriptions/.test(ev)) { - const tpm = merge(data); + const rooms = database.objects('rooms').filtered('_id == $0', data.rid); + const tpm = merge(data, rooms[0]); database.write(() => { database.create('subscriptions', tpm, true); + database.delete(rooms); }); } - if (/rooms/.test(ev) && type === 'updated') { - const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id); - database.write(() => { - merge(sub, data); - }); + if (/rooms/.test(ev)) { + if (type === 'updated') { + const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id); + database.write(() => { + merge(sub, data); + }); + } else if (type === 'inserted') { + database.write(() => { + database.create('rooms', data, true); + }); + } } if (/message/.test(ev)) { const [args] = ddpMessage.fields.args; diff --git a/app/lib/realm.js b/app/lib/realm.js index bfe84ed5..1d8aedde 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -49,10 +49,7 @@ const roomsSchema = { primaryKey: '_id', properties: { _id: 'string', - t: 'string', - lastMessage: 'messages', - description: { type: 'string', optional: true }, - _updatedAt: { type: 'date', optional: true } + broadcast: { type: 'bool', optional: true } } }; @@ -97,11 +94,13 @@ const subscriptionSchema = { announcement: { type: 'string', optional: true }, topic: { type: 'string', optional: true }, blocked: { type: 'bool', optional: true }, + blocker: { type: 'bool', optional: true }, reactWhenReadOnly: { type: 'bool', optional: true }, archived: { type: 'bool', optional: true }, joinCodeRequired: { type: 'bool', optional: true }, notifications: { type: 'bool', optional: true }, - muted: { type: 'list', objectType: 'usersMuted' } + muted: { type: 'list', objectType: 'usersMuted' }, + broadcast: { type: 'bool', optional: true } } }; diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 8605c747..e27dc77a 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -49,8 +49,10 @@ const RocketChat = { subscribeRooms, subscribeRoom, canOpenRoom, - createChannel({ name, users, type }) { - return call(type ? 'createChannel' : 'createPrivateGroup', name, users, type); + createChannel({ + name, users, type, readOnly, broadcast + }) { + return call(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast }); }, async createDirectMessageAndWait(username) { const room = await RocketChat.createDirectMessage(username); diff --git a/app/sagas/messages.js b/app/sagas/messages.js index 7db85f93..0585e833 100644 --- a/app/sagas/messages.js +++ b/app/sagas/messages.js @@ -1,4 +1,5 @@ -import { takeLatest, put, call } from 'redux-saga/effects'; +import { delay } from 'redux-saga'; +import { takeLatest, put, call, select } from 'redux-saga/effects'; import { MESSAGES } from '../actions/actionsTypes'; import { messagesSuccess, @@ -12,9 +13,13 @@ import { permalinkSuccess, permalinkFailure, togglePinSuccess, - togglePinFailure + togglePinFailure, + setInput } from '../actions/messages'; import RocketChat from '../lib/rocketchat'; +import database from '../lib/realm'; +import { goRoom } from '../containers/routes/NavigationService'; +import log from '../utils/log'; const deleteMessage = message => RocketChat.deleteMessage(message); const editMessage = message => RocketChat.editMessage(message); @@ -81,6 +86,25 @@ const handleTogglePinRequest = function* handleTogglePinRequest({ message }) { } }; +const handleReplyBroadcast = function* handleReplyBroadcast({ message }) { + try { + const { username } = message.u; + const subscriptions = database.objects('subscriptions').filtered('name = $0', username); + if (subscriptions.length) { + goRoom({ rid: subscriptions[0].rid, name: subscriptions[0].name }); + } else { + const room = yield RocketChat.createDirectMessage(username); + goRoom({ rid: room.rid, name: username }); + } + yield delay(100); + const server = yield select(state => state.server.server); + const msg = `[ ](${ server }/direct/${ username }?msg=${ message._id })`; + yield put(setInput({ msg })); + } catch (e) { + log('handleReplyBroadcast', e); + } +}; + const root = function* root() { yield takeLatest(MESSAGES.REQUEST, get); yield takeLatest(MESSAGES.DELETE_REQUEST, handleDeleteRequest); @@ -88,5 +112,6 @@ const root = function* root() { yield takeLatest(MESSAGES.TOGGLE_STAR_REQUEST, handleToggleStarRequest); yield takeLatest(MESSAGES.PERMALINK_REQUEST, handlePermalinkRequest); yield takeLatest(MESSAGES.TOGGLE_PIN_REQUEST, handleTogglePinRequest); + yield takeLatest(MESSAGES.REPLY_BROADCAST, handleReplyBroadcast); }; export default root; diff --git a/app/views/CreateChannelView.js b/app/views/CreateChannelView.js index 35253d46..ed221fe3 100644 --- a/app/views/CreateChannelView.js +++ b/app/views/CreateChannelView.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { View, Text, Switch, TouchableOpacity, SafeAreaView, ScrollView } from 'react-native'; +import { View, Text, Switch, SafeAreaView, ScrollView, Platform } from 'react-native'; import RCTextInput from '../containers/TextInput'; import Loading from '../containers/Loading'; @@ -10,6 +10,7 @@ import { createChannelRequest } from '../actions/createChannel'; import styles from './Styles'; import KeyboardView from '../presentation/KeyboardView'; import scrollPersistTaps from '../utils/scrollPersistTaps'; +import Button from '../containers/Button'; @connect( state => ({ @@ -35,22 +36,28 @@ export default class CreateChannelView extends LoggedView { super('CreateChannelView', props); this.state = { channelName: '', - type: true + type: true, + readOnly: false, + broadcast: false }; } - submit() { + submit = () => { if (!this.state.channelName.trim() || this.props.createChannel.isFetching) { return; } - const { channelName, type = true } = this.state; + const { + channelName, type, readOnly, broadcast + } = this.state; let { users } = this.props; // transform users object into array of usernames users = users.map(user => user.name); // create channel - this.props.create({ name: channelName, users, type }); + this.props.create({ + name: channelName, users, type, readOnly, broadcast + }); } renderChannelNameError() { @@ -68,20 +75,62 @@ export default class CreateChannelView extends LoggedView { ); } - renderTypeSwitch() { - return ( - + renderSwitch = ({ + id, value, label, description, onValueChange, disabled = false + }) => ( + + this.setState({ type })} - testID='create-channel-type' + value={value} + onValueChange={onValueChange} + testID={`create-channel-${ id }`} + onTintColor='#2de0a5' + tintColor={Platform.OS === 'android' ? '#f5455c' : null} + disabled={disabled} /> - - {this.state.type ? 'Public' : 'Private'} - + {label} - ); + {description} + + ); + + renderType() { + const { type } = this.state; + return this.renderSwitch({ + id: 'type', + value: type, + label: type ? 'Private Channel' : 'Public Channel', + description: type ? 'Just invited people can access this channel' : 'Everyone can access this channel', + onValueChange: value => this.setState({ type: value }) + }); + } + + renderReadOnly() { + const { readOnly, broadcast } = this.state; + return this.renderSwitch({ + id: 'readonly', + value: readOnly, + label: 'Read Only Channel', + description: readOnly ? 'Only authorized users can write new messages' : 'All users in the channel can write new messages', + onValueChange: value => this.setState({ readOnly: value }), + disabled: broadcast + }); + } + + renderBroadcast() { + const { broadcast, readOnly } = this.state; + return this.renderSwitch({ + id: 'broadcast', + value: broadcast, + label: 'Broadcast Channel', + description: 'Only authorized users can write new messages, but the other users will be able to reply', + onValueChange: (value) => { + this.setState({ + broadcast: value, + readOnly: value ? true : readOnly + }); + } + }); } render() { @@ -102,36 +151,18 @@ export default class CreateChannelView extends LoggedView { testID='create-channel-name' /> {this.renderChannelNameError()} - {this.renderTypeSwitch()} - - {this.state.type ? ( - 'Everyone can access this channel' - ) : ( - 'Just invited people can access this channel' - )} - - this.submit()} - style={[ - styles.buttonContainer_white, - this.state.channelName.length === 0 || this.props.createChannel.isFetching - ? styles.disabledButton - : styles.enabledButton - ]} - testID='create-channel-submit' - > - CREATE - + {this.renderType()} + {this.renderReadOnly()} + {this.renderBroadcast()} + +