* Adde MySQL and Postgres migrations

* Replaced select * with column names

* removed all * from channel SQL store

* cleanup

* Fixed a duplicate column

* cleanup

* Added migrations and store support

* WIP

* used channelname slice in a missed place

* Handled patch

* Added app level tests

* Added API layer tests

* Added API layer tests

* WIP

* converted to query builder

* cleanupo

* added not null and default constraints

* Fixed test

* fixed file name

* review fixes

* review fixes

* updated migration file

* fixed text

* Review fixes

* WIP

* rendered banner

* WPI

* Fixed tooltip and markdown styling

* test: Add comprehensive tests for ChannelBanner component

* refactor: Use renderWithContext in channel_banner test

* WIP

* updated channel banner test

* Updated channel view test

* getContrastingSimpleColor tests

* Added tests

* rendered channel banner

* feat: Add comprehensive tests for ChannelBanner component

* Updated tests

* Made a lazy component

* Added underline to links in channel banner

* renamed param

* Created a selector for checking if channel banner is enabled or disabled

* addded test file

* test: Add tests for selectShowChannelBanner selector

* Added tests

* lint fix

* Used premium SKU constants

* Fixed a redux test
Этот коммит содержится в:
Harshil Sharma
2025-04-04 14:53:03 +05:30
коммит произвёл GitHub
родитель 09488558a0
Коммит d2173eb664
14 изменённых файлов: 844 добавлений и 0 удалений

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

