[MM-62384] Move ChannelHeaderPopover from BootstrapOverlay/Popper to Floating-UI (#29820)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
7cf040655f
Коммит
25a6501e10
@@ -150,12 +150,12 @@ describe('Direct Message', () => {
|
||||
cy.get('#edit_textbox').type(message).type('{enter}').wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// # Hover on channel header
|
||||
cy.get('#channelHeaderDescription .header-description__text').trigger('mouseover');
|
||||
cy.get('#channelHeaderDescription .header-description__text').trigger('mouseenter');
|
||||
|
||||
// * Verify changes have been applied on header
|
||||
cy.wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#header-popover .popover-content').should('be.visible');
|
||||
cy.get('#header-popover').find('.popover-content').should(($el) => {
|
||||
cy.get('.channel-header-text-popover').should('be.visible');
|
||||
cy.get('.channel-header-text-popover').should(($el) => {
|
||||
expect($el.get(0).innerText).to.eq(expectedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,12 +58,13 @@ describe('Header', () => {
|
||||
|
||||
updateAndVerifyChannelHeader('>', header);
|
||||
|
||||
// # Click the header to see the whole text
|
||||
cy.get('#channelHeaderDescription').click();
|
||||
// # Hover on channel header
|
||||
cy.get('#channelHeaderDescription .header-description__text').trigger('mouseenter');
|
||||
|
||||
// * Check that no ellipsis is present
|
||||
cy.get('#header-popover > div.popover-content').
|
||||
should('have.html', `<span><blockquote>\n<p>${header}</p>\n</blockquote></span>`);
|
||||
cy.get('.channel-header-text-popover').should(($el) => {
|
||||
expect($el.get(0).innerText).to.eq(header);
|
||||
});
|
||||
|
||||
cy.apiSaveMessageDisplayPreference('clean');
|
||||
});
|
||||
@@ -75,12 +76,14 @@ function updateAndVerifyChannelHeader(prefix, header) {
|
||||
|
||||
// * Should render blockquote if it starts with ">"
|
||||
if (prefix === '>') {
|
||||
cy.get('#channelHeaderDescription > span > blockquote').should('be.visible');
|
||||
cy.get('.header-description__text').within(() => {
|
||||
cy.get('blockquote').should('be.visible');
|
||||
});
|
||||
}
|
||||
|
||||
// * Check if channel header description has ellipsis
|
||||
cy.get('#channelHeaderDescription > .header-description__text').find('p').
|
||||
should('have.text', header).
|
||||
cy.get('.header-description__text').
|
||||
should('include.text', header).
|
||||
and('have.css', 'overflow', 'hidden').
|
||||
and('have.css', 'text-overflow', 'ellipsis');
|
||||
}
|
||||
|
||||
@@ -47,28 +47,36 @@ describe('Multi-user group header', () => {
|
||||
cy.apiLogin(testUser);
|
||||
cy.visit(`/${testTeam.name}/channels/${groupChannel.name}`);
|
||||
|
||||
// * no channel header is set
|
||||
cy.get('button.header-placeholder').invoke('show').trigger('mouseover');
|
||||
cy.contains('#channelHeaderDescription button span', 'Add a channel header').should('be.visible');
|
||||
const header = 'peace and progress';
|
||||
|
||||
// # click add a channel heander
|
||||
cy.findByRoleExtended('button', {name: 'Add a channel header'}).should('be.visible').click();
|
||||
// * Verify that no channel header is set
|
||||
cy.get('#channel-header').within(() => {
|
||||
cy.findByText('Add a channel header').should('not.be.visible');
|
||||
});
|
||||
|
||||
// # type a header
|
||||
const header = 'this is a header!';
|
||||
// # Force click on button which is hidden and shows on hover
|
||||
cy.findByText('Add a channel header').click({force: true});
|
||||
|
||||
// * Verify the modal open to add header
|
||||
cy.get('#editChannelHeaderModalLabel').should('be.visible').wait(TIMEOUTS.ONE_SEC);
|
||||
cy.get('textarea#edit_textbox').should('be.visible').type(`${header}{enter}`);
|
||||
cy.get('#editChannelHeaderModalLabel').should('not.exist'); // wait for modal to disappear
|
||||
|
||||
// # Add the header in the modal
|
||||
cy.findByPlaceholderText('Edit the Channel Header...').should('be.visible').type(`${header}{enter}`);
|
||||
|
||||
// # Wait for modal to disappear
|
||||
cy.waitUntil(() => cy.get('#editChannelHeaderModalLabel').should('not.be.visible'));
|
||||
|
||||
// * text appears in the top center panel
|
||||
cy.contains('#channelHeaderDescription span.header-description__text p', header);
|
||||
cy.get('#channel-header').within(() => {
|
||||
cy.findByText(header).should('be.visible');
|
||||
});
|
||||
|
||||
checkSystemMessage('updated the channel header');
|
||||
|
||||
// * channel is marked as read for the current user
|
||||
// * Channel is marked as read for the current user
|
||||
cy.get(`#sidebarItem_${groupChannel.name}`).should(beRead);
|
||||
|
||||
// * channel is marked as unread for other user
|
||||
// * Channel is marked as unread for other user
|
||||
cy.apiLogout();
|
||||
cy.apiLogin(userList[0]);
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
@@ -81,18 +89,22 @@ describe('Multi-user group header', () => {
|
||||
cy.apiLogin(testUser);
|
||||
cy.visit(`/${testTeam.name}/channels/${groupChannel.name}`);
|
||||
|
||||
// * verify header is set
|
||||
cy.contains('#channelHeaderDescription button span', 'Add a channel header').should('not.exist');
|
||||
// * Verify that channel header is set
|
||||
cy.get('#channel-header').within(() => {
|
||||
cy.findByText('Add a channel header').should('not.exist');
|
||||
});
|
||||
|
||||
const header = 'this is a new header!';
|
||||
const header = 'In pursuit of peace and progress';
|
||||
editHeader(header);
|
||||
|
||||
// * text appears at the top
|
||||
cy.contains('#channelHeaderDescription span.header-description__text p', header);
|
||||
// * Header text appears at the top
|
||||
cy.get('#channel-header').within(() => {
|
||||
cy.findByText(header).should('be.visible');
|
||||
});
|
||||
|
||||
checkSystemMessage('updated the channel header');
|
||||
|
||||
// * channel is marked as unread for other users
|
||||
// * Channel is marked as unread for other users
|
||||
cy.apiLogout();
|
||||
cy.apiLogin(userList[0]);
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
@@ -101,35 +113,43 @@ describe('Multi-user group header', () => {
|
||||
});
|
||||
|
||||
it('MM-T473_2 Edit GM channel header', () => {
|
||||
// # open existing GM
|
||||
// # Open existing GM
|
||||
cy.apiLogin(testUser);
|
||||
cy.visit(`/${testTeam.name}/channels/${groupChannel.name}`);
|
||||
|
||||
// * verify header is set
|
||||
cy.contains('#channelHeaderDescription button span', 'Add a channel header').should('not.exist');
|
||||
// * Verify that channel header is set
|
||||
cy.get('#channel-header').within(() => {
|
||||
cy.findByText('Add a channel header').should('not.exist');
|
||||
});
|
||||
|
||||
const header = `Header by @${testUser.username}`;
|
||||
const header = `In pursuit of peace and progress by @${testUser.username}`;
|
||||
editHeader(header);
|
||||
|
||||
cy.get('#channelHeaderDescription').find('.header-description__text').
|
||||
find('.mention-link').
|
||||
should('be.visible').and('have.text', `@${testUser.username}`);
|
||||
cy.get('#channelHeaderDescription').find('.header-description__text').
|
||||
find('.mention--highlight').
|
||||
should('not.exist');
|
||||
// * Header text appears at the top
|
||||
cy.get('#channel-header').within(() => {
|
||||
// * Verify mention is present
|
||||
cy.get('.mention-link').should('be.visible').and('have.text', `@${testUser.username}`);
|
||||
|
||||
// * Verify its not highlighted
|
||||
cy.get('.mention--highlight').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
const editHeader = (header) => {
|
||||
function editHeader(header) {
|
||||
// # Click edit conversation header
|
||||
cy.uiOpenChannelMenu('Edit Conversation Header');
|
||||
|
||||
// # type new header
|
||||
cy.get('#editChannelHeaderModalLabel').should('be.visible');
|
||||
cy.get('textarea#edit_textbox').should('be.visible').clear().type(`${header}{enter}`);
|
||||
cy.get('#editChannelHeaderModalLabel').should('not.exist'); // wait for modal to disappear
|
||||
};
|
||||
// * Verify the modal open to add header
|
||||
cy.get('#editChannelHeaderModalLabel').should('be.visible').wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
const checkSystemMessage = (message) => {
|
||||
// # Add the header in the modal
|
||||
cy.get('textarea#edit_textbox').should('be.visible').clear().type(`${header}{enter}`);
|
||||
|
||||
// # Wait for modal to disappear
|
||||
cy.waitUntil(() => cy.get('#editChannelHeaderModalLabel').should('not.be.visible'));
|
||||
}
|
||||
|
||||
function checkSystemMessage(message) {
|
||||
// * system message is posted notifying of the change
|
||||
cy.getLastPostId().then((id) => {
|
||||
cy.get(`#postMessageText_${id}`).should('contain', message);
|
||||
@@ -138,5 +158,5 @@ describe('Multi-user group header', () => {
|
||||
cy.get(`#delete_post_${id}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -2,48 +2,56 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import type {ComponentProps} from 'react';
|
||||
|
||||
import type {ChannelType} from '@mattermost/types/channels';
|
||||
import type {UserCustomStatus} from '@mattermost/types/users';
|
||||
|
||||
import ChannelHeader from 'components/channel_header/channel_header';
|
||||
import ChannelInfoButton from 'components/channel_header/channel_info_button';
|
||||
import Markdown from 'components/markdown';
|
||||
|
||||
import type {MockIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import Constants, {RHSStates} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelHeader from './channel_header';
|
||||
import type {Props} from './channel_header';
|
||||
|
||||
describe('components/ChannelHeader', () => {
|
||||
const baseProps: ComponentProps<typeof ChannelHeader> = {
|
||||
const baseProps: Props = {
|
||||
actions: {
|
||||
showPinnedPosts: jest.fn(),
|
||||
showChannelFiles: jest.fn(),
|
||||
closeRightHandSide: jest.fn(),
|
||||
openModal: jest.fn(),
|
||||
getCustomEmojisInText: jest.fn(),
|
||||
updateChannelNotifyProps: jest.fn(),
|
||||
goToLastViewedChannel: jest.fn(),
|
||||
showChannelMembers: jest.fn(),
|
||||
},
|
||||
announcementBarCount: 1,
|
||||
teamId: 'team_id',
|
||||
channel: TestHelper.getChannelMock({}),
|
||||
channelMember: TestHelper.getChannelMembershipMock({}),
|
||||
currentUser: TestHelper.getUserMock({}),
|
||||
currentRelativeTeamUrl: '',
|
||||
isCustomStatusEnabled: false,
|
||||
isCustomStatusExpired: false,
|
||||
isFileAttachmentsEnabled: true,
|
||||
lastActivityTimestamp: 1632146562846,
|
||||
isLastActiveEnabled: true,
|
||||
memberCount: 2,
|
||||
dmUser: undefined,
|
||||
gmMembers: undefined,
|
||||
rhsState: RHSStates.CHANNEL_INFO,
|
||||
isChannelMuted: false,
|
||||
hasGuests: false,
|
||||
pinnedPostsCount: 0,
|
||||
customStatus: undefined,
|
||||
timestampUnits: [
|
||||
'now',
|
||||
'minute',
|
||||
'hour',
|
||||
],
|
||||
hideGuestTags: false,
|
||||
intl: {
|
||||
formatMessage: jest.fn(({id, defaultMessage}) => defaultMessage || id),
|
||||
} as MockIntl,
|
||||
};
|
||||
|
||||
const populatedProps = {
|
||||
@@ -140,7 +148,7 @@ describe('components/ChannelHeader', () => {
|
||||
test('should render correct menu when muted', () => {
|
||||
const props = {
|
||||
...populatedProps,
|
||||
isMuted: true,
|
||||
isChannelMuted: true,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
@@ -152,7 +160,7 @@ describe('components/ChannelHeader', () => {
|
||||
test('should unmute the channel when mute icon is clicked', () => {
|
||||
const props = {
|
||||
...populatedProps,
|
||||
isMuted: true,
|
||||
isChannelMuted: true,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
@@ -227,30 +235,6 @@ describe('components/ChannelHeader', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should render bot description', () => {
|
||||
const props = {
|
||||
...populatedProps,
|
||||
channel: TestHelper.getChannelMock({
|
||||
header: 'not the bot description',
|
||||
type: Constants.DM_CHANNEL as ChannelType,
|
||||
}),
|
||||
dmUser: TestHelper.getUserMock({
|
||||
id: 'user_id',
|
||||
is_bot: true,
|
||||
bot_description: 'the bot description',
|
||||
}),
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.containsMatchingElement(
|
||||
<Markdown
|
||||
message={props.currentUser.bot_description}
|
||||
/>,
|
||||
)).toEqual(true);
|
||||
});
|
||||
|
||||
test('should render the pinned icon with the pinned posts count', () => {
|
||||
const props = {
|
||||
...populatedProps,
|
||||
|
||||
@@ -4,125 +4,38 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import type {MouseEvent, ReactNode, RefObject} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type {OverlayTrigger as BaseOverlayTrigger} from 'react-bootstrap';
|
||||
import {Overlay} from 'react-bootstrap';
|
||||
import {FormattedMessage, injectIntl} from 'react-intl';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
import type {Channel, ChannelMembership, ChannelNotifyProps} from '@mattermost/types/channels';
|
||||
import type {UserCustomStatus, UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
import {memoizeResult} from 'mattermost-redux/utils/helpers';
|
||||
import type {WrappedComponentProps} from 'react-intl';
|
||||
|
||||
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
|
||||
import CustomStatusText from 'components/custom_status/custom_status_text';
|
||||
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
|
||||
import Markdown from 'components/markdown';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
import Timestamp from 'components/timestamp';
|
||||
import Popover from 'components/widgets/popover';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import CallButton from 'plugins/call_button';
|
||||
import ChannelHeaderPlug from 'plugins/channel_header_plug';
|
||||
import {
|
||||
Constants,
|
||||
ModalIdentifiers,
|
||||
NotificationLevels,
|
||||
RHSStates,
|
||||
} from 'utils/constants';
|
||||
import {handleFormattedTextClick, isEmptyObject} from 'utils/utils';
|
||||
|
||||
import type {ModalData} from 'types/actions';
|
||||
import type {RhsState} from 'types/store/rhs';
|
||||
import {isEmptyObject} from 'utils/utils';
|
||||
|
||||
import ChannelHeaderText from './channel_header_text';
|
||||
import ChannelHeaderTitle from './channel_header_title';
|
||||
import ChannelInfoButton from './channel_info_button';
|
||||
import HeaderIconWrapper from './components/header_icon_wrapper';
|
||||
|
||||
const headerMarkdownOptions = {singleline: true, mentionHighlight: false, atMentions: true};
|
||||
const popoverMarkdownOptions = {singleline: false, mentionHighlight: false, atMentions: true};
|
||||
import type {PropsFromRedux} from './index';
|
||||
|
||||
export type OverlayTrigger = BaseOverlayTrigger & {
|
||||
hide: () => void;
|
||||
};
|
||||
export type Props = WrappedComponentProps & PropsFromRedux;
|
||||
|
||||
export type Props = {
|
||||
teamId: string;
|
||||
currentUser: UserProfile;
|
||||
channel?: Channel;
|
||||
memberCount?: number;
|
||||
channelMember?: ChannelMembership;
|
||||
dmUser?: UserProfile;
|
||||
gmMembers?: UserProfile[];
|
||||
isReadOnly?: boolean;
|
||||
isMuted?: boolean;
|
||||
hasGuests?: boolean;
|
||||
rhsState?: RhsState;
|
||||
rhsOpen?: boolean;
|
||||
isQuickSwitcherOpen?: boolean;
|
||||
intl: IntlShape;
|
||||
pinnedPostsCount?: number;
|
||||
hasMoreThanOneTeam?: boolean;
|
||||
actions: {
|
||||
showPinnedPosts: (channelId?: string) => void;
|
||||
showChannelFiles: (channelId: string) => void;
|
||||
closeRightHandSide: () => void;
|
||||
getCustomEmojisInText: (text: string) => void;
|
||||
updateChannelNotifyProps: (userId: string, channelId: string, props: Partial<ChannelNotifyProps>) => void;
|
||||
goToLastViewedChannel: () => void;
|
||||
openModal: <P>(modalData: ModalData<P>) => void;
|
||||
showChannelMembers: (channelId: string, inEditingMode?: boolean) => void;
|
||||
};
|
||||
currentRelativeTeamUrl: string;
|
||||
announcementBarCount: number;
|
||||
customStatus?: UserCustomStatus;
|
||||
isCustomStatusEnabled: boolean;
|
||||
isCustomStatusExpired: boolean;
|
||||
isFileAttachmentsEnabled: boolean;
|
||||
isLastActiveEnabled: boolean;
|
||||
timestampUnits?: string[];
|
||||
lastActivityTimestamp?: number;
|
||||
hideGuestTags: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
showChannelHeaderPopover: boolean;
|
||||
channelHeaderPoverWidth: number;
|
||||
leftOffset: number;
|
||||
topOffset: number;
|
||||
};
|
||||
|
||||
class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
class ChannelHeader extends React.PureComponent<Props> {
|
||||
toggleFavoriteRef: RefObject<HTMLButtonElement>;
|
||||
headerDescriptionRef: RefObject<HTMLSpanElement>;
|
||||
headerPopoverTextMeasurerRef: RefObject<HTMLDivElement>;
|
||||
headerOverlayRef: RefObject<OverlayTrigger>;
|
||||
getHeaderMarkdownOptions: (channelNamesMap: Record<string, any>) => Record<string, any>;
|
||||
getPopoverMarkdownOptions: (channelNamesMap: Record<string, any>) => Record<string, any>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.toggleFavoriteRef = React.createRef();
|
||||
this.headerDescriptionRef = React.createRef();
|
||||
this.headerPopoverTextMeasurerRef = React.createRef();
|
||||
this.headerOverlayRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
showChannelHeaderPopover: false,
|
||||
channelHeaderPoverWidth: 0,
|
||||
leftOffset: 0,
|
||||
topOffset: 0,
|
||||
};
|
||||
|
||||
this.getHeaderMarkdownOptions = memoizeResult((channelNamesMap: Record<string, any>) => (
|
||||
{...headerMarkdownOptions, channelNamesMap}
|
||||
));
|
||||
this.getPopoverMarkdownOptions = memoizeResult((channelNamesMap: Record<string, any>) => (
|
||||
{...popoverMarkdownOptions, channelNamesMap}
|
||||
));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -137,8 +50,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => this.props.actions.goToLastViewedChannel();
|
||||
|
||||
unmute = () => {
|
||||
const {actions, channel, channelMember, currentUser} = this.props;
|
||||
|
||||
@@ -150,17 +61,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
actions.updateChannelNotifyProps(currentUser.id, channel.id, options);
|
||||
};
|
||||
|
||||
mute = () => {
|
||||
const {actions, channel, channelMember, currentUser} = this.props;
|
||||
|
||||
if (!channelMember || !currentUser || !channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {mark_unread: NotificationLevels.MENTION};
|
||||
actions.updateChannelNotifyProps(currentUser.id, channel.id, options);
|
||||
};
|
||||
|
||||
showPinnedPosts = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (this.props.rhsState === RHSStates.PIN) {
|
||||
@@ -178,45 +78,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
showEditChannelHeaderModal = () => {
|
||||
if (this.headerOverlayRef.current) {
|
||||
this.headerOverlayRef.current.hide();
|
||||
}
|
||||
|
||||
const {actions, channel} = this.props;
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalData = {
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
|
||||
dialogType: EditChannelHeaderModal,
|
||||
dialogProps: {channel},
|
||||
};
|
||||
|
||||
actions.openModal(modalData);
|
||||
};
|
||||
|
||||
showChannelHeaderPopover = (headerText: string) => {
|
||||
const headerDescriptionRect = this.headerDescriptionRef.current?.getBoundingClientRect();
|
||||
const headerPopoverTextMeasurerRect = this.headerPopoverTextMeasurerRef.current?.getBoundingClientRect();
|
||||
const announcementBarSize = 40;
|
||||
|
||||
if (headerPopoverTextMeasurerRect && headerDescriptionRect) {
|
||||
if (headerPopoverTextMeasurerRect.width > headerDescriptionRect.width || headerText.match(/\n{2,}/g)) {
|
||||
const leftOffset = headerDescriptionRect.left - (this.props.hasMoreThanOneTeam ? 313 : 248);
|
||||
this.setState({showChannelHeaderPopover: true, leftOffset});
|
||||
}
|
||||
}
|
||||
|
||||
// add 40px to take the global header into account
|
||||
const topOffset = (announcementBarSize * this.props.announcementBarCount) + 40;
|
||||
const channelHeaderPoverWidth = this.headerDescriptionRef.current?.clientWidth || 0 - (this.props.hasMoreThanOneTeam ? 64 : 0);
|
||||
|
||||
this.setState({topOffset});
|
||||
this.setState({channelHeaderPoverWidth});
|
||||
};
|
||||
|
||||
toggleChannelMembersRHS = () => {
|
||||
if (this.props.rhsState === RHSStates.CHANNEL_MEMBERS) {
|
||||
this.props.actions.closeRightHandSide();
|
||||
@@ -225,8 +86,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
handleFormattedTextClick = (e: MouseEvent<HTMLSpanElement>) => handleFormattedTextClick(e, this.props.currentRelativeTeamUrl);
|
||||
|
||||
renderCustomStatus = () => {
|
||||
const {customStatus, isCustomStatusEnabled, isCustomStatusExpired} = this.props;
|
||||
const isStatusSet = !isCustomStatusExpired && (customStatus?.text || customStatus?.emoji);
|
||||
@@ -259,8 +118,7 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
gmMembers,
|
||||
channel,
|
||||
channelMember,
|
||||
isMuted: channelMuted,
|
||||
isReadOnly,
|
||||
isChannelMuted,
|
||||
dmUser,
|
||||
rhsState,
|
||||
hasGuests,
|
||||
@@ -270,7 +128,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {formatMessage} = this.props.intl;
|
||||
const ariaLabelChannelHeader = this.props.intl.formatMessage({id: 'accessibility.sections.channelHeader', defaultMessage: 'channel header region'});
|
||||
|
||||
let hasGuestsText: ReactNode = '';
|
||||
@@ -287,7 +144,6 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
const channelIsArchived = channel.delete_at !== 0;
|
||||
if (isEmptyObject(channel) ||
|
||||
isEmptyObject(channelMember) ||
|
||||
isEmptyObject(currentUser) ||
|
||||
@@ -299,11 +155,8 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
const channelNamesMap = channel.props && channel.props.channel_mentions;
|
||||
|
||||
const isDirect = (channel.type === Constants.DM_CHANNEL);
|
||||
const isGroup = (channel.type === Constants.GM_CHANNEL);
|
||||
const isPrivate = (channel.type === Constants.PRIVATE_CHANNEL);
|
||||
|
||||
if (isGroup) {
|
||||
if (hasGuests && !hideGuestTags) {
|
||||
@@ -436,136 +289,8 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
let headerTextContainer;
|
||||
const headerText = (isDirect && dmUser?.is_bot) ? dmUser.bot_description : channel.header;
|
||||
if (headerText) {
|
||||
const imageProps = {
|
||||
hideUtilities: true,
|
||||
};
|
||||
const popoverContent = (
|
||||
<Popover
|
||||
id='header-popover'
|
||||
popoverStyle='info'
|
||||
popoverSize='lg'
|
||||
style={{transform: `translate(${this.state.leftOffset}px, ${this.state.topOffset}px)`, maxWidth: this.state.channelHeaderPoverWidth + 16}}
|
||||
placement='bottom'
|
||||
className={classNames('channel-header__popover', {'chanel-header__popover--lhs_offset': this.props.hasMoreThanOneTeam})}
|
||||
>
|
||||
<span
|
||||
onClick={this.handleFormattedTextClick}
|
||||
>
|
||||
<Markdown
|
||||
message={headerText}
|
||||
options={this.getPopoverMarkdownOptions(channelNamesMap)}
|
||||
imageProps={imageProps}
|
||||
/>
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
headerTextContainer = (
|
||||
<div
|
||||
id='channelHeaderDescription'
|
||||
className='channel-header__description'
|
||||
dir='auto'
|
||||
>
|
||||
{dmHeaderTextStatus}
|
||||
{hasGuestsText}
|
||||
<div
|
||||
className='header-popover-text-measurer'
|
||||
ref={this.headerPopoverTextMeasurerRef}
|
||||
>
|
||||
<Markdown
|
||||
message={headerText.replace(/\n+/g, ' ')}
|
||||
options={this.getHeaderMarkdownOptions(channelNamesMap)}
|
||||
imageProps={imageProps}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className='header-description__text'
|
||||
onClick={this.handleFormattedTextClick}
|
||||
onMouseOver={() => this.showChannelHeaderPopover(headerText)}
|
||||
onMouseOut={() => this.setState({showChannelHeaderPopover: false})}
|
||||
ref={this.headerDescriptionRef}
|
||||
>
|
||||
<Overlay
|
||||
show={this.state.showChannelHeaderPopover}
|
||||
placement='bottom'
|
||||
rootClose={true}
|
||||
target={this.headerDescriptionRef.current as React.ReactInstance}
|
||||
ref={this.headerOverlayRef as any}
|
||||
onHide={() => this.setState({showChannelHeaderPopover: false})}
|
||||
>
|
||||
{popoverContent}
|
||||
</Overlay>
|
||||
|
||||
<Markdown
|
||||
message={headerText}
|
||||
options={this.getHeaderMarkdownOptions(channelNamesMap)}
|
||||
imageProps={imageProps}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let editMessage;
|
||||
if (!isReadOnly && !channelIsArchived) {
|
||||
if (isDirect || isGroup) {
|
||||
if (!isDirect || !dmUser?.is_bot) {
|
||||
editMessage = (
|
||||
<button
|
||||
className='header-placeholder style--none'
|
||||
onClick={this.showEditChannelHeaderModal}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='channel_header.addChannelHeader'
|
||||
defaultMessage='Add a channel header'
|
||||
/>
|
||||
<i
|
||||
className='icon icon-pencil-outline edit-icon'
|
||||
aria-label={this.props.intl.formatMessage({id: 'channel_header.editLink', defaultMessage: 'Edit'})}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
editMessage = (
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={teamId}
|
||||
permissions={[isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES]}
|
||||
>
|
||||
<button
|
||||
className='header-placeholder style--none'
|
||||
onClick={this.showEditChannelHeaderModal}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='channel_header.addChannelHeader'
|
||||
defaultMessage='Add a channel header'
|
||||
/>
|
||||
<i
|
||||
className='icon icon-pencil-outline edit-icon'
|
||||
aria-label={this.props.intl.formatMessage({id: 'channel_header.editLink', defaultMessage: 'Edit'})}
|
||||
/>
|
||||
</button>
|
||||
</ChannelPermissionGate>
|
||||
);
|
||||
}
|
||||
}
|
||||
headerTextContainer = (
|
||||
<div
|
||||
id='channelHeaderDescription'
|
||||
className='channel-header__description'
|
||||
>
|
||||
{dmHeaderTextStatus}
|
||||
{hasGuestsText}
|
||||
{editMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let muteTrigger;
|
||||
if (channelMuted) {
|
||||
if (isChannelMuted) {
|
||||
muteTrigger = (
|
||||
<WithTooltip
|
||||
title={
|
||||
@@ -579,9 +304,12 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
id='toggleMute'
|
||||
onClick={this.unmute}
|
||||
className={'channel-header__mute inactive btn btn-icon btn-xs'}
|
||||
aria-label={formatMessage({id: 'generic_icons.muted', defaultMessage: 'Muted Icon'})}
|
||||
aria-label={this.props.intl.formatMessage({id: 'channelHeader.unmute', defaultMessage: 'Unmute'})}
|
||||
>
|
||||
<i className={'icon icon-bell-off-outline'}/>
|
||||
<i
|
||||
className={'icon icon-bell-off-outline'}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
);
|
||||
@@ -627,7 +355,18 @@ class ChannelHeader extends React.PureComponent<Props, State> {
|
||||
</HeaderIconWrapper>
|
||||
}
|
||||
</div>
|
||||
{headerTextContainer}
|
||||
<div
|
||||
id='channelHeaderDescription'
|
||||
className='channel-header__description'
|
||||
>
|
||||
{dmHeaderTextStatus}
|
||||
{hasGuestsText}
|
||||
<ChannelHeaderText
|
||||
teamId={teamId}
|
||||
channel={channel}
|
||||
dmUser={dmUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelHeaderText from './channel_header_text';
|
||||
|
||||
describe('ChannelHeaderText', () => {
|
||||
const defaultTeamId = TestHelper.getTeamMock().id;
|
||||
|
||||
test('should render channel header text when header exists for a channel', () => {
|
||||
const channel = TestHelper.getChannelMock({header: 'Test Header'});
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render channel header of bot description for bot DM channels', () => {
|
||||
const channel = TestHelper.getChannelMock({type: 'D'});
|
||||
const botDm = TestHelper.getUserMock({is_bot: true, bot_description: 'Tranquility'});
|
||||
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
dmUser={botDm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tranquility')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should return null if the channel has no header and is archived', () => {
|
||||
const channel = TestHelper.getChannelMock({delete_at: 1, header: ''});
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.childNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should return null if its a bot DM channels and its description is empty', () => {
|
||||
const channel = TestHelper.getChannelMock({type: 'D'});
|
||||
const botDm = TestHelper.getUserMock({is_bot: true, bot_description: ''});
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
dmUser={botDm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.childNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should show add header button for DM channels without header', () => {
|
||||
const channel = TestHelper.getChannelMock({type: 'D', header: ''});
|
||||
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add a channel header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show add header button for GM channels', () => {
|
||||
const channel = TestHelper.getChannelMock({type: 'G', header: ''});
|
||||
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add a channel header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not show add header button when user lacks permission and channel doesn not have header', () => {
|
||||
const channel = TestHelper.getChannelMock({
|
||||
type: 'O',
|
||||
header: '',
|
||||
});
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
channels: {
|
||||
channels: {
|
||||
[channel.id]: channel,
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
roles: {
|
||||
channel_user: {
|
||||
permissions: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add a channel header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show add header button when user has permission and channel does not have header', () => {
|
||||
const channel = TestHelper.getChannelMock({
|
||||
type: 'O',
|
||||
header: '',
|
||||
});
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
channels: {
|
||||
myMembers: {
|
||||
[channel.id]: {channel_id: channel.id, roles: 'channel_role'},
|
||||
},
|
||||
roles: {
|
||||
[channel.id]: new Set(['channel_role']),
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
myMembers: {
|
||||
[defaultTeamId]: {team_id: defaultTeamId, roles: 'team_role'},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'user_id',
|
||||
profiles: {
|
||||
user_id: {
|
||||
id: 'user_id',
|
||||
roles: 'system_role',
|
||||
},
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
roles: {
|
||||
system_role: {permissions: ['test_system_permission']},
|
||||
team_role: {permissions: ['test_team_permission']},
|
||||
channel_role: {permissions: ['manage_public_channel_properties']},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelHeaderText
|
||||
teamId={defaultTeamId}
|
||||
channel={channel}
|
||||
/>,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add a channel header')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
|
||||
import {
|
||||
Constants,
|
||||
ModalIdentifiers,
|
||||
} from 'utils/constants';
|
||||
import {isChannelNamesMap} from 'utils/text_formatting';
|
||||
|
||||
import {ChannelHeaderTextPopover} from './channel_header_text_popover';
|
||||
|
||||
interface Props {
|
||||
teamId: Team['id'];
|
||||
channel: Channel;
|
||||
dmUser?: UserProfile;
|
||||
}
|
||||
|
||||
export default function ChannelHeaderText(props: Props) {
|
||||
const isArchivedChannel = props.channel.delete_at !== 0;
|
||||
const isDirectChannel = props.channel.type === Constants.DM_CHANNEL;
|
||||
const isGroupChannel = props.channel.type === Constants.GM_CHANNEL;
|
||||
const isBotDMChannel = isDirectChannel && (props.dmUser?.is_bot ?? false);
|
||||
const isPrivateChannel = props.channel.type === Constants.PRIVATE_CHANNEL;
|
||||
const headerText = isBotDMChannel ? props.dmUser?.bot_description ?? '' : props.channel?.header ?? '';
|
||||
const hasHeaderText = headerText.trim().length > 0;
|
||||
|
||||
// If it has a channel then show the channel irrespective of the channel type/state etc
|
||||
if (hasHeaderText) {
|
||||
return (
|
||||
<ChannelHeaderTextPopover
|
||||
text={headerText}
|
||||
channelMentionsNameMap={
|
||||
isChannelNamesMap(props.channel?.props?.channel_mentions) ? props.channel.props.channel_mentions : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If doesn't have a header text then we need to check based on below
|
||||
// conditions if we need to show button to add or not
|
||||
|
||||
if (isArchivedChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isBotDMChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isDirectChannel || isGroupChannel) {
|
||||
return <AddChannelHeaderTextButton channel={props.channel}/>;
|
||||
}
|
||||
|
||||
// should show option to add channel header text for any channel
|
||||
// other than a DM or a GM or a Bot DM based on user's permission
|
||||
return (
|
||||
<ChannelPermissionGate
|
||||
channelId={props.channel.id}
|
||||
teamId={props.teamId}
|
||||
permissions={[
|
||||
isPrivateChannel ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES,
|
||||
]}
|
||||
>
|
||||
<AddChannelHeaderTextButton channel={props.channel}/>
|
||||
</ChannelPermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
function AddChannelHeaderTextButton({channel}: {channel: Channel}) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
function handleClick() {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
|
||||
dialogType: EditChannelHeaderModal,
|
||||
dialogProps: {channel},
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className='header-placeholder style--none'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='channel_header.headerText.addNewButton'
|
||||
defaultMessage='Add a channel header'
|
||||
/>
|
||||
<i
|
||||
className='icon icon-pencil-outline edit-icon'
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@use 'utils/variables';
|
||||
|
||||
.channel-header-text-popover-floating-overlay {
|
||||
z-index: variables.$z-index-popover;
|
||||
}
|
||||
|
||||
.channel-header-text-popover {
|
||||
min-width: 200px;
|
||||
max-width: calc(100vw - 10px);
|
||||
max-height: calc(100vh - 200px);
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--center-channel-bg);
|
||||
box-shadow: var(--elevation-4);
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
|
||||
.markdown__list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-inline-img {
|
||||
max-height: 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&--loading {
|
||||
height: 18px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 2px 0 rgba(variables.$black, 0.1), 0 2px 2px 0 rgba(variables.$black, 0.1);
|
||||
transition: all 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
&--no-border {
|
||||
border: unset;
|
||||
}
|
||||
}
|
||||
|
||||
div.markdown__paragraph-inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.broken-image {
|
||||
max-height: 18px;
|
||||
border-radius: unset;
|
||||
}
|
||||
|
||||
.image-loaded-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-preview__button {
|
||||
display: inline-block;
|
||||
|
||||
.markdown-inline-img {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
ul + p,
|
||||
ol + p {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
p + ul,
|
||||
p + ol {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
overflow: hidden;
|
||||
padding: 0 0 0 20px;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
autoUpdate,
|
||||
useDismiss,
|
||||
safePolygon,
|
||||
useFocus,
|
||||
useHover,
|
||||
useTransitionStyles,
|
||||
useInteractions,
|
||||
useRole,
|
||||
useMergeRefs,
|
||||
useFloating,
|
||||
FloatingPortal,
|
||||
FloatingOverlay,
|
||||
useClick,
|
||||
offset,
|
||||
} from '@floating-ui/react';
|
||||
import React, {useMemo, useRef, useState} from 'react';
|
||||
import type {MouseEvent} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
|
||||
import {OverlaysTimings, OverlayTransitionStyles, RootHtmlPortalId} from 'utils/constants';
|
||||
import type {ChannelNamesMap} from 'utils/text_formatting';
|
||||
import {handleFormattedTextClick} from 'utils/utils';
|
||||
|
||||
import './channel_header_text_popover.scss';
|
||||
|
||||
const TEXT_IN_HEADER_MARKDOWN_OPTIONS = {singleline: true};
|
||||
const TEXT_IN_POPOVER_MARKDOWN_OPTIONS = {singleline: false};
|
||||
const MENTION_MARKDOWN_OPTIONS = {mentionHighlight: false, atMentions: true};
|
||||
const IMAGE_MARKDOWN_OPTIONS = {hideUtilities: true};
|
||||
|
||||
const TRANSITION_STYLE_PROPS = {
|
||||
duration: {
|
||||
open: OverlaysTimings.FADE_IN_DURATION,
|
||||
close: OverlaysTimings.FADE_OUT_DURATION,
|
||||
},
|
||||
initial: OverlayTransitionStyles.START,
|
||||
};
|
||||
|
||||
const PADDING_Y_OF_POPOVER = 6; // padding top & bottom of .channel-header-text-popover in channel_header_text_popover.scss
|
||||
const PADDING_X_OF_POPOVER = 8; // padding right & left of .channel-header-text-popover in channel_header_text_popover.scss
|
||||
const BORDER_WIDTH_OF_POPOVER = 1; // border of .channel-header-text-popover in channel_header_text_popover.scss
|
||||
|
||||
const HEIGHT_OF_HEADER_TEXT = 24; // height of .header-description__text in _headers.scss
|
||||
const SHIFT_UP_OF_POPOVER = -((HEIGHT_OF_HEADER_TEXT + PADDING_Y_OF_POPOVER) - (2 * BORDER_WIDTH_OF_POPOVER));
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
channelMentionsNameMap?: ChannelNamesMap;
|
||||
}
|
||||
export function ChannelHeaderTextPopover(props: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentRelativeTeamUrl = useSelector(getCurrentRelativeTeamUrl);
|
||||
|
||||
const rootElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isTextOverflowing = checkIfTextIsOverflowing(rootElementRef?.current, props.text);
|
||||
|
||||
const markdownOptions = useMemo(() => {
|
||||
const inHeader = {
|
||||
...TEXT_IN_HEADER_MARKDOWN_OPTIONS,
|
||||
...MENTION_MARKDOWN_OPTIONS,
|
||||
channelNamesMap: props.channelMentionsNameMap,
|
||||
};
|
||||
const inPopover = {
|
||||
...TEXT_IN_POPOVER_MARKDOWN_OPTIONS,
|
||||
...MENTION_MARKDOWN_OPTIONS,
|
||||
channelNamesMap: props.channelMentionsNameMap,
|
||||
};
|
||||
|
||||
return {
|
||||
inHeader,
|
||||
inPopover,
|
||||
};
|
||||
}, [props.channelMentionsNameMap]);
|
||||
|
||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
||||
|
||||
const {refs: {setReference, setFloating}, floatingStyles, context: floatingContext} = useFloating({
|
||||
open: isTextOverflowing ? isPopoverOpen : false,
|
||||
onOpenChange: setPopoverOpen,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(SHIFT_UP_OF_POPOVER),
|
||||
],
|
||||
});
|
||||
const {isMounted, styles: transitionStyles} = useTransitionStyles(
|
||||
floatingContext,
|
||||
TRANSITION_STYLE_PROPS,
|
||||
);
|
||||
|
||||
const hover = useHover(floatingContext, {
|
||||
enabled: isTextOverflowing,
|
||||
handleClose: safePolygon({
|
||||
requireIntent: false,
|
||||
blockPointerEvents: true,
|
||||
}),
|
||||
});
|
||||
const focus = useFocus(floatingContext);
|
||||
const dismiss = useDismiss(floatingContext);
|
||||
const click = useClick(floatingContext);
|
||||
const role = useRole(floatingContext, {role: 'tooltip'});
|
||||
|
||||
const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, click, dismiss, role]);
|
||||
|
||||
const rootRef = useMergeRefs([rootElementRef, setReference]);
|
||||
|
||||
const maxWidthOfPopover = getMaxWidthOfPopover(rootElementRef?.current);
|
||||
|
||||
// This action processes clicks on formatted text elements like hashtags, user mentions,
|
||||
// channel mentions, etc. while also allowing other elements to function as is such as external links etc
|
||||
function handleClick(event: MouseEvent<HTMLDivElement>) {
|
||||
dispatch(handleFormattedTextClick(event, currentRelativeTeamUrl));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className='header-description__text'
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<Markdown
|
||||
message={props.text}
|
||||
options={markdownOptions.inHeader}
|
||||
imageProps={IMAGE_MARKDOWN_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMounted && (
|
||||
<FloatingPortal id={RootHtmlPortalId}>
|
||||
<FloatingOverlay
|
||||
className='channel-header-text-popover-floating-overlay'
|
||||
lockScroll={true}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
ref={setFloating}
|
||||
className='channel-header-text-popover'
|
||||
style={{
|
||||
maxWidth: maxWidthOfPopover,
|
||||
...floatingStyles,
|
||||
...transitionStyles,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<Markdown
|
||||
message={props.text}
|
||||
options={markdownOptions.inPopover}
|
||||
imageProps={IMAGE_MARKDOWN_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</FloatingOverlay>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function checkIfTextIsOverflowing(elem: HTMLDivElement | null, text: string): boolean {
|
||||
if (!elem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.match(/\n{2,}/g)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (elem.scrollWidth === elem.clientWidth && elem.scrollHeight === elem.clientHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return elem.scrollWidth > elem.clientWidth || elem.scrollHeight > elem.clientHeight;
|
||||
}
|
||||
|
||||
function getMaxWidthOfPopover(elem: HTMLDivElement | null): string | number {
|
||||
if (!elem) {
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
return (elem.clientWidth) + ((2 * PADDING_X_OF_POPOVER) + (2 * BORDER_WIDTH_OF_POPOVER));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ConnectedProps} from 'react-redux';
|
||||
import {connect} from 'react-redux';
|
||||
import {withRouter} from 'react-router-dom';
|
||||
import {bindActionCreators} from 'redux';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
getCurrentChannelStats,
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getCurrentRelativeTeamUrl, getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {
|
||||
displayLastActiveLabel,
|
||||
getCurrentUser,
|
||||
@@ -29,20 +30,15 @@ import {
|
||||
} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getUserIdFromChannelName} from 'mattermost-redux/utils/channel_utils';
|
||||
|
||||
import {goToLastViewedChannel} from 'actions/views/channel';
|
||||
import {openModal, closeModal} from 'actions/views/modals';
|
||||
import {
|
||||
showPinnedPosts,
|
||||
showChannelFiles,
|
||||
closeRightHandSide,
|
||||
showChannelMembers,
|
||||
} from 'actions/views/rhs';
|
||||
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
|
||||
import {getAnnouncementBarCount} from 'selectors/views/announcement_bar';
|
||||
import {getRhsState} from 'selectors/rhs';
|
||||
import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired} from 'selectors/views/custom_status';
|
||||
import {isModalOpen} from 'selectors/views/modals';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
import {isFileAttachmentsEnabled} from 'utils/file_utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
@@ -57,8 +53,6 @@ function makeMapStateToProps() {
|
||||
return function mapStateToProps(state: GlobalState) {
|
||||
const channel = getCurrentChannel(state);
|
||||
const user = getCurrentUser(state);
|
||||
const teams = getMyTeams(state);
|
||||
const hasMoreThanOneTeam = teams.length > 1;
|
||||
const config = getConfig(state);
|
||||
|
||||
let dmUser;
|
||||
@@ -91,15 +85,9 @@ function makeMapStateToProps() {
|
||||
dmUser,
|
||||
gmMembers,
|
||||
rhsState: getRhsState(state),
|
||||
rhsOpen: getIsRhsOpen(state),
|
||||
isReadOnly: false,
|
||||
isMuted: isCurrentChannelMuted(state),
|
||||
isQuickSwitcherOpen: isModalOpen(state, ModalIdentifiers.QUICK_SWITCH),
|
||||
isChannelMuted: isCurrentChannelMuted(state),
|
||||
hasGuests: stats ? stats.guest_count > 0 : false,
|
||||
pinnedPostsCount: stats?.pinnedpost_count || 0,
|
||||
hasMoreThanOneTeam,
|
||||
currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state),
|
||||
announcementBarCount: getAnnouncementBarCount(state),
|
||||
customStatus,
|
||||
isCustomStatusEnabled: isCustomStatusEnabled(state),
|
||||
isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
|
||||
@@ -119,11 +107,12 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
closeRightHandSide,
|
||||
getCustomEmojisInText,
|
||||
updateChannelNotifyProps,
|
||||
goToLastViewedChannel,
|
||||
openModal,
|
||||
closeModal,
|
||||
showChannelMembers,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
export default withRouter<any, any>(connect(makeMapStateToProps, mapDispatchToProps)(ChannelHeader));
|
||||
const connector = connect(makeMapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
export default withRouter(connector(ChannelHeader));
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@use 'utils/variables';
|
||||
@use 'utils/mixins';
|
||||
|
||||
.user-profile-popover-floating-overlay {
|
||||
// 99 being the z-index of the global header
|
||||
// 1060 being the z-index of the user group popover
|
||||
z-index: 1070;
|
||||
z-index: variables.$z-index-popover;
|
||||
}
|
||||
|
||||
$profilePopoverWidth: 240px;
|
||||
|
||||
@@ -118,7 +118,6 @@ export function ProfilePopoverController<TriggerComponentType = HTMLSpanElement>
|
||||
{isMounted && (
|
||||
<FloatingPortal id={RootHtmlPortalId}>
|
||||
<FloatingOverlay
|
||||
id='user-profile-popover-floating-overlay'
|
||||
className='user-profile-popover-floating-overlay'
|
||||
lockScroll={true}
|
||||
>
|
||||
|
||||
@@ -3183,7 +3183,6 @@
|
||||
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
|
||||
"channel_bookmarks.open": "Open",
|
||||
"channel_groups": "{channel} Groups",
|
||||
"channel_header.addChannelHeader": "Add a channel header",
|
||||
"channel_header.channelFiles": "Channel files",
|
||||
"channel_header.channelHasGuests": "Channel has guests",
|
||||
"channel_header.channelMembers": "Members",
|
||||
@@ -3191,9 +3190,9 @@
|
||||
"channel_header.convert": "Convert to Private Channel",
|
||||
"channel_header.delete": "Archive Channel",
|
||||
"channel_header.directchannel.you": "{displayname} (you) ",
|
||||
"channel_header.editLink": "Edit",
|
||||
"channel_header.flagged": "Saved messages",
|
||||
"channel_header.groupMessageHasGuests": "This group message has guests",
|
||||
"channel_header.headerText.addNewButton": "Add a channel header",
|
||||
"channel_header.lastActive": "Last online {timestamp}",
|
||||
"channel_header.lastOnline": "Last online {timestamp}",
|
||||
"channel_header.leave": "Leave Channel",
|
||||
@@ -3912,7 +3911,6 @@
|
||||
"generic_icons.member": "Member Icon",
|
||||
"generic_icons.mention": "Mention Icon",
|
||||
"generic_icons.menu": "Menu Icon",
|
||||
"generic_icons.muted": "Muted Icon",
|
||||
"generic_icons.next": "Next Icon",
|
||||
"generic_icons.pin": "Pin Icon",
|
||||
"generic_icons.plugin": "Plugin Icon",
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
@use "utils/functions";
|
||||
|
||||
.header-popover-text-measurer {
|
||||
position: absolute;
|
||||
width: auto;
|
||||
height: auto;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-s);
|
||||
@@ -21,54 +13,6 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&.channel-header__popover {
|
||||
z-index: 999;
|
||||
min-width: 200px;
|
||||
margin-left: 3px;
|
||||
box-shadow: var(--elevation-4);
|
||||
font-size: 12px;
|
||||
|
||||
&.chanel-header__popover--lhs_offset {
|
||||
margin-left: 68px;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 5px 8px 8px 8px;
|
||||
|
||||
.markdown__list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-preview__button {
|
||||
display: inline-block;
|
||||
|
||||
.markdown-inline-img {
|
||||
margin: 0 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
margin-top: -25px;
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom,
|
||||
&.right,
|
||||
&.top,
|
||||
@@ -193,13 +137,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.channel-header__info {
|
||||
.popover-content {
|
||||
overflow: auto;
|
||||
max-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
.channel-header {
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
z-index: variables.$z-index-channel-header;
|
||||
width: 100%;
|
||||
max-height: 56px;
|
||||
padding: 12px;
|
||||
@@ -112,14 +112,6 @@
|
||||
flex: 1;
|
||||
padding: 0 24px 0 2px;
|
||||
}
|
||||
|
||||
.markdown__paragraph-inline {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-header_plugin-dropdown {
|
||||
@@ -390,19 +382,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
overflow: hidden;
|
||||
padding: 0 0 0 20px;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-placeholder {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
@@ -446,6 +425,8 @@
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
line-height: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
ul {
|
||||
&:first-child {
|
||||
@@ -462,6 +443,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
&>blockquote {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
padding: 0 0 0 20px;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
width: 18.8px;
|
||||
min-width: 18.8px;
|
||||
@@ -470,6 +465,17 @@
|
||||
min-height: 18.8px;
|
||||
max-height: 18.8px;
|
||||
}
|
||||
|
||||
&>p.markdown__paragraph-inline:first-child {
|
||||
display: inline;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&>p.markdown__paragraph-inline:not(:first-child) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown__heading {
|
||||
@@ -565,7 +571,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.channel-header__popover,
|
||||
.navbar__popover {
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -147,49 +147,6 @@ h6.markdown__heading {
|
||||
}
|
||||
}
|
||||
|
||||
#channelHeaderDescription,
|
||||
#header-popover {
|
||||
.markdown-inline-img {
|
||||
max-height: 18px;
|
||||
border-radius: 2px;
|
||||
margin: 2px;
|
||||
-moz-force-broken-image-icon: 1;
|
||||
}
|
||||
|
||||
.markdown-inline-img--loading {
|
||||
height: 18px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.markdown-inline-img--hover {
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 2px 0 rgba(variables.$black, 0.1), 0 2px 2px 0 rgba(variables.$black, 0.1);
|
||||
transition: all 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-inline-img--no-border {
|
||||
border: unset;
|
||||
}
|
||||
|
||||
div.markdown__paragraph-inline {
|
||||
display: inline-block;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.broken-image {
|
||||
max-height: 18px;
|
||||
border-radius: unset;
|
||||
}
|
||||
|
||||
.image-loaded-container {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.post-code {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -763,66 +763,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
#header-popover {
|
||||
position: fixed;
|
||||
top: 40px !important;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: calc(100% - 50px);
|
||||
border: none;
|
||||
background: functions.alpha-color(variables.$black, 0.9);
|
||||
box-shadow: none;
|
||||
transform: translate3d(0, 100%, 0);
|
||||
transition: all 0.35s ease;
|
||||
|
||||
&.in {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0091ff;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 15px 20px 100px;
|
||||
color: variables.$white;
|
||||
font-size: 15px;
|
||||
|
||||
>div {
|
||||
&:first-child {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 150px);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
bottom: 25px;
|
||||
left: 50%;
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding-top: 13px;
|
||||
border: 1px solid variables.$white;
|
||||
border-radius: 50%;
|
||||
margin-left: -15px;
|
||||
color: variables.$white;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 23px;
|
||||
font-weight: 200;
|
||||
line-height: 0;
|
||||
opacity: 1;
|
||||
text-align: center;
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.video-div {
|
||||
&.embed-responsive-item {
|
||||
iframe {
|
||||
|
||||
@@ -119,11 +119,6 @@
|
||||
width: 274px;
|
||||
}
|
||||
|
||||
.channel-header__popover {
|
||||
top: 44px !important;
|
||||
left: 236px !important;
|
||||
}
|
||||
|
||||
.col-sm-auto {
|
||||
float: left;
|
||||
}
|
||||
|
||||
@@ -42,3 +42,4 @@ $elevation-6: var(--elevation-6);
|
||||
$z-index-popover: 1070;
|
||||
$z-index-user-group-popover: 1060;
|
||||
$z-index-global-header: 99;
|
||||
$z-index-channel-header: 15;
|
||||
|
||||
Ссылка в новой задаче
Block a user