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 <build@mattermost.com>
This commit is contained in:
Harrison Healey 2023-05-30 14:51:55 -04:00 committed by GitHub
parent 68be3a6bcd
commit 19ece476ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1328 additions and 1226 deletions

View File

@ -89,6 +89,7 @@ exports[`components/AddUserToChannelModal should match snapshot 1`] = `
"latestComplete": true,
"latestPrefix": "",
"requestStarted": false,
"triggerCharacter": undefined,
},
]
}

View File

@ -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<ActionResult>;
autocompleteChannelsForSearch: (teamId: string, term: string) => Promise<ActionResult<Channel[]>>;
};
}

View File

@ -249,7 +249,7 @@ function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props<Ch
const getDefaultResults = () => {
let options: GroupedOption[] = [];
const handleDefaultResults = (res: ProviderResult) => {
const handleDefaultResults = (res: ProviderResult<any>) => {
options = [
{
label: formatMessage({id: 'suggestion.mention.recent.channels', defaultMessage: 'Recent'}),
@ -278,7 +278,7 @@ function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props<Ch
*
* @see {@link components/suggestion/switch_channel_provider.jsx}
*/
const handleResults = async (res: ProviderResult) => {
const handleResults = async (res: ProviderResult<any>) => {
callCount++;
await res.items.filter((item) => item?.channel && isValidChannelType(item.channel) && !item.deactivated).forEach((item) => {
const {channel} = item;

View File

@ -90,6 +90,7 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = `
"latestComplete": true,
"latestPrefix": "",
"requestStarted": false,
"triggerCharacter": undefined,
},
]
}

View File

@ -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';

View File

