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>
Этот коммит содержится в:
Harrison Healey
2023-05-30 14:51:55 -04:00
коммит произвёл GitHub
родитель 68be3a6bcd
Коммит 19ece476ad
36 изменённых файлов: 1328 добавлений и 1226 удалений

Просмотреть файл

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

Просмотреть файл

@@ -8,7 +8,7 @@ import {FormattedMessage} from 'react-intl';
import {getFullName} from 'mattermost-redux/utils/user_utils'; import {getFullName} from 'mattermost-redux/utils/user_utils';
import {ActionResult} from 'mattermost-redux/types/actions'; 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 SuggestionBox from 'components/suggestion/suggestion_box';
import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box';
import ModalSuggestionList from 'components/suggestion/modal_suggestion_list'; import ModalSuggestionList from 'components/suggestion/modal_suggestion_list';
@@ -54,7 +54,7 @@ export type Props = {
* SearchChannelWithPermissionsProvider class to fetch channels * SearchChannelWithPermissionsProvider class to fetch channels
* based on a search term * based on a search term
*/ */
autocompleteChannelsForSearch: (teamId: string, term: string) => Promise<ActionResult>; autocompleteChannelsForSearch: (teamId: string, term: string) => Promise<ActionResult<Channel[]>>;
}; };
} }

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -19,7 +19,7 @@ import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import SuggestionBox from 'components/suggestion/suggestion_box'; import SuggestionBox from 'components/suggestion/suggestion_box';
import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box';
import SuggestionList from 'components/suggestion/suggestion_list.jsx'; 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'; import NoResultsIndicator from 'components/no_results_indicator/no_results_indicator';
const CHANNEL_MODE = 'channel'; const CHANNEL_MODE = 'channel';

Просмотреть файл