@@ -0,0 +1,363 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import {renderWithContext} from 'tests/react_testing_utils';
import {LicenseSkus, Constants} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ChannelBanner from './index';
describe('components/channel_banner', () => {
const channel1 = TestHelper.getChannelMock({
id: 'channel_id_1',
team_id: 'team_id',
display_name: 'Test Channel 1',
header: 'This is the channel header',
name: 'test-channel',
type: Constants.OPEN_CHANNEL as ChannelType,
banner_info: {
text: 'Test banner message',
background_color: '#FF0000',
enabled: true,
},
});
const channel2 = TestHelper.getChannelMock({
id: 'channel_id_2',
team_id: 'team_id',
display_name: 'Test Channel 2',
header: 'This is the channel header',
name: 'test-channel',
type: Constants.OPEN_CHANNEL as ChannelType,
banner_info: {
text: 'Disabled banner',
background_color: '#00FF00',
enabled: false,
},
});
const channel3 = TestHelper.getChannelMock({
id: 'channel_id_3',
team_id: 'team_id',
display_name: 'Test Channel 3',
header: 'This is the channel header',
name: 'test-channel',
type: Constants.OPEN_CHANNEL as ChannelType,
banner_info: {
text: 'Banner with **markdown**',
background_color: '#0000FF',
enabled: true,
},
});
const dmChannel = TestHelper.getChannelMock({
id: 'dm_channel_id',
team_id: 'team_id',
display_name: 'DM Channel',
name: 'dm-channel',
type: Constants.DM_CHANNEL as ChannelType,
banner_info: {
text: 'DM Banner',
background_color: '#FF00FF',
enabled: true,
},
});
const privateChannel = TestHelper.getChannelMock({
id: 'private_channel_id',
team_id: 'team_id',
display_name: 'Private Channel',
name: 'private-channel',
type: Constants.PRIVATE_CHANNEL as ChannelType,
banner_info: {
text: 'Private Channel Banner',
background_color: '#FFFF00',
enabled: true,
},
});
const baseState = {
entities: {
general: {
license: {
IsLicensed: 'true',
SkuShortName: LicenseSkus.Premium,
},
},
channels: {
channels: {
[channel1.id]: channel1,
[channel2.id]: channel2,
[channel3.id]: channel3,
[dmChannel.id]: dmChannel,
[privateChannel.id]: privateChannel,
},
},
users: {
currentUserId: 'current-user-id',
profiles: {
'current-user-id': TestHelper.getUserMock({
id: 'current-user-id',
username: 'current-user',
}),
},
},
},
};
test('should not render when license is professional', () => {
const nonEnterpriseLicenseState = {
...baseState,
entities: {
...baseState.entities,
general: {
license: {
IsLicensed: 'true',
SkuShortName: LicenseSkus.Professional,
},
},
},
};
renderWithContext(
<ChannelBanner channelId={'channel_id_1'}/>,
nonEnterpriseLicenseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should not render when license is enterprise', () => {
const nonEnterpriseLicenseState = {
...baseState,
entities: {
...baseState.entities,
general: {
license: {
IsLicensed: 'true',
SkuShortName: LicenseSkus.Enterprise,
},
},
},
};
renderWithContext(
<ChannelBanner channelId={'channel_id_1'}/>,
nonEnterpriseLicenseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should not render when there is no license', () => {
const noLicenseState = {
...baseState,
entities: {
...baseState.entities,
general: {},
},
};
renderWithContext(
<ChannelBanner channelId={'channel_id_1'}/>,
noLicenseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should not render when banner is disabled', () => {
renderWithContext(
<ChannelBanner channelId={'channel_id_2'}/>,
baseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should not render when channel has no banner', () => {
renderWithContext(
<ChannelBanner channelId={'non-existent-channel-id'}/>,
baseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should not render when banner info is incomplete', () => {
const channel = TestHelper.getChannelMock({
id: 'channel_id_1',
team_id: 'team_id',
display_name: 'Test Channel 1',
header: 'This is the channel header',
name: 'test-channel',
banner_info: {
// incomplete channel banner info
enabled: true,
},
});
const incompleteBannerInfoState = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
channels: {
...baseState.entities.channels.channels,
[channel.id]: channel,
},
},
},
};
renderWithContext(
<ChannelBanner channelId={'channel_id_1'}/>,
incompleteBannerInfoState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should render banner with correct text and styling', () => {
renderWithContext(
<ChannelBanner channelId={'channel_id_1'}/>,
baseState,
);
const bannerContainer = screen.getByTestId('channel_banner_container');
expect(bannerContainer).toBeInTheDocument();
expect(bannerContainer).toHaveStyle('background-color: #FF0000');
const bannerText = screen.getByTestId('channel_banner_text');
expect(bannerText).toBeInTheDocument();
expect(bannerText.textContent).toBe('Test banner message');
});
test('should render markdown in banner text', () => {
renderWithContext(
<ChannelBanner channelId={'channel_id_3'}/>,
baseState,
);
const bannerContainer = screen.getByTestId('channel_banner_container');
expect(bannerContainer).toBeInTheDocument();
expect(bannerContainer).toHaveStyle('background-color: #0000FF');
const bannerText = screen.getByTestId('channel_banner_text');
expect(bannerText).toBeInTheDocument();
// Check that markdown was rendered (bold text)
const strongElement = bannerText.querySelector('strong');
expect(strongElement).toBeInTheDocument();
expect(strongElement?.textContent).toBe('markdown');
});
test('should render for private channels', () => {
renderWithContext(
<ChannelBanner channelId={'private_channel_id'}/>,
baseState,
);
const bannerContainer = screen.getByTestId('channel_banner_container');
expect(bannerContainer).toBeInTheDocument();
expect(bannerContainer).toHaveStyle('background-color: #FFFF00');
const bannerText = screen.getByTestId('channel_banner_text');
expect(bannerText).toBeInTheDocument();
expect(bannerText.textContent).toBe('Private Channel Banner');
});
test('should not render for DM channels', () => {
renderWithContext(
<ChannelBanner channelId={'dm_channel_id'}/>,
baseState,
);
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
});
test('should apply contrasting text color based on dark background color', () => {
// dark background should have dark text
const darkBgChannel = TestHelper.getChannelMock({
id: 'light_bg_channel',
team_id: 'team_id',
display_name: 'Light BG Channel',
name: 'light-bg-channel',
type: Constants.OPEN_CHANNEL as ChannelType,
banner_info: {
text: 'Light background banner',
background_color: '#000000',
enabled: true,
},
});
const stateWithLightBgChannel = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
channels: {
...baseState.entities.channels.channels,
[darkBgChannel.id]: darkBgChannel,
},
},
},
};
renderWithContext(
<ChannelBanner channelId={'light_bg_channel'}/>,
stateWithLightBgChannel,
);
// This test might be flaky depending on how getContrastingSimpleColor is implemented
// We're expecting dark text on light background
const darkBgBannerText = screen.getByTestId('channel_banner_text');
expect(darkBgBannerText).toBeInTheDocument();
expect(darkBgBannerText).toHaveStyle('color: rgb(255, 255, 255)');
expect(darkBgBannerText).toHaveStyle('--channel-banner-text-color: #FFFFFF');
});
test('should apply contrasting text color based on light background color', () => {
// Light background should have dark text
const lightBgChannel = TestHelper.getChannelMock({
id: 'light_bg_channel',
team_id: 'team_id',
display_name: 'Light BG Channel',
name: 'light-bg-channel',
type: Constants.OPEN_CHANNEL as ChannelType,
banner_info: {
text: 'Light background banner',
background_color: '#FFFFFF',
enabled: true,
},
});
const stateWithLightBgChannel = {
...baseState,
entities: {
...baseState.entities,
channels: {
...baseState.entities.channels,
channels: {
...baseState.entities.channels.channels,
[lightBgChannel.id]: lightBgChannel,
},
},
},
};
renderWithContext(
<ChannelBanner channelId={'light_bg_channel'}/>,
stateWithLightBgChannel,
);
// This test might be flaky depending on how getContrastingSimpleColor is implemented
// We're expecting dark text on light background
const lightBgBannerText = screen.getByTestId('channel_banner_text');
expect(lightBgBannerText).toBeInTheDocument();
expect(lightBgBannerText).toHaveStyle('color: rgb(0, 0, 0)');
expect(lightBgBannerText).toHaveStyle('--channel-banner-text-color: #000000');
});
});

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

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {selectShowChannelBanner} from 'mattermost-redux/selectors/entities/channel_banner';
import {getChannelBanner} from 'mattermost-redux/selectors/entities/channels';
import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils';
import Markdown from 'components/markdown';
import WithTooltip from 'components/with_tooltip';
import type {TextFormattingOptions} from 'utils/text_formatting';
import type {GlobalState} from 'types/store';
import './style.scss';
const markdownRenderingOptions: Partial<TextFormattingOptions> = {
singleline: true,
mentionHighlight: false,
};
type Props = {
channelId: string;
}
export default function ChannelBanner({channelId}: Props) {
const channelBannerInfo = useSelector((state: GlobalState) => getChannelBanner(state, channelId));
const showChannelBanner = useSelector((state: GlobalState) => selectShowChannelBanner(state, channelId));
const intl = useIntl();
const channelBannerTextAriaLabel = intl.formatMessage({id: 'channel_banner.aria_label', defaultMessage: 'Channel banner text'});
const content = (
<Markdown
message={channelBannerInfo?.text}
options={markdownRenderingOptions}
/>
);
const channelBannerStyle = useMemo(() => {
return {
backgroundColor: channelBannerInfo?.background_color,
};
}, [channelBannerInfo]);
const channelBannerTextStyle = useMemo(() => {
// this is just to satisfy type checks.
if (!channelBannerInfo || !channelBannerInfo.background_color) {
return {};
}
const color = getContrastingSimpleColor(channelBannerInfo.background_color);
// The CSS variable is declared here, and is being used in the stylesheet being imported in this component.
// This is needed because if the user sets background color a share of blue similar to the default link color,
// the markdown link will become almost invisible. So, the CSS variable declared here is used
// to set the color of the text in anchor tag in the stylesheet.
return {
color,
'--channel-banner-text-color': color,
};
}, [channelBannerInfo]);
if (!channelBannerInfo || !showChannelBanner) {
return null;
}
return (
<WithTooltip
title={content}
className='channelBannerTooltip'
delayClose={true}
forcedPlacement='bottom'
>
<div
className='channel_banner'
data-testid='channel_banner_container'
style={channelBannerStyle}
>
<span
data-testid='channel_banner_text'
className='channel_banner_text'
aria-label={channelBannerTextAriaLabel}
style={channelBannerTextStyle}
>
{content}
</span>
</div>
</WithTooltip>
);
}

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

