[NEW] Omnichannel inquiry queue (#2352)

* [WIP] Omnichannel queue

* Request inquiry when login

* Show take inquiry queued room

* Queue List as a Screen

* Poc using unread badge

* Prevent navigation to empty list

* Remove chat from queue when taked

* Fix header status on omnichannel preview room

* Fix room actions view to preview queued chat

* Use isOmnichannelPreview and dont show actions when is preview

* Filter queue chats taken by other people

* Fix room info to omnichannel preview room

* Handle show Queue

* Reset inquiry store when change server

* Improve queue logic

* Disable swipe on RoomItem when is a Queue Item

* Add unreadBadge style

* Move unread badge to presentation folder

* Cleanup inquiry reducers

* Move take saga to rocketchat function

* Remove comments

* Add relevant comments

* Subscribe to public stream if is livechat manager or doesnt have departments

* Add pt-br and improve queue empty message

* Fix take when dont have view-livechat-manager permission

* Add missing events

* Create selector for inquiry queue

* Minor fixes

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Djorkaeff Alexandre 2020-07-31 15:22:30 -03:00 committed by GitHub
parent 34824e0765
commit ac708dd32b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 664 additions and 73 deletions

View File

@ -33,6 +33,7 @@ export const ROOMS = createRequestTypes('ROOMS', [
'CLOSE_SEARCH_HEADER' 'CLOSE_SEARCH_HEADER'
]); ]);
export const ROOM = createRequestTypes('ROOM', ['SUBSCRIBE', 'UNSUBSCRIBE', 'LEAVE', 'DELETE', 'REMOVED', 'CLOSE', 'FORWARD', 'USER_TYPING']); export const ROOM = createRequestTypes('ROOM', ['SUBSCRIBE', 'UNSUBSCRIBE', 'LEAVE', 'DELETE', 'REMOVED', 'CLOSE', 'FORWARD', 'USER_TYPING']);
export const INQUIRY = createRequestTypes('INQUIRY', [...defaultTypes, 'SET_ENABLED', 'RESET', 'QUEUE_ADD', 'QUEUE_UPDATE', 'QUEUE_REMOVE']);
export const APP = createRequestTypes('APP', ['START', 'READY', 'INIT', 'INIT_LOCAL_SETTINGS', 'SET_MASTER_DETAIL']); export const APP = createRequestTypes('APP', ['START', 'READY', 'INIT', 'INIT_LOCAL_SETTINGS', 'SET_MASTER_DETAIL']);
export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']); export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']);
export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]); export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]);

55
app/actions/inquiry.js Normal file
View File

@ -0,0 +1,55 @@
import * as types from './actionsTypes';
export function inquirySetEnabled(enabled) {
return {
type: types.INQUIRY.SET_ENABLED,
enabled
};
}
export function inquiryReset() {
return {
type: types.INQUIRY.RESET
};
}
export function inquiryQueueAdd(inquiry) {
return {
type: types.INQUIRY.QUEUE_ADD,
inquiry
};
}
export function inquiryQueueUpdate(inquiry) {
return {
type: types.INQUIRY.QUEUE_UPDATE,
inquiry
};
}
export function inquiryQueueRemove(inquiryId) {
return {
type: types.INQUIRY.QUEUE_REMOVE,
inquiryId
};
}
export function inquiryRequest() {
return {
type: types.INQUIRY.REQUEST
};
}
export function inquirySuccess(inquiries) {
return {
type: types.INQUIRY.SUCCESS,
inquiries
};
}
export function inquiryFailure(error) {
return {
type: types.INQUIRY.FAILURE,
error
};
}

View File

@ -483,6 +483,7 @@ export default {
Tags: 'Tags', Tags: 'Tags',
Take_a_photo: 'Take a photo', Take_a_photo: 'Take a photo',
Take_a_video: 'Take a video', Take_a_video: 'Take a video',
Take_it: 'Take it!',
tap_to_change_status: 'tap to change status', tap_to_change_status: 'tap to change status',
Tap_to_view_servers_list: 'Tap to view servers list', Tap_to_view_servers_list: 'Tap to view servers list',
Terms_of_Service: ' Terms of Service ', Terms_of_Service: ' Terms of Service ',
@ -621,5 +622,7 @@ export default {
Passcode_app_locked_title: 'App locked', Passcode_app_locked_title: 'App locked',
Passcode_app_locked_subtitle: 'Try again in {{timeLeft}} seconds', Passcode_app_locked_subtitle: 'Try again in {{timeLeft}} seconds',
After_seconds_set_by_admin: 'After {{seconds}} seconds (set by admin)', After_seconds_set_by_admin: 'After {{seconds}} seconds (set by admin)',
Dont_activate: 'Don\'t activate now' Dont_activate: 'Don\'t activate now',
Queued_chats: 'Queued chats',
Queue_is_empty: 'Queue is empty'
}; };

View File

@ -551,5 +551,7 @@ export default {
Passcode_app_locked_title: 'Aplicativo bloqueado', Passcode_app_locked_title: 'Aplicativo bloqueado',
Passcode_app_locked_subtitle: 'Tente novamente em {{timeLeft}} segundos', Passcode_app_locked_subtitle: 'Tente novamente em {{timeLeft}} segundos',
After_seconds_set_by_admin: 'Após {{seconds}} segundos (Configurado pelo adm)', After_seconds_set_by_admin: 'Após {{seconds}} segundos (Configurado pelo adm)',
Dont_activate: 'Não ativar agora' Dont_activate: 'Não ativar agora',
Queued_chats: 'Bate-papos na fila',
Queue_is_empty: 'A fila está vazia'
}; };

