[NEW] Broadcast channels (#301)

* Broadcast channels

* e2e tests
This commit is contained in:
Diego Mello 2018-05-24 17:17:45 -03:00 committed by Guilherme Gazzo
parent 8f90565e55
commit 061c313e3f
23 changed files with 471 additions and 168 deletions

View File

@ -71,7 +71,8 @@ export const MESSAGES = createRequestTypes('MESSAGES', [
'TOGGLE_PIN_FAILURE', 'TOGGLE_PIN_FAILURE',
'SET_INPUT', 'SET_INPUT',
'CLEAR_INPUT', 'CLEAR_INPUT',
'TOGGLE_REACTION_PICKER' 'TOGGLE_REACTION_PICKER',
'REPLY_BROADCAST'
]); ]);
export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]); export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]);
export const SELECTED_USERS = createRequestTypes('SELECTED_USERS', ['ADD_USER', 'REMOVE_USER', 'RESET', 'SET_LOADING']); export const SELECTED_USERS = createRequestTypes('SELECTED_USERS', ['ADD_USER', 'REMOVE_USER', 'RESET', 'SET_LOADING']);

View File

@ -183,3 +183,10 @@ export function toggleReactionPicker(message) {
message message
}; };
} }
export function replyBroadcast(message) {
return {
type: types.MESSAGES.REPLY_BROADCAST,
message
};
}

View File

