diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap
index 56448a03..022ded85 100644
--- a/__tests__/__snapshots__/Storyshots.test.js.snap
+++ b/__tests__/__snapshots__/Storyshots.test.js.snap
@@ -1322,6 +1322,593 @@ exports[`Storyshots BackgroundContainer text 1`] = `
`;
+exports[`Storyshots CannedResponseItem Itens 1`] = `
+Array [
+
+
+
+
+ !
+ !FAQ4
+
+
+ Private
+
+
+
+
+ Use
+
+
+
+
+ “
+ ZCVXZVXCZVZXVZXCVZXCVXZCVZX
+ ”
+
+
+ ,
+
+
+
+
+ !
+ test4mobilePrivate
+
+
+ Private
+
+
+
+
+ Use
+
+
+
+
+ “
+ test for mobile private
+ ”
+
+
+
+
+ HQ
+
+
+
+
+ Closed
+
+
+
+
+ HQ
+
+
+
+
+ Problem in Product Y
+
+
+
+
+ HQ
+
+
+
+
+ Closed
+
+
+
+
+ Problem in Product Y
+
+
+
+ ,
+]
+`;
+
exports[`Storyshots Header Buttons badge 1`] = `
{
+ const context = useContext(MessageboxContext);
+ const { onPressNoMatchCanned } = context;
+
+ if (trackingType === MENTIONS_TRACKING_TYPE_CANNED) {
+ if (loading) {
+ return (
+
+
+ {I18n.t('Searching')}
+
+ );
+ }
+
+ if (!hasMentions) {
+ return (
+
+
+ {I18n.t('No_match_found')} {I18n.t('Check_canned_responses')}
+
+
+
+ );
+ }
+ }
+
+ return null;
+};
+
+MentionHeaderList.propTypes = {
+ trackingType: PropTypes.string,
+ hasMentions: PropTypes.bool,
+ theme: PropTypes.string,
+ loading: PropTypes.bool
+};
+
+export default MentionHeaderList;
diff --git a/app/containers/MessageBox/Mentions/MentionItem.tsx b/app/containers/MessageBox/Mentions/MentionItem.tsx
index 5fc11609..c315b925 100644
--- a/app/containers/MessageBox/Mentions/MentionItem.tsx
+++ b/app/containers/MessageBox/Mentions/MentionItem.tsx
@@ -6,7 +6,7 @@ import Avatar from '../../Avatar';
import MessageboxContext from '../Context';
import FixedMentionItem from './FixedMentionItem';
import MentionEmoji from './MentionEmoji';
-import { MENTIONS_TRACKING_TYPE_COMMANDS, MENTIONS_TRACKING_TYPE_EMOJIS } from '../constants';
+import { MENTIONS_TRACKING_TYPE_EMOJIS, MENTIONS_TRACKING_TYPE_COMMANDS, MENTIONS_TRACKING_TYPE_CANNED } from '../constants';
import { themes } from '../../../constants/colors';
import { IEmoji } from '../../EmojiPicker/interfaces';
@@ -17,6 +17,8 @@ interface IMessageBoxMentionItem {
username: string;
t: string;
id: string;
+ shortcut: string;
+ text: string;
} & IEmoji;
trackingType: string;
theme: string;
@@ -32,6 +34,8 @@ const MentionItem = ({ item, trackingType, theme }: IMessageBoxMentionItem) => {
return `mention-item-${item.name || item}`;
case MENTIONS_TRACKING_TYPE_COMMANDS:
return `mention-item-${item.command || item}`;
+ case MENTIONS_TRACKING_TYPE_CANNED:
+ return `mention-item-${item.shortcut || item}`;
default:
return `mention-item-${item.username || item.name || item}`;
}
@@ -68,6 +72,17 @@ const MentionItem = ({ item, trackingType, theme }: IMessageBoxMentionItem) => {
);
}
+ if (trackingType === MENTIONS_TRACKING_TYPE_CANNED) {
+ content = (
+ <>
+ !{item.shortcut}
+
+ {item.text}
+
+ >
+ );
+ }
+
return (
{
+ ({ mentions, trackingType, theme, loading }: IMessageBoxMentions) => {
if (!trackingType) {
return null;
}
@@ -21,16 +23,22 @@ const Mentions = React.memo(
(
+ 0} theme={theme} loading={loading} />
+ )}
data={mentions}
extraData={mentions}
renderItem={({ item }) => }
- keyExtractor={(item: any) => item.rid || item.name || item.command || item}
+ keyExtractor={item => item.rid || item.name || item.command || item.shortcut || item}
keyboardShouldPersistTaps='always'
/>
);
},
(prevProps, nextProps) => {
+ if (prevProps.loading !== nextProps.loading) {
+ return false;
+ }
if (prevProps.theme !== nextProps.theme) {
return false;
}
diff --git a/app/containers/MessageBox/constants.ts b/app/containers/MessageBox/constants.ts
index 8fe37fc8..dabaee49 100644
--- a/app/containers/MessageBox/constants.ts
+++ b/app/containers/MessageBox/constants.ts
@@ -2,4 +2,5 @@ export const MENTIONS_TRACKING_TYPE_USERS = '@';
export const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
export const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
export const MENTIONS_TRACKING_TYPE_ROOMS = '#';
+export const MENTIONS_TRACKING_TYPE_CANNED = '!';
export const MENTIONS_COUNT_TO_DISPLAY = 4;
diff --git a/app/containers/MessageBox/index.tsx b/app/containers/MessageBox/index.tsx
index 58d7fd43..0366bc1b 100644
--- a/app/containers/MessageBox/index.tsx
+++ b/app/containers/MessageBox/index.tsx
@@ -35,6 +35,7 @@ import Mentions from './Mentions';
import MessageboxContext from './Context';
import {
MENTIONS_COUNT_TO_DISPLAY,
+ MENTIONS_TRACKING_TYPE_CANNED,
MENTIONS_TRACKING_TYPE_COMMANDS,
MENTIONS_TRACKING_TYPE_EMOJIS,
MENTIONS_TRACKING_TYPE_ROOMS,
@@ -107,6 +108,7 @@ interface IMessageBoxProps {
iOSScrollBehavior: number;
sharing: boolean;
isActionsEnabled: boolean;
+ usedCannedResponse: string;
}
interface IMessageBoxState {
@@ -121,6 +123,7 @@ interface IMessageBoxState {
appId?: any;
};
tshow: boolean;
+ mentionLoading: boolean;
}
class MessageBox extends Component {
@@ -175,7 +178,8 @@ class MessageBox extends Component {
commandPreview: [],
showCommandPreview: false,
command: {},
- tshow: false
+ tshow: false,
+ mentionLoading: false
};
this.text = '';
this.selection = { start: 0, end: 0 };
@@ -234,7 +238,7 @@ class MessageBox extends Component {
async componentDidMount() {
const db = database.active;
- const { rid, tmid, navigation, sharing } = this.props;
+ const { rid, tmid, navigation, sharing, usedCannedResponse, isMasterDetail } = this.props;
let msg;
try {
const threadsCollection = db.get('threads');
@@ -269,6 +273,10 @@ class MessageBox extends Component {
EventEmiter.addEventListener(KEY_COMMAND, this.handleCommands);
}
+ if (isMasterDetail && usedCannedResponse) {
+ this.onChangeText('');
+ }
+
this.unsubscribeFocus = navigation.addListener('focus', () => {
// didFocus
// We should wait pushed views be dismissed
@@ -285,10 +293,13 @@ class MessageBox extends Component {
}
UNSAFE_componentWillReceiveProps(nextProps: any) {
- const { isFocused, editing, replying, sharing } = this.props;
+ const { isFocused, editing, replying, sharing, usedCannedResponse } = this.props;
if (!isFocused?.()) {
return;
}
+ if (usedCannedResponse !== nextProps.usedCannedResponse) {
+ this.onChangeText(nextProps.usedCannedResponse ?? '');
+ }
if (sharing) {
this.setInput(nextProps.message.msg ?? '');
return;
@@ -311,10 +322,9 @@ class MessageBox extends Component {
}
shouldComponentUpdate(nextProps: any, nextState: any) {
- const { showEmojiKeyboard, showSend, recording, mentions, commandPreview, tshow } = this.state;
-
- const { roomType, replying, editing, isFocused, message, theme } = this.props;
+ const { showEmojiKeyboard, showSend, recording, mentions, commandPreview, tshow, mentionLoading, trackingType } = this.state;
+ const { roomType, replying, editing, isFocused, message, theme, usedCannedResponse } = this.props;
if (nextProps.theme !== theme) {
return true;
}
@@ -333,6 +343,12 @@ class MessageBox extends Component {
if (nextState.showEmojiKeyboard !== showEmojiKeyboard) {
return true;
}
+ if (nextState.trackingType !== trackingType) {
+ return true;
+ }
+ if (nextState.mentionLoading !== mentionLoading) {
+ return true;
+ }
if (nextState.showSend !== showSend) {
return true;
}
@@ -351,6 +367,9 @@ class MessageBox extends Component {
if (!dequal(nextProps.message?.id, message?.id)) {
return true;
}
+ if (nextProps.usedCannedResponse !== usedCannedResponse) {
+ return true;
+ }
return false;
}
@@ -371,6 +390,9 @@ class MessageBox extends Component {
if (this.getSlashCommands && this.getSlashCommands.stop) {
this.getSlashCommands.stop();
}
+ if (this.getCannedResponses && this.getCannedResponses.stop) {
+ this.getCannedResponses.stop();
+ }
if (this.unsubscribeFocus) {
this.unsubscribeFocus();
}
@@ -395,7 +417,7 @@ class MessageBox extends Component {
// eslint-disable-next-line react/sort-comp
debouncedOnChangeText = debounce(async (text: any) => {
- const { sharing } = this.props;
+ const { sharing, roomType } = this.props;
const isTextEmpty = text.length === 0;
if (isTextEmpty) {
this.stopTrackingMention();
@@ -412,6 +434,7 @@ class MessageBox extends Component {
const channelMention = lastWord.match(/^#/);
const userMention = lastWord.match(/^@/);
const emojiMention = lastWord.match(/^:/);
+ const cannedMention = lastWord.match(/^!/);
if (commandMention && !sharing) {
const command = text.substr(1);
@@ -440,6 +463,9 @@ class MessageBox extends Component {
if (emojiMention) {
return this.identifyMentionKeyword(result, MENTIONS_TRACKING_TYPE_EMOJIS);
}
+ if (cannedMention && roomType === 'l') {
+ return this.identifyMentionKeyword(result, MENTIONS_TRACKING_TYPE_CANNED);
+ }
return this.stopTrackingMention();
}, 100);
@@ -456,11 +482,17 @@ class MessageBox extends Component {
const { start, end } = this.selection;
const cursor = Math.max(start, end);
const regexp = /([a-z0-9._-]+)$/im;
- const result = msg.substr(0, cursor).replace(regexp, '');
+ let result = msg.substr(0, cursor).replace(regexp, '');
+ // Remove the ! after select the canned response
+ if (trackingType === MENTIONS_TRACKING_TYPE_CANNED) {
+ const lastIndexOfExclamation = msg.lastIndexOf('!', cursor);
+ result = msg.substr(0, lastIndexOfExclamation).replace(regexp, '');
+ }
const mentionName =
- trackingType === MENTIONS_TRACKING_TYPE_EMOJIS ? `${item.name || item}:` : item.username || item.name || item.command;
+ trackingType === MENTIONS_TRACKING_TYPE_EMOJIS
+ ? `${item.name || item}:`
+ : item.username || item.name || item.command || item.text;
const text = `${result}${mentionName} ${msg.slice(cursor)}`;
-
if (trackingType === MENTIONS_TRACKING_TYPE_COMMANDS && item.providesPreview) {
this.setState({ showCommandPreview: true });
}
@@ -532,12 +564,12 @@ class MessageBox extends Component {
getUsers = debounce(async (keyword: any) => {
let res = await RocketChat.search({ text: keyword, filterRooms: false, filterUsers: true });
res = [...this.getFixedMentions(keyword), ...res];
- this.setState({ mentions: res });
+ this.setState({ mentions: res, mentionLoading: false });
}, 300);
getRooms = debounce(async (keyword = '') => {
const res = await RocketChat.search({ text: keyword, filterRooms: true, filterUsers: false });
- this.setState({ mentions: res });
+ this.setState({ mentions: res, mentionLoading: false });
}, 300);
getEmojis = debounce(async (keyword: any) => {
@@ -552,7 +584,7 @@ class MessageBox extends Component {
customEmojis = customEmojis.slice(0, MENTIONS_COUNT_TO_DISPLAY);
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
- this.setState({ mentions: mergedEmojis || [] });
+ this.setState({ mentions: mergedEmojis || [], mentionLoading: false });
}, 300);
getSlashCommands = debounce(async (keyword: any) => {
@@ -560,9 +592,14 @@ class MessageBox extends Component {
const commandsCollection = db.get('slash_commands');
const likeString = sanitizeLikeString(keyword);
const commands = await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch();
- this.setState({ mentions: commands || [] });
+ this.setState({ mentions: commands || [], mentionLoading: false });
}, 300);
+ getCannedResponses = debounce(async (text?: string) => {
+ const res = await RocketChat.getListCannedResponse({ text });
+ this.setState({ mentions: res?.cannedResponses || [], mentionLoading: false });
+ }, 500);
+
focus = () => {
if (this.component && this.component.focus) {
this.component.focus();
@@ -695,6 +732,16 @@ class MessageBox extends Component {
}
};
+ onPressNoMatchCanned = () => {
+ const { isMasterDetail, rid } = this.props;
+ const params = { rid };
+ if (isMasterDetail) {
+ Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params });
+ } else {
+ Navigation.navigate('CannedResponsesListView', params);
+ }
+ };
+
openShareView = (attachments: any) => {
const { message, replyCancel, replyWithMention } = this.props;
// Start a thread with an attachment
@@ -854,6 +901,8 @@ class MessageBox extends Component {
this.getEmojis(keyword);
} else if (type === MENTIONS_TRACKING_TYPE_COMMANDS) {
this.getSlashCommands(keyword);
+ } else if (type === MENTIONS_TRACKING_TYPE_CANNED) {
+ this.getCannedResponses(keyword);
} else {
this.getRooms(keyword);
}
@@ -862,7 +911,8 @@ class MessageBox extends Component {
identifyMentionKeyword = (keyword: any, type: string) => {
this.setState({
showEmojiKeyboard: false,
- trackingType: type
+ trackingType: type,
+ mentionLoading: true
});
this.updateMentions(keyword, type);
};
@@ -918,7 +968,8 @@ class MessageBox extends Component {
};
renderContent = () => {
- const { recording, showEmojiKeyboard, showSend, mentions, trackingType, commandPreview, showCommandPreview } = this.state;
+ const { recording, showEmojiKeyboard, showSend, mentions, trackingType, commandPreview, showCommandPreview, mentionLoading } =
+ this.state;
const {
editing,
message,
@@ -950,8 +1001,7 @@ class MessageBox extends Component {
const commandsPreviewAndMentions = !recording ? (
<>
- {/* @ts-ignore*/}
-
+
>
) : null;
@@ -1039,7 +1089,8 @@ class MessageBox extends Component {
user,
baseUrl,
onPressMention: this.onPressMention,
- onPressCommandPreview: this.onPressCommandPreview
+ onPressCommandPreview: this.onPressCommandPreview,
+ onPressNoMatchCanned: this.onPressNoMatchCanned
}}>
(this.tracking = ref)}
diff --git a/app/containers/MessageBox/styles.ts b/app/containers/MessageBox/styles.ts
index 9c5a2ff5..8d934799 100644
--- a/app/containers/MessageBox/styles.ts
+++ b/app/containers/MessageBox/styles.ts
@@ -36,6 +36,30 @@ export default StyleSheet.create({
width: 60,
height: 48
},
+ wrapMentionHeaderList: {
+ height: MENTION_HEIGHT,
+ justifyContent: 'center'
+ },
+ wrapMentionHeaderListRow: {
+ height: MENTION_HEIGHT,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12
+ },
+ loadingPaddingHeader: {
+ paddingRight: 12
+ },
+ mentionHeaderList: {
+ fontSize: 14,
+ ...sharedStyles.textMedium
+ },
+ mentionHeaderListNoMatchFound: {
+ fontSize: 14,
+ ...sharedStyles.textRegular
+ },
+ mentionNoMatchHeader: {
+ justifyContent: 'space-between'
+ },
mentionList: {
maxHeight: MENTION_HEIGHT * 4
},
@@ -67,6 +91,18 @@ export default StyleSheet.create({
fontSize: 14,
...sharedStyles.textRegular
},
+ cannedMentionText: {
+ flex: 1,
+ fontSize: 14,
+ paddingRight: 12,
+ ...sharedStyles.textRegular
+ },
+ cannedItem: {
+ fontSize: 14,
+ ...sharedStyles.textBold,
+ paddingLeft: 12,
+ paddingRight: 8
+ },
emojiKeyboardContainer: {
flex: 1,
borderTopWidth: StyleSheet.hairlineWidth
diff --git a/app/views/ThreadMessagesView/SearchHeader.js b/app/containers/SearchHeader.js
similarity index 78%
rename from app/views/ThreadMessagesView/SearchHeader.js
rename to app/containers/SearchHeader.js
index 3bd9c854..e231d074 100644
--- a/app/views/ThreadMessagesView/SearchHeader.js
+++ b/app/containers/SearchHeader.js
@@ -2,12 +2,12 @@ import React from 'react';
import { StyleSheet, View } from 'react-native';
import PropTypes from 'prop-types';
-import { withTheme } from '../../theme';
-import sharedStyles from '../Styles';
-import { themes } from '../../constants/colors';
-import TextInput from '../../presentation/TextInput';
-import { isIOS, isTablet } from '../../utils/deviceInfo';
-import { useOrientation } from '../../dimensions';
+import { withTheme } from '../theme';
+import sharedStyles from '../views/Styles';
+import { themes } from '../constants/colors';
+import TextInput from '../presentation/TextInput';
+import { isIOS, isTablet } from '../utils/deviceInfo';
+import { useOrientation } from '../dimensions';
const styles = StyleSheet.create({
container: {
diff --git a/app/containers/UIKit/MultiSelect/Input.tsx b/app/containers/UIKit/MultiSelect/Input.tsx
index 611838c0..e03837a2 100644
--- a/app/containers/UIKit/MultiSelect/Input.tsx
+++ b/app/containers/UIKit/MultiSelect/Input.tsx
@@ -12,18 +12,19 @@ interface IInput {
onPress: Function;
theme: string;
inputStyle: object;
- disabled: boolean;
- placeholder: string;
- loading: boolean;
+ disabled?: boolean | object;
+ placeholder?: string;
+ loading?: boolean;
+ innerInputStyle?: object;
}
-const Input = ({ children, onPress, theme, loading, inputStyle, placeholder, disabled }: IInput) => (
+const Input = ({ children, onPress, theme, loading, inputStyle, placeholder, disabled, innerInputStyle }: IInput) => (
-
+
{placeholder ? {placeholder} : children}
{loading ? (
diff --git a/app/containers/UIKit/MultiSelect/index.tsx b/app/containers/UIKit/MultiSelect/index.tsx
index 95bedbeb..d50dede3 100644
--- a/app/containers/UIKit/MultiSelect/index.tsx
+++ b/app/containers/UIKit/MultiSelect/index.tsx
@@ -28,6 +28,7 @@ interface IMultiSelect {
value?: any[];
disabled?: boolean | object;
theme: string;
+ innerInputStyle?: object;
}
const ANIMATION_DURATION = 200;
@@ -53,7 +54,8 @@ export const MultiSelect = React.memo(
onClose = () => {},
disabled,
inputStyle,
- theme
+ theme,
+ innerInputStyle
}: IMultiSelect) => {
const [selected, select] = useState(Array.isArray(values) ? values : []);
const [open, setOpen] = useState(false);
@@ -143,8 +145,13 @@ export const MultiSelect = React.memo(
let button = multiselect ? (
) : (
- // @ts-ignore
-
+
{currentValue || placeholder.text}
@@ -154,8 +161,13 @@ export const MultiSelect = React.memo(
if (context === BLOCK_CONTEXT.FORM) {
const items: any = options.filter((option: any) => selected.includes(option.value));
button = (
- // @ts-ignore
-
+
{items.length ? (
(disabled ? {} : onSelect(item))} theme={theme} />
) : (
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 8eb259f2..8d409c1c 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -772,5 +772,14 @@
"Converting_Team_To_Channel": "Converting Team to Channel",
"Select_Team_Channels_To_Delete": "Select the Team’s Channels you would like to delete, the ones you do not select will be moved to the Workspace. \n\nNotice that public Channels will be public and visible to everyone.",
"You_are_converting_the_team": "You are converting this Team to a Channel",
- "creating_discussion": "creating discussion"
+ "creating_discussion": "creating discussion",
+ "Canned_Responses": "Canned Responses",
+ "No_match_found": "No match found.",
+ "Check_canned_responses": "Check on canned responses.",
+ "Searching": "Searching",
+ "Use": "Use",
+ "Shortcut": "Shortcut",
+ "Content": "Content",
+ "Sharing": "Sharing",
+ "No_canned_responses": "No canned responses"
}
diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json
index 98675ab6..57c31c06 100644
--- a/app/i18n/locales/pt-BR.json
+++ b/app/i18n/locales/pt-BR.json
@@ -672,5 +672,13 @@
"Left_The_Room_Successfully": "Saiu da sala com sucesso",
"Deleted_The_Team_Successfully": "Time deletado com sucesso",
"Deleted_The_Room_Successfully": "Sala deletada com sucesso",
- "Convert_to_Channel": "Converter para um Canal"
+ "Convert_to_Channel": "Converter para um Canal",
+ "Canned_Responses": "Respostas Predefinidas",
+ "No_match_found": "Nenhum resultado encontrado",
+ "Check_canned_responses": "Verifique nas respostas predefinidas",
+ "Searching": "Buscando",
+ "Use": "Use",
+ "Shortcut": "Atalho",
+ "Content": "Conteúdo",
+ "No_canned_responses": "Não há respostas predefinidas"
}
diff --git a/app/lib/methods/getPermissions.js b/app/lib/methods/getPermissions.js
index 0e5a996c..d23268f3 100644
--- a/app/lib/methods/getPermissions.js
+++ b/app/lib/methods/getPermissions.js
@@ -50,7 +50,8 @@ const PERMISSIONS = [
'view-all-team-channels',
'convert-team',
'edit-omnichannel-contact',
- 'edit-livechat-room-customfields'
+ 'edit-livechat-room-customfields',
+ 'view-canned-responses'
];
export async function setPermissions() {
diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js
index a1ae43fb..02e0cf10 100644
--- a/app/lib/rocketchat.js
+++ b/app/lib/rocketchat.js
@@ -1161,6 +1161,19 @@ const RocketChat = {
return this.sdk.get('livechat/custom-fields');
},
+ getListCannedResponse({ scope = '', departmentId = '', offset = 0, count = 25, text = '' }) {
+ const params = {
+ offset,
+ count,
+ ...(departmentId && { departmentId }),
+ ...(text && { text }),
+ ...(scope && { scope })
+ };
+
+ // RC 3.17.0
+ return this.sdk.get('canned-responses', params);
+ },
+
getUidDirectMessage(room) {
const { id: userId } = reduxStore.getState().login.user;
diff --git a/app/stacks/InsideStack.js b/app/stacks/InsideStack.js
index f59cd817..95e6020d 100644
--- a/app/stacks/InsideStack.js
+++ b/app/stacks/InsideStack.js
@@ -30,6 +30,8 @@ import ThreadMessagesView from '../views/ThreadMessagesView';
import TeamChannelsView from '../views/TeamChannelsView';
import MarkdownTableView from '../views/MarkdownTableView';
import ReadReceiptsView from '../views/ReadReceiptView';
+import CannedResponsesListView from '../views/CannedResponsesListView';
+import CannedResponseDetail from '../views/CannedResponseDetail';
import { themes } from '../constants/colors';
// Profile Stack
import ProfileView from '../views/ProfileView';
@@ -136,6 +138,16 @@ const ChatsStackNavigator = () => {
+
+
);
};
diff --git a/app/stacks/MasterDetailStack/index.js b/app/stacks/MasterDetailStack/index.js
index 7e75a6ed..e1ba8495 100644
--- a/app/stacks/MasterDetailStack/index.js
+++ b/app/stacks/MasterDetailStack/index.js
@@ -24,6 +24,8 @@ import DirectoryView from '../../views/DirectoryView';
import NotificationPrefView from '../../views/NotificationPreferencesView';
import VisitorNavigationView from '../../views/VisitorNavigationView';
import ForwardLivechatView from '../../views/ForwardLivechatView';
+import CannedResponsesListView from '../../views/CannedResponsesListView';
+import CannedResponseDetail from '../../views/CannedResponseDetail';
import LivechatEditView from '../../views/LivechatEditView';
import PickerView from '../../views/PickerView';
import ThreadMessagesView from '../../views/ThreadMessagesView';
@@ -159,6 +161,16 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
component={ForwardLivechatView}
options={ForwardLivechatView.navigationOptions}
/>
+
+
diff --git a/app/views/CannedResponseDetail.js b/app/views/CannedResponseDetail.js
new file mode 100644
index 00000000..67002bbd
--- /dev/null
+++ b/app/views/CannedResponseDetail.js
@@ -0,0 +1,171 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { StyleSheet, Text, View, ScrollView } from 'react-native';
+import { useSelector } from 'react-redux';
+
+import I18n from '../i18n';
+import SafeAreaView from '../containers/SafeAreaView';
+import StatusBar from '../containers/StatusBar';
+import Button from '../containers/Button';
+import { useTheme } from '../theme';
+import RocketChat from '../lib/rocketchat';
+import Navigation from '../lib/Navigation';
+import { goRoom } from '../utils/goRoom';
+import { themes } from '../constants/colors';
+import Markdown from '../containers/markdown';
+import sharedStyles from './Styles';
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1
+ },
+ container: {
+ flex: 1,
+ marginTop: 12,
+ marginHorizontal: 15
+ },
+ cannedText: {
+ marginTop: 8,
+ marginBottom: 16,
+ fontSize: 14,
+ paddingTop: 0,
+ paddingBottom: 0,
+ ...sharedStyles.textRegular
+ },
+ cannedTagWrap: {
+ borderRadius: 4,
+ marginRight: 4,
+ marginTop: 8,
+ height: 16
+ },
+ cannedTagContainer: {
+ flexDirection: 'row',
+ flexWrap: 'wrap'
+ },
+ cannedTag: {
+ fontSize: 12,
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingHorizontal: 4,
+ ...sharedStyles.textRegular
+ },
+ button: {
+ margin: 24,
+ marginBottom: 24
+ },
+ item: {
+ paddingVertical: 10,
+ justifyContent: 'center'
+ },
+ itemLabel: {
+ marginBottom: 10,
+ fontSize: 14,
+ ...sharedStyles.textMedium
+ },
+ itemContent: {
+ fontSize: 14,
+ ...sharedStyles.textRegular
+ }
+});
+
+const Item = ({ label, content, theme, testID }) =>
+ content ? (
+
+
+ {label}
+
+
+
+ ) : null;
+Item.propTypes = {
+ label: PropTypes.string,
+ content: PropTypes.string,
+ theme: PropTypes.string,
+ testID: PropTypes.string
+};
+
+const CannedResponseDetail = ({ navigation, route }) => {
+ const { cannedResponse } = route?.params;
+ const { theme } = useTheme();
+ const { isMasterDetail } = useSelector(state => state.app);
+ const { rooms } = useSelector(state => state.room);
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: `!${cannedResponse?.shortcut}`
+ });
+ }, []);
+
+ const navigateToRoom = item => {
+ const { room } = route.params;
+ const { name, username } = room;
+ const params = {
+ rid: room.rid,
+ name: RocketChat.getRoomTitle({
+ t: room.t,
+ fname: name,
+ name: username
+ }),
+ t: room.t,
+ roomUserId: RocketChat.getUidDirectMessage(room),
+ usedCannedResponse: item.text
+ };
+
+ if (room.rid) {
+ // if it's on master detail layout, we close the modal and replace RoomView
+ if (isMasterDetail) {
+ Navigation.navigate('DrawerNavigator');
+ goRoom({ item: params, isMasterDetail, usedCannedResponse: item.text });
+ } else {
+ let navigate = navigation.push;
+ // if this is a room focused
+ if (rooms.includes(room.rid)) {
+ ({ navigate } = navigation);
+ }
+ navigate('RoomView', params);
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {I18n.t('Tags')}
+
+ {cannedResponse?.tags?.length > 0 ? (
+ cannedResponse.tags.map(t => (
+
+ {t}
+
+ ))
+ ) : (
+ -
+ )}
+
+
+
+
+
+ );
+};
+
+CannedResponseDetail.propTypes = {
+ navigation: PropTypes.object,
+ route: PropTypes.object
+};
+
+export default CannedResponseDetail;
diff --git a/app/views/CannedResponsesListView/CannedResponseItem.js b/app/views/CannedResponsesListView/CannedResponseItem.js
new file mode 100644
index 00000000..cc5f3c39
--- /dev/null
+++ b/app/views/CannedResponsesListView/CannedResponseItem.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { View, Text } from 'react-native';
+
+import Touchable from 'react-native-platform-touchable';
+import { themes } from '../../constants/colors';
+import Button from '../../containers/Button';
+import I18n from '../../i18n';
+import styles from './styles';
+
+const CannedResponseItem = ({ theme, onPressDetail, shortcut, scope, onPressUse, text, tags }) => (
+
+ <>
+
+
+ !{shortcut}
+ {scope}
+
+
+
+
+
+
+ “{text}”
+
+
+ {tags?.length > 0
+ ? tags.map(t => (
+
+ {t}
+
+ ))
+ : null}
+
+ >
+
+);
+
+CannedResponseItem.propTypes = {
+ theme: PropTypes.string,
+ onPressDetail: PropTypes.func,
+ shortcut: PropTypes.string,
+ scope: PropTypes.string,
+ onPressUse: PropTypes.func,
+ text: PropTypes.string,
+ tags: PropTypes.array
+};
+
+CannedResponseItem.defaultProps = {
+ onPressDetail: () => {},
+ onPressUse: () => {}
+};
+
+export default CannedResponseItem;
diff --git a/app/views/CannedResponsesListView/CannedResponseItem.stories.js b/app/views/CannedResponsesListView/CannedResponseItem.stories.js
new file mode 100644
index 00000000..5e49f850
--- /dev/null
+++ b/app/views/CannedResponsesListView/CannedResponseItem.stories.js
@@ -0,0 +1,64 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import React from 'react';
+import { storiesOf } from '@storybook/react-native';
+
+import CannedResponseItem from './CannedResponseItem';
+
+const stories = storiesOf('CannedResponseItem', module);
+
+const item = [
+ {
+ _id: 'x1-x1-x1',
+ shortcut: '!FAQ4',
+ text: 'ZCVXZVXCZVZXVZXCVZXCVXZCVZX',
+ scope: 'user',
+ userId: 'xxx-x-xx-x-x-',
+ createdBy: {
+ _id: 'xxx-x-xx-x-x-',
+ username: 'rocket.cat'
+ },
+ _createdAt: '2021-08-11T01:23:17.379Z',
+ _updatedAt: '2021-08-11T01:23:17.379Z',
+ scopeName: 'Private'
+ },
+ {
+ _id: 'x1-1x-1x',
+ shortcut: 'test4mobilePrivate',
+ text: 'test for mobile private',
+ scope: 'user',
+ tags: ['HQ', 'Closed', 'HQ', 'Problem in Product Y', 'HQ', 'Closed', 'Problem in Product Y'],
+ userId: 'laslsaklasal',
+ createdBy: {
+ _id: 'laslsaklasal',
+ username: 'reinaldo.neto'
+ },
+ _createdAt: '2021-09-02T17:44:52.095Z',
+ _updatedAt: '2021-09-02T18:24:40.436Z',
+ scopeName: 'Private'
+ }
+];
+
+const theme = 'light';
+
+stories.add('Itens', () => (
+ <>
+ alert('navigation to CannedResponseDetail')}
+ onPressUse={() => alert('Back to RoomView and wrote in MessageBox')}
+ />
+ alert('navigation to CannedResponseDetail')}
+ onPressUse={() => alert('Back to RoomView and wrote in MessageBox')}
+ />
+ >
+));
diff --git a/app/views/CannedResponsesListView/Dropdown/DropdownItem.js b/app/views/CannedResponsesListView/Dropdown/DropdownItem.js
new file mode 100644
index 00000000..85b9aa70
--- /dev/null
+++ b/app/views/CannedResponsesListView/Dropdown/DropdownItem.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { themes } from '../../../constants/colors';
+import { withTheme } from '../../../theme';
+import Touch from '../../../utils/touch';
+import { CustomIcon } from '../../../lib/Icons';
+import sharedStyles from '../../Styles';
+
+export const ROW_HEIGHT = 44;
+
+const styles = StyleSheet.create({
+ container: {
+ paddingVertical: 11,
+ height: ROW_HEIGHT,
+ paddingHorizontal: 16,
+ flexDirection: 'row',
+ alignItems: 'center'
+ },
+ text: {
+ flex: 1,
+ fontSize: 16,
+ ...sharedStyles.textRegular
+ }
+});
+
+const DropdownItem = React.memo(({ theme, onPress, iconName, text }) => (
+
+
+ {text}
+ {iconName ? : null}
+
+
+));
+
+DropdownItem.propTypes = {
+ text: PropTypes.string,
+ iconName: PropTypes.string,
+ theme: PropTypes.string,
+ onPress: PropTypes.func
+};
+
+export default withTheme(DropdownItem);
diff --git a/app/views/CannedResponsesListView/Dropdown/DropdownItemFilter.js b/app/views/CannedResponsesListView/Dropdown/DropdownItemFilter.js
new file mode 100644
index 00000000..d4e45780
--- /dev/null
+++ b/app/views/CannedResponsesListView/Dropdown/DropdownItemFilter.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DropdownItem from './DropdownItem';
+
+const DropdownItemFilter = ({ currentDepartment, value, onPress }) => (
+ onPress(value)}
+ />
+);
+
+DropdownItemFilter.propTypes = {
+ currentDepartment: PropTypes.object,
+ value: PropTypes.string,
+ onPress: PropTypes.func
+};
+
+export default DropdownItemFilter;
diff --git a/app/views/CannedResponsesListView/Dropdown/DropdownItemHeader.js b/app/views/CannedResponsesListView/Dropdown/DropdownItemHeader.js
new file mode 100644
index 00000000..4f1f2b68
--- /dev/null
+++ b/app/views/CannedResponsesListView/Dropdown/DropdownItemHeader.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DropdownItem from './DropdownItem';
+
+const DropdownItemHeader = ({ department, onPress }) => (
+
+);
+
+DropdownItemHeader.propTypes = {
+ department: PropTypes.object,
+ onPress: PropTypes.func
+};
+
+export default DropdownItemHeader;
diff --git a/app/views/CannedResponsesListView/Dropdown/index.js b/app/views/CannedResponsesListView/Dropdown/index.js
new file mode 100644
index 00000000..e2735e5c
--- /dev/null
+++ b/app/views/CannedResponsesListView/Dropdown/index.js
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Animated, Easing, FlatList, TouchableWithoutFeedback } from 'react-native';
+import { withSafeAreaInsets } from 'react-native-safe-area-context';
+
+import styles from '../styles';
+import { themes } from '../../../constants/colors';
+import { withTheme } from '../../../theme';
+import { headerHeight } from '../../../containers/Header';
+import * as List from '../../../containers/List';
+import DropdownItemFilter from './DropdownItemFilter';
+import DropdownItemHeader from './DropdownItemHeader';
+import { ROW_HEIGHT } from './DropdownItem';
+
+const ANIMATION_DURATION = 200;
+
+class Dropdown extends React.Component {
+ static propTypes = {
+ isMasterDetail: PropTypes.bool,
+ theme: PropTypes.string,
+ insets: PropTypes.object,
+ currentDepartment: PropTypes.object,
+ onClose: PropTypes.func,
+ onDepartmentSelected: PropTypes.func,
+ departments: PropTypes.array
+ };
+
+ constructor(props) {
+ super(props);
+ this.animatedValue = new Animated.Value(0);
+ }
+
+ componentDidMount() {
+ Animated.timing(this.animatedValue, {
+ toValue: 1,
+ duration: ANIMATION_DURATION,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true
+ }).start();
+ }
+
+ close = () => {
+ const { onClose } = this.props;
+ Animated.timing(this.animatedValue, {
+ toValue: 0,
+ duration: ANIMATION_DURATION,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true
+ }).start(() => onClose());
+ };
+
+ render() {
+ const { isMasterDetail, insets, theme, currentDepartment, onDepartmentSelected, departments } = this.props;
+ const statusBarHeight = insets?.top ?? 0;
+ const heightDestination = isMasterDetail ? headerHeight + statusBarHeight : 0;
+ const translateY = this.animatedValue.interpolate({
+ inputRange: [0, 1],
+ outputRange: [-300, heightDestination] // approximated height of the component when closed/open
+ });
+ const backdropOpacity = this.animatedValue.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, themes[theme].backdropOpacity]
+ });
+
+ const maxRows = 5;
+ return (
+ <>
+
+
+
+
+
+
+ item._id}
+ renderItem={({ item }) => (
+
+ )}
+ keyboardShouldPersistTaps='always'
+ />
+
+ >
+ );
+ }
+}
+
+export default withTheme(withSafeAreaInsets(Dropdown));
diff --git a/app/views/CannedResponsesListView/index.js b/app/views/CannedResponsesListView/index.js
new file mode 100644
index 00000000..a9d59b22
--- /dev/null
+++ b/app/views/CannedResponsesListView/index.js
@@ -0,0 +1,376 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { FlatList, RefreshControl } from 'react-native';
+import { useSelector } from 'react-redux';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { HeaderBackButton } from '@react-navigation/stack';
+
+import database from '../../lib/database';
+import I18n from '../../i18n';
+import SafeAreaView from '../../containers/SafeAreaView';
+import StatusBar from '../../containers/StatusBar';
+import ActivityIndicator from '../../containers/ActivityIndicator';
+import SearchHeader from '../../containers/SearchHeader';
+import BackgroundContainer from '../../containers/BackgroundContainer';
+import { getHeaderTitlePosition } from '../../containers/Header';
+import { useTheme } from '../../theme';
+import RocketChat from '../../lib/rocketchat';
+import debounce from '../../utils/debounce';
+import Navigation from '../../lib/Navigation';
+import { goRoom } from '../../utils/goRoom';
+import * as HeaderButton from '../../containers/HeaderButton';
+import * as List from '../../containers/List';
+import { themes } from '../../constants/colors';
+import log from '../../utils/log';
+import CannedResponseItem from './CannedResponseItem';
+import Dropdown from './Dropdown';
+import DropdownItemHeader from './Dropdown/DropdownItemHeader';
+import styles from './styles';
+
+const COUNT = 25;
+
+const fixedScopes = [
+ {
+ _id: 'all',
+ name: I18n.t('All')
+ },
+ {
+ _id: 'global',
+ name: I18n.t('Public')
+ },
+ {
+ _id: 'user',
+ name: I18n.t('Private')
+ }
+];
+
+const CannedResponsesListView = ({ navigation, route }) => {
+ const [room, setRoom] = useState(null);
+
+ const [cannedResponses, setCannedResponses] = useState([]);
+ const [cannedResponsesScopeName, setCannedResponsesScopeName] = useState([]);
+ const [departments, setDepartments] = useState([]);
+ const [refreshing, setRefreshing] = useState(false);
+
+ // states used by the filter in Header and Dropdown
+ const [isSearching, setIsSearching] = useState(false);
+ const [currentDepartment, setCurrentDepartment] = useState(fixedScopes[0]);
+ const [showFilterDropdown, setShowFilterDropDown] = useState(false);
+
+ // states used to do a fetch by onChangeText, onDepartmentSelect and onEndReached
+ const [searchText, setSearchText] = useState('');
+ const [scope, setScope] = useState('');
+ const [departmentId, setDepartmentId] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [offset, setOffset] = useState(0);
+
+ const insets = useSafeAreaInsets();
+ const { theme } = useTheme();
+ const { isMasterDetail } = useSelector(state => state.app);
+ const { rooms } = useSelector(state => state.room);
+
+ const getRoomFromDb = async () => {
+ const { rid } = route.params;
+ const db = database.active;
+ const subsCollection = db.get('subscriptions');
+ try {
+ const r = await subsCollection.find(rid);
+ setRoom(r);
+ } catch (error) {
+ console.log('CannedResponsesListView: Room not found');
+ log(error);
+ }
+ };
+
+ const getDepartments = debounce(async () => {
+ try {
+ const res = await RocketChat.getDepartments();
+ if (res.success) {
+ setDepartments([...fixedScopes, ...res.departments]);
+ }
+ } catch (e) {
+ setDepartments(fixedScopes);
+ log(e);
+ }
+ }, 300);
+
+ const goToDetail = item => {
+ navigation.navigate('CannedResponseDetail', { cannedResponse: item, room });
+ };
+
+ const navigateToRoom = item => {
+ if (!room) {
+ return;
+ }
+ const { name, username } = room;
+ const params = {
+ rid: room.rid,
+ name: RocketChat.getRoomTitle({
+ t: room.t,
+ fname: name,
+ name: username
+ }),
+ t: room.t,
+ roomUserId: RocketChat.getUidDirectMessage(room),
+ usedCannedResponse: item.text
+ };
+
+ if (room.rid) {
+ // if it's on master detail layout, we close the modal and replace RoomView
+ if (isMasterDetail) {
+ Navigation.navigate('DrawerNavigator');
+ goRoom({ item: params, isMasterDetail, usedCannedResponse: item.text });
+ } else {
+ let navigate = navigation.push;
+ // if this is a room focused
+ if (rooms.includes(room.rid)) {
+ ({ navigate } = navigation);
+ }
+ navigate('RoomView', params);
+ }
+ }
+ };
+
+ const getListCannedResponse = async ({ text, department, depId, debounced }) => {
+ try {
+ const res = await RocketChat.getListCannedResponse({
+ text,
+ offset,
+ count: COUNT,
+ departmentId: depId,
+ scope: department
+ });
+ if (res.success) {
+ // search with changes on text or scope are debounced
+ // the begin result and pagination aren't debounced
+ setCannedResponses(prevCanned => (debounced ? res.cannedResponses : [...prevCanned, ...res.cannedResponses]));
+ setLoading(false);
+ setOffset(prevOffset => prevOffset + COUNT);
+ }
+ } catch (e) {
+ log(e);
+ }
+ };
+
+ useEffect(() => {
+ if (departments.length > 0) {
+ const newCannedResponses = cannedResponses.map(cr => {
+ let scopeName = '';
+
+ if (cr?.departmentId) {
+ scopeName = departments.filter(dep => dep._id === cr.departmentId)[0]?.name || 'Department';
+ } else {
+ scopeName = departments.filter(dep => dep._id === cr.scope)[0]?.name;
+ }
+ cr.scopeName = scopeName;
+
+ return cr;
+ });
+ setCannedResponsesScopeName(newCannedResponses);
+ }
+ }, [departments, cannedResponses]);
+
+ const searchCallback = useCallback(
+ debounce(async (text = '', department = '', depId = '') => {
+ await getListCannedResponse({ text, department, depId, debounced: true });
+ }, 1000),
+ []
+ ); // use debounce with useCallback https://stackoverflow.com/a/58594890
+
+ useEffect(() => {
+ getRoomFromDb();
+ getDepartments();
+ getListCannedResponse({ text: '', department: '', depId: '', debounced: false });
+ }, []);
+
+ const newSearch = () => {
+ setCannedResponses([]);
+ setLoading(true);
+ setOffset(0);
+ };
+
+ const onChangeText = text => {
+ newSearch();
+ setSearchText(text);
+ searchCallback(text, scope, departmentId);
+ };
+
+ const onDepartmentSelect = value => {
+ let department = '';
+ let depId = '';
+
+ if (value._id === fixedScopes[0]._id) {
+ department = '';
+ } else if (value._id === fixedScopes[1]._id) {
+ department = 'global';
+ } else if (value._id === fixedScopes[2]._id) {
+ department = 'user';
+ } else {
+ department = 'department';
+ depId = value._id;
+ }
+
+ newSearch();
+ setCurrentDepartment(value);
+ setScope(department);
+ setDepartmentId(depId);
+ setShowFilterDropDown(false);
+ searchCallback(searchText, department, depId);
+ };
+
+ const onEndReached = async () => {
+ if (cannedResponses.length < offset || loading) {
+ return;
+ }
+ setLoading(true);
+ await getListCannedResponse({ text: searchText, department: scope, depId: departmentId, debounced: false });
+ };
+
+ const getHeader = () => {
+ if (isSearching) {
+ const headerTitlePosition = getHeaderTitlePosition({ insets, numIconsRight: 1 });
+ return {
+ headerTitleAlign: 'left',
+ headerLeft: () => (
+
+ {
+ onChangeText();
+ setIsSearching(false);
+ }}
+ />
+
+ ),
+ headerTitle: () => ,
+ headerTitleContainerStyle: {
+ left: headerTitlePosition.left,
+ right: headerTitlePosition.right
+ },
+ headerRight: () => null
+ };
+ }
+
+ const options = {
+ headerLeft: () => (
+ navigation.pop()} tintColor={themes[theme].headerTintColor} />
+ ),
+ headerTitleAlign: 'center',
+ headerTitle: I18n.t('Canned_Responses'),
+ headerTitleContainerStyle: {
+ left: null,
+ right: null
+ }
+ };
+
+ if (isMasterDetail) {
+ options.headerLeft = () => ;
+ }
+
+ options.headerRight = () => (
+
+ setIsSearching(true)} />
+
+ );
+ return options;
+ };
+
+ const setHeader = () => {
+ const options = getHeader();
+ navigation.setOptions(options);
+ };
+
+ useEffect(() => {
+ setHeader();
+ }, [isSearching]);
+
+ const showDropdown = () => {
+ if (isSearching) {
+ setSearchText('');
+ setIsSearching(false);
+ }
+ setShowFilterDropDown(true);
+ };
+
+ const renderFlatListHeader = () => {
+ if (!departments.length) {
+ return null;
+ }
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ const onRefresh = () => {
+ setRefreshing(true);
+ onChangeText('');
+ };
+
+ useEffect(() => {
+ if (refreshing) {
+ setRefreshing(false);
+ }
+ }, [cannedResponses]);
+
+ const renderContent = () => {
+ if (!cannedResponsesScopeName.length && !loading) {
+ return (
+ <>
+ {renderFlatListHeader()}
+
+ >
+ );
+ }
+ return (
+ (
+ goToDetail(item)}
+ onPressUse={() => navigateToRoom(item)}
+ />
+ )}
+ keyExtractor={item => item._id || item.shortcut}
+ refreshControl={}
+ ListHeaderComponent={renderFlatListHeader}
+ stickyHeaderIndices={[0]}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={0.5}
+ ItemSeparatorComponent={List.Separator}
+ ListFooterComponent={loading && !refreshing ? : null}
+ />
+ );
+ };
+
+ return (
+
+
+ {renderContent()}
+ {showFilterDropdown ? (
+ setShowFilterDropDown(false)}
+ />
+ ) : null}
+
+ );
+};
+
+CannedResponsesListView.propTypes = {
+ navigation: PropTypes.object,
+ route: PropTypes.object
+};
+
+export default CannedResponsesListView;
diff --git a/app/views/CannedResponsesListView/styles.js b/app/views/CannedResponsesListView/styles.js
new file mode 100644
index 00000000..d9b8f580
--- /dev/null
+++ b/app/views/CannedResponsesListView/styles.js
@@ -0,0 +1,73 @@
+import { StyleSheet } from 'react-native';
+
+import sharedStyles from '../Styles';
+
+export default StyleSheet.create({
+ list: {
+ flex: 1
+ },
+ dropdownContainer: {
+ width: '100%',
+ position: 'absolute',
+ top: 0,
+ borderBottomWidth: StyleSheet.hairlineWidth
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFill
+ },
+ wrapCannedItem: {
+ minHeight: 117,
+ maxHeight: 141,
+ padding: 16
+ },
+ cannedRow: {
+ flexDirection: 'row',
+ height: 36
+ },
+ cannedWrapShortcutScope: {
+ flex: 1
+ },
+ cannedShortcut: {
+ flex: 1,
+ fontSize: 14,
+ paddingTop: 0,
+ paddingBottom: 0,
+ ...sharedStyles.textMedium
+ },
+ cannedScope: {
+ flex: 1,
+ fontSize: 12,
+ paddingTop: 0,
+ paddingBottom: 0,
+ ...sharedStyles.textRegular
+ },
+ cannedText: {
+ marginTop: 8,
+ fontSize: 14,
+ paddingTop: 0,
+ paddingBottom: 0,
+ ...sharedStyles.textRegular
+ },
+ cannedTagContainer: {
+ flexDirection: 'row',
+ overflow: 'hidden'
+ },
+ cannedTagWrap: {
+ borderRadius: 4,
+ marginRight: 4,
+ marginTop: 8,
+ height: 16
+ },
+ cannedTag: {
+ fontSize: 12,
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingHorizontal: 4,
+ ...sharedStyles.textRegular
+ },
+ cannedUseButton: {
+ height: 28,
+ width: 56,
+ marginLeft: 8
+ }
+});
diff --git a/app/views/RoomActionsView/index.js b/app/views/RoomActionsView/index.js
index 8bb0175c..56073a0a 100644
--- a/app/views/RoomActionsView/index.js
+++ b/app/views/RoomActionsView/index.js
@@ -64,7 +64,8 @@ class RoomActionsView extends React.Component {
transferLivechatGuestPermission: PropTypes.array,
createTeamPermission: PropTypes.array,
addTeamChannelPermission: PropTypes.array,
- convertTeamPermission: PropTypes.array
+ convertTeamPermission: PropTypes.array,
+ viewCannedResponsesPermission: PropTypes.array
};
constructor(props) {
@@ -89,7 +90,8 @@ class RoomActionsView extends React.Component {
canToggleEncryption: false,
canCreateTeam: false,
canAddChannelToTeam: false,
- canConvertTeam: false
+ canConvertTeam: false,
+ canViewCannedResponse: false
};
if (room && room.observe && room.rid) {
this.roomObservable = room.observe();
@@ -157,7 +159,8 @@ class RoomActionsView extends React.Component {
if (room.t === 'l') {
const canForwardGuest = await this.canForwardGuest();
const canReturnQueue = await this.canReturnQueue();
- this.setState({ canForwardGuest, canReturnQueue });
+ const canViewCannedResponse = await this.canViewCannedResponse();
+ this.setState({ canForwardGuest, canReturnQueue, canViewCannedResponse });
}
}
}
@@ -294,6 +297,14 @@ class RoomActionsView extends React.Component {
return permissions[0];
};
+ canViewCannedResponse = async () => {
+ const { room } = this.state;
+ const { viewCannedResponsesPermission } = this.props;
+ const { rid } = room;
+ const permissions = await RocketChat.hasPermission([viewCannedResponsesPermission], rid);
+ return permissions[0];
+ };
+
canReturnQueue = async () => {
try {
const { returnQueue } = await RocketChat.getRoutingConfig();
@@ -927,7 +938,8 @@ class RoomActionsView extends React.Component {
joined,
canAutoTranslate,
canForwardGuest,
- canReturnQueue
+ canReturnQueue,
+ canViewCannedResponse
} = this.state;
const { rid, t } = room;
const isGroupChat = RocketChat.isGroupChat(room);
@@ -1125,6 +1137,18 @@ class RoomActionsView extends React.Component {
{this.teamChannelActions(t, room)}
{this.teamToChannelActions(t, room)}
+ {['l'].includes(t) && !this.isOmnichannelPreview && canViewCannedResponse ? (
+ <>
+ this.onPressTouchable({ route: 'CannedResponsesListView', params: { rid, room } })}
+ left={() => }
+ showActionIndicator
+ />
+
+ >
+ ) : null}
+
{['l'].includes(t) && !this.isOmnichannelPreview ? (
<>
({
transferLivechatGuestPermission: state.permissions['transfer-livechat-guest'],
createTeamPermission: state.permissions['create-team'],
addTeamChannelPermission: state.permissions['add-team-channel'],
- convertTeamPermission: state.permissions['convert-team']
+ convertTeamPermission: state.permissions['convert-team'],
+ viewCannedResponsesPermission: state.permissions['view-canned-responses']
});
const mapDispatchToProps = dispatch => ({
diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js
index 7cb4179a..d7c9d152 100644
--- a/app/views/RoomView/index.js
+++ b/app/views/RoomView/index.js
@@ -1057,8 +1057,10 @@ class RoomView extends React.Component {
};
renderFooter = () => {
- const { joined, room, selectedMessage, editing, replying, replyWithMention, readOnly } = this.state;
- const { navigation, theme } = this.props;
+ const { joined, room, selectedMessage, editing, replying, replyWithMention, readOnly, loading } = this.state;
+ const { navigation, theme, route } = this.props;
+
+ const usedCannedResponse = route?.params?.usedCannedResponse;
if (!this.rid) {
return null;
@@ -1074,6 +1076,7 @@ class RoomView extends React.Component {
{I18n.t(this.isOmnichannel ? 'Take_it' : 'Join')}
@@ -1118,6 +1121,7 @@ class RoomView extends React.Component {
replyCancel={this.onReplyCancel}
getCustomEmoji={this.getCustomEmoji}
navigation={navigation}
+ usedCannedResponse={usedCannedResponse}
/>
);
};
diff --git a/app/views/TeamChannelsView.js b/app/views/TeamChannelsView.js
index 872fe1d4..869d5e4c 100644
--- a/app/views/TeamChannelsView.js
+++ b/app/views/TeamChannelsView.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { Q } from '@nozbe/watermelondb';
import { withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
+import { HeaderBackButton } from '@react-navigation/stack';
import StatusBar from '../containers/StatusBar';
import RoomHeader from '../containers/RoomHeader';
@@ -16,6 +17,7 @@ import * as HeaderButton from '../containers/HeaderButton';
import BackgroundContainer from '../containers/BackgroundContainer';
import SafeAreaView from '../containers/SafeAreaView';
import ActivityIndicator from '../containers/ActivityIndicator';
+import SearchHeader from '../containers/SearchHeader';
import RoomItem, { ROW_HEIGHT } from '../presentation/RoomItem';
import RocketChat from '../lib/rocketchat';
import { withDimensions } from '../dimensions';
@@ -28,7 +30,6 @@ import { withActionSheet } from '../containers/ActionSheet';
import { deleteRoom as deleteRoomAction } from '../actions/room';
import { CustomIcon } from '../lib/Icons';
import { themes } from '../constants/colors';
-import SearchHeader from './ThreadMessagesView/SearchHeader';
const API_FETCH_COUNT = 25;
const PERMISSION_DELETE_C = 'delete-c';
@@ -157,7 +158,7 @@ class TeamChannelsView extends React.Component {
setHeader = () => {
const { isSearching, showCreate, data } = this.state;
- const { navigation, isMasterDetail, insets } = this.props;
+ const { navigation, isMasterDetail, insets, theme } = this.props;
const { team } = this;
if (!team) {
@@ -167,7 +168,7 @@ class TeamChannelsView extends React.Component {
const headerTitlePosition = getHeaderTitlePosition({ insets, numIconsRight: 2 });
if (isSearching) {
- return {
+ const options = {
headerTitleAlign: 'left',
headerLeft: () => (
@@ -181,6 +182,7 @@ class TeamChannelsView extends React.Component {
},
headerRight: () => null
};
+ return navigation.setOptions(options);
}
const options = {
@@ -190,6 +192,9 @@ class TeamChannelsView extends React.Component {
left: headerTitlePosition.left,
right: headerTitlePosition.right
},
+ headerLeft: () => (
+ navigation.pop()} tintColor={themes[theme].headerTintColor} />
+ ),
headerTitle: () => (