@@ -2,44 +2,7 @@
exports[`at mention suggestion Should display nick name of non signed in user 1`] = ` exports[`at mention suggestion Should display nick name of non signed in user 1`] = `
<AtMentionSuggestion <AtMentionSuggestion
intl={ isSelection={false}
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",
}
}
item={ item={
Object { Object {
"first_name": "a", "first_name": "a",
@@ -50,108 +13,91 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
} }
} }
matchedPretext="@" matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user" term="@user"
> >
<div <SuggestionContainer
className="suggestion-list__item"
data-testid="mentionSuggestion_user2" data-testid="mentionSuggestion_user2"
onClick={[Function]} isSelection={false}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "first_name": "a",
"id": "userid2",
"last_name": "b",
"nickname": "c",
"username": "user2",
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
> >
<span <div
className="status-wrapper style--none" className="suggestion-list__item"
data-testid="mentionSuggestion_user2"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
> >
<span <span
className="profile-icon" className="status-wrapper style--none"
> >
<Memo(Avatar) <span
size="sm" className="profile-icon"
url="/api/v4/users/userid2/image?_=0"
username="user2"
> >
<img <Memo(Avatar)
alt="user2 profile image" size="sm"
className="Avatar Avatar-sm" url="/api/v4/users/userid2/image?_=0"
loading="lazy" username="user2"
onError={[Function]} >
src="/api/v4/users/userid2/image?_=0" <img
tabIndex={0} alt="user2 profile image"
/> className="Avatar Avatar-sm"
</Memo(Avatar)> loading="lazy"
onError={[Function]}
src="/api/v4/users/userid2/image?_=0"
tabIndex={0}
/>
</Memo(Avatar)>
</span>
<StatusIcon
button={false}
className=""
/>
</span> </span>
<StatusIcon
button={false}
className=""
/>
</span>
<span
className="suggestion-list__ellipsis"
>
<span <span
className="suggestion-list__main" className="suggestion-list__ellipsis"
> >
@user2 <span
</span> className="suggestion-list__main"
a b (c) >
<Component @user2
emojiSize={15} </span>
emojiStyle={ a b (c)
Object { <Component
"margin": "0 4px 4px", emojiSize={15}
emojiStyle={
Object {
"margin": "0 4px 4px",
}
} }
} showTooltip={true}
showTooltip={true} userID="userid2"
userID="userid2" >
> <div />
<div /> </Component>
</Component> </span>
</span> </div>
</div> </SuggestionContainer>
</AtMentionSuggestion> </AtMentionSuggestion>
`; `;
exports[`at mention suggestion Should not display nick name of the signed in user 1`] = ` exports[`at mention suggestion Should not display nick name of the signed in user 1`] = `
<AtMentionSuggestion <AtMentionSuggestion
intl={ isSelection={false}
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",
}
}
item={ item={
Object { Object {
"first_name": "a", "first_name": "a",
@@ -163,72 +109,93 @@ exports[`at mention suggestion Should not display nick name of the signed in use
} }
} }
matchedPretext="@" matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user" term="@user"
> >
<div <SuggestionContainer
className="suggestion-list__item"
data-testid="mentionSuggestion_user" data-testid="mentionSuggestion_user"
onClick={[Function]} isSelection={false}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "first_name": "a",
"id": "userid1",
"isCurrentUser": true,
"last_name": "b",
"nickname": "c",
"username": "user",
}
}
matchedPretext="@"
onClick={[MockFunction]}
onMouseMove={[MockFunction]}
term="@user"
> >
<span <div
className="status-wrapper style--none" className="suggestion-list__item"
data-testid="mentionSuggestion_user"
onClick={[Function]}
onMouseMove={[Function]}
role="button"
tabIndex={-1}
> >
<span <span
className="profile-icon" className="status-wrapper style--none"
> >
<Memo(Avatar) <span
size="sm" className="profile-icon"
url="/api/v4/users/userid1/image?_=0"
username="user"
> >
<img <Memo(Avatar)
alt="user profile image" size="sm"
className="Avatar Avatar-sm" url="/api/v4/users/userid1/image?_=0"
loading="lazy" username="user"
onError={[Function]} >
src="/api/v4/users/userid1/image?_=0" <img
tabIndex={0} alt="user profile image"
/> className="Avatar Avatar-sm"
</Memo(Avatar)> loading="lazy"
</span> onError={[Function]}
<StatusIcon src="/api/v4/users/userid1/image?_=0"
button={false} tabIndex={0}
className="" />
/> </Memo(Avatar)>
</span>
<span
className="suggestion-list__ellipsis"
>
<span
className="suggestion-list__main"
>
@user
</span>
a b
<FormattedMessage
defaultMessage="(you)"
id="suggestion.user.isCurrent"
>
<span>
(you)
</span> </span>
</FormattedMessage> <StatusIcon
<Component button={false}
emojiSize={15} className=""
emojiStyle={ />
Object { </span>
"margin": "0 4px 4px", <span
} className="suggestion-list__ellipsis"
}
showTooltip={true}
userID="userid1"
> >
<div /> <span
</Component> className="suggestion-list__main"
</span> >
</div> @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> </AtMentionSuggestion>
`; `;

Просмотреть файл

@@ -4,9 +4,10 @@
import React from 'react'; import React from 'react';
import * as Utils from 'utils/utils'; 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 {mountWithIntl} from 'tests/helpers/intl-test-helper';
import AtMentionSuggestion, {Item} from './at_mention_suggestion';
jest.mock('components/custom_status/custom_status_emoji', () => () => <div/>); jest.mock('components/custom_status/custom_status_emoji', () => () => <div/>);
jest.spyOn(Utils, 'getFullName').mockReturnValue('a b'); jest.spyOn(Utils, 'getFullName').mockReturnValue('a b');
@@ -18,7 +19,7 @@ describe('at mention suggestion', () => {
last_name: 'b', last_name: 'b',
nickname: 'c', nickname: 'c',
isCurrentUser: true, isCurrentUser: true,
}; } as Item;
const userid2 = { const userid2 = {
id: 'userid2', id: 'userid2',
@@ -26,14 +27,21 @@ describe('at mention suggestion', () => {
first_name: 'a', first_name: 'a',
last_name: 'b', last_name: 'b',
nickname: 'c', 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', () => { it('Should not display nick name of the signed in user', () => {
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<AtMentionSuggestion <AtMentionSuggestion
{...baseProps}
item={userid1} item={userid1}
matchedPretext='@'
term='@user'
/>, />,
); );
@@ -46,9 +54,8 @@ describe('at mention suggestion', () => {
it('Should display nick name of non signed in user', () => { it('Should display nick name of non signed in user', () => {
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<AtMentionSuggestion <AtMentionSuggestion
{...baseProps}
item={userid2} item={userid2}
matchedPretext='@'
term='@user'
/>, />,
); );

Просмотреть файл

@@ -2,9 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {ReactNode} from 'react'; import React, {ReactNode} from 'react';
import {FormattedMessage, injectIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import classNames from 'classnames';
import {Constants} from 'utils/constants'; import {Constants} from 'utils/constants';
import * as Utils from 'utils/utils'; 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 CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import StatusIcon from 'components/status_icon'; import StatusIcon from 'components/status_icon';
import Suggestion from '../suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from '../suggestion';
import {UserProfile} from '@mattermost/types/users'; import {UserProfile} from '@mattermost/types/users';
interface Item extends UserProfile { export interface Item extends UserProfile {
display_name: string; display_name: string;
name: string; name: string;
isCurrentUser: boolean; isCurrentUser: boolean;
@@ -33,169 +31,167 @@ interface Group extends Item {
member_count: number; member_count: number;
} }
class AtMentionSuggestion extends Suggestion { const AtMentionSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<Item>>((props, ref) => {
render() { const {item} = props;
const {intl} = this.props;
const {isSelection, item} = this.props;
let itemname: string; const intl = useIntl();
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;
if (item.isCurrentUser) { let itemname: string;
if (item.first_name || item.last_name) { let description: ReactNode;
description = Utils.getFullName(item); let icon: JSX.Element;
} let customStatus: ReactNode;
} else if (item.first_name || item.last_name || item.nickname) { if (item.username === 'all') {
description = `${Utils.getFullName(item)} ${item.nickname ? `(${item.nickname})` : ''}`.trim(); itemname = 'all';
} description = (
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 ? (
<FormattedMessage <FormattedMessage
id='suggestion.user.isCurrent' id='suggestion.mention.all'
defaultMessage='(you)' defaultMessage='Notifies everyone in this channel'
/> />
) : null; );
icon = (
const sharedIcon = item.remote_id ? ( <span className='suggestion-list__icon suggestion-list__icon--large'>
<SharedUserIndicator <i
className='shared-user-icon' className='icon icon-account-multiple-outline'
withTooltip={true} 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.isCurrentUser) {
if (item.type === Constants.MENTION_GROUPS) { if (item.first_name || item.last_name) {
countBadge = ( description = Utils.getFullName(item);
<span className='suggestion-list__group-count'> }
<Tag } else if (item.first_name || item.last_name || item.nickname) {
text={ description = `${Utils.getFullName(item)} ${item.nickname ? `(${item.nickname})` : ''}`.trim();
<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 ( icon = (
<div <span className='status-wrapper style--none'>
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})} <span className='profile-icon'>
data-testid={`mentionSuggestion_${itemname}`} <Avatar
onClick={this.handleClick} username={item && item.username}
onMouseMove={this.handleMouseMove} size='sm'
{...Suggestion.baseProps} url={Utils.imageURLForUser(item.id, item.last_picture_update)}
> />
{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> </span>
{countBadge} <StatusIcon status={item && item.status}/>
</div> </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;

Просмотреть файл

@@ -14,73 +14,55 @@ import store from 'stores/redux_store.jsx';
import {Constants} from 'utils/constants'; import {Constants} from 'utils/constants';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
export const MIN_CHANNEL_LINK_LENGTH = 2; export const MIN_CHANNEL_LINK_LENGTH = 2;
export type Results = { type WrappedChannel = {
matchedPretext: string;
terms: string[];
items: WrappedChannels[];
component: React.ElementType;
}
type ResultsCallback = (results: Results) => void;
type WrappedChannels = {
type: string; type: string;
channel?: Channel; channel?: Channel;
loading?: boolean; loading?: boolean;
} }
export class ChannelMentionSuggestion extends Suggestion { export const ChannelMentionSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<WrappedChannel>>((props, ref) => {
render() { const {item} = props;
const isSelection = this.props.isSelection; const channelIsArchived = item.channel && item.channel.delete_at && item.channel.delete_at !== 0;
const item = this.props.item;
const channelIsArchived = item.channel.delete_at && item.channel.delete_at !== 0;
const channelName = item.channel.display_name; const channelName = item.channel?.display_name;
let channelIcon; let channelIcon;
if (channelIsArchived) { if (channelIsArchived) {
channelIcon = ( channelIcon = (
<span className='suggestion-list__icon suggestion-list__icon--large'> <span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon-archive-outline'/> <i className='icon icon-archive-outline'/>
</span> </span>
); );
} else { } else {
channelIcon = ( channelIcon = (
<span className='suggestion-list__icon suggestion-list__icon--large'> <span className='suggestion-list__icon suggestion-list__icon--large'>
<i className={`icon icon--no-spacing icon-${item.channel.type === Constants.OPEN_CHANNEL ? 'globe' : 'lock'}`}/> <i className={`icon icon--no-spacing icon-${item.channel?.type === Constants.OPEN_CHANNEL ? 'globe' : 'lock'}`}/>
</span> </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 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 { export default class ChannelMentionProvider extends Provider {
private lastPrefixTrimmed: string; private lastPrefixTrimmed: string;
@@ -106,7 +88,7 @@ export default class ChannelMentionProvider extends Provider {
this.delayChannelAutocomplete = props.delayChannelAutocomplete; this.delayChannelAutocomplete = props.delayChannelAutocomplete;
} }
handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { handlePretextChanged(pretext: string, resultCallback: ResultsCallback<WrappedChannel>) {
this.resetRequest(); this.resetRequest();
const captured = (/\B(~([^~\r\n]*))$/i).exec(pretext.toLowerCase()); 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 words = prefix.toLowerCase().split(/\s+/);
const wrappedChannelIds: Record<string, boolean> = {}; const wrappedChannelIds: Record<string, boolean> = {};
let wrappedChannels: WrappedChannels[] = []; let wrappedChannels: WrappedChannel[] = [];
getMyChannels(store.getState()).forEach((item) => { getMyChannels(store.getState()).forEach((item) => {
if (item.type !== 'O' || item.delete_at > 0) { if (item.type !== 'O' || item.delete_at > 0) {
return; return;
@@ -212,7 +194,7 @@ export default class ChannelMentionProvider extends Provider {
} }
// Wrap channels in an outer object to avoid overwriting the 'type' property. // Wrap channels in an outer object to avoid overwriting the 'type' property.
const wrappedMoreChannels: WrappedChannels[] = []; const wrappedMoreChannels: WrappedChannel[] = [];
channels.forEach((item) => { channels.forEach((item) => {
if (item.delete_at > 0 && !myMembers[item.id]) { if (item.delete_at > 0 && !myMembers[item.id]) {
return; return;

Просмотреть файл

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

Просмотреть файл

@@ -7,7 +7,7 @@ import {Store} from 'redux';
import globalStore from 'stores/redux_store'; import globalStore from 'stores/redux_store';
import Provider from '../provider'; import Provider, {ResultsCallback} from '../provider';
import {GlobalState} from 'types/store'; import {GlobalState} from 'types/store';
import {Constants} from 'utils/constants'; import {Constants} from 'utils/constants';
@@ -31,19 +31,11 @@ type Props = {
rootId?: string; rootId?: string;
}; };
export type Results = { type Item = AutocompleteSuggestion | UserProfile | {channel: Channel};
matchedPretext: string;
terms: string[];
items: Array<AutocompleteSuggestion | UserProfile | {channel: Channel}>;
component?: React.ElementType;
components?: React.ElementType[];
}
type ResultsCallback = (results: Results) => void;
export default class AppCommandProvider extends Provider { export default class AppCommandProvider extends Provider {
private store: Store<GlobalState>; private store: Store<GlobalState>;
private triggerCharacter: string; public triggerCharacter: string;
private appCommandParser: AppCommandParser; private appCommandParser: AppCommandParser;
constructor(props: Props) { constructor(props: Props) {
@@ -58,7 +50,7 @@ export default class AppCommandProvider extends Provider {
this.appCommandParser.setChannelContext(props.channelId, props.teamId, props.rootId); 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)) { if (!pretext.startsWith(this.triggerCharacter)) {
return false; return false;
} }

Просмотреть файл

@@ -8,7 +8,7 @@ import {Client4} from 'mattermost-redux/client';
import {AutocompleteSuggestion} from '@mattermost/types/integrations'; import {AutocompleteSuggestion} from '@mattermost/types/integrations';
import CommandProvider, {CommandSuggestion, Results} from './command_provider'; import CommandProvider, {CommandSuggestion} from './command_provider';
describe('CommandSuggestion', () => { describe('CommandSuggestion', () => {
const suggestion: AutocompleteSuggestion = { const suggestion: AutocompleteSuggestion = {
@@ -24,6 +24,8 @@ describe('CommandSuggestion', () => {
isSelection: true, isSelection: true,
term: '/', term: '/',
matchedPretext: '', matchedPretext: '',
onClick: jest.fn(),
onMouseMove: jest.fn(),
}; };
test('should match snapshot', () => { test('should match snapshot', () => {
@@ -61,7 +63,7 @@ describe('CommandProvider', () => {
provider.handlePretextChanged('/jira issue', callback); provider.handlePretextChanged('/jira issue', callback);
await mockFunc(); await mockFunc();
const expected: Results = { const expected = {
matchedPretext: '/jira issue', matchedPretext: '/jira issue',
terms: ['/jira issue'], terms: ['/jira issue'],
items: [{ items: [{
@@ -102,7 +104,7 @@ describe('CommandProvider', () => {
provider.handlePretextChanged('/jira issue', callback); provider.handlePretextChanged('/jira issue', callback);
await mockFunc(); await mockFunc();
const expected: Results = { const expected = {
matchedPretext: '/jira issue', matchedPretext: '/jira issue',
terms: ['/jira issue'], terms: ['/jira issue'],
items: [{ items: [{

Просмотреть файл

@@ -14,8 +14,8 @@ import globalStore from 'stores/redux_store';
import * as UserAgent from 'utils/user_agent'; import * as UserAgent from 'utils/user_agent';
import {Constants} from 'utils/constants'; import {Constants} from 'utils/constants';
import Suggestion from '../suggestion'; import {SuggestionContainer, SuggestionProps} from '../suggestion';
import Provider from '../provider'; import Provider, {ResultsCallback} from '../provider';
import {GlobalState} from 'types/store'; 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 OPEN_COMMAND_IN_MODAL_ITEM_ID = Constants.Integrations.OPEN_COMMAND_IN_MODAL_ITEM_ID;
const COMMAND_SUGGESTION_ERROR = Constants.Integrations.COMMAND_SUGGESTION_ERROR; const COMMAND_SUGGESTION_ERROR = Constants.Integrations.COMMAND_SUGGESTION_ERROR;
export class CommandSuggestion extends Suggestion { const CommandSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<AutocompleteSuggestion>>((props, ref) => {
render() { const {item} = props;
const {isSelection} = this.props;
const item = this.props.item as AutocompleteSuggestion;
let className = 'slash-command'; let symbolSpan = <span>{'/'}</span>;
if (isSelection) { switch (item.IconData) {
className += ' suggestion--selected'; case EXECUTE_CURRENT_COMMAND_ITEM_ID:
} symbolSpan = <span className='block mt-1'>{'↵'}</span>;
let symbolSpan = <span>{'/'}</span>; break;
switch (item.IconData) { case OPEN_COMMAND_IN_MODAL_ITEM_ID:
case EXECUTE_CURRENT_COMMAND_ITEM_ID: symbolSpan = (
symbolSpan = <span className='block mt-1'>{'↵'}</span>; <span className='block mt-1'>
break; <DockWindowIcon size={28}/>
case OPEN_COMMAND_IN_MODAL_ITEM_ID: </span>
symbolSpan = ( );
<span className='block mt-1'> break;
<DockWindowIcon size={28}/> case COMMAND_SUGGESTION_ERROR:
</span> symbolSpan = <span>{'!'}</span>;
); break;
break; }
case COMMAND_SUGGESTION_ERROR: let icon = <div className='slash-command__icon'>{symbolSpan}</div>;
symbolSpan = <span>{'!'}</span>; if (item.IconData && ![EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, OPEN_COMMAND_IN_MODAL_ITEM_ID].includes(item.IconData)) {
break; icon = (
}
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 (
<div <div
className={className} className='slash-command__icon'
onClick={this.handleClick} style={{backgroundColor: 'transparent'}}
onMouseMove={this.handleMouseMove}
{...Suggestion.baseProps}
> >
{icon} <img src={item.IconData}/>
<div className='slash-command__info'> </div>);
<div className='slash-command__title'> }
{item.Suggestion.substring(1) + ' ' + item.Hint}
</div> return (
<div className='slash-command__desc'> <SuggestionContainer
{item.Description} ref={ref}
</div> {...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>
</div> </div>
); </SuggestionContainer>
} );
} });
CommandSuggestion.displayName = 'CommandSuggestion';
export {CommandSuggestion};
type Props = { type Props = {
teamId: string; teamId: string;
@@ -90,19 +83,10 @@ type Props = {
rootId?: string; 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 { export default class CommandProvider extends Provider {
private props: Props; private props: Props;
private store: Store<GlobalState>; private store: Store<GlobalState>;
private triggerCharacter: string; public triggerCharacter: string;
private appCommandParser: AppCommandParser; private appCommandParser: AppCommandParser;
constructor(props: Props) { constructor(props: Props) {
@@ -119,7 +103,7 @@ export default class CommandProvider extends Provider {
this.appCommandParser.setChannelContext(props.channelId, props.teamId, props.rootId); 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)) { if (!pretext.startsWith(this.triggerCharacter)) {
return false; return false;
} }
@@ -156,7 +140,7 @@ export default class CommandProvider extends Provider {
callback(term + ' '); callback(term + ' ');
} }
handleMobile(pretext: string, resultCallback: ResultsCallback) { handleMobile(pretext: string, resultCallback: ResultsCallback<AutocompleteSuggestion>) {
const {teamId} = this.props; const {teamId} = this.props;
const command = pretext.toLowerCase(); 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 command = pretext.toLowerCase();
const {teamId, channelId, rootId} = this.props; const {teamId, channelId, rootId} = this.props;

Просмотреть файл

@@ -7,7 +7,7 @@ import {getEmojiMap, getRecentEmojisNames} from 'selectors/emojis';
import EmoticonProvider, { import EmoticonProvider, {
MIN_EMOTICON_LENGTH, MIN_EMOTICON_LENGTH,
EMOJI_CATEGORY_SUGGESTION_BLOCKLIST, EMOJI_CATEGORY_SUGGESTION_BLOCKLIST,
} from 'components/suggestion/emoticon_provider.jsx'; } from './emoticon_provider';
jest.mock('selectors/emojis', () => ({ jest.mock('selectors/emojis', () => ({
getEmojiMap: jest.fn(), getEmojiMap: jest.fn(),
@@ -57,9 +57,9 @@ describe('components/EmoticonProvider', () => {
expect(results.map((item) => item.name)).toEqual([ expect(results.map((item) => item.name)).toEqual([
'thumbsup', // thumbsup is a special case where it always appears before thumbsdown 'thumbsup', // thumbsup is a special case where it always appears before thumbsdown
'thumbsdown', 'thumbsdown',
'thunder_cloud_and_rain',
'thumbsdown-custom', 'thumbsdown-custom',
'thumbsup-custom', 'thumbsup-custom',
'thunder_cloud_and_rain',
'lithuania', 'lithuania',
'lithuania-custom', 'lithuania-custom',
]); ]);
@@ -141,8 +141,8 @@ describe('components/EmoticonProvider', () => {
'lithuania-custom', 'lithuania-custom',
'thumbsup', 'thumbsup',
'thumbsdown', 'thumbsdown',
'thumbsup-custom',
'thunder_cloud_and_rain', 'thunder_cloud_and_rain',
'thumbsup-custom',
'lithuania', 'lithuania',
]); ]);
}); });
@@ -162,8 +162,8 @@ describe('components/EmoticonProvider', () => {
'thumbsdown', 'thumbsdown',
'thumbsdown-custom', 'thumbsdown-custom',
'lithuania-custom', 'lithuania-custom',
'thumbsup-custom',
'thunder_cloud_and_rain', 'thunder_cloud_and_rain',
'thumbsup-custom',
'lithuania', 'lithuania',
]); ]);
}); });

Просмотреть файл

@@ -3,7 +3,7 @@
import React from 'react'; import React from 'react';
import {Preferences} from 'utils/constants'; import {Emoji} from '@mattermost/types/emojis';
import {autocompleteCustomEmojis} from 'mattermost-redux/actions/emojis'; import {autocompleteCustomEmojis} from 'mattermost-redux/actions/emojis';
import {getEmojiImageUrl, isSystemEmoji} from 'mattermost-redux/utils/emoji_utils'; 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 store from 'stores/redux_store.jsx';
import {Preferences} from 'utils/constants';
import * as Emoticons from 'utils/emoticons'; import * as Emoticons from 'utils/emoticons';
import {compareEmojis, emojiMatchesSkin} from 'utils/emoji_utils'; import {compareEmojis, emojiMatchesSkin} from 'utils/emoji_utils';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
export const MIN_EMOTICON_LENGTH = 2; export const MIN_EMOTICON_LENGTH = 2;
export const EMOJI_CATEGORY_SUGGESTION_BLOCKLIST = ['skintone']; export const EMOJI_CATEGORY_SUGGESTION_BLOCKLIST = ['skintone'];
class EmoticonSuggestion extends Suggestion { type EmojiItem = {
render() { name: string;
const text = this.props.term; emoji: Emoji;
const emoji = this.props.item.emoji; type: string;
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>
);
}
} }
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 { export default class EmoticonProvider extends Provider {
constructor() { constructor() {
super(); super();
this.triggerCharacter = ':'; 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 // 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()); const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext.toLowerCase());
if (!captured) { if (!captured) {
@@ -94,7 +94,7 @@ export default class EmoticonProvider extends Provider {
return true; return true;
} }
formatEmojis(emojis) { formatEmojis(emojis: EmojiItem[]) {
return emojis.map((item) => ':' + item.name + ':'); 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. // For now, this behaviour and difference is by design.
// See https://mattermost.atlassian.net/browse/MM-17320. // See https://mattermost.atlassian.net/browse/MM-17320.
findAndSuggestEmojis(text, partialName, resultsCallback) { findAndSuggestEmojis(text: string, partialName: string, resultsCallback: ResultsCallback<EmojiItem>) {
const recentMatched = []; const recentMatched: EmojiItem[] = [];
const matched = []; const matched: EmojiItem[] = [];
const state = store.getState(); const state = store.getState();
const skintone = state.entities?.preferences?.myPreferences['emoji--emoji_skintone']?.value || 'default'; const skintone = state.entities?.preferences?.myPreferences['emoji--emoji_skintone']?.value || 'default';
const emojiMap = getEmojiMap(state); const emojiMap = getEmojiMap(state);
@@ -148,8 +148,8 @@ export default class EmoticonProvider extends Provider {
} }
} }
const sortEmojisHelper = (a, b) => { const sortEmojisHelper = (a: EmojiItem, b: EmojiItem) => {
return compareEmojis(a, b, partialName); return compareEmojis(a.emoji, b.emoji, partialName);
}; };
recentMatched.sort(sortEmojisHelper); recentMatched.sort(sortEmojisHelper);

Просмотреть файл

@@ -7,61 +7,44 @@ import {Channel} from '@mattermost/types/channels';
import {ServerError} from '@mattermost/types/errors'; import {ServerError} from '@mattermost/types/errors';
import {ActionResult} from 'mattermost-redux/types/actions'; import {ActionResult} from 'mattermost-redux/types/actions';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
export type Results = {
matchedPretext: string;
terms: string[];
items: Channel[];
component: React.ElementType;
}
type ResultsCallback = (results: Results) => void;
type ChannelSearchFunc = (term: string, success: (channels: Channel[]) => void, error?: (err: ServerError) => void) => (ActionResult | Promise<ActionResult | ActionResult[]>); type ChannelSearchFunc = (term: string, success: (channels: Channel[]) => void, error?: (err: ServerError) => void) => (ActionResult | Promise<ActionResult | ActionResult[]>);
class ChannelSuggestion extends Suggestion { const GenericChannelSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<Channel>>((props, ref) => {
render() { const {item} = props;
const isSelection = this.props.isSelection;
const item = this.props.item;
const channelName = item.display_name; const channelName = item.display_name;
const purpose = item.purpose; const purpose = item.purpose;
const icon = ( const icon = (
<span className='suggestion-list__icon suggestion-list__icon--large'> <span className='suggestion-list__icon suggestion-list__icon--large'>
<i className='icon icon--standard icon--no-spacing icon-globe'/> <i className='icon icon--standard icon--no-spacing icon-globe'/>
</span> </span>
); );
let className = 'suggestion-list__item';
if (isSelection) {
className += ' suggestion--selected';
}
const description = '(~' + item.name + ')'; const description = '(~' + item.name + ')';
return ( return (
<div <SuggestionContainer
className={className} ref={ref}
onClick={this.handleClick} {...props}
onMouseMove={this.handleMouseMove} >
{...Suggestion.baseProps} {icon}
> <div className='suggestion-list__ellipsis'>
{icon} <span className='suggestion-list__main'>
<div className='suggestion-list__ellipsis'> {channelName}
<span className='suggestion-list__main'> </span>
{channelName} {description}
</span> {purpose}
{description}
{purpose}
</div>
</div> </div>
); </SuggestionContainer>
} );
} });
GenericChannelSuggestion.displayName = 'GenericChannelSuggestion';
export default class ChannelProvider extends Provider { export default class GenericChannelProvider extends Provider {
autocompleteChannels: ChannelSearchFunc; autocompleteChannels: ChannelSearchFunc;
constructor(channelSearchFunc: ChannelSearchFunc) { constructor(channelSearchFunc: ChannelSearchFunc) {
super(); super();
@@ -69,24 +52,22 @@ export default class ChannelProvider extends Provider {
this.autocompleteChannels = channelSearchFunc; this.autocompleteChannels = channelSearchFunc;
} }
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<Channel>) {
const normalizedPretext = pretext.toLowerCase(); const normalizedPretext = pretext.toLowerCase();
this.startNewRequest(normalizedPretext); this.startNewRequest(normalizedPretext);
this.autocompleteChannels( this.autocompleteChannels(
normalizedPretext, normalizedPretext,
(data: Channel[]) => { (channels: Channel[]) => {
if (this.shouldCancelDispatch(normalizedPretext)) { if (this.shouldCancelDispatch(normalizedPretext)) {
return; return;
} }
const channels: Channel[] = Object.assign([], data);
resultsCallback({ resultsCallback({
matchedPretext: normalizedPretext, matchedPretext: normalizedPretext,
terms: channels.map((channel: Channel) => channel.display_name), terms: channels.map((channel: Channel) => channel.display_name),
items: channels, items: channels,
component: ChannelSuggestion, component: GenericChannelSuggestion,
}); });
}, },
); );

Просмотреть файл

@@ -13,85 +13,72 @@ import {isGuest} from 'mattermost-redux/utils/user_utils';
import Avatar from 'components/widgets/users/avatar'; import Avatar from 'components/widgets/users/avatar';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
import {UserAutocomplete, UserProfile} from './command_provider/app_command_parser/app_command_parser_dependencies.js'; import {UserAutocomplete, UserProfile} from './command_provider/app_command_parser/app_command_parser_dependencies.js';
export type ProviderResults = { const GenericUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
matchedPretext: string; const {item} = props;
terms: string[];
items: Array<Record<string, any>>;
component?: React.ReactNode;
}
class UserSuggestion extends Suggestion { const username = item.username;
render() { let description = '';
const {item, isSelection} = this.props;
let className = 'suggestion-list__item'; if ((item.first_name || item.last_name) && item.nickname) {
if (isSelection) { description = `- ${Utils.getFullName(item)} (${item.nickname})`;
className += ' suggestion--selected'; } else if (item.nickname) {
} description = `- (${item.nickname})`;
} else if (item.first_name || item.last_name) {
const username = item.username; description = `- ${Utils.getFullName(item)}`;
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>
);
} }
}
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>; autocompleteUsers: (text: string) => Promise<UserAutocomplete>;
constructor(searchUsersFunc: (username: string) => Promise<UserAutocomplete>) { constructor(searchUsersFunc: (username: string) => Promise<UserAutocomplete>) {
super(); super();
this.autocompleteUsers = searchUsersFunc; this.autocompleteUsers = searchUsersFunc;
} }
async handlePretextChanged(pretext: string, resultsCallback: (res: ProviderResults) => void) {
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<UserProfile>) {
const normalizedPretext = pretext.toLowerCase(); const normalizedPretext = pretext.toLowerCase();
this.startNewRequest(normalizedPretext); this.startNewRequest(normalizedPretext);
const data = await this.autocompleteUsers(normalizedPretext); this.autocompleteUsers(normalizedPretext).then((data) => {
if (this.shouldCancelDispatch(normalizedPretext)) {
return;
}
if (this.shouldCancelDispatch(normalizedPretext)) { const users = data.users;
return false;
}
const users = Object.assign([], data.users); resultsCallback({
matchedPretext: normalizedPretext,
resultsCallback({ terms: users.map((user: UserProfile) => user.username),
matchedPretext: normalizedPretext, items: users,
terms: users.map((user: UserProfile) => user.username), component: GenericUserSuggestion,
items: users, });
component: UserSuggestion,
}); });
return true; return true;

Просмотреть файл

@@ -3,42 +3,37 @@
import React from 'react'; import React from 'react';
import {ProviderResults} from './generic_user_provider'; import Provider, {ResultsCallback} from './provider';
import {SuggestionContainer, SuggestionProps} from './suggestion';
import Provider from './provider'; interface MenuAction {
import Suggestion from './suggestion.jsx'; text: string;
value: string;
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>
);
}
} }
export default class MenuActionProvider extends Provider { const MenuActionSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<MenuAction>>((props, ref) => {
private options: Array<Record<string, any>>; 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(); super();
this.options = options; this.options = options;
} }
handlePretextChanged(prefix: string, resultsCallback: (res: ProviderResults) => void) { handlePretextChanged(prefix: string, resultsCallback: ResultsCallback<MenuAction>) {
if (prefix.length === 0) { if (prefix.length === 0) {
this.displayAllOptions(resultsCallback); this.displayAllOptions(resultsCallback);
return true; return true;
@@ -52,7 +47,7 @@ export default class MenuActionProvider extends Provider {
return false; return false;
} }
async displayAllOptions(resultsCallback: (res: ProviderResults) => void) { async displayAllOptions(resultsCallback: ResultsCallback<MenuAction>) {
const terms = this.options.map((option) => option.text); const terms = this.options.map((option) => option.text);
resultsCallback({ 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 filteredOptions = this.options.filter((option) => option.text.toLowerCase().indexOf(prefix.toLowerCase()) >= 0);
const terms = filteredOptions.map((option) => option.text); const terms = filteredOptions.map((option) => option.text);

Просмотреть файл

@@ -1,20 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
export type ProviderResult = { import {RequireOnlyOne} from '@mattermost/types/utilities';
export type ProviderResult<Item> = {
matchedPretext: string; matchedPretext: string;
terms: string[]; terms: string[];
items: Array<Record<string, any>>; items: Array<Item | Loading>;
component?: React.ReactNode; } & 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; latestPrefix: string;
latestComplete: boolean; latestComplete: boolean;
disableDispatches: boolean; disableDispatches: boolean;
requestStarted: boolean; requestStarted: boolean;
forceDispatch: boolean; forceDispatch: boolean;
triggerCharacter?: string;
constructor() { constructor() {
this.latestPrefix = ''; this.latestPrefix = '';
this.latestComplete = true; this.latestComplete = true;
@@ -23,9 +36,7 @@ export default class Provider {
this.forceDispatch = false; this.forceDispatch = false;
} }
handlePretextChanged(pretext: string, callback: (res: ProviderResult) => void) {// eslint-disable-line @typescript-eslint/no-unused-vars abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult<unknown>) => void): boolean;
// NO-OP for inherited classes to override
}
resetRequest() { resetRequest() {
this.requestStarted = false; this.requestStarted = false;

Просмотреть файл

@@ -11,22 +11,13 @@ import store from 'stores/redux_store.jsx';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import {getCurrentLocale} from 'selectors/i18n'; import {getCurrentLocale} from 'selectors/i18n';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import SearchChannelSuggestion from './search_channel_suggestion'; import SearchChannelSuggestion from './search_channel_suggestion';
import {Channel} from './command_provider/app_command_parser/app_command_parser_dependencies.js'; import {Channel} from './command_provider/app_command_parser/app_command_parser_dependencies.js';
const getState = store.getState; 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 }) { function itemToTerm(isAtSearch: boolean, item: { type: string; display_name: string; name: string }) {
const prefix = isAtSearch ? '' : '@'; const prefix = isAtSearch ? '' : '@';
if (item.type === Constants.DM_CHANNEL) { if (item.type === Constants.DM_CHANNEL) {
@@ -48,7 +39,7 @@ export default class SearchChannelProvider extends Provider {
this.autocompleteChannelsForSearch = channelSearchFunc; 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()); const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) { if (captured) {
let channelPrefix = captured[1]; let channelPrefix = captured[1];

Просмотреть файл

@@ -1,12 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/suggestion/search_channel_suggestion should match snapshot 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot 1`] = `
<div <SuggestionContainer
className="suggestion-list__item" currentUserId="userid1"
onClick={[Function]} isSelection={false}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -25,16 +46,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot 1
</span> </span>
~DN ~DN
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type DM_CHANNEL 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type DM_CHANNEL 1`] = `
<div <SuggestionContainer
className="suggestion-list__item suggestion--selected" currentUserId="userid1"
onClick={[Function]} isSelection={true}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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) <Memo(Avatar)
size="sm" size="sm"
@@ -49,16 +91,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
@name @name
</span> </span>
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type GM_CHANNEL 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type GM_CHANNEL 1`] = `
<div <SuggestionContainer
className="suggestion-list__item suggestion--selected" currentUserId="userid1"
onClick={[Function]} isSelection={true}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -78,16 +141,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
@name @name
</span> </span>
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type OPEN_CHANNEL 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type OPEN_CHANNEL 1`] = `
<div <SuggestionContainer
className="suggestion-list__item suggestion--selected" currentUserId="userid1"
onClick={[Function]} isSelection={true}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -106,16 +190,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span> </span>
~DN ~DN
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type PRIVATE_CHANNEL 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, channel type PRIVATE_CHANNEL 1`] = `
<div <SuggestionContainer
className="suggestion-list__item suggestion--selected" currentUserId="userid1"
onClick={[Function]} isSelection={true}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -134,16 +239,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span> </span>
~DN ~DN
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is false 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is false 1`] = `
<div <SuggestionContainer
className="suggestion-list__item" currentUserId="userid1"
onClick={[Function]} isSelection={false}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -162,16 +288,37 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span> </span>
~DN ~DN
</div> </div>
</div> </SuggestionContainer>
`; `;
exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is true 1`] = ` exports[`components/suggestion/search_channel_suggestion should match snapshot, isSelection is true 1`] = `
<div <SuggestionContainer
className="suggestion-list__item suggestion--selected" currentUserId="userid1"
onClick={[Function]} isSelection={true}
onMouseMove={[Function]} item={
role="button" Object {
tabIndex={-1} "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 <span
className="suggestion-list__icon suggestion-list__icon--large" className="suggestion-list__icon suggestion-list__icon--large"
@@ -190,5 +337,5 @@ exports[`components/suggestion/search_channel_suggestion should match snapshot,
</span> </span>
~DN ~DN
</div> </div>
</div> </SuggestionContainer>
`; `;

Просмотреть файл

@@ -16,9 +16,11 @@ type OwnProps = {
} }
const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const teammate = getDirectTeammate(state, ownProps.item.id);
return { return {
teammate: getDirectTeammate(state, ownProps.item.id), teammateIsBot: Boolean(teammate && teammate.is_bot),
currentUser: getCurrentUserId(state), currentUserId: getCurrentUserId(state),
}; };
}; };

Просмотреть файл

@@ -10,15 +10,16 @@ import SearchChannelSuggestion from './search_channel_suggestion';
describe('components/suggestion/search_channel_suggestion', () => { describe('components/suggestion/search_channel_suggestion', () => {
const mockChannel = TestHelper.getChannelMock(); const mockChannel = TestHelper.getChannelMock();
const mockTeamMate = TestHelper.getUserMock();
const baseProps = { const baseProps = {
item: mockChannel, item: mockChannel,
isSelection: false, isSelection: false,
teammate: mockTeamMate, currentUserId: 'userid1',
currentUser: 'userid1', teammateIsBot: false,
term: '', term: '',
matchedPretext: '', matchedPretext: '',
onClick: jest.fn(),
onMouseMove: jest.fn(),
}; };
test('should match snapshot', () => { test('should match snapshot', () => {

Просмотреть файл

@@ -9,15 +9,16 @@ import {getUserIdFromChannelName} from 'mattermost-redux/utils/channel_utils';
import {imageURLForUser} from 'utils/utils'; import {imageURLForUser} from 'utils/utils';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import Avatar from 'components/widgets/users/avatar'; import Avatar from 'components/widgets/users/avatar';
import Suggestion from '../suggestion';
import {SuggestionContainer, SuggestionProps} from '../suggestion';
import {Channel} from '@mattermost/types/channels'; 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) { if (item.type === Constants.DM_CHANNEL) {
const profilePicture = ( const profilePicture = (
<Avatar <Avatar
url={imageURLForUser(getUserIdFromChannelName(currentUser, item.name))} url={imageURLForUser(getUserIdFromChannelName(currentUserId, item.name))}
size='sm' size='sm'
/> />
); );
@@ -68,40 +69,38 @@ function itemToName(item: Channel, currentUser: string): {icon: React.ReactEleme
return null; return null;
} }
export default class SearchChannelSuggestion extends Suggestion { type Props = SuggestionProps<Channel> & {
render(): JSX.Element { currentUserId: string;
const {item, isSelection, teammate, currentUser} = this.props; teammateIsBot: boolean;
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>
);
}
} }
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;

Просмотреть файл

@@ -5,7 +5,7 @@ import {getState} from 'stores/redux_store';
import mockStore from 'tests/test_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', () => ({ jest.mock('stores/redux_store', () => ({
dispatch: jest.fn(), dispatch: jest.fn(),

Просмотреть файл

@@ -3,6 +3,8 @@
import React from 'react'; import React from 'react';
import {Channel} from '@mattermost/types/channels';
import { import {
getChannelsInCurrentTeam, getChannelsInCurrentTeam,
} from 'mattermost-redux/selectors/entities/channels'; } 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 {Permissions} from 'mattermost-redux/constants';
import {sortChannelsByTypeAndDisplayName} from 'mattermost-redux/utils/channel_utils'; import {sortChannelsByTypeAndDisplayName} from 'mattermost-redux/utils/channel_utils';
import {logError} from 'mattermost-redux/actions/errors'; import {logError} from 'mattermost-redux/actions/errors';
import {ActionResult} from 'mattermost-redux/types/actions';
import store from 'stores/redux_store.jsx'; import store from 'stores/redux_store.jsx';
import {Constants} from 'utils/constants'; import {Constants} from 'utils/constants';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
class SearchChannelWithPermissionsSuggestion extends Suggestion { type WrappedChannel = {
static get propTypes() { channel: Channel;
return { name: string;
...super.propTypes, }
};
}
render() { const SearchChannelWithPermissionsSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<WrappedChannel>>((props, ref) => {
const {item, isSelection} = this.props; const {item} = props;
const channel = item.channel; const channel = item.channel;
const channelIsArchived = channel.delete_at && channel.delete_at !== 0; const channelIsArchived = channel.delete_at && channel.delete_at !== 0;
let className = 'suggestion-list__item'; const displayName = channel.display_name;
if (isSelection) { let icon = null;
className += ' suggestion--selected'; if (channelIsArchived) {
} icon = (
<i className='icon icon--no-spacing icon-archive-outline'/>
const displayName = channel.display_name; );
let icon = null; } else if (channel.type === Constants.OPEN_CHANNEL) {
if (channelIsArchived) { icon = (
icon = ( <i className='icon icon--no-spacing icon-globe'/>
<i className='icon icon--no-spacing icon-archive-outline'/> );
); } else if (channel.type === Constants.PRIVATE_CHANNEL) {
} else if (channel.type === Constants.OPEN_CHANNEL) { icon = (
icon = ( <i className='icon icon--no-spacing icon-lock-outline'/>
<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>
); );
} }
}
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 = ''; 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 aIsArchived = wrappedA.channel.delete_at ? wrappedA.channel.delete_at !== 0 : false;
const bIsArchived = wrappedB.channel.delete_at ? wrappedB.channel.delete_at !== 0 : false; const bIsArchived = wrappedB.channel.delete_at ? wrappedB.channel.delete_at !== 0 : false;
if (aIsArchived && !bIsArchived) { if (aIsArchived && !bIsArchived) {
@@ -106,15 +97,17 @@ function channelSearchSorter(wrappedA, wrappedB) {
} }
export default class SearchChannelWithPermissionsProvider extends Provider { export default class SearchChannelWithPermissionsProvider extends Provider {
constructor(channelSearchFunc) { autocompleteChannelsForSearch: (channelId: string, userId: string) => Promise<ActionResult<Channel[]>>;
constructor(channelSearchFunc: SearchChannelWithPermissionsProvider['autocompleteChannelsForSearch']) {
super(); super();
this.autocompleteChannelsForSearch = channelSearchFunc; this.autocompleteChannelsForSearch = channelSearchFunc;
} }
makeChannelSearchFilter(channelPrefix) { makeChannelSearchFilter(channelPrefix: string) {
const channelPrefixLower = channelPrefix.toLowerCase(); const channelPrefixLower = channelPrefix.toLowerCase();
return (channel) => { return (channel: Channel) => {
const state = store.getState(); const state = store.getState();
const channelId = channel.id; const channelId = channel.id;
const teamId = getCurrentTeamId(state); 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) { if (channelPrefix) {
prefix = channelPrefix; prefix = channelPrefix;
this.startNewRequest(channelPrefix); this.startNewRequest(channelPrefix);
@@ -150,7 +143,7 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
return true; return true;
} }
async fetchChannels(channelPrefix, resultsCallback) { async fetchChannels(channelPrefix: string, resultsCallback: ResultsCallback<WrappedChannel>) {
const state = store.getState(); const state = store.getState();
const teamId = getCurrentTeamId(state); const teamId = getCurrentTeamId(state);
if (!teamId) { if (!teamId) {
@@ -159,10 +152,10 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
const channelsAsync = this.autocompleteChannelsForSearch(teamId, channelPrefix); const channelsAsync = this.autocompleteChannelsForSearch(teamId, channelPrefix);
let channelsFromServer = []; let channelsFromServer: Channel[] = [];
try { try {
const {data} = await channelsAsync; const {data} = await channelsAsync;
channelsFromServer = data; channelsFromServer = data ?? [];
} catch (err) { } catch (err) {
store.dispatch(logError(err)); store.dispatch(logError(err));
} }
@@ -175,7 +168,7 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
this.formatChannelsAndDispatch(channelPrefix, resultsCallback, channels); this.formatChannelsAndDispatch(channelPrefix, resultsCallback, channels);
} }
formatChannelsAndDispatch(channelPrefix, resultsCallback, allChannels) { formatChannelsAndDispatch(channelPrefix: string, resultsCallback: ResultsCallback<WrappedChannel>, allChannels: Channel[]) {
const channels = []; const channels = [];
const state = store.getState(); const state = store.getState();
@@ -186,15 +179,14 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
return; return;
} }
const completedChannels = {}; const completedChannels: Record<string, boolean> = {};
const channelFilter = this.makeChannelSearchFilter(channelPrefix); const channelFilter = this.makeChannelSearchFilter(channelPrefix);
const config = getConfig(state); const config = getConfig(state);
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
for (const id of Object.keys(allChannels)) { for (const channel of allChannels) {
const channel = allChannels[id];
if (!channel) { if (!channel) {
continue; continue;
} }
@@ -207,16 +199,15 @@ export default class SearchChannelWithPermissionsProvider extends Provider {
const newChannel = Object.assign({}, channel); const newChannel = Object.assign({}, channel);
const channelIsArchived = channel.delete_at !== 0; 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) { if (!viewArchivedChannels && channelIsArchived) {
continue; continue;
} else if (!members[channel.id]) { } else if (!members[channel.id]) {
continue; continue;
} else if (channel.type === Constants.OPEN_CHANNEL) { } else if (channel.type !== Constants.OPEN_CHANNEL && channel.type !== Constants.PRIVATE_CHANNEL) {
wrappedChannel.type = Constants.OPEN_CHANNEL;
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
wrappedChannel.type = Constants.PRIVATE_CHANNEL;
} else {
continue; continue;
} }
completedChannels[channel.id] = true; completedChannels[channel.id] = true;

Просмотреть файл

@@ -1,15 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import SearchDateSuggestion from './search_date_suggestion'; import SearchDateSuggestion from './search_date_suggestion';
type DateItem = {label: string; date: string}; 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 { 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()); const captured = (/\b(?:on|before|after):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) { if (captured) {
const datePrefix = captured[1]; const datePrefix = captured[1];

Просмотреть файл

@@ -7,7 +7,7 @@ import {DayPicker} from 'react-day-picker';
import type {Locale} from 'date-fns'; import type {Locale} from 'date-fns';
import Suggestion from '../suggestion.jsx'; import {SuggestionProps} from '../suggestion';
import * as Keyboard from 'utils/keyboard'; import * as Keyboard from 'utils/keyboard';
import * as Utils from 'utils/utils'; import * as Utils from 'utils/utils';
@@ -15,7 +15,14 @@ import Constants from 'utils/constants';
import 'react-day-picker/dist/style.css'; 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> = {}; private loadedLocales: Record<string, Locale> = {};
state = { state = {

Просмотреть файл

@@ -12,68 +12,55 @@ import SharedUserIndicator from 'components/shared_user_indicator';
import {UserProfile} from '@mattermost/types/users'; import {UserProfile} from '@mattermost/types/users';
import {UserAutocomplete} from '@mattermost/types/autocomplete'; import {UserAutocomplete} from '@mattermost/types/autocomplete';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
import {ProviderResults} from './generic_user_provider';
class SearchUserSuggestion extends Suggestion { const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
private node?: HTMLDivElement | null; const {item} = props;
render() {
const {item, isSelection} = this.props;
let className = 'suggestion-list__item'; const username = item.username;
if (isSelection) { let description = '';
className += ' suggestion--selected';
}
const username = item.username; if ((item.first_name || item.last_name) && item.nickname) {
let description = ''; 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) { let sharedIcon;
description = `${Utils.getFullName(item)} (${item.nickname})`; if (item.remote_id) {
} else if (item.nickname) { sharedIcon = (
description = `(${item.nickname})`; <SharedUserIndicator
} else if (item.first_name || item.last_name) { className='mention__shared-user-icon'
description = `${Utils.getFullName(item)}`; withTooltip={true}
} />
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>
); );
} }
}
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 { export default class SearchUserProvider extends Provider {
private autocompleteUsersInTeam: (username: string) => Promise<UserAutocomplete>; private autocompleteUsersInTeam: (username: string) => Promise<UserAutocomplete>;
@@ -82,7 +69,7 @@ export default class SearchUserProvider extends Provider {
this.autocompleteUsersInTeam = userSearchFunc; 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()); const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext.toLowerCase());
this.doAutocomplete(captured, resultsCallback); this.doAutocomplete(captured, resultsCallback);
@@ -90,7 +77,7 @@ export default class SearchUserProvider extends Provider {
return Boolean(captured); return Boolean(captured);
} }
async doAutocomplete(captured: RegExpExecArray | null, resultsCallback: (res: ProviderResults) => void) { async doAutocomplete(captured: RegExpExecArray | null, resultsCallback: ResultsCallback<UserProfile>) {
if (!captured) { if (!captured) {
return; return;
} }

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -5,9 +5,10 @@ import {getState} from 'stores/redux_store';
import mockStore from 'tests/test_store'; import mockStore from 'tests/test_store';
import SwitchChannelProvider from 'components/suggestion/switch_channel_provider.jsx';
import {Preferences} from 'mattermost-redux/constants'; import {Preferences} from 'mattermost-redux/constants';
import SwitchChannelProvider from './switch_channel_provider';
const latestPost = { const latestPost = {
id: 'latest_post_id', id: 'latest_post_id',
user_id: 'current_user_id', user_id: 'current_user_id',

Просмотреть файл

@@ -2,10 +2,15 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import classNames from 'classnames'; 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 GuestTag from 'components/widgets/tag/guest_tag';
import BotTag from 'components/widgets/tag/bot_tag'; import BotTag from 'components/widgets/tag/bot_tag';
@@ -36,11 +41,11 @@ import {
getUser, getUser,
makeSearchProfilesMatchingWithTerm, makeSearchProfilesMatchingWithTerm,
getStatusForUserId, getStatusForUserId,
getUserByUsername,
} from 'mattermost-redux/selectors/entities/users'; } from 'mattermost-redux/selectors/entities/users';
import {fetchAllMyTeamsChannelsAndChannelMembersREST, searchAllChannels} from 'mattermost-redux/actions/channels'; import {fetchAllMyTeamsChannelsAndChannelMembersREST, searchAllChannels} from 'mattermost-redux/actions/channels';
import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads'; import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads';
import {logError} from 'mattermost-redux/actions/errors'; import {logError} from 'mattermost-redux/actions/errors';
import {ActionResult} from 'mattermost-redux/types/actions';
import {sortChannelsByTypeAndDisplayName, isChannelMuted} from 'mattermost-redux/utils/channel_utils'; import {sortChannelsByTypeAndDisplayName, isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import SharedChannelIndicator from 'components/shared_channel_indicator'; import SharedChannelIndicator from 'components/shared_channel_indicator';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji'; 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 {Preferences} from 'mattermost-redux/constants';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils'; import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import Provider from './provider'; import Provider, {ResultsCallback} from './provider';
import Suggestion from './suggestion.jsx'; import {SuggestionContainer, SuggestionProps} from './suggestion';
import {GlobalState} from 'types/store';
const getState = store.getState; const getState = store.getState;
const searchProfilesMatchingWithTerm = makeSearchProfilesMatchingWithTerm(); const searchProfilesMatchingWithTerm = makeSearchProfilesMatchingWithTerm();
const ThreadsChannel = {
const ThreadsChannel: FakeChannel = {
id: 'threads', id: 'threads',
name: 'threads', name: 'threads',
display_name: 'Threads', display_name: 'Threads',
@@ -66,7 +73,7 @@ const ThreadsChannel = {
delete_at: 0, delete_at: 0,
}; };
const InsightsChannel = { const InsightsChannel: FakeChannel = {
id: 'insights', id: 'insights',
name: 'activity-and-insights', name: 'activity-and-insights',
display_name: 'Insights', display_name: 'Insights',
@@ -74,216 +81,244 @@ const InsightsChannel = {
delete_at: 0, delete_at: 0,
}; };
class SwitchChannelSuggestion extends Suggestion { type FakeChannel = Pick<Channel, 'id' | 'name' | 'display_name' | 'delete_at'> & {
static get propTypes() { type: string;
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>
);
}
} }
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 channel = ownProps.item && ownProps.item.channel;
const channelId = channel ? channel.id : ''; const channelId = channel ? channel.id : '';
const draft = channelId ? getPostDraft(state, StoragePrefixes.DRAFT, channelId) : false; 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); 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 { return {
channelMember: getMyChannelMemberships(state)[channelId], channelMember: getMyChannelMemberships(state)[channelId],
hasDraft: draft && Boolean(draft.message.trim() || draft.fileInfos.length || draft.uploadsInProgress.length), hasDraft: draft && Boolean(draft.message.trim() || draft.fileInfos.length || draft.uploadsInProgress.length),
userImageUrl,
dmChannelTeammate, dmChannelTeammate,
status, status,
userItem,
collapsedThreads, collapsedThreads,
team, team,
isPartOfOnlyOneTeam, isPartOfOnlyOneTeam,
@@ -294,7 +329,7 @@ const ConnectedSwitchChannelSuggestion = connect(mapStateToPropsForSwitchChannel
let prefix = ''; let prefix = '';
function sortChannelsByRecencyAndTypeAndDisplayName(wrappedA, wrappedB) { function sortChannelsByRecencyAndTypeAndDisplayName(wrappedA: WrappedChannel, wrappedB: WrappedChannel) {
if (wrappedA.last_viewed_at && wrappedB.last_viewed_at) { if (wrappedA.last_viewed_at && wrappedB.last_viewed_at) {
return wrappedB.last_viewed_at - wrappedA.last_viewed_at; return wrappedB.last_viewed_at - wrappedA.last_viewed_at;
} else if (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 // 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 aIsArchived = wrappedA.channel.delete_at ? wrappedA.channel.delete_at !== 0 : false;
const bIsArchived = wrappedB.channel.delete_at ? wrappedB.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); return sortChannelsByRecencyAndTypeAndDisplayName(wrappedA, wrappedB);
} }
function makeChannelSearchFilter(channelPrefix) { function makeChannelSearchFilter(channelPrefix: string) {
const channelPrefixLower = channelPrefix.toLowerCase(); const channelPrefixLower = channelPrefix.toLowerCase();
const splitPrefixBySpace = channelPrefixLower.trim().split(/[ ,]+/); const splitPrefixBySpace = channelPrefixLower.trim().split(/[ ,]+/);
const curState = getState(); const curState = getState();
const usersInChannels = getUserIdsInChannels(curState); const usersInChannels = getUserIdsInChannels(curState);
const userSearchStrings = {}; const userSearchStrings: RelationOneToOne<UserProfile, string> = {};
return (channel) => { return (channel: ChannelItem) => {
let searchString = `${channel.display_name}${channel.name}`; let searchString = `${channel.display_name}${channel.name}`;
if (channel.type === Constants.GM_CHANNEL || channel.type === Constants.DM_CHANNEL) { if (channel.type === Constants.GM_CHANNEL || channel.type === Constants.DM_CHANNEL) {
const usersInChannel = usersInChannels[channel.id] || new Set([]); 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} * @see {@link components/forward_post_modal/forward_post_channel_select.tsx}
*/ */
handlePretextChanged(channelPrefix, resultsCallback) { handlePretextChanged(channelPrefix: string, resultsCallback: ResultsCallback<WrappedChannel>) {
if (channelPrefix) { if (channelPrefix) {
prefix = channelPrefix; prefix = channelPrefix;
this.startNewRequest(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) // 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 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); const formattedData = this.formatList(channelPrefix, [ThreadsChannel, InsightsChannel, ...channels], users, true, true);
if (formattedData) { if (formattedData) {
resultsCallback(formattedData); resultsCallback(formattedData);
@@ -436,7 +471,7 @@ export default class SwitchChannelProvider extends Provider {
return true; return true;
} }
async fetchUsersAndChannels(channelPrefix, resultsCallback) { async fetchUsersAndChannels(channelPrefix: string, resultsCallback: ResultsCallback<WrappedChannel>) {
const state = getState(); const state = getState();
const teamId = getCurrentTeamId(state); const teamId = getCurrentTeamId(state);
@@ -454,15 +489,16 @@ export default class SwitchChannelProvider extends Provider {
const channelsAsync = searchAllChannels(channelPrefix, {nonAdminSearch: true})(store.dispatch, store.getState); const channelsAsync = searchAllChannels(channelPrefix, {nonAdminSearch: true})(store.dispatch, store.getState);
let usersFromServer = []; let usersFromServer;
let channelsFromServer = []; let channelsFromServer;
try { try {
usersFromServer = await usersAsync; usersFromServer = await usersAsync;
const {data} = await channelsAsync; const channelsResponse = await channelsAsync;
channelsFromServer = data; channelsFromServer = (channelsResponse as ActionResult).data;
} catch (err) { } catch (err) {
store.dispatch(logError(err)); store.dispatch(logError(err));
return;
} }
if (this.shouldCancelDispatch(channelPrefix)) { if (this.shouldCancelDispatch(channelPrefix)) {
@@ -473,10 +509,10 @@ export default class SwitchChannelProvider extends Provider {
// filter out deleted and archived channels from local store data // filter out deleted and archived channels from local store data
const localChannelData = getChannelsInAllTeams(state).concat(getDirectAndGroupChannels(state)).filter((c) => c.delete_at === 0) || []; 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 localFormattedData = this.formatList(channelPrefix, [ThreadsChannel, InsightsChannel, ...localChannelData], localUserData);
const remoteChannelData = channelsFromServer.concat(getGroupChannels(state)) || []; const remoteChannelData = channelsFromServer.concat(getGroupChannels(state)) || [];
const remoteUserData = Object.assign([], usersFromServer.users) || []; const remoteUserData = usersFromServer.users || [];
const remoteFormattedData = this.formatList(channelPrefix, remoteChannelData, remoteUserData, false); const remoteFormattedData = this.formatList(channelPrefix, remoteChannelData, remoteUserData, false);
store.dispatch({ 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)], 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 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({ resultsCallback({
...localFormattedData, ...localFormattedData,
...{ items: combinedItems,
items: combinedItems, terms: combinedTerms,
terms: combinedTerms,
},
}); });
} }
userWrappedChannel(user, channel) { userWrappedChannel(user: UserProfile, channel?: ChannelItem): WrappedChannel {
let displayName = ''; let displayName = '';
const currentUserId = getCurrentUserId(getState()); const currentUserId = getCurrentUserId(getState());
@@ -523,21 +557,21 @@ export default class SwitchChannelProvider extends Provider {
id: channel ? channel.id : user.id, id: channel ? channel.id : user.id,
userId: user.id, userId: user.id,
update_at: user.update_at, update_at: user.update_at,
delete_at: 0,
type: Constants.DM_CHANNEL, type: Constants.DM_CHANNEL,
last_picture_update: user.last_picture_update || 0,
}, },
type: 'search.direct', type: 'search.direct',
name: user.username, 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 channels = [];
const members = getMyChannelMemberships(getState()); const members = getMyChannelMemberships(getState());
const completedChannels = {}; const completedChannels: RelationOneToOne<Channel, boolean> = {};
const channelFilter = makeChannelSearchFilter(channelPrefix); const channelFilter = makeChannelSearchFilter(channelPrefix);
@@ -548,17 +582,15 @@ export default class SwitchChannelProvider extends Provider {
const allUnreadChannelIdsSet = new Set(allUnreadChannelIds); const allUnreadChannelIdsSet = new Set(allUnreadChannelIds);
const currentUserId = getCurrentUserId(state); const currentUserId = getCurrentUserId(state);
for (const id of Object.keys(allChannels)) { for (const channel of allChannels) {
const channel = allChannels[id];
if (completedChannels[channel.id]) { if (completedChannels[channel.id]) {
continue; continue;
} }
if (channelFilter(channel)) { if (channelFilter(channel)) {
const newChannel = Object.assign({}, channel); const newChannel = {...channel};
const channelIsArchived = channel.delete_at !== 0; 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]) { if (members[channel.id]) {
wrappedChannel.last_viewed_at = members[channel.id].last_viewed_at; wrappedChannel.last_viewed_at = members[channel.id].last_viewed_at;
} else if (skipNotMember && (newChannel.type !== Constants.THREADS && newChannel.type !== Constants.INSIGHTS)) { } else if (skipNotMember && (newChannel.type !== Constants.THREADS && newChannel.type !== Constants.INSIGHTS)) {
@@ -642,9 +674,11 @@ export default class SwitchChannelProvider extends Provider {
continue; continue;
} }
const unread = allUnreadChannelIdsSet.has(channel?.id) && !isChannelMuted(members[channel.id]); if (channel) {
if (unread) { const unread = allUnreadChannelIdsSet.has(channel.id) && !isChannelMuted(members[channel.id]);
wrappedChannel.unread = true; if (unread) {
wrappedChannel.unread = true;
}
} }
completedChannels[user.id] = true; completedChannels[user.id] = true;
@@ -653,7 +687,13 @@ export default class SwitchChannelProvider extends Provider {
const channelNames = channels. const channelNames = channels.
sort(quickSwitchSorter). 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) { if (localData && !channels.length) {
channels.push({ channels.push({
@@ -670,7 +710,7 @@ export default class SwitchChannelProvider extends Provider {
}; };
} }
fetchAndFormatRecentlyViewedChannels(resultsCallback) { fetchAndFormatRecentlyViewedChannels(resultsCallback: ResultsCallback<WrappedChannel>) {
const state = getState(); const state = getState();
const recentChannels = getChannelsInAllTeams(state).concat(getDirectAndGroupChannels(state)); const recentChannels = getChannelsInAllTeams(state).concat(getDirectAndGroupChannels(state));
const wrappedRecentChannels = this.wrapChannels(recentChannels, Constants.MENTION_RECENT_CHANNELS); 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 state = getState();
const counts = getThreadCountsInCurrentTeam(state); const counts = getThreadCountsInCurrentTeam(state);
const collapsedThreads = isCollapsedThreadsEnabled(state); const collapsedThreads = isCollapsedThreadsEnabled(state);
// adding last viewed at equal to Date.now() to push it to the top of the list // adding last viewed at equal to Date.now() to push it to the top of the list
let threadsItem = { let threadsItem: WrappedChannel = {
channel: ThreadsChannel, channel: ThreadsChannel,
name: ThreadsChannel.name, name: ThreadsChannel.name,
unread: Boolean(counts?.total_unread_threads),
unread_mentions: counts?.total_unread_mentions || 0, unread_mentions: counts?.total_unread_mentions || 0,
deactivated: false, deactivated: false,
last_viewed_at: Date.now(), last_viewed_at: Date.now(),
@@ -719,9 +760,6 @@ export default class SwitchChannelProvider extends Provider {
if (itemType) { if (itemType) {
threadsItem = {...threadsItem, type: itemType}; threadsItem = {...threadsItem, type: itemType};
} }
if (counts?.total_unread_threads) {
threadsItem.unread = true;
}
if (collapsedThreads && ((countType === 'unread' && counts?.total_unread_threads) || (countType === 'total'))) { if (collapsedThreads && ((countType === 'unread' && counts?.total_unread_threads) || (countType === 'total'))) {
return threadsItem; return threadsItem;
} }
@@ -737,6 +775,7 @@ export default class SwitchChannelProvider extends Provider {
const insightsItem = { const insightsItem = {
channel: InsightsChannel, channel: InsightsChannel,
name: InsightsChannel.name, name: InsightsChannel.name,
unread: false,
unread_mentions: 0, unread_mentions: 0,
deactivated: false, deactivated: false,
last_viewed_at: Date.now(), last_viewed_at: Date.now(),
@@ -749,13 +788,13 @@ export default class SwitchChannelProvider extends Provider {
return null; return null;
} }
getTimestampFromPrefs(myPreferences, category, name) { getTimestampFromPrefs(myPreferences: Record<string, PreferenceType>, category: string, name: string) {
const pref = myPreferences[getPreferenceKey(category, name)]; const pref = myPreferences[getPreferenceKey(category, name)];
const prefValue = pref ? pref.value : '0'; 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, // 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. // So thought of using preferences but it seems that also not keeping track.
// TODO Update and remove comment once solution is finalized // 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 state = getState();
const currentChannel = getCurrentChannel(state); const currentChannel = getCurrentChannel(state);
const myMembers = getMyChannelMemberships(state); const myMembers = getMyChannelMemberships(state);
const myPreferences = getMyPreferences(state); const myPreferences = getMyPreferences(state);
const collapsedThreads = isCollapsedThreadsEnabled(state);
const allUnreadChannelIds = getAllTeamsUnreadChannelIds(state); const allUnreadChannelIds = getAllTeamsUnreadChannelIds(state);
const allUnreadChannelIdsSet = new Set(allUnreadChannelIds); const allUnreadChannelIdsSet = new Set(allUnreadChannelIds);
@@ -781,14 +819,11 @@ export default class SwitchChannelProvider extends Provider {
if (channel.id === currentChannel?.id) { if (channel.id === currentChannel?.id) {
continue; continue;
} }
let wrappedChannel = {channel, name: channel.name, deactivated: false}; let wrappedChannel: WrappedChannel = {channel, name: channel.name, deactivated: false};
const member = myMembers[channel.id]; const member = myMembers[channel.id];
if (member) { if (member) {
wrappedChannel.last_viewed_at = this.getLastViewedAt(member, myPreferences, channel); 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) { if (channel.type === Constants.GM_CHANNEL) {
wrappedChannel.name = channel.display_name; wrappedChannel.name = channel.display_name;
} else if (channel.type === Constants.DM_CHANNEL) { } else if (channel.type === Constants.DM_CHANNEL) {
@@ -814,20 +849,21 @@ export default class SwitchChannelProvider extends Provider {
return channelList; return channelList;
} }
async fetchChannels(resultsCallback) { async fetchChannels(resultsCallback: ResultsCallback<WrappedChannel>) {
const state = getState(); const state = getState();
const teamId = getCurrentTeamId(state); const teamId = getCurrentTeamId(state);
if (!teamId) { if (!teamId) {
return; return;
} }
const channelsAsync = fetchAllMyTeamsChannelsAndChannelMembersREST()(store.dispatch, store.getState); const channelsAsync = store.dispatch(fetchAllMyTeamsChannelsAndChannelMembersREST());
let channels; let channels;
try { try {
const {data} = await channelsAsync; const {data} = await channelsAsync;
channels = data.channels; channels = data.channels as Channel[];
} catch (err) { } catch (err) {
store.dispatch(logError(err)); store.dispatch(logError(err));
return;
} }
if (this.latestPrefix !== '') { if (this.latestPrefix !== '') {

Просмотреть файл

@@ -16,7 +16,7 @@ import AtMentionProvider from 'components/suggestion/at_mention_provider';
import ChannelMentionProvider from 'components/suggestion/channel_mention_provider'; import ChannelMentionProvider from 'components/suggestion/channel_mention_provider';
import AppCommandProvider from 'components/suggestion/command_provider/app_provider'; import AppCommandProvider from 'components/suggestion/command_provider/app_provider';
import CommandProvider from 'components/suggestion/command_provider/command_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 SuggestionBox from 'components/suggestion/suggestion_box';
import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box'; import SuggestionBoxComponent from 'components/suggestion/suggestion_box/suggestion_box';
import SuggestionList from 'components/suggestion/suggestion_list.jsx'; import SuggestionList from 'components/suggestion/suggestion_list.jsx';

Просмотреть файл

@@ -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); return getChannelByNameHelper(getAllChannels(state), channelName);
} }