@ -2,44 +2,7 @@
exports[`at mention suggestion Should display nick name of non signed in user 1`] = `
<AtMentionSuggestion
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isSelection={false}
item={
Object {
"first_name": "a",
@ -50,108 +13,91 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
>
<div
className="suggestion-list__item"
<SuggestionContainer
data-testid="mentionSuggestion_user2"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
isSelection={false}
item={
Object {
"first_name": "a",
"id": "userid2",
"last_name": "b",
"nickname": "c",
"username": "user2",
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
>
<span
className="status-wrapper style--none"
<div
className="suggestion-list__item"
data-testid="mentionSuggestion_user2"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
>
<span
className="profile-icon"
className="status-wrapper style--none"
>
<Memo(Avatar)
size="sm"
url="/api/v4/users/userid2/image?_=0"
username="user2"
<span
className="profile-icon"
>
<img
alt="user2 profile image"
className="Avatar Avatar-sm"
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid2/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
<Memo(Avatar)
size="sm"
url="/api/v4/users/userid2/image?_=0"
username="user2"
>
<img
alt="user2 profile image"
className="Avatar Avatar-sm"
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid2/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>
<StatusIcon
button={false}
className=""
/>
</span>
<StatusIcon
button={false}
className=""
/>
</span>
<span
className="suggestion-list__ellipsis"
>
<span
className="suggestion-list__main"
className="suggestion-list__ellipsis"
>
@user2
</span>
a b (c)
<Component
emojiSize={15}
emojiStyle={
Object {
"margin": "0 4px 4px",
<span
className="suggestion-list__main"
>
@user2
</span>
a b (c)
<Component
emojiSize={15}
emojiStyle={
Object {
"margin": "0 4px 4px",
}
}
}
showTooltip={true}
userID="userid2"
>
<div />
</Component>
</span>
</div>
showTooltip={true}
userID="userid2"
>
<div />
</Component>
</span>
</div>
</SuggestionContainer>
</AtMentionSuggestion>
`;
exports[`at mention suggestion Should not display nick name of the signed in user 1`] = `
<AtMentionSuggestion
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isSelection={false}
item={
Object {
"first_name": "a",
@ -163,72 +109,93 @@ exports[`at mention suggestion Should not display nick name of the signed in use
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
>
<div
className="suggestion-list__item"
<SuggestionContainer
data-testid="mentionSuggestion_user"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
isSelection={false}
item={
Object {
"first_name": "a",
"id": "userid1",
"isCurrentUser": true,
"last_name": "b",
"nickname": "c",
"username": "user",
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
>
<span
className="status-wrapper style--none"
<div
className="suggestion-list__item"
data-testid="mentionSuggestion_user"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
>
<span
className="profile-icon"
className="status-wrapper style--none"
>
<Memo(Avatar)
size="sm"
url="/api/v4/users/userid1/image?_=0"
username="user"
<span
className="profile-icon"
>
<img
alt="user profile image"
className="Avatar Avatar-sm"
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid1/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>
<StatusIcon
button={false}
className=""
/>
</span>
<span
className="suggestion-list__ellipsis"
>
<span
className="suggestion-list__main"
>
@user
</span>
a b
<FormattedMessage
defaultMessage="(you)"
id="suggestion.user.isCurrent"
>
<span>
(you)
<Memo(Avatar)
size="sm"
url="/api/v4/users/userid1/image?_=0"
username="user"
>
<img
alt="user profile image"
className="Avatar Avatar-sm"
loading="lazy"
onError={[Function]}
src="/api/v4/users/userid1/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>
</FormattedMessage>
<Component
emojiSize={15}
emojiStyle={
Object {
"margin": "0 4px 4px",
}
}
showTooltip={true}
userID="userid1"
<StatusIcon
button={false}
className=""
/>
</span>
<span
className="suggestion-list__ellipsis"
>
<div />
</Component>
</span>
</div>
<span
className="suggestion-list__main"
>
@user
</span>
a b
<FormattedMessage
defaultMessage="(you)"
id="suggestion.user.isCurrent"
>
<span>
(you)
</span>
</FormattedMessage>
<Component
emojiSize={15}
emojiStyle={
Object {
"margin": "0 4px 4px",
}
}
showTooltip={true}
userID="userid1"
>
<div />
</Component>
</span>
</div>
</SuggestionContainer>
</AtMentionSuggestion>
`;

View File

@ -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', () => () => <div/>);
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(
<AtMentionSuggestion
{...baseProps}
item={userid1}
matchedPretext='@'
term='@user'
/>,
);
@ -46,9 +54,8 @@ describe('at mention suggestion', () => {
it('Should display nick name of non signed in user', () => {
const wrapper = mountWithIntl(
<AtMentionSuggestion
{...baseProps}
item={userid2}
matchedPretext='@'
term='@user'
/>,
);

View File

@ -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<HTMLDivElement, SuggestionProps<Item>>((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 = (
<FormattedMessage
id='suggestion.mention.all'
defaultMessage='Notifies everyone in this channel'
/>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.username === 'channel') {
itemname = 'channel';
description = (
<FormattedMessage
id='suggestion.mention.channel'
defaultMessage='Notifies everyone in this channel'
/>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.username === 'here') {
itemname = 'here';
description = (
<FormattedMessage
id='suggestion.mention.here'
defaultMessage='Notifies everyone online in this channel'
/>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.type === Constants.MENTION_GROUPS) {
itemname = item.name;
description = (
<span className='ml-1'>{'- '}{item.display_name}</span>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} 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 = (
<span className='status-wrapper style--none'>
<span className='profile-icon'>
<Avatar
username={item && item.username}
size='sm'
url={Utils.imageURLForUser(item.id, item.last_picture_update)}
/>
</span>
<StatusIcon status={item && item.status}/>
</span>
);
customStatus = (
<CustomStatusEmoji
showTooltip={true}
userID={item.id}
emojiSize={15}
emojiStyle={{
margin: '0 4px 4px',
}}
/>
);
}
const youElement = item.isCurrentUser ? (
let itemname: string;
let description: ReactNode;
let icon: JSX.Element;
let customStatus: ReactNode;
if (item.username === 'all') {
itemname = 'all';
description = (
<FormattedMessage
id='suggestion.user.isCurrent'
defaultMessage='(you)'
id='suggestion.mention.all'
defaultMessage='Notifies everyone in this channel'
/>
) : null;
const sharedIcon = item.remote_id ? (
<SharedUserIndicator
className='shared-user-icon'
withTooltip={true}
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.username === 'channel') {
itemname = 'channel';
description = (
<FormattedMessage
id='suggestion.mention.channel'
defaultMessage='Notifies everyone in this channel'
/>
) : null;
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.username === 'here') {
itemname = 'here';
description = (
<FormattedMessage
id='suggestion.mention.here'
defaultMessage='Notifies everyone online in this channel'
/>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else if (item.type === Constants.MENTION_GROUPS) {
itemname = item.name;
description = (
<span className='ml-1'>{'- '}{item.display_name}</span>
);
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i
className='icon icon-account-multiple-outline'
title={intl.formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}
/>
</span>
);
} else {
itemname = item.username;
let countBadge;
if (item.type === Constants.MENTION_GROUPS) {
countBadge = (
<span className='suggestion-list__group-count'>
<Tag
text={
<FormattedMessage
id='suggestion.group.members'
defaultMessage='{member_count} {member_count, plural, one {member} other {members}}'
values={{
member_count: (item as Group).member_count,
}}
/>
}
/>
</span>
);
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 (
<div
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})}
data-testid={`mentionSuggestion_${itemname}`}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
{icon}
<span className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@' + itemname}
</span>
{item.is_bot && <BotTag/>}
{description}
{youElement}
{customStatus}
{sharedIcon}
{isGuest(item.roles) && <GuestTag/>}
icon = (
<span className='status-wrapper style--none'>
<span className='profile-icon'>
<Avatar
username={item && item.username}
size='sm'
url={Utils.imageURLForUser(item.id, item.last_picture_update)}
/>
</span>
{countBadge}
</div>
<StatusIcon status={item && item.status}/>
</span>
);
customStatus = (
<CustomStatusEmoji
showTooltip={true}
userID={item.id}
emojiSize={15}
emojiStyle={{
margin: '0 4px 4px',
}}
/>
);
}
}
export default injectIntl(AtMentionSuggestion);
const youElement = item.isCurrentUser ? (
<FormattedMessage
id='suggestion.user.isCurrent'
defaultMessage='(you)'
/>
) : null;
const sharedIcon = item.remote_id ? (
<SharedUserIndicator
className='shared-user-icon'
withTooltip={true}
/>
) : null;
let countBadge;
if (item.type === Constants.MENTION_GROUPS) {
countBadge = (
<span className='suggestion-list__group-count'>
<Tag
text={
<FormattedMessage
id='suggestion.group.members'
defaultMessage='{member_count} {member_count, plural, one {member} other {members}}'
values={{
member_count: (item as Group).member_count,
}}
/>
}
/>
</span>
);
}
return (
<SuggestionContainer
ref={ref}
{...props}
data-testid={`mentionSuggestion_${itemname}`}
>
{icon}
<span className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@' + itemname}
</span>
{item.is_bot && <BotTag/>}
{description}
{youElement}
{customStatus}
{sharedIcon}
{isGuest(item.roles) && <GuestTag/>}
</span>
{countBadge}
</SuggestionContainer>
);
});
AtMentionSuggestion.displayName = 'AtMentionSuggestion';
export default AtMentionSuggestion;

View File

@ -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<HTMLDivElement, SuggestionProps<WrappedChannel>>((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 = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-archive-outline'/>
</span>
);
} else {
channelIcon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className={`icon icon--no-spacing icon-${item.channel.type === Constants.OPEN_CHANNEL ? 'globe' : 'lock'}`}/>
</span>
);
}
let className = 'suggestion-list__item';
if (isSelection) {
className += ' suggestion--selected';
}
const description = '~' + item.channel.name;
return (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
{channelIcon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{channelName}
</span>
{description}
</div>
</div>
const channelName = item.channel?.display_name;
let channelIcon;
if (channelIsArchived) {
channelIcon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-archive-outline'/>
</span>
);
} else {
channelIcon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className={`icon icon--no-spacing icon-${item.channel?.type === Constants.OPEN_CHANNEL ? 'globe' : 'lock'}`}/>
</span>
);
}
}
const description = '~' + item.channel?.name;
return (
<SuggestionContainer
ref={ref}
{...props}
>
{channelIcon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{channelName}
</span>
{description}
</div>
</SuggestionContainer>
);
});
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<WrappedChannel>) {
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<string, boolean> = {};
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;

View File

@ -1,12 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CommandSuggestion should match snapshot 1`] = `
<div
className="slash-command suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
isSelection={true}
item={
Object {
"Complete": "/invite",
"Description": "Invite a user to a channel",
"Hint": "@[username] ~[channel]",
"IconData": "",
"Suggestion": "/invite",
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="/"
>
<div
className="slash-command__icon"
@ -29,5 +38,5 @@ exports[`CommandSuggestion should match snapshot 1`] = `
Invite a user to a channel
</div>
</div>
</div>
</SuggestionContainer>
`;

View File

@ -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<AutocompleteSuggestion | UserProfile | {channel: Channel}>;
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<GlobalState>;
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<Item>) {
if (!pretext.startsWith(this.triggerCharacter)) {
return false;
}

View File

@ -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: [{

View File

@ -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<HTMLDivElement, SuggestionProps<AutocompleteSuggestion>>((props, ref) => {
const {item} = props;
let className = 'slash-command';
if (isSelection) {
className += ' suggestion--selected';
}
let symbolSpan = <span>{'/'}</span>;
switch (item.IconData) {
case EXECUTE_CURRENT_COMMAND_ITEM_ID:
symbolSpan = <span className='block mt-1'>{'↵'}</span>;
break;
case OPEN_COMMAND_IN_MODAL_ITEM_ID:
symbolSpan = (
<span className='block mt-1'>
<DockWindowIcon size={28}/>
</span>
);
break;
case COMMAND_SUGGESTION_ERROR:
symbolSpan = <span>{'!'}</span>;
break;
}
let icon = <div className='slash-command__icon'>{symbolSpan}</div>;
if (item.IconData && ![EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, OPEN_COMMAND_IN_MODAL_ITEM_ID].includes(item.IconData)) {
icon = (
<div
className='slash-command__icon'
style={{backgroundColor: 'transparent'}}
>
<img src={item.IconData}/>
</div>);
}
return (
let symbolSpan = <span>{'/'}</span>;
switch (item.IconData) {
case EXECUTE_CURRENT_COMMAND_ITEM_ID:
symbolSpan = <span className='block mt-1'>{'↵'}</span>;
break;
case OPEN_COMMAND_IN_MODAL_ITEM_ID:
symbolSpan = (
<span className='block mt-1'>
<DockWindowIcon size={28}/>
</span>
);
break;
case COMMAND_SUGGESTION_ERROR:
symbolSpan = <span>{'!'}</span>;
break;
}
let icon = <div className='slash-command__icon'>{symbolSpan}</div>;
if (item.IconData && ![EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, OPEN_COMMAND_IN_MODAL_ITEM_ID].includes(item.IconData)) {
icon = (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
className='slash-command__icon'
style={{backgroundColor: 'transparent'}}
>
{icon}
<div className='slash-command__info'>
<div className='slash-command__title'>
{item.Suggestion.substring(1) + ' ' + item.Hint}
</div>
<div className='slash-command__desc'>
{item.Description}
</div>
<img src={item.IconData}/>
</div>);
}
return (
<SuggestionContainer
ref={ref}
{...props}
>
{icon}
<div className='slash-command__info'>
<div className='slash-command__title'>
{item.Suggestion.substring(1) + ' ' + item.Hint}
</div>
<div className='slash-command__desc'>
{item.Description}
</div>
</div>
);
}
}
</SuggestionContainer>
);
});
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<GlobalState>;
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<AutocompleteSuggestion>) {
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<AutocompleteSuggestion>) {
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<AutocompleteSuggestion>) {
const command = pretext.toLowerCase();
const {teamId, channelId, rootId} = this.props;

View File

@ -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',
]);
});

View File

@ -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 (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
<div className='pull-left'>
<img
alt={text}
className='emoticon-suggestion__image'
src={getEmojiImageUrl(emoji)}
title={text}
/>
</div>
<div className='pull-left'>
{text}
</div>
</div>
);
}
type EmojiItem = {
name: string;
emoji: Emoji;
type: string;
}
const EmoticonSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<EmojiItem>>((props, ref) => {
const text = props.term;
const emoji = props.item.emoji;
return (
<SuggestionContainer
ref={ref}
{...props}
>
<div className='pull-left'>
<img
alt={text}
className='emoticon-suggestion__image'
src={getEmojiImageUrl(emoji)}
title={text}
/>
</div>
<div className='pull-left'>
{text}
</div>
</SuggestionContainer>
);
});
EmoticonSuggestion.displayName = 'EmoticonSuggestion';
export default class EmoticonProvider extends Provider {
constructor() {
super();
this.triggerCharacter = ':';
}
handlePretextChanged(pretext, resultsCallback) {
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<EmojiItem>) {
// 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<EmojiItem>) {
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);

View File

@ -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<ActionResult | ActionResult[]>);
class ChannelSuggestion extends Suggestion {
render() {
const isSelection = this.props.isSelection;
const item = this.props.item;
const GenericChannelSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<Channel>>((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 = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon--standard icon--no-spacing icon-globe'/>
</span>
);
let className = 'suggestion-list__item';
if (isSelection) {
className += ' suggestion--selected';
}
const icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon--standard icon--no-spacing icon-globe'/>
</span>
);
const description = '(~' + item.name + ')';
const description = '(~' + item.name + ')';
return (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
{icon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{channelName}
</span>
{description}
{purpose}
</div>
return (
<SuggestionContainer
ref={ref}
{...props}
>
{icon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{channelName}
</span>
{description}
{purpose}
</div>
);
}
}
</SuggestionContainer>
);
});
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<Channel>) {
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,
});
},
);

View File

@ -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<Record<string, any>>;
component?: React.ReactNode;
}
const GenericUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((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 (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
<Avatar
size='xxs'
username={username}
url={Client4.getUsersRoute() + '/' + item.id + '/image?_=' + (item.last_picture_update || 0)}
/>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@' + username}
</span>
{description}
</div>
{item.is_bot && <BotTag/>}
{isGuest(item.roles) && <GuestTag/>}
</div>
);
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 (
<SuggestionContainer
ref={ref}
{...props}
>
<Avatar
size='xxs'
username={username}
url={Client4.getUsersRoute() + '/' + item.id + '/image?_=' + (item.last_picture_update || 0)}
/>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@' + username}
</span>
{description}
</div>
{item.is_bot && <BotTag/>}
{isGuest(item.roles) && <GuestTag/>}
</SuggestionContainer>
);
});
GenericUserSuggestion.displayName = 'GenericUserSuggestion';
export default class GenericUserProvider extends Provider {
autocompleteUsers: (text: string) => Promise<UserAutocomplete>;
constructor(searchUsersFunc: (username: string) => Promise<UserAutocomplete>) {
super();
this.autocompleteUsers = searchUsersFunc;
}
async handlePretextChanged(pretext: string, resultsCallback: (res: ProviderResults) => void) {
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<UserProfile>) {
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;

View File

@ -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 (
<div
className={className}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
{item.text}
</div>
);
}
interface MenuAction {
text: string;
value: string;
}
export default class MenuActionProvider extends Provider {
private options: Array<Record<string, any>>;
const MenuActionSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<MenuAction>>((props, ref) => {
const {item} = props;
constructor(options: Array<Record<string, any>>) {
return (
<SuggestionContainer
ref={ref}
{...props}
>
{item.text}
</SuggestionContainer>
);
});
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<MenuAction>) {
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<MenuAction>) {
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<MenuAction>) {
const filteredOptions = this.options.filter((option) => option.text.toLowerCase().indexOf(prefix.toLowerCase()) >= 0);
const terms = filteredOptions.map((option) => option.text);

View File

@ -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<Item> = {
matchedPretext: string;
terms: string[];
items: Array<Record<string, any>>;
component?: React.ReactNode;
}
items: Array<Item | Loading>;
} & RequireOnlyOne<{
component: React.ReactNode;
components: React.ReactNode[];
}>;
export default class Provider {
export type Loading = {
type: string;
loading: boolean;
};
export type ResultsCallback<Item> = (result: ProviderResult<Item>) => 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<unknown>) => void): boolean;
resetRequest() {
this.requestStarted = false;

View File

@ -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<Channel>) {
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) {
let channelPrefix = captured[1];

View File

@ -1,12 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/suggestion/search_channel_suggestion should match snapshot 1`] = `
<div
className="suggestion-list__item"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={false}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -25,16 +46,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot 1
</span>
~DN
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type DM_CHANNEL 1`] = `
<div
className="suggestion-list__item suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={true}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "D",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<Memo(Avatar)
size="sm"
@ -49,16 +91,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
@name
</span>
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type GM_CHANNEL 1`] = `
<div
className="suggestion-list__item suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={true}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "G",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -78,16 +141,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
@name
</span>
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type OPEN_CHANNEL 1`] = `
<div
className="suggestion-list__item suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={true}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -106,16 +190,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span>
~DN
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type PRIVATE_CHANNEL 1`] = `
<div
className="suggestion-list__item suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={true}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "P",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -134,16 +239,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span>
~DN
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is false 1`] = `
<div
className="suggestion-list__item"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={false}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -162,16 +288,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span>
~DN
</div>
</div>
</SuggestionContainer>
`;
exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is true 1`] = `
<div
className="suggestion-list__item suggestion--selected"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
<SuggestionContainer
currentUserId="userid1"
isSelection={true}
item={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
matchedPretext=""
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
teammateIsBot={false}
term=""
>
<span
className="suggestion-list__icon suggestion-list__icon--large"
@ -190,5 +337,5 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span>
~DN
</div>
</div>
</SuggestionContainer>
`;

View File

@ -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),
};
};

View File

@ -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', () => {

View File

@ -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 = (
<Avatar
url={imageURLForUser(getUserIdFromChannelName(currentUser, item.name))}
url={imageURLForUser(getUserIdFromChannelName(currentUserId, item.name))}
size='sm'
/>
);
@ -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 ? <BotTag/> : null;
return (
<div
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
className={className}
{...Suggestion.baseProps}
>
{icon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{name}
</span>
{description}
</div>
{tag}
</div>
);
}
type Props = SuggestionProps<Channel> & {
currentUserId: string;
teammateIsBot: boolean;
}
const SearchChannelSuggestion = React.forwardRef<HTMLDivElement, Props>((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 ? <BotTag/> : null;
return (
<SuggestionContainer
ref={ref}
{...props}
>
{icon}
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{name}
</span>
{description}
</div>
{tag}
</SuggestionContainer>
);
});
SearchChannelSuggestion.displayName = 'SearchChannelSuggestion';
export default SearchChannelSuggestion;

View File

@ -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(),

View File

@ -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<HTMLDivElement, SuggestionProps<WrappedChannel>>((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 = (
<i className='icon icon--no-spacing icon-archive-outline'/>
);
} else if (channel.type === Constants.OPEN_CHANNEL) {
icon = (
<i className='icon icon--no-spacing icon-globe'/>
);
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = (
<i className='icon icon--no-spacing icon-lock-outline'/>
);
}
return (
<div
onClick={this.handleClick}
className={className}
onMouseMove={this.handleMouseMove}
ref={(node) => {
this.node = node;
}}
{...Suggestion.baseProps}
>
<span className='suggestion-list__icon suggestion-list__icon--large'>{icon}</span>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>{displayName}</span>
</div>
</div>
const displayName = channel.display_name;
let icon = null;
if (channelIsArchived) {
icon = (
<i className='icon icon--no-spacing icon-archive-outline'/>
);
} else if (channel.type === Constants.OPEN_CHANNEL) {
icon = (
<i className='icon icon--no-spacing icon-globe'/>
);
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = (
<i className='icon icon--no-spacing icon-lock-outline'/>
);
}
}
return (
<SuggestionContainer
ref={ref}
{...props}
>
<span className='suggestion-list__icon suggestion-list__icon--large'>{icon}</span>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>{displayName}</span>
</div>
</SuggestionContainer>
);
});
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<ActionResult<Channel[]>>;
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<WrappedChannel>) {
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<WrappedChannel>) {
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<WrappedChannel>, allChannels: Channel[]) {
const channels = [];
const state = store.getState();
@ -186,15 +179,14 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
return;
}
const completedChannels = {};
const completedChannels: Record<string, boolean> = {};
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;

View File

@ -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<DateItem>) {
const captured = (/\b(?:on|before|after):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) {
const datePrefix = captured[1];

View File

@ -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<never> & {
currentDate?: Date;
handleEscape: () => void;
locale: string;
preventClose: () => void;
}
export default class SearchDateSuggestion extends React.PureComponent<Props> {
private loadedLocales: Record<string, Locale> = {};
state = {

View File

@ -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<HTMLDivElement, SuggestionProps<UserProfile>>((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 = (
<SharedUserIndicator
className='mention__shared-user-icon'
withTooltip={true}
/>
);
}
return (
<div
className={className}
ref={(node) => {
this.node = node;
}}
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
>
<Avatar
size='sm'
username={username}
url={Utils.imageURLForUser(item.id, item.last_picture_update)}
/>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@'}{username}
</span>
{item.is_bot && <BotTag/>}
{description}
</div>
{sharedIcon}
</div>
let sharedIcon;
if (item.remote_id) {
sharedIcon = (
<SharedUserIndicator
className='mention__shared-user-icon'
withTooltip={true}
/>
);
}
}
return (
<SuggestionContainer
ref={ref}
{...props}
>
<Avatar
size='sm'
username={username}
url={Utils.imageURLForUser(item.id, item.last_picture_update)}
/>
<div className='suggestion-list__ellipsis'>
<span className='suggestion-list__main'>
{'@'}{username}
</span>
{item.is_bot && <BotTag/>}
{description}
</div>
{sharedIcon}
</SuggestionContainer>
);
});
SearchUserSuggestion.displayName = 'SearchUserSuggestion';
export default class SearchUserProvider extends Provider {
private autocompleteUsersInTeam: (username: string) => Promise<UserAutocomplete>;
@ -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<UserProfile>) {
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<UserProfile>) {
if (!captured) {
return;
}

View File

@ -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);
};
}

View File

@ -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<Item> extends Omit<React.HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement, SuggestionProps<unknown>>((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 (
<div
ref={ref}
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})}
onClick={handleClick}
onMouseMove={handleMouseMove}
role={role}
tabIndex={tabIndex}
{...otherProps}
>
{children}
</div>
);
});
SuggestionContainer.displayName = 'SuggestionContainer';
export {SuggestionContainer};

View File

@ -24,7 +24,7 @@ interface Props {
items: any[];
terms: string[];
selection: string;
components: Array<React.FunctionComponent<any>>;
components: Array<React.FunctionComponent<SuggestionProps<unknown>>>;
wrapperHeight?: number;
// suggestionBoxAlgn is an optional object that can be passed to align the SuggestionList with the keyboard caret

View File

@ -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',

View File

@ -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 = (
<div className={classNames('suggestion-list_unread-mentions', (isPartOfOnlyOneTeam ? 'position-end' : ''))}>
<span className='badge'>
{unreadMentions}
</span>
</div>
);
}
}
let className = 'suggestion-list__item';
if (isSelection) {
className += ' suggestion--selected';
}
let name = channel.display_name;
let description = '~' + channel.name;
let icon;
if (channelIsArchived) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-archive-outline'/>
</span>
);
} else if (this.props.hasDraft) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-pencil-outline'/>
</span>
);
} else if (channel.type === Constants.OPEN_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-globe'/>
</span>
);
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-lock-outline'/>
</span>
);
} else if (channel.type === Constants.THREADS) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-message-text-outline'/>
</span>
);
} else if (channel.type === Constants.INSIGHTS) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-chart-line'/>
</span>
);
} else if (channel.type === Constants.GM_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<div className='status status--group'>{'G'}</div>
</span>
);
} else {
icon = (
<ProfilePicture
src={userImageUrl}
status={teammate && teammate.is_bot ? null : status}
size='sm'
/>
);
}
let tag = null;
let customStatus = null;
if (channel.type === Constants.DM_CHANNEL) {
if (teammate && teammate.is_bot) {
tag = <BotTag/>;
} else if (isGuest(teammate ? teammate.roles : '')) {
tag = <GuestTag/>;
}
customStatus = (
<CustomStatusEmoji
showTooltip={true}
userID={userItem.id}
emojiStyle={{
marginBottom: 2,
}}
/>
);
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 = (
<SharedChannelIndicator
className='shared-channel-icon'
channelType={channel.type}
/>
);
}
let teamName = null;
if (channel.team_id && team) {
teamName = (<span className='ml-2 suggestion-list__team-name'>{team.display_name}</span>);
}
const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS;
return (
<div
onClick={this.handleClick}
onMouseMove={this.handleMouseMove}
className={className}
role='listitem'
ref={(node) => {
this.node = node;
}}
id={`switchChannel_${channel.name}`}
data-testid={channel.name}
aria-label={name}
{...Suggestion.baseProps}
>
{icon}
<div className='suggestion-list__ellipsis suggestion-list__flex'>
<span className='suggestion-list__main'>
<span className={classNames({'suggestion-list__unread': item.unread && !channelIsArchived})}>{name}</span>
{showSlug && description && <span className='ml-2 suggestion-list__desc'>{description}</span>}
</span>
{customStatus}
{sharedIcon}
{tag}
{badge}
{!isPartOfOnlyOneTeam && teamName}
</div>
</div>
);
}
type FakeChannel = Pick<Channel, 'id' | 'name' | 'display_name' | 'delete_at'> & {
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<WrappedChannel> & {
channelMember: ChannelMembership;
collapsedThreads: boolean;
dmChannelTeammate?: UserProfile;
hasDraft: boolean;
isPartOfOnlyOneTeam: boolean;
status?: string;
team?: Team;
}
const SwitchChannelSuggestion = React.forwardRef<HTMLDivElement, Props>((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 = (
<div className={classNames('suggestion-list_unread-mentions', (isPartOfOnlyOneTeam ? 'position-end' : ''))}>
<span className='badge'>
{unreadMentions}
</span>
</div>
);
}
}
let name = channel.display_name;
let description = '~' + channel.name;
let icon;
if (channelIsArchived) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-archive-outline'/>
</span>
);
} else if (props.hasDraft) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-pencil-outline'/>
</span>
);
} else if (channel.type === Constants.OPEN_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-globe'/>
</span>
);
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-lock-outline'/>
</span>
);
} else if (channel.type === Constants.THREADS) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-message-text-outline'/>
</span>
);
} else if (channel.type === Constants.INSIGHTS) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-chart-line'/>
</span>
);
} else if (channel.type === Constants.GM_CHANNEL) {
icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'>
<div className='status status--group'>{'G'}</div>
</span>
);
} else if (teammate) {
icon = (
<ProfilePicture
src={Utils.imageURLForUser(teammate.id, teammate.last_picture_update)}
status={teammate.is_bot ? undefined : status}
size='sm'
/>
);
}
let tag = null;
let customStatus = null;
if (channel.type === Constants.DM_CHANNEL && teammate) {
if (teammate && teammate.is_bot) {
tag = <BotTag/>;
} else if (isGuest(teammate ? teammate.roles : '')) {
tag = <GuestTag/>;
}
customStatus = (
<CustomStatusEmoji
showTooltip={true}
userID={teammate.id}
emojiStyle={{
marginBottom: 2,
}}
/>
);
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 = (
<SharedChannelIndicator
className='shared-channel-icon'
channelType={channel.type as ChannelType}
/>
);
}
let teamName = null;
if (isRealChannel(channel) && channel.team_id && team) {
teamName = (<span className='ml-2 suggestion-list__team-name'>{team.display_name}</span>);
}
const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS;
return (
<SuggestionContainer
ref={ref}
id={`switchChannel_${channel.name}`}
data-testid={channel.name}
role='listitem'
{...props}
>
{icon}
<div className='suggestion-list__ellipsis suggestion-list__flex'>
<span className='suggestion-list__main'>
<span className={classNames({'suggestion-list__unread': item.unread && !channelIsArchived})}>{name}</span>
{showSlug && description && <span className='ml-2 suggestion-list__desc'>{description}</span>}
</span>
{customStatus}
{sharedIcon}
{tag}
{badge}
{!isPartOfOnlyOneTeam && teamName}
</div>
</SuggestionContainer>
);
});
SwitchChannelSuggestion.displayName = 'SwitchChannelSuggestion';
type OwnProps = SuggestionProps<WrappedChannel>;
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<UserProfile, string> = {};
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<WrappedChannel>) {
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<WrappedChannel>) {
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<Channel, boolean> = {};
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<WrappedChannel>) {
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<string, PreferenceType>, 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<string, PreferenceType>, 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<WrappedChannel>) {
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 !== '') {

View File

@ -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';

View File

@ -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);
}