View File

@ -0,0 +1,95 @@
import log from '../../../utils/log';
import store from '../../createStore';
import RocketChat from '../../rocketchat';
import {
inquiryRequest,
inquiryQueueAdd,
inquiryQueueUpdate,
inquiryQueueRemove
} from '../../../actions/inquiry';
const removeListener = listener => listener.stop();
let connectedListener;
let disconnectedListener;
let queueListener;
const streamTopic = 'stream-livechat-inquiry-queue-observer';
export default function subscribeInquiry() {
const handleConnection = () => {
store.dispatch(inquiryRequest());
};
const handleQueueMessageReceived = (ddpMessage) => {
const [{ type, ...sub }] = ddpMessage.fields.args;
// added can be ignored, since it is handled by 'changed' event
if (/added/.test(type)) {
return;
}
// if the sub isn't on the queue anymore
if (sub.status !== 'queued') {
// remove it from the queue
store.dispatch(inquiryQueueRemove(sub._id));
return;
}
const { queued } = store.getState().inquiry;
// check if this sub is on the current queue
const idx = queued.findIndex(item => item._id === sub._id);
if (idx >= 0) {
// if it is on the queue let's update
store.dispatch(inquiryQueueUpdate(sub));
} else {
// if it is not on the queue let's add
store.dispatch(inquiryQueueAdd(sub));
}
};
const stop = () => {
if (connectedListener) {
connectedListener.then(removeListener);
connectedListener = false;
}
if (disconnectedListener) {
disconnectedListener.then(removeListener);
disconnectedListener = false;
}
if (queueListener) {
queueListener.then(removeListener);
queueListener = false;
}
};
connectedListener = this.sdk.onStreamData('connected', handleConnection);
disconnectedListener = this.sdk.onStreamData('close', handleConnection);
queueListener = this.sdk.onStreamData(streamTopic, handleQueueMessageReceived);
try {
const { user } = store.getState().login;
RocketChat.getAgentDepartments(user.id).then((result) => {
if (result.success) {
const { departments } = result;
if (!departments.length || RocketChat.hasRole('livechat-manager')) {
this.sdk.subscribe(streamTopic, 'public').catch(e => console.log(e));
}
const departmentIds = departments.map(({ departmentId }) => departmentId);
departmentIds.forEach((departmentId) => {
// subscribe to all departments of the agent
this.sdk.subscribe(streamTopic, `department/${ departmentId }`).catch(e => console.log(e));
});
}
});
return {
stop: () => stop()
};
} catch (e) {
log(e);
return Promise.reject();
}
}

View File

