From 19ece476ad136d70f76195bb0a70cd25c2c27d1c Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 30 May 2023 14:51:55 -0400 Subject: [PATCH] MM-47064/MM-34345/MM-47072 Remove inheritance from Suggestion components and migrate to TS (#23455) * MM-47064 Remove inheritance from Suggestion components * Address feedback * Fix users without DM channels not appearing in the channel switcher --------- Co-authored-by: Mattermost Build --- .../add_user_to_channel_modal.test.tsx.snap | 1 + .../add_user_to_channel_modal.tsx | 4 +- .../forward_post_channel_select.tsx | 4 +- .../quick_switch_modal.test.tsx.snap | 1 + .../quick_switch_modal/quick_switch_modal.tsx | 2 +- .../at_mention_suggestion.test.tsx.snap | 327 +++++------ .../at_mention_suggestion.test.tsx | 19 +- .../at_mention_suggestion.tsx | 314 +++++----- .../suggestion/channel_mention_provider.tsx | 100 ++-- .../command_provider.test.tsx.snap | 23 +- .../command_provider/app_provider.tsx | 16 +- .../command_provider.test.tsx | 8 +- .../command_provider/command_provider.tsx | 116 ++-- .../suggestion/emoticon_provider.test.jsx | 8 +- ...con_provider.jsx => emoticon_provider.tsx} | 82 +-- .../suggestion/generic_channel_provider.tsx | 83 ++- .../suggestion/generic_user_provider.tsx | 113 ++-- .../suggestion/menu_action_provider.tsx | 55 +- .../src/components/suggestion/provider.tsx | 27 +- .../suggestion/search_channel_provider.tsx | 13 +- .../search_channel_suggestion.test.tsx.snap | 245 ++++++-- .../search_channel_suggestion/index.ts | 6 +- .../search_channel_suggestion.test.tsx | 7 +- .../search_channel_suggestion.tsx | 77 ++- ...channel_with_permissions_provider.test.jsx | 2 +- ...rch_channel_with_permissions_provider.tsx} | 125 ++-- .../suggestion/search_date_provider.tsx | 6 +- .../search_date_suggestion.tsx | 11 +- .../suggestion/search_user_provider.tsx | 103 ++-- .../src/components/suggestion/suggestion.jsx | 38 -- .../src/components/suggestion/suggestion.tsx | 65 +++ .../suggestion/suggestion_list.d.ts | 2 +- .../switch_channel_provider.test.jsx | 3 +- ...ovider.jsx => switch_channel_provider.tsx} | 544 ++++++++++-------- .../src/components/textbox/textbox.tsx | 2 +- .../src/selectors/entities/channels.ts | 2 +- 36 files changed, 1328 insertions(+), 1226 deletions(-) rename webapp/channels/src/components/suggestion/{emoticon_provider.jsx => emoticon_provider.tsx} (78%) rename webapp/channels/src/components/suggestion/{search_channel_with_permissions_provider.jsx => search_channel_with_permissions_provider.tsx} (67%) delete mode 100644 webapp/channels/src/components/suggestion/suggestion.jsx create mode 100644 webapp/channels/src/components/suggestion/suggestion.tsx rename webapp/channels/src/components/suggestion/{switch_channel_provider.jsx => switch_channel_provider.tsx} (67%) diff --git a/webapp/channels/src/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.tsx.snap b/webapp/channels/src/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.tsx.snap index 18e2c0e96f..9fed2e2d6a 100644 --- a/webapp/channels/src/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.tsx.snap +++ b/webapp/channels/src/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.tsx.snap @@ -89,6 +89,7 @@ exports[`components/AddUserToChannelModal should match snapshot 1`] = ` "latestComplete": true, "latestPrefix": "", "requestStarted": false, + "triggerCharacter": undefined, }, ] } diff --git a/webapp/channels/src/components/add_user_to_channel_modal/add_user_to_channel_modal.tsx b/webapp/channels/src/components/add_user_to_channel_modal/add_user_to_channel_modal.tsx index 6b2027fc6d..e90ca0b05e 100644 --- a/webapp/channels/src/components/add_user_to_channel_modal/add_user_to_channel_modal.tsx +++ b/webapp/channels/src/components/add_user_to_channel_modal/add_user_to_channel_modal.tsx @@ -8,7 +8,7 @@ import {FormattedMessage} from 'react-intl'; import {getFullName} from 'mattermost-redux/utils/user_utils'; import {ActionResult} from 'mattermost-redux/types/actions'; -import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider.jsx'; +import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider'; import SuggestionBox from 'components/suggestion/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import ModalSuggestionList from 'components/suggestion/modal_suggestion_list'; @@ -54,7 +54,7 @@ export type Props = { * SearchChannelWithPermissionsProvider class to fetch channels * based on a search term */ - autocompleteChannelsForSearch: (teamId: string, term: string) => Promise; + autocompleteChannelsForSearch: (teamId: string, term: string) => Promise>; }; } diff --git a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx index c598ae62ce..499fe52260 100644 --- a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx +++ b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx @@ -249,7 +249,7 @@ function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props { let options: GroupedOption[] = []; - const handleDefaultResults = (res: ProviderResult) => { + const handleDefaultResults = (res: ProviderResult) => { options = [ { label: formatMessage({id: 'suggestion.mention.recent.channels', defaultMessage: 'Recent'}), @@ -278,7 +278,7 @@ function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props { + const handleResults = async (res: ProviderResult) => { callCount++; await res.items.filter((item) => item?.channel && isValidChannelType(item.channel) && !item.deactivated).forEach((item) => { const {channel} = item; diff --git a/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap b/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap index 8f54f90b71..88f0221c60 100644 --- a/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap +++ b/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap @@ -90,6 +90,7 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = ` "latestComplete": true, "latestPrefix": "", "requestStarted": false, + "triggerCharacter": undefined, }, ] } diff --git a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx index e22e1e31e3..250b334bad 100644 --- a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx +++ b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx @@ -19,7 +19,7 @@ import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import SuggestionBox from 'components/suggestion/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import SuggestionList from 'components/suggestion/suggestion_list.jsx'; -import SwitchChannelProvider from 'components/suggestion/switch_channel_provider.jsx'; +import SwitchChannelProvider from 'components/suggestion/switch_channel_provider'; import NoResultsIndicator from 'components/no_results_indicator/no_results_indicator'; const CHANNEL_MODE = 'channel'; diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap b/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap index 5e338d7941..8b794b1fdb 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap +++ b/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap @@ -2,44 +2,7 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`] = ` -
- - - user2 profile image - + + user2 profile image + + + - - - - @user2 - - a b (c) - + @user2 + + a b (c) + -
- - -
+ showTooltip={true} + userID="userid2" + > +
+ + +
+ `; exports[`at mention suggestion Should not display nick name of the signed in user 1`] = ` -
- - - user profile image - - - - - - - @user - - a b - - - (you) + + user profile image + - - + + -
- - -
+ + @user + + a b + + + (you) + + + +
+ + +
+ `; diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.test.tsx b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.test.tsx index d9a15f42f6..c23b48063e 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.test.tsx +++ b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.test.tsx @@ -4,9 +4,10 @@ import React from 'react'; import * as Utils from 'utils/utils'; -import AtMentionSuggestion from 'components/suggestion/at_mention_provider/at_mention_suggestion'; import {mountWithIntl} from 'tests/helpers/intl-test-helper'; +import AtMentionSuggestion, {Item} from './at_mention_suggestion'; + jest.mock('components/custom_status/custom_status_emoji', () => () =>
); jest.spyOn(Utils, 'getFullName').mockReturnValue('a b'); @@ -18,7 +19,7 @@ describe('at mention suggestion', () => { last_name: 'b', nickname: 'c', isCurrentUser: true, - }; + } as Item; const userid2 = { id: 'userid2', @@ -26,14 +27,21 @@ describe('at mention suggestion', () => { first_name: 'a', last_name: 'b', nickname: 'c', + } as Item; + + const baseProps = { + matchedPretext: '@', + term: '@user', + isSelection: false, + onClick: jest.fn(), + onMouseMove: jest.fn(), }; it('Should not display nick name of the signed in user', () => { const wrapper = mountWithIntl( , ); @@ -46,9 +54,8 @@ describe('at mention suggestion', () => { it('Should display nick name of non signed in user', () => { const wrapper = mountWithIntl( , ); diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx index 5189ae9f04..31f725da2a 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx +++ b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx @@ -2,9 +2,7 @@ // See LICENSE.txt for license information. import React, {ReactNode} from 'react'; -import {FormattedMessage, injectIntl} from 'react-intl'; - -import classNames from 'classnames'; +import {FormattedMessage, useIntl} from 'react-intl'; import {Constants} from 'utils/constants'; import * as Utils from 'utils/utils'; @@ -18,11 +16,11 @@ import GuestTag from 'components/widgets/tag/guest_tag'; import CustomStatusEmoji from 'components/custom_status/custom_status_emoji'; import StatusIcon from 'components/status_icon'; -import Suggestion from '../suggestion.jsx'; +import {SuggestionContainer, SuggestionProps} from '../suggestion'; import {UserProfile} from '@mattermost/types/users'; -interface Item extends UserProfile { +export interface Item extends UserProfile { display_name: string; name: string; isCurrentUser: boolean; @@ -33,169 +31,167 @@ interface Group extends Item { member_count: number; } -class AtMentionSuggestion extends Suggestion { - render() { - const {intl} = this.props; - const {isSelection, item} = this.props; +const AtMentionSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; - let itemname: string; - let description: ReactNode; - let icon: JSX.Element; - let customStatus: ReactNode; - if (item.username === 'all') { - itemname = 'all'; - description = ( - - ); - icon = ( - - - - ); - } else if (item.username === 'channel') { - itemname = 'channel'; - description = ( - - ); - icon = ( - - - - ); - } else if (item.username === 'here') { - itemname = 'here'; - description = ( - - ); - icon = ( - - - - ); - } else if (item.type === Constants.MENTION_GROUPS) { - itemname = item.name; - description = ( - {'- '}{item.display_name} - ); - icon = ( - - - - ); - } else { - itemname = item.username; + const intl = useIntl(); - if (item.isCurrentUser) { - if (item.first_name || item.last_name) { - description = Utils.getFullName(item); - } - } else if (item.first_name || item.last_name || item.nickname) { - description = `${Utils.getFullName(item)} ${item.nickname ? `(${item.nickname})` : ''}`.trim(); - } - - icon = ( - - - - - - - ); - - customStatus = ( - - ); - } - - const youElement = item.isCurrentUser ? ( + let itemname: string; + let description: ReactNode; + let icon: JSX.Element; + let customStatus: ReactNode; + if (item.username === 'all') { + itemname = 'all'; + description = ( - ) : null; - - const sharedIcon = item.remote_id ? ( - + + + ); + } else if (item.username === 'channel') { + itemname = 'channel'; + description = ( + - ) : null; + ); + icon = ( + + + + ); + } else if (item.username === 'here') { + itemname = 'here'; + description = ( + + ); + icon = ( + + + + ); + } else if (item.type === Constants.MENTION_GROUPS) { + itemname = item.name; + description = ( + {'- '}{item.display_name} + ); + icon = ( + + + + ); + } else { + itemname = item.username; - let countBadge; - if (item.type === Constants.MENTION_GROUPS) { - countBadge = ( - - - } - /> - - ); + if (item.isCurrentUser) { + if (item.first_name || item.last_name) { + description = Utils.getFullName(item); + } + } else if (item.first_name || item.last_name || item.nickname) { + description = `${Utils.getFullName(item)} ${item.nickname ? `(${item.nickname})` : ''}`.trim(); } - return ( -
- {icon} - - - {'@' + itemname} - - {item.is_bot && } - {description} - {youElement} - {customStatus} - {sharedIcon} - {isGuest(item.roles) && } + icon = ( + + + - {countBadge} -
+ + + ); + + customStatus = ( + ); } -} -export default injectIntl(AtMentionSuggestion); + const youElement = item.isCurrentUser ? ( + + ) : null; + + const sharedIcon = item.remote_id ? ( + + ) : null; + + let countBadge; + if (item.type === Constants.MENTION_GROUPS) { + countBadge = ( + + + } + /> + + ); + } + + return ( + + {icon} + + + {'@' + itemname} + + {item.is_bot && } + {description} + {youElement} + {customStatus} + {sharedIcon} + {isGuest(item.roles) && } + + {countBadge} + + ); +}); + +AtMentionSuggestion.displayName = 'AtMentionSuggestion'; +export default AtMentionSuggestion; diff --git a/webapp/channels/src/components/suggestion/channel_mention_provider.tsx b/webapp/channels/src/components/suggestion/channel_mention_provider.tsx index 445f8ccfc9..4a0d43efce 100644 --- a/webapp/channels/src/components/suggestion/channel_mention_provider.tsx +++ b/webapp/channels/src/components/suggestion/channel_mention_provider.tsx @@ -14,73 +14,55 @@ import store from 'stores/redux_store.jsx'; import {Constants} from 'utils/constants'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; export const MIN_CHANNEL_LINK_LENGTH = 2; -export type Results = { - matchedPretext: string; - terms: string[]; - items: WrappedChannels[]; - component: React.ElementType; -} - -type ResultsCallback = (results: Results) => void; - -type WrappedChannels = { +type WrappedChannel = { type: string; channel?: Channel; loading?: boolean; } -export class ChannelMentionSuggestion extends Suggestion { - render() { - const isSelection = this.props.isSelection; - const item = this.props.item; - const channelIsArchived = item.channel.delete_at && item.channel.delete_at !== 0; +export const ChannelMentionSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; + const channelIsArchived = item.channel && item.channel.delete_at && item.channel.delete_at !== 0; - const channelName = item.channel.display_name; - let channelIcon; - if (channelIsArchived) { - channelIcon = ( - - - - ); - } else { - channelIcon = ( - - - - ); - } - - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - const description = '~' + item.channel.name; - - return ( -
- {channelIcon} -
- - {channelName} - - {description} -
-
+ const channelName = item.channel?.display_name; + let channelIcon; + if (channelIsArchived) { + channelIcon = ( + + + + ); + } else { + channelIcon = ( + + + ); } -} + + const description = '~' + item.channel?.name; + + return ( + + {channelIcon} +
+ + {channelName} + + {description} +
+
+ ); +}); +ChannelMentionSuggestion.displayName = 'ChannelMentionSuggestion'; export default class ChannelMentionProvider extends Provider { private lastPrefixTrimmed: string; @@ -106,7 +88,7 @@ export default class ChannelMentionProvider extends Provider { this.delayChannelAutocomplete = props.delayChannelAutocomplete; } - handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { this.resetRequest(); const captured = (/\B(~([^~\r\n]*))$/i).exec(pretext.toLowerCase()); @@ -151,7 +133,7 @@ export default class ChannelMentionProvider extends Provider { const words = prefix.toLowerCase().split(/\s+/); const wrappedChannelIds: Record = {}; - let wrappedChannels: WrappedChannels[] = []; + let wrappedChannels: WrappedChannel[] = []; getMyChannels(store.getState()).forEach((item) => { if (item.type !== 'O' || item.delete_at > 0) { return; @@ -212,7 +194,7 @@ export default class ChannelMentionProvider extends Provider { } // Wrap channels in an outer object to avoid overwriting the 'type' property. - const wrappedMoreChannels: WrappedChannels[] = []; + const wrappedMoreChannels: WrappedChannel[] = []; channels.forEach((item) => { if (item.delete_at > 0 && !myMembers[item.id]) { return; diff --git a/webapp/channels/src/components/suggestion/command_provider/__snapshots__/command_provider.test.tsx.snap b/webapp/channels/src/components/suggestion/command_provider/__snapshots__/command_provider.test.tsx.snap index f663f3b7ac..bd86fd02f6 100644 --- a/webapp/channels/src/components/suggestion/command_provider/__snapshots__/command_provider.test.tsx.snap +++ b/webapp/channels/src/components/suggestion/command_provider/__snapshots__/command_provider.test.tsx.snap @@ -1,12 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CommandSuggestion should match snapshot 1`] = ` -
-
+ `; diff --git a/webapp/channels/src/components/suggestion/command_provider/app_provider.tsx b/webapp/channels/src/components/suggestion/command_provider/app_provider.tsx index 7e37f840e6..e1a4c77485 100644 --- a/webapp/channels/src/components/suggestion/command_provider/app_provider.tsx +++ b/webapp/channels/src/components/suggestion/command_provider/app_provider.tsx @@ -7,7 +7,7 @@ import {Store} from 'redux'; import globalStore from 'stores/redux_store'; -import Provider from '../provider'; +import Provider, {ResultsCallback} from '../provider'; import {GlobalState} from 'types/store'; import {Constants} from 'utils/constants'; @@ -31,19 +31,11 @@ type Props = { rootId?: string; }; -export type Results = { - matchedPretext: string; - terms: string[]; - items: Array; - component?: React.ElementType; - components?: React.ElementType[]; -} - -type ResultsCallback = (results: Results) => void; +type Item = AutocompleteSuggestion | UserProfile | {channel: Channel}; export default class AppCommandProvider extends Provider { private store: Store; - private triggerCharacter: string; + public triggerCharacter: string; private appCommandParser: AppCommandParser; constructor(props: Props) { @@ -58,7 +50,7 @@ export default class AppCommandProvider extends Provider { this.appCommandParser.setChannelContext(props.channelId, props.teamId, props.rootId); } - handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { if (!pretext.startsWith(this.triggerCharacter)) { return false; } diff --git a/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx b/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx index e0403ca282..7913e77df3 100644 --- a/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx +++ b/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx @@ -8,7 +8,7 @@ import {Client4} from 'mattermost-redux/client'; import {AutocompleteSuggestion} from '@mattermost/types/integrations'; -import CommandProvider, {CommandSuggestion, Results} from './command_provider'; +import CommandProvider, {CommandSuggestion} from './command_provider'; describe('CommandSuggestion', () => { const suggestion: AutocompleteSuggestion = { @@ -24,6 +24,8 @@ describe('CommandSuggestion', () => { isSelection: true, term: '/', matchedPretext: '', + onClick: jest.fn(), + onMouseMove: jest.fn(), }; test('should match snapshot', () => { @@ -61,7 +63,7 @@ describe('CommandProvider', () => { provider.handlePretextChanged('/jira issue', callback); await mockFunc(); - const expected: Results = { + const expected = { matchedPretext: '/jira issue', terms: ['/jira issue'], items: [{ @@ -102,7 +104,7 @@ describe('CommandProvider', () => { provider.handlePretextChanged('/jira issue', callback); await mockFunc(); - const expected: Results = { + const expected = { matchedPretext: '/jira issue', terms: ['/jira issue'], items: [{ diff --git a/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx b/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx index a4e8f5151c..e48e756de9 100644 --- a/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx +++ b/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx @@ -14,8 +14,8 @@ import globalStore from 'stores/redux_store'; import * as UserAgent from 'utils/user_agent'; import {Constants} from 'utils/constants'; -import Suggestion from '../suggestion'; -import Provider from '../provider'; +import {SuggestionContainer, SuggestionProps} from '../suggestion'; +import Provider, {ResultsCallback} from '../provider'; import {GlobalState} from 'types/store'; @@ -27,62 +27,55 @@ const EXECUTE_CURRENT_COMMAND_ITEM_ID = Constants.Integrations.EXECUTE_CURRENT_C const OPEN_COMMAND_IN_MODAL_ITEM_ID = Constants.Integrations.OPEN_COMMAND_IN_MODAL_ITEM_ID; const COMMAND_SUGGESTION_ERROR = Constants.Integrations.COMMAND_SUGGESTION_ERROR; -export class CommandSuggestion extends Suggestion { - render() { - const {isSelection} = this.props; - const item = this.props.item as AutocompleteSuggestion; +const CommandSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; - let className = 'slash-command'; - if (isSelection) { - className += ' suggestion--selected'; - } - let symbolSpan = {'/'}; - switch (item.IconData) { - case EXECUTE_CURRENT_COMMAND_ITEM_ID: - symbolSpan = {'↵'}; - break; - case OPEN_COMMAND_IN_MODAL_ITEM_ID: - symbolSpan = ( - - - - ); - break; - case COMMAND_SUGGESTION_ERROR: - symbolSpan = {'!'}; - break; - } - let icon =
{symbolSpan}
; - if (item.IconData && ![EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, OPEN_COMMAND_IN_MODAL_ITEM_ID].includes(item.IconData)) { - icon = ( -
- -
); - } - - return ( + let symbolSpan = {'/'}; + switch (item.IconData) { + case EXECUTE_CURRENT_COMMAND_ITEM_ID: + symbolSpan = {'↵'}; + break; + case OPEN_COMMAND_IN_MODAL_ITEM_ID: + symbolSpan = ( + + + + ); + break; + case COMMAND_SUGGESTION_ERROR: + symbolSpan = {'!'}; + break; + } + let icon =
{symbolSpan}
; + if (item.IconData && ![EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, OPEN_COMMAND_IN_MODAL_ITEM_ID].includes(item.IconData)) { + icon = (
- {icon} -
-
- {item.Suggestion.substring(1) + ' ' + item.Hint} -
-
- {item.Description} -
+ +
); + } + + return ( + + {icon} +
+
+ {item.Suggestion.substring(1) + ' ' + item.Hint} +
+
+ {item.Description}
- ); - } -} +
+ ); +}); +CommandSuggestion.displayName = 'CommandSuggestion'; +export {CommandSuggestion}; type Props = { teamId: string; @@ -90,19 +83,10 @@ type Props = { rootId?: string; }; -export type Results = { - matchedPretext: string; - terms: string[]; - items: AutocompleteSuggestion[]; - component: React.ElementType; -} - -type ResultsCallback = (results: Results) => void; - export default class CommandProvider extends Provider { private props: Props; private store: Store; - private triggerCharacter: string; + public triggerCharacter: string; private appCommandParser: AppCommandParser; constructor(props: Props) { @@ -119,7 +103,7 @@ export default class CommandProvider extends Provider { this.appCommandParser.setChannelContext(props.channelId, props.teamId, props.rootId); } - handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { if (!pretext.startsWith(this.triggerCharacter)) { return false; } @@ -156,7 +140,7 @@ export default class CommandProvider extends Provider { callback(term + ' '); } - handleMobile(pretext: string, resultCallback: ResultsCallback) { + handleMobile(pretext: string, resultCallback: ResultsCallback) { const {teamId} = this.props; const command = pretext.toLowerCase(); @@ -209,7 +193,7 @@ export default class CommandProvider extends Provider { ); } - handleWebapp(pretext: string, resultCallback: ResultsCallback) { + handleWebapp(pretext: string, resultCallback: ResultsCallback) { const command = pretext.toLowerCase(); const {teamId, channelId, rootId} = this.props; diff --git a/webapp/channels/src/components/suggestion/emoticon_provider.test.jsx b/webapp/channels/src/components/suggestion/emoticon_provider.test.jsx index 06e7d35cb3..8e533a667b 100644 --- a/webapp/channels/src/components/suggestion/emoticon_provider.test.jsx +++ b/webapp/channels/src/components/suggestion/emoticon_provider.test.jsx @@ -7,7 +7,7 @@ import {getEmojiMap, getRecentEmojisNames} from 'selectors/emojis'; import EmoticonProvider, { MIN_EMOTICON_LENGTH, EMOJI_CATEGORY_SUGGESTION_BLOCKLIST, -} from 'components/suggestion/emoticon_provider.jsx'; +} from './emoticon_provider'; jest.mock('selectors/emojis', () => ({ getEmojiMap: jest.fn(), @@ -57,9 +57,9 @@ describe('components/EmoticonProvider', () => { expect(results.map((item) => item.name)).toEqual([ 'thumbsup', // thumbsup is a special case where it always appears before thumbsdown 'thumbsdown', + 'thunder_cloud_and_rain', 'thumbsdown-custom', 'thumbsup-custom', - 'thunder_cloud_and_rain', 'lithuania', 'lithuania-custom', ]); @@ -141,8 +141,8 @@ describe('components/EmoticonProvider', () => { 'lithuania-custom', 'thumbsup', 'thumbsdown', - 'thumbsup-custom', 'thunder_cloud_and_rain', + 'thumbsup-custom', 'lithuania', ]); }); @@ -162,8 +162,8 @@ describe('components/EmoticonProvider', () => { 'thumbsdown', 'thumbsdown-custom', 'lithuania-custom', - 'thumbsup-custom', 'thunder_cloud_and_rain', + 'thumbsup-custom', 'lithuania', ]); }); diff --git a/webapp/channels/src/components/suggestion/emoticon_provider.jsx b/webapp/channels/src/components/suggestion/emoticon_provider.tsx similarity index 78% rename from webapp/channels/src/components/suggestion/emoticon_provider.jsx rename to webapp/channels/src/components/suggestion/emoticon_provider.tsx index 81acc2dad6..e2efcfa386 100644 --- a/webapp/channels/src/components/suggestion/emoticon_provider.jsx +++ b/webapp/channels/src/components/suggestion/emoticon_provider.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import {Preferences} from 'utils/constants'; +import {Emoji} from '@mattermost/types/emojis'; import {autocompleteCustomEmojis} from 'mattermost-redux/actions/emojis'; import {getEmojiImageUrl, isSystemEmoji} from 'mattermost-redux/utils/emoji_utils'; @@ -12,55 +12,55 @@ import {getEmojiMap, getRecentEmojisNames} from 'selectors/emojis'; import store from 'stores/redux_store.jsx'; +import {Preferences} from 'utils/constants'; import * as Emoticons from 'utils/emoticons'; import {compareEmojis, emojiMatchesSkin} from 'utils/emoji_utils'; -import Suggestion from './suggestion.jsx'; -import Provider from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; +import Provider, {ResultsCallback} from './provider'; export const MIN_EMOTICON_LENGTH = 2; export const EMOJI_CATEGORY_SUGGESTION_BLOCKLIST = ['skintone']; -class EmoticonSuggestion extends Suggestion { - render() { - const text = this.props.term; - const emoji = this.props.item.emoji; - - let className = 'emoticon-suggestion'; - if (this.props.isSelection) { - className += ' suggestion--selected'; - } - - return ( -
-
- {text} -
-
- {text} -
-
- ); - } +type EmojiItem = { + name: string; + emoji: Emoji; + type: string; } +const EmoticonSuggestion = React.forwardRef>((props, ref) => { + const text = props.term; + const emoji = props.item.emoji; + + return ( + +
+ {text} +
+
+ {text} +
+
+ ); +}); +EmoticonSuggestion.displayName = 'EmoticonSuggestion'; + export default class EmoticonProvider extends Provider { constructor() { super(); this.triggerCharacter = ':'; } - handlePretextChanged(pretext, resultsCallback) { + + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { // Look for the potential emoticons at the start of the text, after whitespace, and at the start of emoji reaction commands const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext.toLowerCase()); if (!captured) { @@ -94,7 +94,7 @@ export default class EmoticonProvider extends Provider { return true; } - formatEmojis(emojis) { + formatEmojis(emojis: EmojiItem[]) { return emojis.map((item) => ':' + item.name + ':'); } @@ -108,9 +108,9 @@ export default class EmoticonProvider extends Provider { // // For now, this behaviour and difference is by design. // See https://mattermost.atlassian.net/browse/MM-17320. - findAndSuggestEmojis(text, partialName, resultsCallback) { - const recentMatched = []; - const matched = []; + findAndSuggestEmojis(text: string, partialName: string, resultsCallback: ResultsCallback) { + const recentMatched: EmojiItem[] = []; + const matched: EmojiItem[] = []; const state = store.getState(); const skintone = state.entities?.preferences?.myPreferences['emoji--emoji_skintone']?.value || 'default'; const emojiMap = getEmojiMap(state); @@ -148,8 +148,8 @@ export default class EmoticonProvider extends Provider { } } - const sortEmojisHelper = (a, b) => { - return compareEmojis(a, b, partialName); + const sortEmojisHelper = (a: EmojiItem, b: EmojiItem) => { + return compareEmojis(a.emoji, b.emoji, partialName); }; recentMatched.sort(sortEmojisHelper); diff --git a/webapp/channels/src/components/suggestion/generic_channel_provider.tsx b/webapp/channels/src/components/suggestion/generic_channel_provider.tsx index 2aa1797976..a9b2d0cd65 100644 --- a/webapp/channels/src/components/suggestion/generic_channel_provider.tsx +++ b/webapp/channels/src/components/suggestion/generic_channel_provider.tsx @@ -7,61 +7,44 @@ import {Channel} from '@mattermost/types/channels'; import {ServerError} from '@mattermost/types/errors'; import {ActionResult} from 'mattermost-redux/types/actions'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; - -export type Results = { - matchedPretext: string; - terms: string[]; - items: Channel[]; - component: React.ElementType; -} - -type ResultsCallback = (results: Results) => void; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; type ChannelSearchFunc = (term: string, success: (channels: Channel[]) => void, error?: (err: ServerError) => void) => (ActionResult | Promise); -class ChannelSuggestion extends Suggestion { - render() { - const isSelection = this.props.isSelection; - const item = this.props.item; +const GenericChannelSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; - const channelName = item.display_name; - const purpose = item.purpose; + const channelName = item.display_name; + const purpose = item.purpose; - const icon = ( - - - - ); - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } + const icon = ( + + + + ); - const description = '(~' + item.name + ')'; + const description = '(~' + item.name + ')'; - return ( -
- {icon} -
- - {channelName} - - {description} - {purpose} -
+ return ( + + {icon} +
+ + {channelName} + + {description} + {purpose}
- ); - } -} +
+ ); +}); +GenericChannelSuggestion.displayName = 'GenericChannelSuggestion'; -export default class ChannelProvider extends Provider { +export default class GenericChannelProvider extends Provider { autocompleteChannels: ChannelSearchFunc; constructor(channelSearchFunc: ChannelSearchFunc) { super(); @@ -69,24 +52,22 @@ export default class ChannelProvider extends Provider { this.autocompleteChannels = channelSearchFunc; } - handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { const normalizedPretext = pretext.toLowerCase(); this.startNewRequest(normalizedPretext); this.autocompleteChannels( normalizedPretext, - (data: Channel[]) => { + (channels: Channel[]) => { if (this.shouldCancelDispatch(normalizedPretext)) { return; } - const channels: Channel[] = Object.assign([], data); - resultsCallback({ matchedPretext: normalizedPretext, terms: channels.map((channel: Channel) => channel.display_name), items: channels, - component: ChannelSuggestion, + component: GenericChannelSuggestion, }); }, ); diff --git a/webapp/channels/src/components/suggestion/generic_user_provider.tsx b/webapp/channels/src/components/suggestion/generic_user_provider.tsx index dda5f733f6..a4299cdf05 100644 --- a/webapp/channels/src/components/suggestion/generic_user_provider.tsx +++ b/webapp/channels/src/components/suggestion/generic_user_provider.tsx @@ -13,85 +13,72 @@ import {isGuest} from 'mattermost-redux/utils/user_utils'; import Avatar from 'components/widgets/users/avatar'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; import {UserAutocomplete, UserProfile} from './command_provider/app_command_parser/app_command_parser_dependencies.js'; -export type ProviderResults = { - matchedPretext: string; - terms: string[]; - items: Array>; - component?: React.ReactNode; -} +const GenericUserSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; -class UserSuggestion extends Suggestion { - render() { - const {item, isSelection} = this.props; + const username = item.username; + let description = ''; - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - const username = item.username; - let description = ''; - - if ((item.first_name || item.last_name) && item.nickname) { - description = `- ${Utils.getFullName(item)} (${item.nickname})`; - } else if (item.nickname) { - description = `- (${item.nickname})`; - } else if (item.first_name || item.last_name) { - description = `- ${Utils.getFullName(item)}`; - } - - return ( -
- - - {'@' + username} - - {description} -
- {item.is_bot && } - {isGuest(item.roles) && } -
- ); + if ((item.first_name || item.last_name) && item.nickname) { + description = `- ${Utils.getFullName(item)} (${item.nickname})`; + } else if (item.nickname) { + description = `- (${item.nickname})`; + } else if (item.first_name || item.last_name) { + description = `- ${Utils.getFullName(item)}`; } -} -export default class UserProvider extends Provider { + return ( + + + + {'@' + username} + + {description} +
+ {item.is_bot && } + {isGuest(item.roles) && } + + ); +}); +GenericUserSuggestion.displayName = 'GenericUserSuggestion'; + +export default class GenericUserProvider extends Provider { autocompleteUsers: (text: string) => Promise; + constructor(searchUsersFunc: (username: string) => Promise) { super(); this.autocompleteUsers = searchUsersFunc; } - async handlePretextChanged(pretext: string, resultsCallback: (res: ProviderResults) => void) { + + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { const normalizedPretext = pretext.toLowerCase(); this.startNewRequest(normalizedPretext); - const data = await this.autocompleteUsers(normalizedPretext); + this.autocompleteUsers(normalizedPretext).then((data) => { + if (this.shouldCancelDispatch(normalizedPretext)) { + return; + } - if (this.shouldCancelDispatch(normalizedPretext)) { - return false; - } + const users = data.users; - const users = Object.assign([], data.users); - - resultsCallback({ - matchedPretext: normalizedPretext, - terms: users.map((user: UserProfile) => user.username), - items: users, - component: UserSuggestion, + resultsCallback({ + matchedPretext: normalizedPretext, + terms: users.map((user: UserProfile) => user.username), + items: users, + component: GenericUserSuggestion, + }); }); return true; diff --git a/webapp/channels/src/components/suggestion/menu_action_provider.tsx b/webapp/channels/src/components/suggestion/menu_action_provider.tsx index e06b980f83..ea4337e561 100644 --- a/webapp/channels/src/components/suggestion/menu_action_provider.tsx +++ b/webapp/channels/src/components/suggestion/menu_action_provider.tsx @@ -3,42 +3,37 @@ import React from 'react'; -import {ProviderResults} from './generic_user_provider'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; - -class MenuActionSuggestion extends Suggestion { - render() { - const {item, isSelection} = this.props; - - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - return ( -
- {item.text} -
- ); - } +interface MenuAction { + text: string; + value: string; } -export default class MenuActionProvider extends Provider { - private options: Array>; +const MenuActionSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; - constructor(options: Array>) { + return ( + + {item.text} + + ); +}); +MenuActionSuggestion.displayName = 'MenuActionSuggestion'; + +export default class MenuActionProvider extends Provider { + private options: MenuAction[]; + + constructor(options: MenuAction[]) { super(); this.options = options; } - handlePretextChanged(prefix: string, resultsCallback: (res: ProviderResults) => void) { + handlePretextChanged(prefix: string, resultsCallback: ResultsCallback) { if (prefix.length === 0) { this.displayAllOptions(resultsCallback); return true; @@ -52,7 +47,7 @@ export default class MenuActionProvider extends Provider { return false; } - async displayAllOptions(resultsCallback: (res: ProviderResults) => void) { + async displayAllOptions(resultsCallback: ResultsCallback) { const terms = this.options.map((option) => option.text); resultsCallback({ @@ -63,7 +58,7 @@ export default class MenuActionProvider extends Provider { }); } - async filterOptions(prefix: string, resultsCallback: (res: ProviderResults) => void) { + async filterOptions(prefix: string, resultsCallback: ResultsCallback) { const filteredOptions = this.options.filter((option) => option.text.toLowerCase().indexOf(prefix.toLowerCase()) >= 0); const terms = filteredOptions.map((option) => option.text); diff --git a/webapp/channels/src/components/suggestion/provider.tsx b/webapp/channels/src/components/suggestion/provider.tsx index b56398a911..809db7b487 100644 --- a/webapp/channels/src/components/suggestion/provider.tsx +++ b/webapp/channels/src/components/suggestion/provider.tsx @@ -1,20 +1,33 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -export type ProviderResult = { +import {RequireOnlyOne} from '@mattermost/types/utilities'; + +export type ProviderResult = { matchedPretext: string; terms: string[]; - items: Array>; - component?: React.ReactNode; -} + items: Array; +} & RequireOnlyOne<{ + component: React.ReactNode; + components: React.ReactNode[]; +}>; -export default class Provider { +export type Loading = { + type: string; + loading: boolean; +}; + +export type ResultsCallback = (result: ProviderResult) => void; + +export default abstract class Provider { latestPrefix: string; latestComplete: boolean; disableDispatches: boolean; requestStarted: boolean; forceDispatch: boolean; + triggerCharacter?: string; + constructor() { this.latestPrefix = ''; this.latestComplete = true; @@ -23,9 +36,7 @@ export default class Provider { this.forceDispatch = false; } - handlePretextChanged(pretext: string, callback: (res: ProviderResult) => void) {// eslint-disable-line @typescript-eslint/no-unused-vars - // NO-OP for inherited classes to override - } + abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult) => void): boolean; resetRequest() { this.requestStarted = false; diff --git a/webapp/channels/src/components/suggestion/search_channel_provider.tsx b/webapp/channels/src/components/suggestion/search_channel_provider.tsx index 55c13835b5..0d6ac11ad7 100644 --- a/webapp/channels/src/components/suggestion/search_channel_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_channel_provider.tsx @@ -11,22 +11,13 @@ import store from 'stores/redux_store.jsx'; import Constants from 'utils/constants'; import {getCurrentLocale} from 'selectors/i18n'; -import Provider from './provider'; +import Provider, {ResultsCallback} from './provider'; import SearchChannelSuggestion from './search_channel_suggestion'; import {Channel} from './command_provider/app_command_parser/app_command_parser_dependencies.js'; const getState = store.getState; -export type Results = { - matchedPretext: string; - terms: string[]; - items: Channel[]; - component: React.ElementType; -} - -type ResultsCallback = (results: Results) => void; - function itemToTerm(isAtSearch: boolean, item: { type: string; display_name: string; name: string }) { const prefix = isAtSearch ? '' : '@'; if (item.type === Constants.DM_CHANNEL) { @@ -48,7 +39,7 @@ export default class SearchChannelProvider extends Provider { this.autocompleteChannelsForSearch = channelSearchFunc; } - handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase()); if (captured) { let channelPrefix = captured[1]; diff --git a/webapp/channels/src/components/suggestion/search_channel_suggestion/__snapshots__/search_channel_suggestion.test.tsx.snap b/webapp/channels/src/components/suggestion/search_channel_suggestion/__snapshots__/search_channel_suggestion.test.tsx.snap index 7fac6174e4..c1f2465f77 100644 --- a/webapp/channels/src/components/suggestion/search_channel_suggestion/__snapshots__/search_channel_suggestion.test.tsx.snap +++ b/webapp/channels/src/components/suggestion/search_channel_suggestion/__snapshots__/search_channel_suggestion.test.tsx.snap @@ -1,12 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/suggestion/search_channel_suggestion should match snapshot 1`] = ` -
~DN
-
+ `; exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type DM_CHANNEL 1`] = ` -
-
+ `; exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type GM_CHANNEL 1`] = ` -
-
+ `; exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type OPEN_CHANNEL 1`] = ` -
~DN
- + `; exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type PRIVATE_CHANNEL 1`] = ` -
~DN
- + `; exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is false 1`] = ` -
~DN
- + `; exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is true 1`] = ` -
~DN
- + `; diff --git a/webapp/channels/src/components/suggestion/search_channel_suggestion/index.ts b/webapp/channels/src/components/suggestion/search_channel_suggestion/index.ts index 43af0ed6fc..fb22de509a 100644 --- a/webapp/channels/src/components/suggestion/search_channel_suggestion/index.ts +++ b/webapp/channels/src/components/suggestion/search_channel_suggestion/index.ts @@ -16,9 +16,11 @@ type OwnProps = { } const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { + const teammate = getDirectTeammate(state, ownProps.item.id); + return { - teammate: getDirectTeammate(state, ownProps.item.id), - currentUser: getCurrentUserId(state), + teammateIsBot: Boolean(teammate && teammate.is_bot), + currentUserId: getCurrentUserId(state), }; }; diff --git a/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.test.tsx b/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.test.tsx index 11ba7213f4..69015fee6c 100644 --- a/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.test.tsx +++ b/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.test.tsx @@ -10,15 +10,16 @@ import SearchChannelSuggestion from './search_channel_suggestion'; describe('components/suggestion/search_channel_suggestion', () => { const mockChannel = TestHelper.getChannelMock(); - const mockTeamMate = TestHelper.getUserMock(); const baseProps = { item: mockChannel, isSelection: false, - teammate: mockTeamMate, - currentUser: 'userid1', + currentUserId: 'userid1', + teammateIsBot: false, term: '', matchedPretext: '', + onClick: jest.fn(), + onMouseMove: jest.fn(), }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.tsx b/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.tsx index 5cbce29f87..5a4e2cc5ea 100644 --- a/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.tsx +++ b/webapp/channels/src/components/suggestion/search_channel_suggestion/search_channel_suggestion.tsx @@ -9,15 +9,16 @@ import {getUserIdFromChannelName} from 'mattermost-redux/utils/channel_utils'; import {imageURLForUser} from 'utils/utils'; import Constants from 'utils/constants'; import Avatar from 'components/widgets/users/avatar'; -import Suggestion from '../suggestion'; + +import {SuggestionContainer, SuggestionProps} from '../suggestion'; import {Channel} from '@mattermost/types/channels'; -function itemToName(item: Channel, currentUser: string): {icon: React.ReactElement; name: string; description: string} | null { +function itemToName(item: Channel, currentUserId: string): {icon: React.ReactElement; name: string; description: string} | null { if (item.type === Constants.DM_CHANNEL) { const profilePicture = ( ); @@ -68,40 +69,38 @@ function itemToName(item: Channel, currentUser: string): {icon: React.ReactEleme return null; } -export default class SearchChannelSuggestion extends Suggestion { - render(): JSX.Element { - const {item, isSelection, teammate, currentUser} = this.props; - - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - const nameObject = itemToName(item, currentUser); - if (!nameObject) { - return (<>); - } - - const {icon, name, description} = nameObject; - - const tag = item.type === Constants.DM_CHANNEL && teammate && teammate.is_bot ? : null; - - return ( -
- {icon} -
- - {name} - - {description} -
- {tag} -
- ); - } +type Props = SuggestionProps & { + currentUserId: string; + teammateIsBot: boolean; } + +const SearchChannelSuggestion = React.forwardRef((props, ref) => { + const {item, teammateIsBot, currentUserId} = props; + + const nameObject = itemToName(item, currentUserId); + if (!nameObject) { + return (<>); + } + + const {icon, name, description} = nameObject; + + const tag = item.type === Constants.DM_CHANNEL && teammateIsBot ? : null; + + return ( + + {icon} +
+ + {name} + + {description} +
+ {tag} +
+ ); +}); +SearchChannelSuggestion.displayName = 'SearchChannelSuggestion'; +export default SearchChannelSuggestion; diff --git a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.test.jsx b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.test.jsx index ba13d2dfd1..6e15862f60 100644 --- a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.test.jsx +++ b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.test.jsx @@ -5,7 +5,7 @@ import {getState} from 'stores/redux_store'; import mockStore from 'tests/test_store'; -import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider.jsx'; +import SearchChannelWithPermissionsProvider from './search_channel_with_permissions_provider'; jest.mock('stores/redux_store', () => ({ dispatch: jest.fn(), diff --git a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.jsx b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx similarity index 67% rename from webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.jsx rename to webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx index 3ff2e2b0aa..7152d00959 100644 --- a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.jsx +++ b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx @@ -3,6 +3,8 @@ import React from 'react'; +import {Channel} from '@mattermost/types/channels'; + import { getChannelsInCurrentTeam, } from 'mattermost-redux/selectors/entities/channels'; @@ -14,68 +16,57 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles' import {Permissions} from 'mattermost-redux/constants'; import {sortChannelsByTypeAndDisplayName} from 'mattermost-redux/utils/channel_utils'; import {logError} from 'mattermost-redux/actions/errors'; +import {ActionResult} from 'mattermost-redux/types/actions'; import store from 'stores/redux_store.jsx'; import {Constants} from 'utils/constants'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; -class SearchChannelWithPermissionsSuggestion extends Suggestion { - static get propTypes() { - return { - ...super.propTypes, - }; - } +type WrappedChannel = { + channel: Channel; + name: string; +} - render() { - const {item, isSelection} = this.props; - const channel = item.channel; - const channelIsArchived = channel.delete_at && channel.delete_at !== 0; +const SearchChannelWithPermissionsSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; + const channel = item.channel; + const channelIsArchived = channel.delete_at && channel.delete_at !== 0; - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - const displayName = channel.display_name; - let icon = null; - if (channelIsArchived) { - icon = ( - - ); - } else if (channel.type === Constants.OPEN_CHANNEL) { - icon = ( - - ); - } else if (channel.type === Constants.PRIVATE_CHANNEL) { - icon = ( - - ); - } - - return ( -
{ - this.node = node; - }} - {...Suggestion.baseProps} - > - {icon} -
- {displayName} -
-
+ const displayName = channel.display_name; + let icon = null; + if (channelIsArchived) { + icon = ( + + ); + } else if (channel.type === Constants.OPEN_CHANNEL) { + icon = ( + + ); + } else if (channel.type === Constants.PRIVATE_CHANNEL) { + icon = ( + ); } -} + + return ( + + {icon} +
+ {displayName} +
+
+ ); +}); +SearchChannelWithPermissionsSuggestion.displayName = 'SearchChannelWithPermissionsSuggestion'; let prefix = ''; -function channelSearchSorter(wrappedA, wrappedB) { +function channelSearchSorter(wrappedA: WrappedChannel, wrappedB: WrappedChannel) { const aIsArchived = wrappedA.channel.delete_at ? wrappedA.channel.delete_at !== 0 : false; const bIsArchived = wrappedB.channel.delete_at ? wrappedB.channel.delete_at !== 0 : false; if (aIsArchived && !bIsArchived) { @@ -106,15 +97,17 @@ function channelSearchSorter(wrappedA, wrappedB) { } export default class SearchChannelWithPermissionsProvider extends Provider { - constructor(channelSearchFunc) { + autocompleteChannelsForSearch: (channelId: string, userId: string) => Promise>; + + constructor(channelSearchFunc: SearchChannelWithPermissionsProvider['autocompleteChannelsForSearch']) { super(); this.autocompleteChannelsForSearch = channelSearchFunc; } - makeChannelSearchFilter(channelPrefix) { + makeChannelSearchFilter(channelPrefix: string) { const channelPrefixLower = channelPrefix.toLowerCase(); - return (channel) => { + return (channel: Channel) => { const state = store.getState(); const channelId = channel.id; const teamId = getCurrentTeamId(state); @@ -133,7 +126,7 @@ export default class SearchChannelWithPermissionsProvider extends Provider { }; } - handlePretextChanged(channelPrefix, resultsCallback) { + handlePretextChanged(channelPrefix: string, resultsCallback: ResultsCallback) { if (channelPrefix) { prefix = channelPrefix; this.startNewRequest(channelPrefix); @@ -150,7 +143,7 @@ export default class SearchChannelWithPermissionsProvider extends Provider { return true; } - async fetchChannels(channelPrefix, resultsCallback) { + async fetchChannels(channelPrefix: string, resultsCallback: ResultsCallback) { const state = store.getState(); const teamId = getCurrentTeamId(state); if (!teamId) { @@ -159,10 +152,10 @@ export default class SearchChannelWithPermissionsProvider extends Provider { const channelsAsync = this.autocompleteChannelsForSearch(teamId, channelPrefix); - let channelsFromServer = []; + let channelsFromServer: Channel[] = []; try { const {data} = await channelsAsync; - channelsFromServer = data; + channelsFromServer = data ?? []; } catch (err) { store.dispatch(logError(err)); } @@ -175,7 +168,7 @@ export default class SearchChannelWithPermissionsProvider extends Provider { this.formatChannelsAndDispatch(channelPrefix, resultsCallback, channels); } - formatChannelsAndDispatch(channelPrefix, resultsCallback, allChannels) { + formatChannelsAndDispatch(channelPrefix: string, resultsCallback: ResultsCallback, allChannels: Channel[]) { const channels = []; const state = store.getState(); @@ -186,15 +179,14 @@ export default class SearchChannelWithPermissionsProvider extends Provider { return; } - const completedChannels = {}; + const completedChannels: Record = {}; const channelFilter = this.makeChannelSearchFilter(channelPrefix); const config = getConfig(state); const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; - for (const id of Object.keys(allChannels)) { - const channel = allChannels[id]; + for (const channel of allChannels) { if (!channel) { continue; } @@ -207,16 +199,15 @@ export default class SearchChannelWithPermissionsProvider extends Provider { const newChannel = Object.assign({}, channel); const channelIsArchived = channel.delete_at !== 0; - const wrappedChannel = {channel: newChannel, name: newChannel.name, deactivated: false}; + const wrappedChannel = { + channel: newChannel, + name: newChannel.name, + }; if (!viewArchivedChannels && channelIsArchived) { continue; } else if (!members[channel.id]) { continue; - } else if (channel.type === Constants.OPEN_CHANNEL) { - wrappedChannel.type = Constants.OPEN_CHANNEL; - } else if (channel.type === Constants.PRIVATE_CHANNEL) { - wrappedChannel.type = Constants.PRIVATE_CHANNEL; - } else { + } else if (channel.type !== Constants.OPEN_CHANNEL && channel.type !== Constants.PRIVATE_CHANNEL) { continue; } completedChannels[channel.id] = true; diff --git a/webapp/channels/src/components/suggestion/search_date_provider.tsx b/webapp/channels/src/components/suggestion/search_date_provider.tsx index 9dd0af96b6..f0252b7c54 100644 --- a/webapp/channels/src/components/suggestion/search_date_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_date_provider.tsx @@ -1,15 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import Provider from './provider'; +import Provider, {ResultsCallback} from './provider'; import SearchDateSuggestion from './search_date_suggestion'; type DateItem = {label: string; date: string}; -type ResultsCallback = (results: {matchedPretext: string; terms: string[]; items: DateItem[]; component: typeof SearchDateSuggestion}) => void; - export default class SearchDateProvider extends Provider { - handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { const captured = (/\b(?:on|before|after):\s*(\S*)$/i).exec(pretext.toLowerCase()); if (captured) { const datePrefix = captured[1]; diff --git a/webapp/channels/src/components/suggestion/search_date_suggestion/search_date_suggestion.tsx b/webapp/channels/src/components/suggestion/search_date_suggestion/search_date_suggestion.tsx index 0de0c3239c..5cf5167885 100644 --- a/webapp/channels/src/components/suggestion/search_date_suggestion/search_date_suggestion.tsx +++ b/webapp/channels/src/components/suggestion/search_date_suggestion/search_date_suggestion.tsx @@ -7,7 +7,7 @@ import {DayPicker} from 'react-day-picker'; import type {Locale} from 'date-fns'; -import Suggestion from '../suggestion.jsx'; +import {SuggestionProps} from '../suggestion'; import * as Keyboard from 'utils/keyboard'; import * as Utils from 'utils/utils'; @@ -15,7 +15,14 @@ import Constants from 'utils/constants'; import 'react-day-picker/dist/style.css'; -export default class SearchDateSuggestion extends Suggestion { +type Props = SuggestionProps & { + currentDate?: Date; + handleEscape: () => void; + locale: string; + preventClose: () => void; +} + +export default class SearchDateSuggestion extends React.PureComponent { private loadedLocales: Record = {}; state = { diff --git a/webapp/channels/src/components/suggestion/search_user_provider.tsx b/webapp/channels/src/components/suggestion/search_user_provider.tsx index a55d450d14..f429a3873c 100644 --- a/webapp/channels/src/components/suggestion/search_user_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_user_provider.tsx @@ -12,68 +12,55 @@ import SharedUserIndicator from 'components/shared_user_indicator'; import {UserProfile} from '@mattermost/types/users'; import {UserAutocomplete} from '@mattermost/types/autocomplete'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; -import {ProviderResults} from './generic_user_provider'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; -class SearchUserSuggestion extends Suggestion { - private node?: HTMLDivElement | null; - render() { - const {item, isSelection} = this.props; +const SearchUserSuggestion = React.forwardRef>((props, ref) => { + const {item} = props; - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } + const username = item.username; + let description = ''; - const username = item.username; - let description = ''; + if ((item.first_name || item.last_name) && item.nickname) { + description = `${Utils.getFullName(item)} (${item.nickname})`; + } else if (item.nickname) { + description = `(${item.nickname})`; + } else if (item.first_name || item.last_name) { + description = `${Utils.getFullName(item)}`; + } - if ((item.first_name || item.last_name) && item.nickname) { - description = `${Utils.getFullName(item)} (${item.nickname})`; - } else if (item.nickname) { - description = `(${item.nickname})`; - } else if (item.first_name || item.last_name) { - description = `${Utils.getFullName(item)}`; - } - - let sharedIcon; - if (item.remote_id) { - sharedIcon = ( - - ); - } - - return ( -
{ - this.node = node; - }} - onClick={this.handleClick} - onMouseMove={this.handleMouseMove} - {...Suggestion.baseProps} - > - -
- - {'@'}{username} - - {item.is_bot && } - {description} -
- {sharedIcon} -
+ let sharedIcon; + if (item.remote_id) { + sharedIcon = ( + ); } -} + + return ( + + +
+ + {'@'}{username} + + {item.is_bot && } + {description} +
+ {sharedIcon} +
+ ); +}); +SearchUserSuggestion.displayName = 'SearchUserSuggestion'; export default class SearchUserProvider extends Provider { private autocompleteUsersInTeam: (username: string) => Promise; @@ -82,7 +69,7 @@ export default class SearchUserProvider extends Provider { this.autocompleteUsersInTeam = userSearchFunc; } - handlePretextChanged(pretext: string, resultsCallback: (res: ProviderResults) => void) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext.toLowerCase()); this.doAutocomplete(captured, resultsCallback); @@ -90,7 +77,7 @@ export default class SearchUserProvider extends Provider { return Boolean(captured); } - async doAutocomplete(captured: RegExpExecArray | null, resultsCallback: (res: ProviderResults) => void) { + async doAutocomplete(captured: RegExpExecArray | null, resultsCallback: ResultsCallback) { if (!captured) { return; } diff --git a/webapp/channels/src/components/suggestion/suggestion.jsx b/webapp/channels/src/components/suggestion/suggestion.jsx deleted file mode 100644 index 2c665d3cc3..0000000000 --- a/webapp/channels/src/components/suggestion/suggestion.jsx +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import PropTypes from 'prop-types'; -import React from 'react'; - -export default class Suggestion extends React.PureComponent { - static get propTypes() { - return { - item: PropTypes.oneOfType([ - PropTypes.object, - PropTypes.string, - ]).isRequired, - term: PropTypes.string.isRequired, - matchedPretext: PropTypes.string.isRequired, - isSelection: PropTypes.bool, - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - }; - } - - static baseProps = { - role: 'button', - tabIndex: -1, - }; - - handleClick = (e) => { - e.preventDefault(); - - this.props.onClick(this.props.term, this.props.matchedPretext); - }; - - handleMouseMove = (e) => { - e.preventDefault(); - - this.props.onMouseMove(this.props.term); - }; -} diff --git a/webapp/channels/src/components/suggestion/suggestion.tsx b/webapp/channels/src/components/suggestion/suggestion.tsx new file mode 100644 index 0000000000..5ed17d74c3 --- /dev/null +++ b/webapp/channels/src/components/suggestion/suggestion.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback} from 'react'; + +export interface SuggestionProps extends Omit, 'onClick' | 'onMouseMove'> { + // eslint-disable-next-line react/no-unused-prop-types + item: Item; + + term: string; + matchedPretext: string; + isSelection: boolean; + + children?: React.ReactNode; + onClick: (term: string, matchedPretext: string) => void; + onMouseMove: (term: string) => void; +} + +const SuggestionContainer = React.forwardRef>((props, ref) => { + const { + children, + term, + matchedPretext, + isSelection, + + onClick, + onMouseMove, + + role = 'button', + tabIndex = -1, + ...otherProps + } = props; + + Reflect.deleteProperty(otherProps, 'item'); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + + onClick(term, matchedPretext); + }, [onClick, term, matchedPretext]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + + onMouseMove(term); + }, [onMouseMove, term]); + + return ( +
+ {children} +
+ ); +}); + +SuggestionContainer.displayName = 'SuggestionContainer'; +export {SuggestionContainer}; diff --git a/webapp/channels/src/components/suggestion/suggestion_list.d.ts b/webapp/channels/src/components/suggestion/suggestion_list.d.ts index 0083f67ed6..b2536df358 100644 --- a/webapp/channels/src/components/suggestion/suggestion_list.d.ts +++ b/webapp/channels/src/components/suggestion/suggestion_list.d.ts @@ -24,7 +24,7 @@ interface Props { items: any[]; terms: string[]; selection: string; - components: Array>; + components: Array>>; wrapperHeight?: number; // suggestionBoxAlgn is an optional object that can be passed to align the SuggestionList with the keyboard caret diff --git a/webapp/channels/src/components/suggestion/switch_channel_provider.test.jsx b/webapp/channels/src/components/suggestion/switch_channel_provider.test.jsx index 9e82d61cb3..64eca564ba 100644 --- a/webapp/channels/src/components/suggestion/switch_channel_provider.test.jsx +++ b/webapp/channels/src/components/suggestion/switch_channel_provider.test.jsx @@ -5,9 +5,10 @@ import {getState} from 'stores/redux_store'; import mockStore from 'tests/test_store'; -import SwitchChannelProvider from 'components/suggestion/switch_channel_provider.jsx'; import {Preferences} from 'mattermost-redux/constants'; +import SwitchChannelProvider from './switch_channel_provider'; + const latestPost = { id: 'latest_post_id', user_id: 'current_user_id', diff --git a/webapp/channels/src/components/suggestion/switch_channel_provider.jsx b/webapp/channels/src/components/suggestion/switch_channel_provider.tsx similarity index 67% rename from webapp/channels/src/components/suggestion/switch_channel_provider.jsx rename to webapp/channels/src/components/suggestion/switch_channel_provider.tsx index 4dbaf31ace..070a843dcf 100644 --- a/webapp/channels/src/components/suggestion/switch_channel_provider.jsx +++ b/webapp/channels/src/components/suggestion/switch_channel_provider.tsx @@ -2,10 +2,15 @@ // See LICENSE.txt for license information. import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import classNames from 'classnames'; +import {Channel, ChannelMembership, ChannelType} from '@mattermost/types/channels'; +import {PreferenceType} from '@mattermost/types/preferences'; +import {Team} from '@mattermost/types/teams'; +import {UserProfile} from '@mattermost/types/users'; +import {RelationOneToOne} from '@mattermost/types/utilities'; + import GuestTag from 'components/widgets/tag/guest_tag'; import BotTag from 'components/widgets/tag/bot_tag'; @@ -36,11 +41,11 @@ import { getUser, makeSearchProfilesMatchingWithTerm, getStatusForUserId, - getUserByUsername, } from 'mattermost-redux/selectors/entities/users'; import {fetchAllMyTeamsChannelsAndChannelMembersREST, searchAllChannels} from 'mattermost-redux/actions/channels'; import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads'; import {logError} from 'mattermost-redux/actions/errors'; +import {ActionResult} from 'mattermost-redux/types/actions'; import {sortChannelsByTypeAndDisplayName, isChannelMuted} from 'mattermost-redux/utils/channel_utils'; import SharedChannelIndicator from 'components/shared_channel_indicator'; import CustomStatusEmoji from 'components/custom_status/custom_status_emoji'; @@ -53,12 +58,14 @@ import {isGuest} from 'mattermost-redux/utils/user_utils'; import {Preferences} from 'mattermost-redux/constants'; import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils'; -import Provider from './provider'; -import Suggestion from './suggestion.jsx'; +import Provider, {ResultsCallback} from './provider'; +import {SuggestionContainer, SuggestionProps} from './suggestion'; +import {GlobalState} from 'types/store'; const getState = store.getState; const searchProfilesMatchingWithTerm = makeSearchProfilesMatchingWithTerm(); -const ThreadsChannel = { + +const ThreadsChannel: FakeChannel = { id: 'threads', name: 'threads', display_name: 'Threads', @@ -66,7 +73,7 @@ const ThreadsChannel = { delete_at: 0, }; -const InsightsChannel = { +const InsightsChannel: FakeChannel = { id: 'insights', name: 'activity-and-insights', display_name: 'Insights', @@ -74,216 +81,244 @@ const InsightsChannel = { delete_at: 0, }; -class SwitchChannelSuggestion extends Suggestion { - static get propTypes() { - return { - ...super.propTypes, - channelMember: PropTypes.object, - hasDraft: PropTypes.bool, - userImageUrl: PropTypes.string, - dmChannelTeammate: PropTypes.object, - collapsedThreads: PropTypes.bool, - }; - } - - render() { - const {item, isSelection, userImageUrl, status, userItem, collapsedThreads, team, isPartOfOnlyOneTeam} = this.props; - const channel = item.channel; - const channelIsArchived = channel.delete_at && channel.delete_at !== 0; - - const member = this.props.channelMember; - const teammate = this.props.dmChannelTeammate; - let badge = null; - - if ((member && member.notify_props) || item.unread_mentions) { - let unreadMentions; - if (item.unread_mentions) { - unreadMentions = item.unread_mentions; - } else { - unreadMentions = collapsedThreads ? member.mention_count_root : member.mention_count; - } - if (unreadMentions > 0 && !channelIsArchived) { - badge = ( -
- - {unreadMentions} - -
- ); - } - } - - let className = 'suggestion-list__item'; - if (isSelection) { - className += ' suggestion--selected'; - } - - let name = channel.display_name; - let description = '~' + channel.name; - let icon; - if (channelIsArchived) { - icon = ( - - - - ); - } else if (this.props.hasDraft) { - icon = ( - - - - ); - } else if (channel.type === Constants.OPEN_CHANNEL) { - icon = ( - - - - ); - } else if (channel.type === Constants.PRIVATE_CHANNEL) { - icon = ( - - - - ); - } else if (channel.type === Constants.THREADS) { - icon = ( - - - - ); - } else if (channel.type === Constants.INSIGHTS) { - icon = ( - - - - ); - } else if (channel.type === Constants.GM_CHANNEL) { - icon = ( - -
{'G'}
-
- ); - } else { - icon = ( - - ); - } - - let tag = null; - let customStatus = null; - if (channel.type === Constants.DM_CHANNEL) { - if (teammate && teammate.is_bot) { - tag = ; - } else if (isGuest(teammate ? teammate.roles : '')) { - tag = ; - } - - customStatus = ( - - ); - - let deactivated = ''; - if (userItem.delete_at) { - deactivated = (' - ' + Utils.localizeMessage('channel_switch_modal.deactivated', 'Deactivated')); - } - - if (channel.display_name && !(teammate && teammate.is_bot)) { - description = '@' + userItem.username + deactivated; - } else { - name = userItem.username; - const currentUserId = getCurrentUserId(getState()); - if (userItem.id === currentUserId) { - name += (' ' + Utils.localizeMessage('suggestion.user.isCurrent', '(you)')); - } - description = deactivated; - } - } else if (channel.type === Constants.GM_CHANNEL) { - // remove the slug from the option - name = channel.display_name; - description = ''; - } - - let sharedIcon = null; - if (channel.shared) { - sharedIcon = ( - - ); - } - - let teamName = null; - if (channel.team_id && team) { - teamName = ({team.display_name}); - } - const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS; - - return ( -
{ - this.node = node; - }} - id={`switchChannel_${channel.name}`} - data-testid={channel.name} - aria-label={name} - {...Suggestion.baseProps} - > - {icon} -
- - {name} - {showSlug && description && {description}} - - {customStatus} - {sharedIcon} - {tag} - {badge} - {!isPartOfOnlyOneTeam && teamName} -
-
- ); - } +type FakeChannel = Pick & { + type: string; } -function mapStateToPropsForSwitchChannelSuggestion(state, ownProps) { +type FakeDirectChannel = FakeChannel & { + userId: string; +} + +type ChannelItem = Channel | FakeChannel | FakeDirectChannel; + +function isRealChannel(item?: ChannelItem): item is Channel { + return Boolean(item) && !isFakeChannel(item) && !isFakeDirectChannel(item); +} + +function isFakeChannel(item?: ChannelItem): item is FakeChannel { + return Boolean(item) && !('create_at' in item!); +} + +function isFakeDirectChannel(item?: ChannelItem): item is FakeDirectChannel { + return Boolean(item && 'userId' in item); +} + +interface WrappedChannel { + channel: ChannelItem; + name: string; + deactivated: boolean; + last_viewed_at?: number; + type?: string; + unread?: boolean; + unread_mentions?: number; +} + +type Props = SuggestionProps & { + channelMember: ChannelMembership; + collapsedThreads: boolean; + dmChannelTeammate?: UserProfile; + hasDraft: boolean; + isPartOfOnlyOneTeam: boolean; + status?: string; + team?: Team; +} + +const SwitchChannelSuggestion = React.forwardRef((props, ref) => { + const {item, status, collapsedThreads, team, isPartOfOnlyOneTeam} = props; + const channel = item.channel; + const channelIsArchived = channel.delete_at && channel.delete_at !== 0; + + const member = props.channelMember; + const teammate = props.dmChannelTeammate; + let badge = null; + + if ((member && member.notify_props) || item.unread_mentions) { + let unreadMentions; + if (item.unread_mentions) { + unreadMentions = item.unread_mentions; + } else { + unreadMentions = collapsedThreads ? member.mention_count_root : member.mention_count; + } + if (unreadMentions > 0 && !channelIsArchived) { + badge = ( +
+ + {unreadMentions} + +
+ ); + } + } + + let name = channel.display_name; + let description = '~' + channel.name; + let icon; + if (channelIsArchived) { + icon = ( + + + + ); + } else if (props.hasDraft) { + icon = ( + + + + ); + } else if (channel.type === Constants.OPEN_CHANNEL) { + icon = ( + + + + ); + } else if (channel.type === Constants.PRIVATE_CHANNEL) { + icon = ( + + + + ); + } else if (channel.type === Constants.THREADS) { + icon = ( + + + + ); + } else if (channel.type === Constants.INSIGHTS) { + icon = ( + + + + ); + } else if (channel.type === Constants.GM_CHANNEL) { + icon = ( + +
{'G'}
+
+ ); + } else if (teammate) { + icon = ( + + ); + } + + let tag = null; + let customStatus = null; + if (channel.type === Constants.DM_CHANNEL && teammate) { + if (teammate && teammate.is_bot) { + tag = ; + } else if (isGuest(teammate ? teammate.roles : '')) { + tag = ; + } + + customStatus = ( + + ); + + let deactivated = ''; + if (teammate.delete_at) { + deactivated = (' - ' + Utils.localizeMessage('channel_switch_modal.deactivated', 'Deactivated')); + } + + if (channel.display_name && !(teammate && teammate.is_bot)) { + description = '@' + teammate.username + deactivated; + } else { + name = teammate.username; + const currentUserId = getCurrentUserId(getState()); + if (teammate.id === currentUserId) { + name += (' ' + Utils.localizeMessage('suggestion.user.isCurrent', '(you)')); + } + description = deactivated; + } + } else if (channel.type === Constants.GM_CHANNEL) { + // remove the slug from the option + name = channel.display_name; + description = ''; + } + + let sharedIcon = null; + if (isRealChannel(channel) && channel.shared) { + sharedIcon = ( + + ); + } + + let teamName = null; + if (isRealChannel(channel) && channel.team_id && team) { + teamName = ({team.display_name}); + } + const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS; + + return ( + + {icon} +
+ + {name} + {showSlug && description && {description}} + + {customStatus} + {sharedIcon} + {tag} + {badge} + {!isPartOfOnlyOneTeam && teamName} +
+
+ ); +}); +SwitchChannelSuggestion.displayName = 'SwitchChannelSuggestion'; + +type OwnProps = SuggestionProps; + +function mapStateToPropsForSwitchChannelSuggestion(state: GlobalState, ownProps: OwnProps) { const channel = ownProps.item && ownProps.item.channel; const channelId = channel ? channel.id : ''; const draft = channelId ? getPostDraft(state, StoragePrefixes.DRAFT, channelId) : false; - const user = channel && getUser(state, channel.userId); - const userImageUrl = user && Utils.imageURLForUser(user.id, user.last_picture_update); - let dmChannelTeammate = channel && channel.type === Constants.DM_CHANNEL && getDirectTeammate(state, channel.id); - const userItem = getUserByUsername(state, channel.name); - const status = getStatusForUserId(state, channel.userId); - const collapsedThreads = isCollapsedThreadsEnabled(state); - const team = getTeam(state, channel.team_id); - const isPartOfOnlyOneTeam = getMyTeams(state).length === 1; - if (channel && !dmChannelTeammate) { + let dmChannelTeammate; + if (isRealChannel(channel) && channel.type === Constants.DM_CHANNEL) { + dmChannelTeammate = getDirectTeammate(state, channel.id); + } else if (isFakeDirectChannel(channel)) { dmChannelTeammate = getUser(state, channel.userId); } + let status; + if (dmChannelTeammate) { + status = getStatusForUserId(state, dmChannelTeammate.id); + } + + const collapsedThreads = isCollapsedThreadsEnabled(state); + + let team; + if (isRealChannel(channel)) { + team = getTeam(state, channel.team_id); + } + + const isPartOfOnlyOneTeam = getMyTeams(state).length === 1; + return { channelMember: getMyChannelMemberships(state)[channelId], hasDraft: draft && Boolean(draft.message.trim() || draft.fileInfos.length || draft.uploadsInProgress.length), - userImageUrl, dmChannelTeammate, status, - userItem, collapsedThreads, team, isPartOfOnlyOneTeam, @@ -294,7 +329,7 @@ const ConnectedSwitchChannelSuggestion = connect(mapStateToPropsForSwitchChannel let prefix = ''; -function sortChannelsByRecencyAndTypeAndDisplayName(wrappedA, wrappedB) { +function sortChannelsByRecencyAndTypeAndDisplayName(wrappedA: WrappedChannel, wrappedB: WrappedChannel) { if (wrappedA.last_viewed_at && wrappedB.last_viewed_at) { return wrappedB.last_viewed_at - wrappedA.last_viewed_at; } else if (wrappedA.last_viewed_at) { @@ -304,10 +339,10 @@ function sortChannelsByRecencyAndTypeAndDisplayName(wrappedA, wrappedB) { } // MM-12677 When this is migrated this needs to be fixed to pull the user's locale - return sortChannelsByTypeAndDisplayName('en', wrappedA.channel, wrappedB.channel); + return sortChannelsByTypeAndDisplayName('en', wrappedA.channel as Channel, wrappedB.channel as Channel); } -export function quickSwitchSorter(wrappedA, wrappedB) { +export function quickSwitchSorter(wrappedA: WrappedChannel, wrappedB: WrappedChannel) { const aIsArchived = wrappedA.channel.delete_at ? wrappedA.channel.delete_at !== 0 : false; const bIsArchived = wrappedB.channel.delete_at ? wrappedB.channel.delete_at !== 0 : false; @@ -356,14 +391,14 @@ export function quickSwitchSorter(wrappedA, wrappedB) { return sortChannelsByRecencyAndTypeAndDisplayName(wrappedA, wrappedB); } -function makeChannelSearchFilter(channelPrefix) { +function makeChannelSearchFilter(channelPrefix: string) { const channelPrefixLower = channelPrefix.toLowerCase(); const splitPrefixBySpace = channelPrefixLower.trim().split(/[ ,]+/); const curState = getState(); const usersInChannels = getUserIdsInChannels(curState); - const userSearchStrings = {}; + const userSearchStrings: RelationOneToOne = {}; - return (channel) => { + return (channel: ChannelItem) => { let searchString = `${channel.display_name}${channel.name}`; if (channel.type === Constants.GM_CHANNEL || channel.type === Constants.DM_CHANNEL) { const usersInChannel = usersInChannels[channel.id] || new Set([]); @@ -411,7 +446,7 @@ export default class SwitchChannelProvider extends Provider { * * @see {@link components/forward_post_modal/forward_post_channel_select.tsx} */ - handlePretextChanged(channelPrefix, resultsCallback) { + handlePretextChanged(channelPrefix: string, resultsCallback: ResultsCallback) { if (channelPrefix) { prefix = channelPrefix; this.startNewRequest(channelPrefix); @@ -421,7 +456,7 @@ export default class SwitchChannelProvider extends Provider { // Dispatch suggestions for local data (filter out deleted and archived channels from local store data) const channels = getChannelsInAllTeams(getState()).concat(getDirectAndGroupChannels(getState())).filter((c) => c.delete_at === 0); - const users = Object.assign([], searchProfilesMatchingWithTerm(getState(), channelPrefix, false)); + const users = searchProfilesMatchingWithTerm(getState(), channelPrefix, false); const formattedData = this.formatList(channelPrefix, [ThreadsChannel, InsightsChannel, ...channels], users, true, true); if (formattedData) { resultsCallback(formattedData); @@ -436,7 +471,7 @@ export default class SwitchChannelProvider extends Provider { return true; } - async fetchUsersAndChannels(channelPrefix, resultsCallback) { + async fetchUsersAndChannels(channelPrefix: string, resultsCallback: ResultsCallback) { const state = getState(); const teamId = getCurrentTeamId(state); @@ -454,15 +489,16 @@ export default class SwitchChannelProvider extends Provider { const channelsAsync = searchAllChannels(channelPrefix, {nonAdminSearch: true})(store.dispatch, store.getState); - let usersFromServer = []; - let channelsFromServer = []; + let usersFromServer; + let channelsFromServer; try { usersFromServer = await usersAsync; - const {data} = await channelsAsync; - channelsFromServer = data; + const channelsResponse = await channelsAsync; + channelsFromServer = (channelsResponse as ActionResult).data; } catch (err) { store.dispatch(logError(err)); + return; } if (this.shouldCancelDispatch(channelPrefix)) { @@ -473,10 +509,10 @@ export default class SwitchChannelProvider extends Provider { // filter out deleted and archived channels from local store data const localChannelData = getChannelsInAllTeams(state).concat(getDirectAndGroupChannels(state)).filter((c) => c.delete_at === 0) || []; - const localUserData = Object.assign([], searchProfilesMatchingWithTerm(state, channelPrefix, false)) || []; + const localUserData = searchProfilesMatchingWithTerm(state, channelPrefix, false); const localFormattedData = this.formatList(channelPrefix, [ThreadsChannel, InsightsChannel, ...localChannelData], localUserData); const remoteChannelData = channelsFromServer.concat(getGroupChannels(state)) || []; - const remoteUserData = Object.assign([], usersFromServer.users) || []; + const remoteUserData = usersFromServer.users || []; const remoteFormattedData = this.formatList(channelPrefix, remoteChannelData, remoteUserData, false); store.dispatch({ @@ -484,18 +520,16 @@ export default class SwitchChannelProvider extends Provider { data: [...localUserData.filter((user) => user.id !== currentUserId), ...remoteUserData.filter((user) => user.id !== currentUserId)], }); const combinedTerms = [...localFormattedData.terms, ...remoteFormattedData.terms.filter((term) => !localFormattedData.terms.includes(term))]; - const combinedItems = [...localFormattedData.items, ...remoteFormattedData.items.filter((item) => !localFormattedData.terms.includes(item.channel.userId || item.channel.id))]; + const combinedItems = [...localFormattedData.items, ...remoteFormattedData.items.filter((item: any) => !localFormattedData.terms.includes((item.channel as FakeDirectChannel).userId || item.channel.id))]; resultsCallback({ ...localFormattedData, - ...{ - items: combinedItems, - terms: combinedTerms, - }, + items: combinedItems, + terms: combinedTerms, }); } - userWrappedChannel(user, channel) { + userWrappedChannel(user: UserProfile, channel?: ChannelItem): WrappedChannel { let displayName = ''; const currentUserId = getCurrentUserId(getState()); @@ -523,21 +557,21 @@ export default class SwitchChannelProvider extends Provider { id: channel ? channel.id : user.id, userId: user.id, update_at: user.update_at, + delete_at: 0, type: Constants.DM_CHANNEL, - last_picture_update: user.last_picture_update || 0, }, type: 'search.direct', name: user.username, - deactivated: user.delete_at, + deactivated: Boolean(user.delete_at), }; } - formatList(channelPrefix, allChannels, users, skipNotMember = true, localData = false) { + formatList(channelPrefix: string, allChannels: ChannelItem[], users: UserProfile[], skipNotMember = true, localData = false) { const channels = []; const members = getMyChannelMemberships(getState()); - const completedChannels = {}; + const completedChannels: RelationOneToOne = {}; const channelFilter = makeChannelSearchFilter(channelPrefix); @@ -548,17 +582,15 @@ export default class SwitchChannelProvider extends Provider { const allUnreadChannelIdsSet = new Set(allUnreadChannelIds); const currentUserId = getCurrentUserId(state); - for (const id of Object.keys(allChannels)) { - const channel = allChannels[id]; - + for (const channel of allChannels) { if (completedChannels[channel.id]) { continue; } if (channelFilter(channel)) { - const newChannel = Object.assign({}, channel); + const newChannel = {...channel}; const channelIsArchived = channel.delete_at !== 0; - let wrappedChannel = {channel: newChannel, name: newChannel.name, deactivated: false}; + let wrappedChannel: WrappedChannel = {channel: newChannel, name: newChannel.name, deactivated: false}; if (members[channel.id]) { wrappedChannel.last_viewed_at = members[channel.id].last_viewed_at; } else if (skipNotMember && (newChannel.type !== Constants.THREADS && newChannel.type !== Constants.INSIGHTS)) { @@ -642,9 +674,11 @@ export default class SwitchChannelProvider extends Provider { continue; } - const unread = allUnreadChannelIdsSet.has(channel?.id) && !isChannelMuted(members[channel.id]); - if (unread) { - wrappedChannel.unread = true; + if (channel) { + const unread = allUnreadChannelIdsSet.has(channel.id) && !isChannelMuted(members[channel.id]); + if (unread) { + wrappedChannel.unread = true; + } } completedChannels[user.id] = true; @@ -653,7 +687,13 @@ export default class SwitchChannelProvider extends Provider { const channelNames = channels. sort(quickSwitchSorter). - map((wrappedChannel) => wrappedChannel.channel.userId || wrappedChannel.channel.id); + map((wrappedChannel) => { + if (isFakeDirectChannel(wrappedChannel.channel) && wrappedChannel.channel.userId) { + return wrappedChannel.channel.userId; + } + + return wrappedChannel.channel.id; + }); if (localData && !channels.length) { channels.push({ @@ -670,7 +710,7 @@ export default class SwitchChannelProvider extends Provider { }; } - fetchAndFormatRecentlyViewedChannels(resultsCallback) { + fetchAndFormatRecentlyViewedChannels(resultsCallback: ResultsCallback) { const state = getState(); const recentChannels = getChannelsInAllTeams(state).concat(getDirectAndGroupChannels(state)); const wrappedRecentChannels = this.wrapChannels(recentChannels, Constants.MENTION_RECENT_CHANNELS); @@ -703,15 +743,16 @@ export default class SwitchChannelProvider extends Provider { }); } - getThreadsItem(countType = 'total', itemType) { + getThreadsItem(countType = 'total', itemType?: string) { const state = getState(); const counts = getThreadCountsInCurrentTeam(state); const collapsedThreads = isCollapsedThreadsEnabled(state); // adding last viewed at equal to Date.now() to push it to the top of the list - let threadsItem = { + let threadsItem: WrappedChannel = { channel: ThreadsChannel, name: ThreadsChannel.name, + unread: Boolean(counts?.total_unread_threads), unread_mentions: counts?.total_unread_mentions || 0, deactivated: false, last_viewed_at: Date.now(), @@ -719,9 +760,6 @@ export default class SwitchChannelProvider extends Provider { if (itemType) { threadsItem = {...threadsItem, type: itemType}; } - if (counts?.total_unread_threads) { - threadsItem.unread = true; - } if (collapsedThreads && ((countType === 'unread' && counts?.total_unread_threads) || (countType === 'total'))) { return threadsItem; } @@ -737,6 +775,7 @@ export default class SwitchChannelProvider extends Provider { const insightsItem = { channel: InsightsChannel, name: InsightsChannel.name, + unread: false, unread_mentions: 0, deactivated: false, last_viewed_at: Date.now(), @@ -749,13 +788,13 @@ export default class SwitchChannelProvider extends Provider { return null; } - getTimestampFromPrefs(myPreferences, category, name) { + getTimestampFromPrefs(myPreferences: Record, category: string, name: string) { const pref = myPreferences[getPreferenceKey(category, name)]; const prefValue = pref ? pref.value : '0'; - return parseInt(prefValue, 10); + return parseInt(prefValue ?? '', 10); } - getLastViewedAt(member, myPreferences, channel) { + getLastViewedAt(member: ChannelMembership, myPreferences: Record, channel: Channel) { // The server only ever sets the last_viewed_at to the time of the last post in channel, // So thought of using preferences but it seems that also not keeping track. // TODO Update and remove comment once solution is finalized @@ -766,12 +805,11 @@ export default class SwitchChannelProvider extends Provider { ); } - wrapChannels(channels, channelType) { + wrapChannels(channels: Channel[], channelType: string) { const state = getState(); const currentChannel = getCurrentChannel(state); const myMembers = getMyChannelMemberships(state); const myPreferences = getMyPreferences(state); - const collapsedThreads = isCollapsedThreadsEnabled(state); const allUnreadChannelIds = getAllTeamsUnreadChannelIds(state); const allUnreadChannelIdsSet = new Set(allUnreadChannelIds); @@ -781,14 +819,11 @@ export default class SwitchChannelProvider extends Provider { if (channel.id === currentChannel?.id) { continue; } - let wrappedChannel = {channel, name: channel.name, deactivated: false}; + let wrappedChannel: WrappedChannel = {channel, name: channel.name, deactivated: false}; const member = myMembers[channel.id]; if (member) { wrappedChannel.last_viewed_at = this.getLastViewedAt(member, myPreferences, channel); } - if (member && channelType === Constants.MENTION_UNREAD) { - wrappedChannel.unreadMentions = collapsedThreads ? member.mention_count_root : member.mention_count; - } if (channel.type === Constants.GM_CHANNEL) { wrappedChannel.name = channel.display_name; } else if (channel.type === Constants.DM_CHANNEL) { @@ -814,20 +849,21 @@ export default class SwitchChannelProvider extends Provider { return channelList; } - async fetchChannels(resultsCallback) { + async fetchChannels(resultsCallback: ResultsCallback) { const state = getState(); const teamId = getCurrentTeamId(state); if (!teamId) { return; } - const channelsAsync = fetchAllMyTeamsChannelsAndChannelMembersREST()(store.dispatch, store.getState); + const channelsAsync = store.dispatch(fetchAllMyTeamsChannelsAndChannelMembersREST()); let channels; try { const {data} = await channelsAsync; - channels = data.channels; + channels = data.channels as Channel[]; } catch (err) { store.dispatch(logError(err)); + return; } if (this.latestPrefix !== '') { diff --git a/webapp/channels/src/components/textbox/textbox.tsx b/webapp/channels/src/components/textbox/textbox.tsx index 8cf131c2c1..61082e52e3 100644 --- a/webapp/channels/src/components/textbox/textbox.tsx +++ b/webapp/channels/src/components/textbox/textbox.tsx @@ -16,7 +16,7 @@ import AtMentionProvider from 'components/suggestion/at_mention_provider'; import ChannelMentionProvider from 'components/suggestion/channel_mention_provider'; import AppCommandProvider from 'components/suggestion/command_provider/app_provider'; import CommandProvider from 'components/suggestion/command_provider/command_provider'; -import EmoticonProvider from 'components/suggestion/emoticon_provider.jsx'; +import EmoticonProvider from 'components/suggestion/emoticon_provider'; import SuggestionBox from 'components/suggestion/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import SuggestionList from 'components/suggestion/suggestion_list.jsx'; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index a84b797244..92d978c06b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -349,7 +349,7 @@ export function makeGetChannelUnreadCount(): (state: GlobalState, channelId: str ); } -export function getChannelByName(state: GlobalState, channelName: string): Channel | undefined | null { +export function getChannelByName(state: GlobalState, channelName: string): Channel | undefined { return getChannelByNameHelper(getAllChannels(state), channelName); }