diff --git a/webapp/channels/src/components/channel_banner/channel_banner.test.tsx b/webapp/channels/src/components/channel_banner/channel_banner.test.tsx new file mode 100644 index 0000000000..db9a5bb730 --- /dev/null +++ b/webapp/channels/src/components/channel_banner/channel_banner.test.tsx @@ -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( + , + 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( + , + 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( + , + noLicenseState, + ); + expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument(); + }); + + test('should not render when banner is disabled', () => { + renderWithContext( + , + baseState, + ); + expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument(); + }); + + test('should not render when channel has no banner', () => { + renderWithContext( + , + 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( + , + incompleteBannerInfoState, + ); + expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument(); + }); + + test('should render banner with correct text and styling', () => { + renderWithContext( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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'); + }); +}); diff --git a/webapp/channels/src/components/channel_banner/index.tsx b/webapp/channels/src/components/channel_banner/index.tsx new file mode 100644 index 0000000000..f4b9542171 --- /dev/null +++ b/webapp/channels/src/components/channel_banner/index.tsx @@ -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 = { + 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 = ( + + ); + + 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 ( + +
+ + {content} + +
+
+ ); +} diff --git a/webapp/channels/src/components/channel_banner/style.scss b/webapp/channels/src/components/channel_banner/style.scss new file mode 100644 index 0000000000..bd07207396 --- /dev/null +++ b/webapp/channels/src/components/channel_banner/style.scss @@ -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; +} + diff --git a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap index 8b27d74063..9ca0713658 100644 --- a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap +++ b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap @@ -31,6 +31,9 @@ exports[`components/channel_view Should match snapshot if channel is archived 1` teamUrl="/team" viewArchivedChannels={false} /> + @@ -96,6 +99,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated teamUrl="/team" viewArchivedChannels={false} /> + @@ -160,6 +166,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = ` teamUrl="/team" viewArchivedChannels={false} /> + diff --git a/webapp/channels/src/components/channel_view/channel_view.tsx b/webapp/channels/src/components/channel_view/channel_view.tsx index 214d6aced6..f1760672c4 100644 --- a/webapp/channels/src/components/channel_view/channel_view.tsx +++ b/webapp/channels/src/components/channel_view/channel_view.tsx @@ -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 { /> {this.props.isChannelBookmarksEnabled && } + 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) { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 131e38ac6f..47ef6f785b 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/general.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/general.ts index 00bd38ba21..faf8a21128 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/general.ts @@ -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', }; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.test.ts new file mode 100644 index 0000000000..c2c8f64f9c --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.test.ts @@ -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 = { + 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 = { + ...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 = { + ...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 = { + ...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 = { + ...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 = { + ...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 = { + ...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 = { + ...baseState, + entities: { + ...baseState.entities, + channels: { + channels: {}, + }, + }, + }; + + expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.ts new file mode 100644 index 0000000000..aabdb6cdd1 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_banner.ts @@ -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); +}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index f1a0cb962b..51ee4f4e13 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -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; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.test.ts index 72b1a88fee..4672455280 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.test.ts @@ -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'); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.ts index 6231e560ba..7529325230 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.ts @@ -170,3 +170,33 @@ export function setThemeDefaults(theme: Partial): 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'; +} diff --git a/webapp/platform/types/src/channels.ts b/webapp/platform/types/src/channels.ts index 94a3c2f71f..beeda9a0e2 100644 --- a/webapp/platform/types/src/channels.ts +++ b/webapp/platform/types/src/channels.ts @@ -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; policy_id?: string | null; + banner_info?: ChannelBanner; }; export type ServerChannel = Channel & {