@ -20,6 +20,7 @@ import {
} from '../actions/share'; } from '../actions/share';
import subscribeRooms from './methods/subscriptions/rooms'; import subscribeRooms from './methods/subscriptions/rooms';
import subscribeInquiry from './methods/subscriptions/inquiry';
import getUsersPresence, { getUserPresence, subscribeUsersPresence } from './methods/getUsersPresence'; import getUsersPresence, { getUserPresence, subscribeUsersPresence } from './methods/getUsersPresence';
import protectedFunction from './methods/helpers/protectedFunction'; import protectedFunction from './methods/helpers/protectedFunction';
@ -72,6 +73,15 @@ const RocketChat = {
} }
} }
}, },
async subscribeInquiry() {
if (!this.inquirySub) {
try {
this.inquirySub = await subscribeInquiry.call(this);
} catch (e) {
log(e);
}
}
},
canOpenRoom, canOpenRoom,
createChannel({ createChannel({
name, users, type, readOnly, broadcast name, users, type, readOnly, broadcast
@ -203,6 +213,11 @@ const RocketChat = {
this.roomsSub = null; this.roomsSub = null;
} }
if (this.inquirySub) {
this.inquirySub.stop();
this.inquirySub = null;
}
if (this.sdk) { if (this.sdk) {
this.sdk.disconnect(); this.sdk.disconnect();
this.sdk = null; this.sdk = null;
@ -816,7 +831,7 @@ const RocketChat = {
}, },
getAgentDepartments(uid) { getAgentDepartments(uid) {
// RC 2.4.0 // RC 2.4.0
return this.sdk.get(`livechat/agents/${ uid }/departments`); return this.sdk.get(`livechat/agents/${ uid }/departments?enabledDepartmentsOnly=true`);
}, },
getCustomFields() { getCustomFields() {
// RC 2.2.0 // RC 2.2.0
@ -826,6 +841,16 @@ const RocketChat = {
// RC 0.26.0 // RC 0.26.0
return this.methodCallWrapper('livechat:changeLivechatStatus'); return this.methodCallWrapper('livechat:changeLivechatStatus');
}, },
getInquiriesQueued() {
// RC 2.4.0
return this.sdk.get('livechat/inquiries.queued');
},
takeInquiry(inquiryId) {
// this inquiry is added to the db by the subscriptions stream
// and will be removed by the queue stream
// RC 2.4.0
return this.methodCallWrapper('livechat:takeInquiry', inquiryId);
},
getUidDirectMessage(room) { getUidDirectMessage(room) {
const { id: userId } = reduxStore.getState().login.user; const { id: userId } = reduxStore.getState().login.user;
@ -963,6 +988,14 @@ const RocketChat = {
// RC 0.47.0 // RC 0.47.0
return this.sdk.get('chat.getMessage', { msgId }); return this.sdk.get('chat.getMessage', { msgId });
}, },
hasRole(role) {
const shareUser = reduxStore.getState().share.user;
const loginUser = reduxStore.getState().login.user;
// get user roles on the server from redux
const userRoles = (shareUser?.roles || loginUser?.roles) || [];
return userRoles.indexOf(r => r === role) > -1;
},
async hasPermission(permissions, rid) { async hasPermission(permissions, rid) {
const db = database.active; const db = database.active;
const subsCollection = db.collections.get('subscriptions'); const subsCollection = db.collections.get('subscriptions');

View File

@ -4,7 +4,7 @@ import { View } from 'react-native';
import styles from './styles'; import styles from './styles';
import Wrapper from './Wrapper'; import Wrapper from './Wrapper';
import UnreadBadge from './UnreadBadge'; import UnreadBadge from '../UnreadBadge';
import TypeIcon from './TypeIcon'; import TypeIcon from './TypeIcon';
import LastMessage from './LastMessage'; import LastMessage from './LastMessage';
import Title from './Title'; import Title from './Title';
@ -41,6 +41,7 @@ const RoomItem = ({
groupMentions, groupMentions,
roomUpdatedAt, roomUpdatedAt,
testID, testID,
swipeEnabled,
onPress, onPress,
toggleFav, toggleFav,
toggleRead, toggleRead,
@ -59,6 +60,7 @@ const RoomItem = ({
type={type} type={type}
theme={theme} theme={theme}
isFocused={isFocused} isFocused={isFocused}
swipeEnabled={swipeEnabled}
> >
<Wrapper <Wrapper
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
@ -172,6 +174,7 @@ RoomItem.propTypes = {
userMentions: PropTypes.number, userMentions: PropTypes.number,
groupMentions: PropTypes.number, groupMentions: PropTypes.number,
roomUpdatedAt: PropTypes.instanceOf(Date), roomUpdatedAt: PropTypes.instanceOf(Date),
swipeEnabled: PropTypes.bool,
toggleFav: PropTypes.func, toggleFav: PropTypes.func,
toggleRead: PropTypes.func, toggleRead: PropTypes.func,
onPress: PropTypes.func, onPress: PropTypes.func,
@ -180,7 +183,8 @@ RoomItem.propTypes = {
RoomItem.defaultProps = { RoomItem.defaultProps = {
avatarSize: 48, avatarSize: 48,
status: 'offline' status: 'offline',
swipeEnabled: true
}; };
export default RoomItem; export default RoomItem;

View File

@ -26,7 +26,8 @@ class Touchable extends React.Component {
hideChannel: PropTypes.func, hideChannel: PropTypes.func,
children: PropTypes.element, children: PropTypes.element,
theme: PropTypes.string, theme: PropTypes.string,
isFocused: PropTypes.bool isFocused: PropTypes.bool,
swipeEnabled: PropTypes.bool
} }
constructor(props) { constructor(props) {
@ -168,7 +169,7 @@ class Touchable extends React.Component {
render() { render() {
const { const {
testID, isRead, width, favorite, children, theme, isFocused testID, isRead, width, favorite, children, theme, isFocused, swipeEnabled
} = this.props; } = this.props;
return ( return (
@ -177,6 +178,7 @@ class Touchable extends React.Component {
minDeltaX={20} minDeltaX={20}
onGestureEvent={this._onGestureEvent} onGestureEvent={this._onGestureEvent}
onHandlerStateChange={this._onHandlerStateChange} onHandlerStateChange={this._onHandlerStateChange}
enabled={swipeEnabled}
> >
<Animated.View> <Animated.View>
<LeftActions <LeftActions

View File

@ -45,7 +45,8 @@ const RoomItemContainer = React.memo(({
getRoomTitle, getRoomTitle,
getRoomAvatar, getRoomAvatar,
getIsGroupChat, getIsGroupChat,
getIsRead getIsRead,
swipeEnabled
}) => { }) => {
const [, setForceUpdate] = useState(1); const [, setForceUpdate] = useState(1);
@ -125,6 +126,7 @@ const RoomItemContainer = React.memo(({
useRealName={useRealName} useRealName={useRealName}
unread={item.unread} unread={item.unread}
groupMentions={item.groupMentions} groupMentions={item.groupMentions}
swipeEnabled={swipeEnabled}
/> />
); );
}, arePropsEqual); }, arePropsEqual);
@ -153,7 +155,8 @@ RoomItemContainer.propTypes = {
getRoomTitle: PropTypes.func, getRoomTitle: PropTypes.func,
getRoomAvatar: PropTypes.func, getRoomAvatar: PropTypes.func,
getIsGroupChat: PropTypes.func, getIsGroupChat: PropTypes.func,
getIsRead: PropTypes.func getIsRead: PropTypes.func,
swipeEnabled: PropTypes.bool
}; };
RoomItemContainer.defaultProps = { RoomItemContainer.defaultProps = {
@ -163,7 +166,8 @@ RoomItemContainer.defaultProps = {
getRoomTitle: () => 'title', getRoomTitle: () => 'title',
getRoomAvatar: () => '', getRoomAvatar: () => '',
getIsGroupChat: () => false, getIsGroupChat: () => false,
getIsRead: () => false getIsRead: () => false,
swipeEnabled: true
}; };
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {

View File

@ -51,23 +51,6 @@ export default StyleSheet.create({
updateAlert: { updateAlert: {
...sharedStyles.textSemibold ...sharedStyles.textSemibold
}, },
unreadNumberContainer: {
minWidth: 21,
height: 21,
paddingVertical: 3,
paddingHorizontal: 5,
borderRadius: 10.5,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10
},
unreadText: {
overflow: 'hidden',
fontSize: 13,
...sharedStyles.textMedium,
letterSpacing: 0.56,
textAlign: 'center'
},
status: { status: {
marginLeft: 4, marginLeft: 4,
marginRight: 7, marginRight: 7,

View File

@ -1,12 +1,32 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { View, Text } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import styles from './styles'; import sharedStyles from '../views/Styles';
import { themes } from '../../constants/colors'; import { themes } from '../constants/colors';
const styles = StyleSheet.create({
unreadNumberContainer: {
minWidth: 21,
height: 21,
paddingVertical: 3,
paddingHorizontal: 5,
borderRadius: 10.5,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10
},
unreadText: {
overflow: 'hidden',
fontSize: 13,
...sharedStyles.textMedium,
letterSpacing: 0.56,
textAlign: 'center'
}
});
const UnreadBadge = React.memo(({ const UnreadBadge = React.memo(({
theme, unread, userMentions, groupMentions theme, unread, userMentions, groupMentions, style
}) => { }) => {
if (!unread || unread <= 0) { if (!unread || unread <= 0) {
return; return;
@ -27,7 +47,8 @@ const UnreadBadge = React.memo(({
<View <View
style={[ style={[
styles.unreadNumberContainer, styles.unreadNumberContainer,
{ backgroundColor } { backgroundColor },
style
]} ]}
> >
<Text <Text
@ -35,7 +56,7 @@ const UnreadBadge = React.memo(({
styles.unreadText, styles.unreadText,
{ color } { color }
]} ]}
>{ unread } >{unread}
</Text> </Text>
</View> </View>
); );
@ -45,7 +66,8 @@ UnreadBadge.propTypes = {
theme: PropTypes.string, theme: PropTypes.string,
unread: PropTypes.number, unread: PropTypes.number,
userMentions: PropTypes.number, userMentions: PropTypes.number,
groupMentions: PropTypes.number groupMentions: PropTypes.number,
style: PropTypes.object
}; };
export default UnreadBadge; export default UnreadBadge;

View File

@ -16,6 +16,7 @@ import activeUsers from './activeUsers';
import usersTyping from './usersTyping'; import usersTyping from './usersTyping';
import inviteLinks from './inviteLinks'; import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion'; import createDiscussion from './createDiscussion';
import inquiry from './inquiry';
export default combineReducers({ export default combineReducers({
settings, settings,
@ -34,5 +35,6 @@ export default combineReducers({
activeUsers, activeUsers,
usersTyping, usersTyping,
inviteLinks, inviteLinks,
createDiscussion createDiscussion,
inquiry
}); });

51
app/reducers/inquiry.js Normal file
View File

@ -0,0 +1,51 @@
import { INQUIRY } from '../actions/actionsTypes';
const initialState = {
enabled: false,
queued: [],
error: {}
};
export default function inquiry(state = initialState, action) {
switch (action.type) {
case INQUIRY.SUCCESS:
return {
...state,
queued: action.inquiries
};
case INQUIRY.FAILURE:
return {
...state,
error: action.error
};
case INQUIRY.SET_ENABLED:
return {
...state,
enabled: action.enabled
};
case INQUIRY.QUEUE_ADD:
return {
...state,
queued: [...state.queued, action.inquiry]
};
case INQUIRY.QUEUE_UPDATE:
return {
...state,
queued: state.queued.map((item) => {
if (item._id === action.inquiry._id) {
return action.inquiry;
}
return item;
})
};
case INQUIRY.QUEUE_REMOVE:
return {
...state,
queued: state.queued.filter(({ _id }) => _id !== action.inquiryId)
};
case INQUIRY.RESET:
return initialState;
default:
return state;
}
}

View File

@ -10,6 +10,7 @@ import state from './state';
import deepLinking from './deepLinking'; import deepLinking from './deepLinking';
import inviteLinks from './inviteLinks'; import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion'; import createDiscussion from './createDiscussion';
import inquiry from './inquiry';
const root = function* root() { const root = function* root() {
yield all([ yield all([
@ -23,7 +24,8 @@ const root = function* root() {
state(), state(),
deepLinking(), deepLinking(),
inviteLinks(), inviteLinks(),
createDiscussion() createDiscussion(),
inquiry()
]); ]);
}; };

38
app/sagas/inquiry.js Normal file
View File

@ -0,0 +1,38 @@
import { put, takeLatest, select } from 'redux-saga/effects';
import * as types from '../actions/actionsTypes';
import RocketChat from '../lib/rocketchat';
import { inquirySuccess, inquiryFailure, inquirySetEnabled } from '../actions/inquiry';
const handleRequest = function* handleRequest() {
try {
const routingConfig = yield RocketChat.getRoutingConfig();
const statusLivechat = yield select(state => state.login.user.statusLivechat);
// if routingConfig showQueue is enabled and omnichannel is enabled
const showQueue = routingConfig.showQueue && statusLivechat === 'available';
if (showQueue) {
// get all the current chats on the queue
const result = yield RocketChat.getInquiriesQueued();
if (result.success) {
const { inquiries } = result;
// subscribe to inquiry queue changes
RocketChat.subscribeInquiry();
// put request result on redux state
yield put(inquirySuccess(inquiries));
}
}
// set enabled to know if we should show the queue button
yield put(inquirySetEnabled(showQueue));
} catch (e) {
yield put(inquiryFailure(e));
}
};
const root = function* root() {
yield takeLatest(types.INQUIRY.REQUEST, handleRequest);
};
export default root;

View File

@ -15,6 +15,7 @@ import {
loginFailure, loginSuccess, setUser, logout loginFailure, loginSuccess, setUser, logout
} from '../actions/login'; } from '../actions/login';
import { roomsRequest } from '../actions/rooms'; import { roomsRequest } from '../actions/rooms';
import { inquiryRequest } from '../actions/inquiry';
import { toMomentLocale } from '../utils/moment'; import { toMomentLocale } from '../utils/moment';
import RocketChat from '../lib/rocketchat'; import RocketChat from '../lib/rocketchat';
import log, { logEvent, events } from '../utils/log'; import log, { logEvent, events } from '../utils/log';
@ -93,6 +94,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
const server = yield select(getServer); const server = yield select(getServer);
yield put(roomsRequest()); yield put(roomsRequest());
yield put(inquiryRequest());
yield fork(fetchPermissions); yield fork(fetchPermissions);
yield fork(fetchCustomEmojis); yield fork(fetchCustomEmojis);
yield fork(fetchRoles); yield fork(fetchRoles);
@ -206,6 +208,10 @@ const handleSetUser = function* handleSetUser({ user }) {
const userId = yield select(state => state.login.user.id); const userId = yield select(state => state.login.user.id);
yield put(setActiveUsers({ [userId]: user })); yield put(setActiveUsers({ [userId]: user }));
} }
if (user && user.statusLivechat) {
yield put(inquiryRequest());
}
}; };
const root = function* root() { const root = function* root() {

View File

@ -19,6 +19,7 @@ import I18n from '../i18n';
import { SERVERS, TOKEN, SERVER_URL } from '../constants/userDefaults'; import { SERVERS, TOKEN, SERVER_URL } from '../constants/userDefaults';
import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch'; import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch';
import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app'; import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app';
import { inquiryReset } from '../actions/inquiry';
const getServerInfo = function* getServerInfo({ server, raiseError = true }) { const getServerInfo = function* getServerInfo({ server, raiseError = true }) {
try { try {
@ -65,6 +66,7 @@ const getServerInfo = function* getServerInfo({ server, raiseError = true }) {
const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) { const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) {
try { try {
yield put(inquiryReset());
const serversDB = database.servers; const serversDB = database.servers;
yield RNUserDefaults.set('currentServer', server); yield RNUserDefaults.set('currentServer', server);
const userId = yield RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ server }`); const userId = yield RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ server }`);

8
app/selectors/inquiry.js Normal file
View File

@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const getInquiryQueue = state => state.inquiry.queued;
export const getInquiryQueueSelector = createSelector(
[getInquiryQueue],
queue => queue
);

View File

@ -30,6 +30,7 @@ import PickerView from '../views/PickerView';
import ThreadMessagesView from '../views/ThreadMessagesView'; import ThreadMessagesView from '../views/ThreadMessagesView';
import MarkdownTableView from '../views/MarkdownTableView'; import MarkdownTableView from '../views/MarkdownTableView';
import ReadReceiptsView from '../views/ReadReceiptView'; import ReadReceiptsView from '../views/ReadReceiptView';
import QueueListView from '../views/QueueListView';
// Profile Stack // Profile Stack
import ProfileView from '../views/ProfileView'; import ProfileView from '../views/ProfileView';
@ -163,6 +164,11 @@ const ChatsStackNavigator = () => {
component={ReadReceiptsView} component={ReadReceiptsView}
options={ReadReceiptsView.navigationOptions} options={ReadReceiptsView.navigationOptions}
/> />
<ChatsStack.Screen
name='QueueListView'
component={QueueListView}
options={QueueListView.navigationOptions}
/>
</ChatsStack.Navigator> </ChatsStack.Navigator>
); );
}; };

View File

@ -41,6 +41,7 @@ import ScreenLockConfigView from '../../views/ScreenLockConfigView';
import AdminPanelView from '../../views/AdminPanelView'; import AdminPanelView from '../../views/AdminPanelView';
import NewMessageView from '../../views/NewMessageView'; import NewMessageView from '../../views/NewMessageView';
import CreateChannelView from '../../views/CreateChannelView'; import CreateChannelView from '../../views/CreateChannelView';
import QueueListView from '../../views/QueueListView';
// InsideStackNavigator // InsideStackNavigator
import AttachmentView from '../../views/AttachmentView'; import AttachmentView from '../../views/AttachmentView';
@ -150,6 +151,11 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
component={DirectoryView} component={DirectoryView}
options={props => DirectoryView.navigationOptions({ ...props, isMasterDetail: true })} options={props => DirectoryView.navigationOptions({ ...props, isMasterDetail: true })}
/> />
<ModalStack.Screen
name='QueueListView'
component={QueueListView}
options={props => QueueListView.navigationOptions({ ...props, isMasterDetail: true })}
/>
<ModalStack.Screen <ModalStack.Screen
name='NotificationPrefView' name='NotificationPrefView'
component={NotificationPrefView} component={NotificationPrefView}

View File

@ -60,6 +60,7 @@ export default {
RL_NAVIGATE_TO_NEW_MSG: 'rl_navigate_to_new_msg', RL_NAVIGATE_TO_NEW_MSG: 'rl_navigate_to_new_msg',
RL_SEARCH: 'rl_search', RL_SEARCH: 'rl_search',
RL_NAVIGATE_TO_DIRECTORY: 'rl_navigate_to_directory', RL_NAVIGATE_TO_DIRECTORY: 'rl_navigate_to_directory',
RL_GO_QUEUE: 'rl_go_queue',
RL_GO_TO_ROOM: 'rl_go_to_room', RL_GO_TO_ROOM: 'rl_go_to_room',
RL_FAVORITE_CHANNEL: 'rl_favorite_channel', RL_FAVORITE_CHANNEL: 'rl_favorite_channel',
RL_UNFAVORITE_CHANNEL: 'rl_unfavorite_channel', RL_UNFAVORITE_CHANNEL: 'rl_unfavorite_channel',
@ -77,6 +78,8 @@ export default {
RL_GROUP_CHANNELS_BY_FAVORITE: 'rl_group_channels_by_favorite', RL_GROUP_CHANNELS_BY_FAVORITE: 'rl_group_channels_by_favorite',
RL_GROUP_CHANNELS_BY_UNREAD: 'rl_group_channels_by_unread', RL_GROUP_CHANNELS_BY_UNREAD: 'rl_group_channels_by_unread',
QL_GO_ROOM: 'ql_go_room',
// DIRECTORY VIEW // DIRECTORY VIEW
DIRECTORY_SEARCH_USERS: 'directory_search_users', DIRECTORY_SEARCH_USERS: 'directory_search_users',
DIRECTORY_SEARCH_CHANNELS: 'directory_search_channels', DIRECTORY_SEARCH_CHANNELS: 'directory_search_channels',
@ -193,5 +196,6 @@ export default {
ROOM_MSG_ACTION_PIN_F: 'room_msg_action_pin_f', ROOM_MSG_ACTION_PIN_F: 'room_msg_action_pin_f',
ROOM_MSG_ACTION_REACTION: 'room_msg_action_reaction', ROOM_MSG_ACTION_REACTION: 'room_msg_action_reaction',
ROOM_MSG_ACTION_REPORT: 'room_msg_action_report', ROOM_MSG_ACTION_REPORT: 'room_msg_action_report',
ROOM_MSG_ACTION_REPORT_F: 'room_msg_action_report_f' ROOM_MSG_ACTION_REPORT_F: 'room_msg_action_report_f',
ROOM_JOIN: 'room_join'
}; };

160
app/views/QueueListView.js Normal file
View File

@ -0,0 +1,160 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FlatList } from 'react-native';
import { connect } from 'react-redux';
import isEqual from 'react-fast-compare';
import I18n from '../i18n';
import RoomItem, { ROW_HEIGHT } from '../presentation/RoomItem';
import { MAX_SIDEBAR_WIDTH } from '../constants/tablet';
import { isTablet, isIOS } from '../utils/deviceInfo';
import { getUserSelector } from '../selectors/login';
import { withTheme } from '../theme';
import { withDimensions } from '../dimensions';
import SafeAreaView from '../containers/SafeAreaView';
import { themes } from '../constants/colors';
import StatusBar from '../containers/StatusBar';
import { goRoom } from '../utils/goRoom';
import { CloseModalButton } from '../containers/HeaderButton';
import RocketChat from '../lib/rocketchat';
import { logEvent, events } from '../utils/log';
import { getInquiryQueueSelector } from '../selectors/inquiry';
const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12;
const getItemLayout = (data, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index
});
const keyExtractor = item => item.rid;
class QueueListView extends React.Component {
static navigationOptions = ({ navigation, isMasterDetail }) => {
const options = {
title: I18n.t('Queued_chats')
};
if (isMasterDetail) {
options.headerLeft = () => <CloseModalButton navigation={navigation} testID='directory-view-close' />;
}
return options;
}
static propTypes = {
user: PropTypes.shape({
id: PropTypes.string,
username: PropTypes.string,
token: PropTypes.string
}),
isMasterDetail: PropTypes.bool,
width: PropTypes.number,
queued: PropTypes.array,
server: PropTypes.string,
useRealName: PropTypes.bool,
navigation: PropTypes.object,
theme: PropTypes.string
}
shouldComponentUpdate(nextProps) {
const { queued } = this.props;
if (!isEqual(nextProps.queued, queued)) {
return true;
}
return false;
}
onPressItem = (item = {}) => {
logEvent(events.QL_GO_ROOM);
const { navigation, isMasterDetail } = this.props;
if (isMasterDetail) {
navigation.navigate('DrawerNavigator');
} else {
navigation.navigate('RoomsListView');
}
goRoom({
item: {
...item,
// we're calling v as visitor on our mergeSubscriptionsRooms
visitor: item.v
},
isMasterDetail
});
};
getRoomTitle = item => RocketChat.getRoomTitle(item)
getRoomAvatar = item => RocketChat.getRoomAvatar(item)
getUidDirectMessage = room => RocketChat.getUidDirectMessage(room)
renderItem = ({ item }) => {
const {
user: {
id: userId,
username,
token
},
server,
useRealName,
theme,
isMasterDetail,
width
} = this.props;
const id = this.getUidDirectMessage(item);
return (
<RoomItem
item={item}
theme={theme}
id={id}
type={item.t}
userId={userId}
username={username}
token={token}
baseUrl={server}
onPress={this.onPressItem}
testID={`queue-list-view-item-${ item.name }`}
width={isMasterDetail ? MAX_SIDEBAR_WIDTH : width}
useRealName={useRealName}
getRoomTitle={this.getRoomTitle}
getRoomAvatar={this.getRoomAvatar}
visitor={item.v}
swipeEnabled={false}
/>
);
}
render() {
const { queued, theme } = this.props;
return (
<SafeAreaView testID='queue-list-view' theme={theme} style={{ backgroundColor: themes[theme].backgroundColor }}>
<StatusBar theme={theme} />
<FlatList
ref={this.getScrollRef}
data={queued}
extraData={queued}
keyExtractor={keyExtractor}
style={{ backgroundColor: themes[theme].backgroundColor }}
renderItem={this.renderItem}
getItemLayout={getItemLayout}
removeClippedSubviews={isIOS}
keyboardShouldPersistTaps='always'
initialNumToRender={INITIAL_NUM_TO_RENDER}
windowSize={9}
onEndReached={this.onEndReached}
onEndReachedThreshold={0.5}
/>
</SafeAreaView>
);
}
}
const mapStateToProps = state => ({
user: getUserSelector(state),
isMasterDetail: state.app.isMasterDetail,
server: state.server.server,
useRealName: state.settings.UI_Use_Real_Name,
queued: getInquiryQueueSelector(state)
});
export default connect(mapStateToProps)(withDimensions(withTheme(QueueListView)));

View File

@ -91,7 +91,7 @@ class RoomActionsView extends React.Component {
this.mounted = true; this.mounted = true;
const { room, member } = this.state; const { room, member } = this.state;
if (room.rid) { if (room.rid) {
if (!room.id) { if (!room.id && !this.isOmnichannelPreview) {
try { try {
const result = await RocketChat.getChannelInfo(room.rid); const result = await RocketChat.getChannelInfo(room.rid);
if (result.success) { if (result.success) {
@ -135,6 +135,11 @@ class RoomActionsView extends React.Component {
} }
} }
get isOmnichannelPreview() {
const { room } = this.state;
return room.t === 'l' && room.status === 'queued';
}
onPressTouchable = (item) => { onPressTouchable = (item) => {
if (item.route) { if (item.route) {
const { navigation } = this.props; const { navigation } = this.props;
@ -407,6 +412,7 @@ class RoomActionsView extends React.Component {
} else if (t === 'l') { } else if (t === 'l') {
sections[2].data = []; sections[2].data = [];
if (!this.isOmnichannelPreview) {
sections[2].data.push({ sections[2].data.push({
icon: 'close', icon: 'close',
name: I18n.t('Close'), name: I18n.t('Close'),
@ -436,6 +442,7 @@ class RoomActionsView extends React.Component {
route: 'VisitorNavigationView', route: 'VisitorNavigationView',
params: { rid } params: { rid }
}); });
}
sections.push({ sections.push({
data: [notificationsAction], data: [notificationsAction],

View File

@ -44,7 +44,7 @@ const Livechat = ({ room, roomUser, theme }) => {
} }
}; };
useEffect(() => { getRoom(); }, []); useEffect(() => { getRoom(); }, [room]);
return ( return (
<> <>

View File

@ -195,6 +195,7 @@ class RoomInfoView extends React.Component {
} }
loadRoom = async() => { loadRoom = async() => {
const { room: roomState } = this.state;
const { route } = this.props; const { route } = this.props;
let room = route.params?.room; let room = route.params?.room;
if (room && room.observe) { if (room && room.observe) {
@ -208,7 +209,7 @@ class RoomInfoView extends React.Component {
const result = await RocketChat.getRoomInfo(this.rid); const result = await RocketChat.getRoomInfo(this.rid);
if (result.success) { if (result.success) {
({ room } = result); ({ room } = result);
this.setState({ room }); this.setState({ room: { ...roomState, ...room } });
} }
} catch (e) { } catch (e) {
log(e); log(e);

View File

@ -282,6 +282,11 @@ class RoomView extends React.Component {
console.countReset(`${ this.constructor.name }.render calls`); console.countReset(`${ this.constructor.name }.render calls`);
} }
get isOmnichannel() {
const { room } = this.state;
return room.t === 'l';
}
setHeader = () => { setHeader = () => {
const { room, unreadsCount, roomUserId: stateRoomUserId } = this.state; const { room, unreadsCount, roomUserId: stateRoomUserId } = this.state;
const { const {
@ -678,8 +683,15 @@ class RoomView extends React.Component {
setLastOpen = lastOpen => this.setState({ lastOpen }); setLastOpen = lastOpen => this.setState({ lastOpen });
joinRoom = async() => { joinRoom = async() => {
logEvent(events.ROOM_JOIN);
try { try {
const { room } = this.state;
if (this.isOmnichannel) {
await RocketChat.takeInquiry(room._id);
} else {
await RocketChat.joinRoom(this.rid, this.t); await RocketChat.joinRoom(this.rid, this.t);
}
this.internalSetState({ this.internalSetState({
joined: true joined: true
}); });
@ -896,7 +908,7 @@ class RoomView extends React.Component {
style={[styles.joinRoomButton, { backgroundColor: themes[theme].actionTintColor }]} style={[styles.joinRoomButton, { backgroundColor: themes[theme].actionTintColor }]}
theme={theme} theme={theme}
> >
<Text style={[styles.joinRoomText, { color: themes[theme].buttonText }]} testID='room-view-join-button'>{I18n.t('Join')}</Text> <Text style={[styles.joinRoomText, { color: themes[theme].buttonText }]} testID='room-view-join-button'>{I18n.t(this.isOmnichannel ? 'Take_it' : 'Join')}</Text>
</Touch> </Touch>
</View> </View>
); );

View File

@ -0,0 +1,49 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import PropTypes from 'prop-types';
import Touch from '../../../utils/touch';
import I18n from '../../../i18n';
import styles from '../styles';
import { themes } from '../../../constants/colors';
import { withTheme } from '../../../theme';
import UnreadBadge from '../../../presentation/UnreadBadge';
const Queue = React.memo(({
searching, goQueue, queueSize, inquiryEnabled, theme
}) => {
if (searching > 0 || !inquiryEnabled) {
return null;
}
return (
<Touch
onPress={goQueue}
theme={theme}
style={{ backgroundColor: themes[theme].headerSecondaryBackground }}
>
<View
style={[
styles.dropdownContainerHeader,
{ borderBottomWidth: StyleSheet.hairlineWidth, borderColor: themes[theme].separatorColor }
]}
>
<Text style={[styles.sortToggleText, { color: themes[theme].auxiliaryText }]}>{I18n.t('Queued_chats')}</Text>
<UnreadBadge
style={styles.sortIcon}
unread={queueSize}
theme={theme}
/>
</View>
</Touch>
);
});
Queue.propTypes = {
searching: PropTypes.bool,
goQueue: PropTypes.func,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool,
theme: PropTypes.string
};
export default withTheme(Queue);

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Queue from './Queue';
import Directory from './Directory'; import Directory from './Directory';
import Sort from './Sort'; import Sort from './Sort';
@ -8,11 +9,15 @@ const ListHeader = React.memo(({
searching, searching,
sortBy, sortBy,
toggleSort, toggleSort,
goDirectory goDirectory,
goQueue,
queueSize,
inquiryEnabled
}) => ( }) => (
<> <>
<Directory searching={searching} goDirectory={goDirectory} /> <Directory searching={searching} goDirectory={goDirectory} />
<Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} /> <Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} />
<Queue searching={searching} goQueue={goQueue} queueSize={queueSize} inquiryEnabled={inquiryEnabled} />
</> </>
)); ));
@ -20,7 +25,10 @@ ListHeader.propTypes = {
searching: PropTypes.bool, searching: PropTypes.bool,
sortBy: PropTypes.string, sortBy: PropTypes.string,
toggleSort: PropTypes.func, toggleSort: PropTypes.func,
goDirectory: PropTypes.func goDirectory: PropTypes.func,
goQueue: PropTypes.func,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool
}; };
export default ListHeader; export default ListHeader;

View File

@ -62,6 +62,8 @@ import { goRoom } from '../../utils/goRoom';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import Header, { getHeaderTitlePosition } from '../../containers/Header'; import Header, { getHeaderTitlePosition } from '../../containers/Header';
import { withDimensions } from '../../dimensions'; import { withDimensions } from '../../dimensions';
import { showErrorAlert } from '../../utils/info';
import { getInquiryQueueSelector } from '../../selectors/inquiry';
const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12; const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12;
const CHATS_HEADER = 'Chats'; const CHATS_HEADER = 'Chats';
@ -90,7 +92,9 @@ const shouldUpdateProps = [
'appState', 'appState',
'theme', 'theme',
'isMasterDetail', 'isMasterDetail',
'refreshing' 'refreshing',
'queueSize',
'inquiryEnabled'
]; ];
const getItemLayout = (data, index) => ({ const getItemLayout = (data, index) => ({
length: ROW_HEIGHT, length: ROW_HEIGHT,
@ -131,7 +135,9 @@ class RoomsListView extends React.Component {
isMasterDetail: PropTypes.bool, isMasterDetail: PropTypes.bool,
rooms: PropTypes.array, rooms: PropTypes.array,
width: PropTypes.number, width: PropTypes.number,
insets: PropTypes.object insets: PropTypes.object,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool
}; };
constructor(props) { constructor(props) {
@ -671,6 +677,20 @@ class RoomsListView extends React.Component {
} }
}; };
goQueue = () => {
logEvent(events.RL_GO_QUEUE);
const { navigation, isMasterDetail, queueSize } = this.props;
// prevent navigation to empty list
if (!queueSize) {
return showErrorAlert(I18n.t('Queue_is_empty'), I18n.t('Oops'));
}
if (isMasterDetail) {
navigation.navigate('ModalStackNavigator', { screen: 'QueueListView' });
} else {
navigation.navigate('QueueListView');
}
};
goRoom = ({ item, isMasterDetail }) => { goRoom = ({ item, isMasterDetail }) => {
logEvent(events.RL_GO_TO_ROOM); logEvent(events.RL_GO_TO_ROOM);
const { item: currentItem } = this.state; const { item: currentItem } = this.state;
@ -787,13 +807,16 @@ class RoomsListView extends React.Component {
renderListHeader = () => { renderListHeader = () => {
const { searching } = this.state; const { searching } = this.state;
const { sortBy } = this.props; const { sortBy, queueSize, inquiryEnabled } = this.props;
return ( return (
<ListHeader <ListHeader
searching={searching} searching={searching}
sortBy={sortBy} sortBy={sortBy}
toggleSort={this.toggleSort} toggleSort={this.toggleSort}
goDirectory={this.goDirectory} goDirectory={this.goDirectory}
goQueue={this.goQueue}
queueSize={queueSize}
inquiryEnabled={inquiryEnabled}
/> />
); );
}; };
@ -960,7 +983,9 @@ const mapStateToProps = state => ({
useRealName: state.settings.UI_Use_Real_Name, useRealName: state.settings.UI_Use_Real_Name,
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background', appState: state.app.ready && state.app.foreground ? 'foreground' : 'background',
StoreLastMessage: state.settings.Store_Last_Message, StoreLastMessage: state.settings.Store_Last_Message,
rooms: state.room.rooms rooms: state.room.rooms,
queueSize: getInquiryQueueSelector(state).length,
inquiryEnabled: state.inquiry.enabled
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({