@@ -0,0 +1,35 @@
@use "utils/variables";
.channel_banner {
display: flex;
width: 100%;
min-height: variables.$announcement-bar-height;
max-height: variables.$announcement-bar-height;
align-items: center;
justify-content: center;
padding-block: 6px;
padding-inline: 24px;
white-space: nowrap;
.channel_banner_text {
display: block;
overflow: hidden;
max-width: 100%;
text-align: center;
text-overflow: ellipsis;
a {
color: var(--channel-banner-text-color);
text-decoration: underline;
}
}
}
.channelBannerTooltip {
overflow: auto;
min-width: 400px;
max-width: min(1000px, 70vw);
max-height: 80vh;
pointer-events: auto;
}

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

@@ -31,6 +31,9 @@ exports[`components/channel_view Should match snapshot if channel is archived 1`
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBanner
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>
@@ -96,6 +99,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBanner
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>
@@ -160,6 +166,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBanner
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>

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

@@ -20,6 +20,7 @@ const ChannelHeader = makeAsyncComponent('ChannelHeader', lazy(() => import('com
const FileUploadOverlay = makeAsyncComponent('FileUploadOverlay', lazy(() => import('components/file_upload_overlay')));
const ChannelBookmarks = makeAsyncComponent('ChannelBookmarks', lazy(() => import('components/channel_bookmarks')));
const AdvancedCreatePost = makeAsyncComponent('AdvancedCreatePost', lazy(() => import('components/advanced_create_post')));
const ChannelBanner = makeAsyncComponent('ChannelBanner', lazy(() => import('components/channel_banner')));
export type Props = PropsFromRedux & RouteComponentProps<{
postid?: string;
@@ -193,6 +194,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
/>
<ChannelHeader {...this.props}/>
{this.props.isChannelBookmarksEnabled && <ChannelBookmarks channelId={this.props.channelId}/>}
<ChannelBanner channelId={this.props.channelId}/>
<DeferredPostView
channelId={this.props.channelId}
focusedPostId={this.state.focusedPostId}

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

@@ -83,6 +83,8 @@ interface Props {
*/
onOpen?: () => void;
children: ReactElement;
forcedPlacement?: Placement;
}
export default function WithTooltip({
@@ -97,6 +99,7 @@ export default function WithTooltip({
className,
onOpen,
disabled,
forcedPlacement,
}: Props) {
const [open, setOpen] = useState(false);
@@ -111,6 +114,11 @@ export default function WithTooltip({
}
const placements = useMemo<{initial: Placement; fallback: Placement[]}>(() => {
// if an explicit placement is provided, use it exclusively
if (forcedPlacement) {
return {initial: forcedPlacement, fallback: [forcedPlacement]};
}
let initial: Placement;
let fallback: Placement[];
if (isVertical) {

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

@@ -3212,6 +3212,7 @@
"change_url.shorter": "URLs must have maximum 64 characters.",
"change_url.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
"change_url.startWithLetter": "URLs must start with a lowercase letter or number.",
"channel_banner.aria_label": "Channel banner text",
"channel_bookmarks.addBookmark": "Add a bookmark",
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
"channel_bookmarks.addLink": "Add a link",

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

@@ -76,4 +76,6 @@ export default {
DEFAULT_GROUP: 'board',
CUSTOM_GROUP_USER_ROLE: 'custom_group_user',
MAX_GET_ROLES_BY_NAMES: 100,
SKUEnterprise: 'enterprise',
SKUPremium: 'premium',
};

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

@@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {DeepPartial} from 'redux';
import type {GlobalState} from '@mattermost/types/store';
import {General} from 'mattermost-redux/constants';
import {selectShowChannelBanner} from './channel_banner';
describe('Selectors.ChannelBanner', () => {
const channelId = 'channel1';
const teamId = 'team1';
const baseState: DeepPartial<GlobalState> = {
entities: {
general: {
license: {
SkuShortName: General.SKUPremium,
},
},
channels: {
channels: {
channel1: {
id: channelId,
team_id: teamId,
type: General.OPEN_CHANNEL,
banner_info: {
enabled: true,
text: 'Text',
background_color: '#000000',
},
},
},
},
},
};
test('should return false when license is not premium', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
general: {
license: {
SkuShortName: 'starter',
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
test('should return false when license is professional', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
general: {
license: {
SkuShortName: 'professional',
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
test('should return false when license is enterprise', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
general: {
license: {
SkuShortName: 'enterprise',
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
test('should return false when channel type is not open or private', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
channels: {
channels: {
channel1: {
id: channelId,
team_id: teamId,
type: General.OPEN_CHANNEL,
banner_info: {
enabled: false,
text: 'Text',
background_color: '#000000',
},
},
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
test('should return false when channel banner is not enabled', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
channels: {
channels: {
channel1: {
id: channelId,
team_id: teamId,
type: General.OPEN_CHANNEL,
banner_info: {
enabled: false,
text: 'Text',
background_color: '#000000',
},
},
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
test('should return true when all conditions are met for open channel', () => {
expect(selectShowChannelBanner(baseState as GlobalState, channelId)).toBe(true);
});
test('should return true when all conditions are met for private channel', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
channels: {
channels: {
channel1: {
id: channelId,
team_id: teamId,
type: General.PRIVATE_CHANNEL,
banner_info: {
enabled: true,
text: 'Text',
background_color: '#000000',
},
},
},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(true);
});
test('should return false when channel does not exist', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
channels: {
channels: {},
},
},
};
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
});
});

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

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {channelBannerEnabled} from '@mattermost/types/channels';
import type {GlobalState} from '@mattermost/types/store';
import {General} from 'mattermost-redux/constants';
import {getChannel, getChannelBanner} from 'mattermost-redux/selectors/entities/channels';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
export const selectShowChannelBanner = (state: GlobalState, channelId: string): boolean => {
const license = getLicense(state);
const isPremiumLicense = license?.SkuShortName === General.SKUPremium;
const channelBannerInfo = getChannelBanner(state, channelId);
const channel = getChannel(state, channelId);
const isValidChannelType = Boolean(channel && (channel.type === General.OPEN_CHANNEL || channel.type === General.PRIVATE_CHANNEL));
return isPremiumLicense && isValidChannelType && channelBannerEnabled(channelBannerInfo);
};

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

@@ -5,6 +5,7 @@ import max from 'lodash/max';
import type {
Channel,
ChannelBanner,
ChannelMemberCountsByGroup,
ChannelMembership,
ChannelMessageCount,
@@ -1445,3 +1446,8 @@ export const isDeactivatedDirectChannel = (state: GlobalState, channelId: string
const teammate = getDirectTeammate(state, channelId);
return Boolean(teammate && teammate.delete_at);
};
export function getChannelBanner(state: GlobalState, channelId: string): ChannelBanner | undefined {
const channel = getChannel(state, channelId);
return channel ? channel.banner_info : undefined;
}

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

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import * as ThemeUtils from 'mattermost-redux/utils/theme_utils';
import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils';
import {Preferences} from '../constants';
@@ -92,3 +93,81 @@ describe('ThemeUtils', () => {
});
});
});
describe('getContrastingSimpleColor', () => {
// Test for dark colors that should return white text
it('should return white (#FFFFFF) for black', () => {
expect(getContrastingSimpleColor('#000000')).toBe('#FFFFFF');
});
it('should return white for dark blue', () => {
expect(getContrastingSimpleColor('#0000FF')).toBe('#FFFFFF');
});
it('should return white for dark red', () => {
expect(getContrastingSimpleColor('#8B0000')).toBe('#FFFFFF');
});
it('should return white for dark green', () => {
expect(getContrastingSimpleColor('#006400')).toBe('#FFFFFF');
});
// Test for light colors that should return black text
it('should return black (#000000) for white', () => {
expect(getContrastingSimpleColor('#FFFFFF')).toBe('#000000');
});
it('should return black for light yellow', () => {
expect(getContrastingSimpleColor('#FFFF00')).toBe('#000000');
});
it('should return black for light cyan', () => {
expect(getContrastingSimpleColor('#00FFFF')).toBe('#000000');
});
it('should return black for light pink', () => {
expect(getContrastingSimpleColor('#FFC0CB')).toBe('#000000');
});
it('should not crash for invalid colors', () => {
expect(getContrastingSimpleColor('')).toBe('');
expect(getContrastingSimpleColor('##########')).toBe('');
expect(getContrastingSimpleColor(' ')).toBe('');
});
// Test for colors near the threshold
it('should return black for colors just above the luminance threshold', () => {
// for this background color, black text has a
// contrast ratio of 4.4:1, whereas white has that of 4.6:1,
// giving it a slight advantage.
expect(getContrastingSimpleColor('#747474')).toBe('#FFFFFF');
});
it('should return white for colors just below the luminance threshold', () => {
// #737373 has a luminance of approximately 0.178 (just below threshold)
expect(getContrastingSimpleColor('#737373')).toBe('#FFFFFF');
});
// Test for input format variations
it('should handle hex colors with or without # prefix', () => {
expect(getContrastingSimpleColor('000000')).toBe('#FFFFFF');
expect(getContrastingSimpleColor('#000000')).toBe('#FFFFFF');
expect(getContrastingSimpleColor('FFFFFF')).toBe('#000000');
expect(getContrastingSimpleColor('#FFFFFF')).toBe('#000000');
});
// Test for more realistic use cases
it('should return appropriate contrast colors for common UI colors', () => {
// Mattermost denim blue
expect(getContrastingSimpleColor('#1e325c')).toBe('#FFFFFF');
// Mattermost Onyx grey
expect(getContrastingSimpleColor('#202228')).toBe('#FFFFFF');
// Mattermost Indigo blue
expect(getContrastingSimpleColor('#151e32')).toBe('#FFFFFF');
// Mattermost quartz white
expect(getContrastingSimpleColor('#f4f4f6')).toBe('#000000');
});
});

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

@@ -170,3 +170,33 @@ export function setThemeDefaults(theme: Partial<Theme>): Theme {
return processedTheme as Theme;
}
// getContrastingSimpleColor returns a contrasting color - either black or white, depending on the luminance
// of the supplied color. Both input and outpur colors are in hexadecimal color code.
export function getContrastingSimpleColor(colorHexCode: string): string {
const color = colorHexCode.startsWith('#') ? colorHexCode.slice(1) : colorHexCode;
if (color.length !== 6) {
return '';
}
// split red, green and blue components
const red = parseInt(color.substring(0, 2), 16);
const green = parseInt(color.substring(2, 4), 16);
const blue = parseInt(color.substring(4, 6), 16);
// calculate relative luminance of each color channel - https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
const srgb = [red / 255, green / 255, blue / 255];
const [redLuminance, greenLuminance, blueLuminance] = srgb.map((i) => {
if (i <= 0.04045) {
return i / 12.92;
}
return Math.pow((i + 0.055) / 1.055, 2.4);
});
// calculate luminance of the whole color by adding percieved luminance of each channel
const colorLuminance = (0.2126 * redLuminance) + (0.7152 * greenLuminance) + (0.0722 * blueLuminance);
// return black or white based on color's luminance
return colorLuminance > 0.179 ? '#000000' : '#FFFFFF';
}

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

@@ -32,6 +32,20 @@ export type ChannelNotifyProps = {
channel_auto_follow_threads: 'off' | 'on';
};
export type ChannelBanner = {
enabled?: boolean;
text?: string;
background_color?: string;
}
export function channelBannerEnabled(banner: ChannelBanner | undefined): boolean {
if (!banner) {
return false;
}
return Boolean(banner.enabled) && Boolean(banner.text) && Boolean(banner.background_color);
}
export type Channel = {
id: string;
create_at: number;
@@ -53,6 +67,7 @@ export type Channel = {
shared?: boolean;
props?: Record<string, any>;
policy_id?: string | null;
banner_info?: ChannelBanner;
};
export type ServerChannel = Channel & {