@ -11,6 +11,10 @@ const styles = StyleSheet.create({
}); });
const RoomTypeIcon = ({ type, size }) => { const RoomTypeIcon = ({ type, size }) => {
if (!type) {
return null;
}
const icon = { const icon = {
c: 'pound', c: 'pound',
p: 'lock', p: 'lock',
@ -21,7 +25,7 @@ const RoomTypeIcon = ({ type, size }) => {
}; };
RoomTypeIcon.propTypes = { RoomTypeIcon.propTypes = {
type: PropTypes.string.isRequired, type: PropTypes.string,
size: PropTypes.number size: PropTypes.number
}; };

View File

@ -18,10 +18,28 @@ import Reply from './Reply';
import ReactionsModal from './ReactionsModal'; import ReactionsModal from './ReactionsModal';
import Emoji from './Emoji'; import Emoji from './Emoji';
import styles from './styles'; 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 messagesStatus from '../../constants/messagesStatus';
import Touch from '../../utils/touch'; 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 = ({ const getInfoMessage = ({
t, role, msg, u t, role, msg, u
}) => { }) => {
@ -68,7 +86,8 @@ const getInfoMessage = ({
}), dispatch => ({ }), dispatch => ({
actionsShow: actionMessage => dispatch(actionsShow(actionMessage)), actionsShow: actionMessage => dispatch(actionsShow(actionMessage)),
errorActionsShow: actionMessage => dispatch(errorActionsShow(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 { export default class Message extends React.Component {
static propTypes = { static propTypes = {
@ -83,17 +102,20 @@ export default class Message extends React.Component {
editing: PropTypes.bool, editing: PropTypes.bool,
errorActionsShow: PropTypes.func, errorActionsShow: PropTypes.func,
toggleReactionPicker: PropTypes.func, toggleReactionPicker: PropTypes.func,
replyBroadcast: PropTypes.func,
onReactionPress: PropTypes.func, onReactionPress: PropTypes.func,
style: ViewPropTypes.style, style: ViewPropTypes.style,
onLongPress: PropTypes.func, onLongPress: PropTypes.func,
_updatedAt: PropTypes.instanceOf(Date), _updatedAt: PropTypes.instanceOf(Date),
archived: PropTypes.bool archived: PropTypes.bool,
broadcast: PropTypes.bool
} }
static defaultProps = { static defaultProps = {
onLongPress: () => {}, onLongPress: () => {},
_updatedAt: new Date(), _updatedAt: new Date(),
archived: false archived: false,
broadcast: false
} }
constructor(props) { constructor(props) {
@ -116,6 +138,9 @@ export default class Message extends React.Component {
if (!equal(this.props.reactions, nextProps.reactions)) { if (!equal(this.props.reactions, nextProps.reactions)) {
return true; return true;
} }
if (this.props.broadcast !== nextProps.broadcast) {
return true;
}
return this.props._updatedAt.toGMTString() !== nextProps._updatedAt.toGMTString(); 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)); parseMessage = () => JSON.parse(JSON.stringify(this.props.item));
isInfoMessage() { isInfoMessage() {
return [ return SYSTEM_MESSAGES.includes(this.props.item.t);
'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);
} }
isOwn = () => this.props.item.u && this.props.item.u._id === this.props.user.id;
isDeleted() { isDeleted() {
return this.props.item.t === 'rm'; return this.props.item.t === 'rm';
} }
@ -187,7 +198,7 @@ export default class Message extends React.Component {
if (previousItem && ( if (previousItem && (
(previousItem.ts.toDateString() === item.ts.toDateString()) && (previousItem.ts.toDateString() === item.ts.toDateString()) &&
(previousItem.u.username === item.u.username) && (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) && (previousItem.status === item.status) &&
(item.ts - previousItem.ts < this.props.Message_GroupingPeriod * 1000) (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 (
<TouchableOpacity
style={styles.broadcastButton}
onPress={() => this.props.replyBroadcast(this.parseMessage())}
>
<Text style={styles.broadcastButtonText}>Reply</Text>
</TouchableOpacity>
);
}
render() { render() {
const { const {
item, message, editing, style, archived item, message, editing, style, archived
@ -329,6 +354,7 @@ export default class Message extends React.Component {
{this.renderAttachment()} {this.renderAttachment()}
{this.renderUrl()} {this.renderUrl()}
{this.renderReactions()} {this.renderReactions()}
{this.renderBroadcastReply()}
</View> </View>
</View> </View>
{this.state.reactionsModal && {this.state.reactionsModal &&

View File

@ -84,5 +84,18 @@ export default StyleSheet.create({
padding: 10, padding: 10,
paddingRight: 12, paddingRight: 12,
paddingLeft: 0 paddingLeft: 0
},
broadcastButton: {
borderColor: '#1d74f5',
borderWidth: 2,
borderRadius: 2,
paddingVertical: 10,
width: 100,
alignItems: 'center',
justifyContent: 'center',
marginTop: 6
},
broadcastButtonText: {
color: '#1d74f5'
} }
}); });

View File

@ -4,6 +4,9 @@ import normalizeMessage from './normalizeMessage';
export const merge = (subscription, room) => { export const merge = (subscription, room) => {
subscription.muted = []; subscription.muted = [];
if (room) { if (room) {
if (room.rid) {
subscription.rid = room.rid;
}
subscription.roomUpdatedAt = room._updatedAt; subscription.roomUpdatedAt = room._updatedAt;
subscription.lastMessage = normalizeMessage(room.lastMessage); subscription.lastMessage = normalizeMessage(room.lastMessage);
subscription.ro = room.ro; subscription.ro = room.ro;
@ -13,6 +16,7 @@ export const merge = (subscription, room) => {
subscription.reactWhenReadOnly = room.reactWhenReadOnly; subscription.reactWhenReadOnly = room.reactWhenReadOnly;
subscription.archived = room.archived; subscription.archived = room.archived;
subscription.joinCodeRequired = room.joinCodeRequired; subscription.joinCodeRequired = room.joinCodeRequired;
subscription.broadcast = room.broadcast;
if (room.muted && room.muted.length) { if (room.muted && room.muted.length) {
subscription.muted = room.muted.filter(user => user).map(user => ({ value: user })); subscription.muted = room.muted.filter(user => user).map(user => ({ value: user }));
@ -28,7 +32,8 @@ export const merge = (subscription, room) => {
subscription.notifications = false; subscription.notifications = false;
} }
subscription.blocked = !!subscription.blocker; subscription.blocker = !!subscription.blocker;
subscription.blocked = !!subscription.blocked;
return subscription; return subscription;
}; };

View File

@ -51,16 +51,24 @@ export default async function subscribeRooms(id) {
const [type, data] = ddpMessage.fields.args; const [type, data] = ddpMessage.fields.args;
const [, ev] = ddpMessage.fields.eventName.split('/'); const [, ev] = ddpMessage.fields.eventName.split('/');
if (/subscriptions/.test(ev)) { 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.write(() => {
database.create('subscriptions', tpm, true); database.create('subscriptions', tpm, true);
database.delete(rooms);
}); });
} }
if (/rooms/.test(ev) && type === 'updated') { if (/rooms/.test(ev)) {
if (type === 'updated') {
const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id); const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id);
database.write(() => { database.write(() => {
merge(sub, data); merge(sub, data);
}); });
} else if (type === 'inserted') {
database.write(() => {
database.create('rooms', data, true);
});
}
} }
if (/message/.test(ev)) { if (/message/.test(ev)) {
const [args] = ddpMessage.fields.args; const [args] = ddpMessage.fields.args;

View File

@ -49,10 +49,7 @@ const roomsSchema = {
primaryKey: '_id', primaryKey: '_id',
properties: { properties: {
_id: 'string', _id: 'string',
t: 'string', broadcast: { type: 'bool', optional: true }
lastMessage: 'messages',
description: { type: 'string', optional: true },
_updatedAt: { type: 'date', optional: true }
} }
}; };
@ -97,11 +94,13 @@ const subscriptionSchema = {
announcement: { type: 'string', optional: true }, announcement: { type: 'string', optional: true },
topic: { type: 'string', optional: true }, topic: { type: 'string', optional: true },
blocked: { type: 'bool', optional: true }, blocked: { type: 'bool', optional: true },
blocker: { type: 'bool', optional: true },
reactWhenReadOnly: { type: 'bool', optional: true }, reactWhenReadOnly: { type: 'bool', optional: true },
archived: { type: 'bool', optional: true }, archived: { type: 'bool', optional: true },
joinCodeRequired: { type: 'bool', optional: true }, joinCodeRequired: { type: 'bool', optional: true },
notifications: { type: 'bool', optional: true }, notifications: { type: 'bool', optional: true },
muted: { type: 'list', objectType: 'usersMuted' } muted: { type: 'list', objectType: 'usersMuted' },
broadcast: { type: 'bool', optional: true }
} }
}; };

View File

@ -49,8 +49,10 @@ const RocketChat = {
subscribeRooms, subscribeRooms,
subscribeRoom, subscribeRoom,
canOpenRoom, canOpenRoom,
createChannel({ name, users, type }) { createChannel({
return call(type ? 'createChannel' : 'createPrivateGroup', name, users, type); name, users, type, readOnly, broadcast
}) {
return call(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast });
}, },
async createDirectMessageAndWait(username) { async createDirectMessageAndWait(username) {
const room = await RocketChat.createDirectMessage(username); const room = await RocketChat.createDirectMessage(username);

View File

@ -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 { MESSAGES } from '../actions/actionsTypes';
import { import {
messagesSuccess, messagesSuccess,
@ -12,9 +13,13 @@ import {
permalinkSuccess, permalinkSuccess,
permalinkFailure, permalinkFailure,
togglePinSuccess, togglePinSuccess,
togglePinFailure togglePinFailure,
setInput
} from '../actions/messages'; } from '../actions/messages';
import RocketChat from '../lib/rocketchat'; 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 deleteMessage = message => RocketChat.deleteMessage(message);
const editMessage = message => RocketChat.editMessage(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() { const root = function* root() {
yield takeLatest(MESSAGES.REQUEST, get); yield takeLatest(MESSAGES.REQUEST, get);
yield takeLatest(MESSAGES.DELETE_REQUEST, handleDeleteRequest); yield takeLatest(MESSAGES.DELETE_REQUEST, handleDeleteRequest);
@ -88,5 +112,6 @@ const root = function* root() {
yield takeLatest(MESSAGES.TOGGLE_STAR_REQUEST, handleToggleStarRequest); yield takeLatest(MESSAGES.TOGGLE_STAR_REQUEST, handleToggleStarRequest);
yield takeLatest(MESSAGES.PERMALINK_REQUEST, handlePermalinkRequest); yield takeLatest(MESSAGES.PERMALINK_REQUEST, handlePermalinkRequest);
yield takeLatest(MESSAGES.TOGGLE_PIN_REQUEST, handleTogglePinRequest); yield takeLatest(MESSAGES.TOGGLE_PIN_REQUEST, handleTogglePinRequest);
yield takeLatest(MESSAGES.REPLY_BROADCAST, handleReplyBroadcast);
}; };
export default root; export default root;

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; 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 RCTextInput from '../containers/TextInput';
import Loading from '../containers/Loading'; import Loading from '../containers/Loading';
@ -10,6 +10,7 @@ import { createChannelRequest } from '../actions/createChannel';
import styles from './Styles'; import styles from './Styles';
import KeyboardView from '../presentation/KeyboardView'; import KeyboardView from '../presentation/KeyboardView';
import scrollPersistTaps from '../utils/scrollPersistTaps'; import scrollPersistTaps from '../utils/scrollPersistTaps';
import Button from '../containers/Button';
@connect( @connect(
state => ({ state => ({
@ -35,22 +36,28 @@ export default class CreateChannelView extends LoggedView {
super('CreateChannelView', props); super('CreateChannelView', props);
this.state = { this.state = {
channelName: '', channelName: '',
type: true type: true,
readOnly: false,
broadcast: false
}; };
} }
submit() { submit = () => {
if (!this.state.channelName.trim() || this.props.createChannel.isFetching) { if (!this.state.channelName.trim() || this.props.createChannel.isFetching) {
return; return;
} }
const { channelName, type = true } = this.state; const {
channelName, type, readOnly, broadcast
} = this.state;
let { users } = this.props; let { users } = this.props;
// transform users object into array of usernames // transform users object into array of usernames
users = users.map(user => user.name); users = users.map(user => user.name);
// create channel // create channel
this.props.create({ name: channelName, users, type }); this.props.create({
name: channelName, users, type, readOnly, broadcast
});
} }
renderChannelNameError() { renderChannelNameError() {
@ -68,20 +75,62 @@ export default class CreateChannelView extends LoggedView {
); );
} }
renderTypeSwitch() { renderSwitch = ({
return ( id, value, label, description, onValueChange, disabled = false
<View style={[styles.view_white, styles.switchContainer]}> }) => (
<View style={{ marginBottom: 15 }}>
<View style={styles.switchContainer}>
<Switch <Switch
style={[{ flexGrow: 0, flexShrink: 1 }]} value={value}
value={this.state.type} onValueChange={onValueChange}
onValueChange={type => this.setState({ type })} testID={`create-channel-${ id }`}
testID='create-channel-type' onTintColor='#2de0a5'
tintColor={Platform.OS === 'android' ? '#f5455c' : null}
disabled={disabled}
/> />
<Text style={[styles.label_white, styles.switchLabel]}> <Text style={styles.switchLabel}>{label}</Text>
{this.state.type ? 'Public' : 'Private'} </View>
</Text> <Text style={styles.switchDescription}>{description}</Text>
</View> </View>
); );
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() { render() {
@ -102,36 +151,18 @@ export default class CreateChannelView extends LoggedView {
testID='create-channel-name' testID='create-channel-name'
/> />
{this.renderChannelNameError()} {this.renderChannelNameError()}
{this.renderTypeSwitch()} {this.renderType()}
<Text {this.renderReadOnly()}
style={[ {this.renderBroadcast()}
styles.label_white, <View style={styles.alignItemsFlexStart}>
{ <Button
color: '#9ea2a8', title='Create'
flexGrow: 1, type='primary'
paddingHorizontal: 0, onPress={this.submit}
marginBottom: 20 disabled={this.state.channelName.length === 0 || this.props.createChannel.isFetching}
}
]}
>
{this.state.type ? (
'Everyone can access this channel'
) : (
'Just invited people can access this channel'
)}
</Text>
<TouchableOpacity
onPress={() => this.submit()}
style={[
styles.buttonContainer_white,
this.state.channelName.length === 0 || this.props.createChannel.isFetching
? styles.disabledButton
: styles.enabledButton
]}
testID='create-channel-submit' testID='create-channel-submit'
> />
<Text style={styles.button_white}>CREATE</Text> </View>
</TouchableOpacity>
<Loading visible={this.props.createChannel.isFetching} /> <Loading visible={this.props.createChannel.isFetching} />
</SafeAreaView> </SafeAreaView>
</ScrollView> </ScrollView>

View File

@ -71,6 +71,11 @@ export default class RoomActionsView extends LoggedView {
updateRoomMembers = async() => { updateRoomMembers = async() => {
const { t } = this.state.room; const { t } = this.state.room;
if (!this.canViewMembers) {
return {};
}
if (t === 'c' || t === 'p') { if (t === 'c' || t === 'p') {
let onlineMembers = []; let onlineMembers = [];
let allMembers = []; let allMembers = [];
@ -123,9 +128,20 @@ export default class RoomActionsView extends LoggedView {
} }
return false; return false;
} }
get canViewMembers() {
const { rid, t, broadcast } = this.state.room;
if (broadcast) {
const viewBroadcastMemberListPermission = 'view-broadcast-member-list';
const permissions = RocketChat.hasPermission([viewBroadcastMemberListPermission], rid);
if (!permissions[viewBroadcastMemberListPermission]) {
return false;
}
}
return (t === 'c' || t === 'p');
}
get sections() { get sections() {
const { const {
rid, t, blocked, notifications rid, t, blocker, notifications
} = this.room; } = this.room;
const { onlineMembers } = this.state; const { onlineMembers } = this.state;
@ -219,7 +235,7 @@ export default class RoomActionsView extends LoggedView {
data: [ data: [
{ {
icon: 'block', icon: 'block',
name: `${ blocked ? 'Unblock' : 'Block' } user`, name: `${ blocker ? 'Unblock' : 'Block' } user`,
type: 'danger', type: 'danger',
event: () => this.toggleBlockUser(), event: () => this.toggleBlockUser(),
testID: 'room-actions-block-user' testID: 'room-actions-block-user'
@ -228,14 +244,18 @@ export default class RoomActionsView extends LoggedView {
renderItem: this.renderItem renderItem: this.renderItem
}); });
} else if (t === 'c' || t === 'p') { } else if (t === 'c' || t === 'p') {
const actions = [{ const actions = [];
if (this.canViewMembers) {
actions.push({
icon: 'ios-people', icon: 'ios-people',
name: 'Members', name: 'Members',
description: (onlineMembers.length === 1 ? `${ onlineMembers.length } member` : `${ onlineMembers.length } members`), description: (onlineMembers.length === 1 ? `${ onlineMembers.length } member` : `${ onlineMembers.length } members`),
route: 'RoomMembers', route: 'RoomMembers',
params: { rid, members: onlineMembers }, params: { rid, members: onlineMembers },
testID: 'room-actions-members' testID: 'room-actions-members'
}]; });
}
if (this.canAddUser) { if (this.canAddUser) {
actions.push({ actions.push({
@ -276,10 +296,10 @@ export default class RoomActionsView extends LoggedView {
} }
toggleBlockUser = async() => { toggleBlockUser = async() => {
const { rid, blocked } = this.state.room; const { rid, blocker } = this.state.room;
const { member } = this.state; const { member } = this.state;
try { try {
RocketChat.toggleBlockUser(rid, member._id, !blocked); RocketChat.toggleBlockUser(rid, member._id, !blocker);
} catch (e) { } catch (e) {
log('toggleBlockUser', e); log('toggleBlockUser', e);
} }

View File

@ -324,10 +324,10 @@ export default class RoomInfoEditView extends LoggedView {
rightLabelPrimary='Read Only' rightLabelPrimary='Read Only'
rightLabelSecondary='Only authorized users can write new messages' rightLabelSecondary='Only authorized users can write new messages'
onValueChange={value => this.setState({ ro: value })} onValueChange={value => this.setState({ ro: value })}
disabled={!this.permissions[PERMISSION_SET_READONLY]} disabled={!this.permissions[PERMISSION_SET_READONLY] || room.broadcast}
testID='room-info-edit-view-ro' testID='room-info-edit-view-ro'
/> />
{ro && {ro && !room.broadcast &&
<SwitchContainer <SwitchContainer
value={reactWhenReadOnly} value={reactWhenReadOnly}
leftLabelPrimary='No Reactions' leftLabelPrimary='No Reactions'
@ -339,6 +339,12 @@ export default class RoomInfoEditView extends LoggedView {
testID='room-info-edit-view-react-when-ro' testID='room-info-edit-view-react-when-ro'
/> />
} }
{room.broadcast &&
[
<Text style={styles.broadcast}>Broadcast channel</Text>,
<View style={styles.divider} />
]
}
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]} style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]}
onPress={this.submit} onPress={this.submit}

View File

@ -42,5 +42,9 @@ export default StyleSheet.create({
borderColor: '#ddd', borderColor: '#ddd',
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
marginVertical: 20 marginVertical: 20
},
broadcast: {
fontWeight: 'bold',
textAlign: 'center'
} }
}); });

View File

@ -21,8 +21,11 @@ const PERMISSION_EDIT_ROOM = 'edit-room';
const camelize = str => str.replace(/^(.)/, (match, chr) => chr.toUpperCase()); const camelize = str => str.replace(/^(.)/, (match, chr) => chr.toUpperCase());
const getRoomTitle = room => (room.t === 'd' ? const getRoomTitle = room => (room.t === 'd' ?
<Text testID='room-info-view-name'>{room.fname}</Text> : <Text testID='room-info-view-name' style={styles.roomTitle}>{room.fname}</Text> :
[<RoomTypeIcon type={room.t} />, <Text testID='room-info-view-name'>{room.name}</Text>] [
<RoomTypeIcon type={room.t} key='room-info-type' />,
<Text testID='room-info-view-name' style={styles.roomTitle} key='room-info-name'>{room.name}</Text>
]
); );
@connect(state => ({ @connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
@ -184,6 +187,17 @@ export default class RoomInfoView extends LoggedView {
</Avatar> </Avatar>
) )
renderBroadcast = () => (
<View style={styles.item}>
<Text style={styles.itemLabel}>Broadcast Channel</Text>
<Text
style={styles.itemContent}
testID='room-info-view-broadcast'
>Only authorized users can write new messages, but the other users will be able to reply
</Text>
</View>
)
render() { render() {
const { room, roomUser } = this.state; const { room, roomUser } = this.state;
if (!room) { if (!room) {
@ -193,13 +207,14 @@ export default class RoomInfoView extends LoggedView {
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
<View style={styles.avatarContainer} testID='room-info-view'> <View style={styles.avatarContainer} testID='room-info-view'>
{this.renderAvatar(room, roomUser)} {this.renderAvatar(room, roomUser)}
<View style={styles.roomTitle}>{ getRoomTitle(room) }</View> <View style={styles.roomTitleContainer}>{ getRoomTitle(room) }</View>
</View> </View>
{!this.isDirect() && this.renderItem('description', room)} {!this.isDirect() && this.renderItem('description', room)}
{!this.isDirect() && this.renderItem('topic', room)} {!this.isDirect() && this.renderItem('topic', room)}
{!this.isDirect() && this.renderItem('announcement', room)} {!this.isDirect() && this.renderItem('announcement', room)}
{this.isDirect() && this.renderRoles()} {this.isDirect() && this.renderRoles()}
{this.isDirect() && this.renderTimezone(roomUser._id)} {this.isDirect() && this.renderTimezone(roomUser._id)}
{room.broadcast && this.renderBroadcast()}
</ScrollView> </ScrollView>
); );
} }

View File

@ -29,11 +29,13 @@ export default StyleSheet.create({
avatar: { avatar: {
marginHorizontal: 10 marginHorizontal: 10
}, },
roomTitle: { roomTitleContainer: {
fontSize: 18,
paddingTop: 20, paddingTop: 20,
flexDirection: 'row' flexDirection: 'row'
}, },
roomTitle: {
fontSize: 18
},
roomDescription: { roomDescription: {
fontSize: 14, fontSize: 14,
color: '#ccc', color: '#ccc',

View File

@ -153,6 +153,22 @@ export default class RoomView extends LoggedView {
} }
}; };
isOwner = () => this.state.room && this.state.room.roles && Array.from(Object.keys(this.state.room.roles), i => this.state.room.roles[i].value).includes('owner');
isMuted = () => this.state.room && this.state.room.muted && Array.from(Object.keys(this.state.room.muted), i => this.state.room.muted[i].value).includes(this.props.user.username);
isReadOnly = () => this.state.room.ro && this.isMuted() && !this.isOwner();
isBlocked = () => {
if (this.state.room) {
const { t, blocked, blocker } = this.state.room;
if (t === 'd' && (blocked || blocker)) {
return true;
}
}
return false;
}
renderItem = (item, previousItem) => ( renderItem = (item, previousItem) => (
<Message <Message
key={item._id} key={item._id}
@ -164,6 +180,7 @@ export default class RoomView extends LoggedView {
onReactionPress={this.onReactionPress} onReactionPress={this.onReactionPress}
onLongPress={this.onMessageLongPress} onLongPress={this.onMessageLongPress}
archived={this.state.room.archived} archived={this.state.room.archived}
broadcast={this.state.room.broadcast}
previousItem={previousItem} previousItem={previousItem}
/> />
); );
@ -179,13 +196,20 @@ export default class RoomView extends LoggedView {
</View> </View>
); );
} }
if (this.state.room.ro || this.state.room.archived) { if (this.state.room.archived || this.isReadOnly()) {
return ( return (
<View style={styles.readOnly}> <View style={styles.readOnly}>
<Text>This room is read only</Text> <Text>This room is read only</Text>
</View> </View>
); );
} }
if (this.isBlocked()) {
return (
<View style={styles.blockedOrBlocker}>
<Text>This room is blocked</Text>
</View>
);
}
return <MessageBox onSubmit={this.sendMessage} rid={this.rid} />; return <MessageBox onSubmit={this.sendMessage} rid={this.rid} />;
}; };

View File

@ -34,6 +34,9 @@ export default StyleSheet.create({
readOnly: { readOnly: {
padding: 10 padding: 10
}, },
blockedOrBlocker: {
padding: 10
},
reactionPickerContainer: { reactionPickerContainer: {
// width: width - 20, // width: width - 20,
// height: width - 20, // height: width - 20,

View File

@ -80,12 +80,18 @@ export default StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'flex-start', justifyContent: 'flex-start',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 0 paddingHorizontal: 0,
paddingBottom: 5
}, },
switchLabel: { switchLabel: {
flexGrow: 1, fontSize: 16,
color: '#2f343d',
paddingHorizontal: 10 paddingHorizontal: 10
}, },
switchDescription: {
fontSize: 16,
color: '#9ea2a8'
},
disabledButton: { disabledButton: {
backgroundColor: '#e1e5e8' backgroundColor: '#e1e5e8'
}, },

View File

@ -59,7 +59,9 @@ describe('Create room screen', () => {
await expect(element(by.id('create-channel-view'))).toBeVisible(); await expect(element(by.id('create-channel-view'))).toBeVisible();
await expect(element(by.id('create-channel-name'))).toBeVisible(); await expect(element(by.id('create-channel-name'))).toBeVisible();
await expect(element(by.id('create-channel-type'))).toBeVisible(); await expect(element(by.id('create-channel-type'))).toBeVisible();
await expect(element(by.id('create-channel-submit'))).toBeVisible(); await expect(element(by.id('create-channel-readonly'))).toBeVisible();
await expect(element(by.id('create-channel-broadcast'))).toExist();
await expect(element(by.id('create-channel-submit'))).toExist();
}); });
it('should get invalid room', async() => { it('should get invalid room', async() => {
@ -69,28 +71,9 @@ describe('Create room screen', () => {
await expect(element(by.id('create-channel-error'))).toBeVisible(); await expect(element(by.id('create-channel-error'))).toBeVisible();
}); });
it('should create private room', async() => {
await element(by.id('create-channel-name')).replaceText(`private${ data.random }`);
await element(by.id('create-channel-type')).tap();
await element(by.id('create-channel-submit')).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(60000);
await expect(element(by.id('room-view'))).toBeVisible();
await waitFor(element(by.id('room-view-title'))).toHaveText(`private${ data.random }`).withTimeout(60000);
await expect(element(by.id('room-view-title'))).toHaveText(`private${ data.random }`);
await element(by.id('header-back')).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(2000);
await waitFor(element(by.id(`rooms-list-view-item-private${ data.random }`))).toBeVisible().withTimeout(60000);
await expect(element(by.id(`rooms-list-view-item-private${ data.random }`))).toBeVisible();
});
it('should create public room', async() => { it('should create public room', async() => {
await element(by.id('rooms-list-view-create-channel')).tap();
await waitFor(element(by.id('select-users-view'))).toBeVisible().withTimeout(2000);
await element(by.id('select-users-view-item-rocket.cat')).tap();
await waitFor(element(by.id('selected-user-rocket.cat'))).toBeVisible().withTimeout(5000);
await element(by.id('selected-users-view-submit')).tap();
await waitFor(element(by.id('create-channel-view'))).toBeVisible().withTimeout(5000);
await element(by.id('create-channel-name')).replaceText(`public${ data.random }`); await element(by.id('create-channel-name')).replaceText(`public${ data.random }`);
await element(by.id('create-channel-type')).tap();
await element(by.id('create-channel-submit')).tap(); await element(by.id('create-channel-submit')).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(60000); await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(60000);
await expect(element(by.id('room-view'))).toBeVisible(); await expect(element(by.id('room-view'))).toBeVisible();
@ -102,6 +85,25 @@ describe('Create room screen', () => {
await expect(element(by.id(`rooms-list-view-item-public${ data.random }`))).toBeVisible(); await expect(element(by.id(`rooms-list-view-item-public${ data.random }`))).toBeVisible();
}); });
it('should create private room', async() => {
await element(by.id('rooms-list-view-create-channel')).tap();
await waitFor(element(by.id('select-users-view'))).toBeVisible().withTimeout(2000);
await element(by.id('select-users-view-item-rocket.cat')).tap();
await waitFor(element(by.id('selected-user-rocket.cat'))).toBeVisible().withTimeout(5000);
await element(by.id('selected-users-view-submit')).tap();
await waitFor(element(by.id('create-channel-view'))).toBeVisible().withTimeout(5000);
await element(by.id('create-channel-name')).replaceText(`private${ data.random }`);
await element(by.id('create-channel-submit')).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(60000);
await expect(element(by.id('room-view'))).toBeVisible();
await waitFor(element(by.id('room-view-title'))).toHaveText(`private${ data.random }`).withTimeout(60000);
await expect(element(by.id('room-view-title'))).toHaveText(`private${ data.random }`);
await element(by.id('header-back')).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(2000);
await waitFor(element(by.id(`rooms-list-view-item-private${ data.random }`))).toBeVisible().withTimeout(60000);
await expect(element(by.id(`rooms-list-view-item-private${ data.random }`))).toBeVisible();
});
afterEach(async() => { afterEach(async() => {
takeScreenshot(); takeScreenshot();
}); });

View File

@ -176,12 +176,6 @@ describe('Room screen', () => {
}); });
describe('Message', async() => { describe('Message', async() => {
before(async() => {
await mockMessage('reply');
await mockMessage('edit');
await mockMessage('quote');
});
it('should show message actions', async() => { it('should show message actions', async() => {
await element(by.text(`${ data.random }message`)).longPress(); await element(by.text(`${ data.random }message`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000); await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
@ -190,27 +184,6 @@ describe('Room screen', () => {
await waitFor(element(by.text('Cancel'))).toBeNotVisible().withTimeout(2000); await waitFor(element(by.text('Cancel'))).toBeNotVisible().withTimeout(2000);
}); });
it('should reply message', async() => {
await element(by.text(`${ data.random }reply`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Reply')).tap();
await element(by.id('messagebox-input')).typeText('replied');
await element(by.id('messagebox-send-message')).tap();
// TODO: test if reply was sent
});
it('should edit message', async() => {
await element(by.text(`${ data.random }edit`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Edit')).tap();
await element(by.id('messagebox-input')).typeText('ed');
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.text(`${ data.random }edited`))).toBeVisible().withTimeout(60000);
await expect(element(by.text(`${ data.random }edited`))).toBeVisible();
});
it('should copy permalink', async() => { it('should copy permalink', async() => {
await element(by.text(`${ data.random }message`)).longPress(); await element(by.text(`${ data.random }message`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000); await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
@ -232,16 +205,6 @@ describe('Room screen', () => {
// TODO: test clipboard // TODO: test clipboard
}); });
it('should quote message', async() => {
await element(by.text(`${ data.random }quote`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Quote')).tap();
await element(by.id('messagebox-input')).typeText(`${ data.random }quoted`);
await element(by.id('messagebox-send-message')).tap();
// TODO: test if quote was sent
});
it('should star message', async() => { it('should star message', async() => {
await element(by.text(`${ data.random }message`)).longPress(); await element(by.text(`${ data.random }message`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000); await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
@ -288,6 +251,40 @@ describe('Room screen', () => {
await expect(element(by.id('message-reaction-:grinning:'))).toBeNotVisible(); await expect(element(by.id('message-reaction-:grinning:'))).toBeNotVisible();
}); });
it('should reply message', async() => {
await mockMessage('reply');
await element(by.text(`${ data.random }reply`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Reply')).tap();
await element(by.id('messagebox-input')).typeText('replied');
await element(by.id('messagebox-send-message')).tap();
// TODO: test if reply was sent
});
it('should edit message', async() => {
await mockMessage('edit');
await element(by.text(`${ data.random }edit`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Edit')).tap();
await element(by.id('messagebox-input')).typeText('ed');
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.text(`${ data.random }edited`))).toBeVisible().withTimeout(60000);
await expect(element(by.text(`${ data.random }edited`))).toBeVisible();
});
it('should quote message', async() => {
await mockMessage('quote');
await element(by.text(`${ data.random }quote`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Messages actions'))).toBeVisible();
await element(by.text('Quote')).tap();
await element(by.id('messagebox-input')).typeText(`${ data.random }quoted`);
await element(by.id('messagebox-send-message')).tap();
// TODO: test if quote was sent
});
it('should pin message', async() => { it('should pin message', async() => {
await element(by.text(`${ data.random }edited`)).longPress(); await element(by.text(`${ data.random }edited`)).longPress();
await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000); await waitFor(element(by.text('Messages actions'))).toBeVisible().withTimeout(5000);

101
e2e/11-broadcast.spec.js Normal file
View File

@ -0,0 +1,101 @@
const {
device, expect, element, by, waitFor
} = require('detox');
const { takeScreenshot } = require('./helpers/screenshot');
const { logout, navigateToLogin } = require('./helpers/app');
const data = require('./data');
describe('Broadcast room', () => {
before(async() => {
await device.reloadReactNative();
});
it('should create broadcast room', async() => {
await element(by.id('rooms-list-view-create-channel')).tap();
await waitFor(element(by.id('select-users-view'))).toBeVisible().withTimeout(2000);
await element(by.id(`select-users-view-item-${ data.alternateUser }`)).tap();
await waitFor(element(by.id(`selected-user-${ data.alternateUser }`))).toBeVisible().withTimeout(5000);
await element(by.id('selected-users-view-submit')).tap();
await waitFor(element(by.id('create-channel-view'))).toBeVisible().withTimeout(5000);
await element(by.id('create-channel-name')).replaceText(`broadcast${ data.random }`);
await element(by.id('create-channel-broadcast')).tap();
await element(by.id('create-channel-submit')).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(60000);
await expect(element(by.id('room-view'))).toBeVisible();
await waitFor(element(by.id('room-view-title'))).toHaveText(`broadcast${ data.random }`).withTimeout(60000);
await expect(element(by.id('room-view-title'))).toHaveText(`broadcast${ data.random }`);
await element(by.id('room-view-title')).tap();
await waitFor(element(by.id('room-info-view'))).toBeVisible().withTimeout(2000);
await waitFor(element(by.id('room-info-view-broadcast'))).toBeVisible().withTimeout(2000);
await expect(element(by.id('room-info-view-broadcast'))).toBeVisible();
await element(by.id('header-back')).atIndex(0).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(2000);
await element(by.id('header-back')).atIndex(0).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(2000);
await waitFor(element(by.id(`rooms-list-view-item-broadcast${ data.random }`))).toBeVisible().withTimeout(60000);
await expect(element(by.id(`rooms-list-view-item-broadcast${ data.random }`))).toBeVisible();
});
it('should send message', async() => {
await element(by.id(`rooms-list-view-item-broadcast${ data.random }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText(`${ data.random }message`);
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.text(`${ data.random }message`))).toBeVisible().withTimeout(60000);
await expect(element(by.text(`${ data.random }message`))).toBeVisible();
});
it('should login as user without write message authorization and enter room', async() => {
await element(by.id('header-back')).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(2000);
await expect(element(by.id('rooms-list-view'))).toBeVisible();
await logout();
await navigateToLogin();
await element(by.id('login-view-email')).replaceText(data.alternateUser);
await element(by.id('login-view-password')).replaceText(data.alternateUserPassword);
await element(by.id('login-view-submit')).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(10000);
await waitFor(element(by.id(`rooms-list-view-item-broadcast${ data.random }`))).toBeVisible().withTimeout(60000);
await expect(element(by.id(`rooms-list-view-item-broadcast${ data.random }`))).toBeVisible();
await element(by.id(`rooms-list-view-item-broadcast${ data.random }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
await expect(element(by.id('room-view-title'))).toHaveText(`broadcast${ data.random }`);
});
it('should not have messagebox', async() => {
await expect(element(by.id('messagebox'))).toBeNotVisible();
});
it('should be read only', async() => {
await expect(element(by.text('This room is read only'))).toBeVisible();
});
it('should have the message created earlier', async() => {
await waitFor(element(by.text(`${ data.random }message`))).toBeVisible().withTimeout(60000);
await expect(element(by.text(`${ data.random }message`))).toBeVisible();
});
it('should have reply button', async() => {
await expect(element(by.text('Reply'))).toBeVisible();
});
it('should tap on reply button and navigate to direct room', async() => {
await element(by.text('Reply')).tap();
await waitFor(element(by.id('room-view-title'))).toHaveText(data.user).withTimeout(60000);
await expect(element(by.id('room-view-title'))).toHaveText(data.user);
});
it('should reply broadcasted message', async() => {
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText(`${ data.random }broadcastreply`);
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.text(`${ data.random }message`))).toBeVisible().withTimeout(60000);
await expect(element(by.text(`${ data.random }message`))).toBeVisible(); // broadcasted message
await expect(element(by.text(` ${ data.random }broadcastreply`))).toBeVisible(); // reply
});
afterEach(async() => {
takeScreenshot();
});
});

View File

@ -1,10 +1,12 @@
const random = require('./helpers/random'); const random = require('./helpers/random');
const value = random(20); const value = random(20);
const data = { const data = {
server: 'https://unstable.rocket.chat', server: 'https://stable.rocket.chat',
alternateServer: 'https://stable.rocket.chat', alternateServer: 'https://unstable.rocket.chat',
user: `user${ value }`, user: `user${ value }`,
password: `password${ value }`, password: `password${ value }`,
alternateUser: 'detoxrn',
alternateUserPassword: '123',
email: `detoxrn+${ value }@rocket.chat`, email: `detoxrn+${ value }@rocket.chat`,
random: value random: value
} }