From a52199a49ce0333a7fc9b5c2f90bb57f1e074205 Mon Sep 17 00:00:00 2001 From: Reinaldo Neto <47038980+reinaldonetof@users.noreply.github.com> Date: Wed, 22 Sep 2021 14:29:26 -0300 Subject: [PATCH] [NEW] Canned responses (#3355) Co-authored-by: Diego Mello --- .../__snapshots__/Storyshots.test.js.snap | 587 ++++++++++++++++++ app/constants/settings.ts | 3 + .../MessageBox/Mentions/MentionHeaderList.js | 49 ++ .../MessageBox/Mentions/MentionItem.tsx | 17 +- app/containers/MessageBox/Mentions/index.tsx | 14 +- app/containers/MessageBox/constants.ts | 1 + app/containers/MessageBox/index.tsx | 89 ++- app/containers/MessageBox/styles.ts | 36 ++ .../SearchHeader.js | 12 +- app/containers/UIKit/MultiSelect/Input.tsx | 11 +- app/containers/UIKit/MultiSelect/index.tsx | 22 +- app/i18n/locales/en.json | 11 +- app/i18n/locales/pt-BR.json | 10 +- app/lib/methods/getPermissions.js | 3 +- app/lib/rocketchat.js | 13 + app/stacks/InsideStack.js | 12 + app/stacks/MasterDetailStack/index.js | 12 + app/views/CannedResponseDetail.js | 171 +++++ .../CannedResponseItem.js | 61 ++ .../CannedResponseItem.stories.js | 64 ++ .../Dropdown/DropdownItem.js | 44 ++ .../Dropdown/DropdownItemFilter.js | 20 + .../Dropdown/DropdownItemHeader.js | 15 + .../CannedResponsesListView/Dropdown/index.js | 106 ++++ app/views/CannedResponsesListView/index.js | 376 +++++++++++ app/views/CannedResponsesListView/styles.js | 73 +++ app/views/RoomActionsView/index.js | 35 +- app/views/RoomView/index.js | 8 +- app/views/TeamChannelsView.js | 11 +- app/views/ThreadMessagesView/index.js | 2 +- storybook/stories/index.js | 1 + 31 files changed, 1836 insertions(+), 53 deletions(-) create mode 100644 app/containers/MessageBox/Mentions/MentionHeaderList.js rename app/{views/ThreadMessagesView => containers}/SearchHeader.js (78%) create mode 100644 app/views/CannedResponseDetail.js create mode 100644 app/views/CannedResponsesListView/CannedResponseItem.js create mode 100644 app/views/CannedResponsesListView/CannedResponseItem.stories.js create mode 100644 app/views/CannedResponsesListView/Dropdown/DropdownItem.js create mode 100644 app/views/CannedResponsesListView/Dropdown/DropdownItemFilter.js create mode 100644 app/views/CannedResponsesListView/Dropdown/DropdownItemHeader.js create mode 100644 app/views/CannedResponsesListView/Dropdown/index.js create mode 100644 app/views/CannedResponsesListView/index.js create mode 100644 app/views/CannedResponsesListView/styles.js diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 56448a036..022ded85c 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 5fc11609e..c315b9250 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 8fe37fc8c..dabaee497 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 58d7fd436..0366bc1b3 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 9c5a2ff52..8d934799f 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 3bd9c8540..e231d0748 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 611838c08..e03837a2b 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 95bedbebd..d50dede31 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 ? (