Channel banner display (#8735)

* channel banner DB migration

* Display channel banner

* Updated database docs

* used premium sku

* misc fixes

* Handled channel banner with calls

* Updated tests

* Updated tests

* test: Add comprehensive tests for shouldShowChannelBanner function

* Added more tests

* lint fix

* minor refactoring

* lint fix

* reverted package.resolved changes

* made a param mandatory

* Added some comments
This commit is contained in:
Harshil Sharma 2025-04-08 11:15:18 +05:30 committed by GitHub
parent 52330847e2
commit 738c7e6b1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 652 additions and 11 deletions

View file

@ -25,6 +25,7 @@ import {typography} from '@utils/typography';
type Props = {
allowDismissal: boolean;
bannerText: string;
headingText?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -55,6 +56,7 @@ const close = () => {
const ExpandedAnnouncementBanner = ({
allowDismissal,
bannerText,
headingText,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
@ -88,14 +90,16 @@ const ExpandedAnnouncementBanner = ({
const Scroll = useMemo(() => (isTablet ? ScrollView : BottomSheetScrollView), [isTablet]);
const heading = headingText || intl.formatMessage({
id: 'mobile.announcement_banner.title',
defaultMessage: 'Announcement',
});
return (
<View style={containerStyle}>
{!isTablet && (
<Text style={style.title}>
{intl.formatMessage({
id: 'mobile.announcement_banner.title',
defaultMessage: 'Announcement',
})}
{heading}
</Text>
)}
<Scroll

View file

@ -31,6 +31,7 @@ describe('components/channel_list_row', () => {
group_constrained: null,
shared: true,
};
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;

View file

@ -8,6 +8,7 @@ export default {
Starter: 'starter',
Professional: 'professional',
Enterprise: 'enterprise',
Premium: 'premium',
},
SelfHostedProducts: {
STARTER: 'starter',

View file

@ -81,6 +81,7 @@ export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit';
export const GENERIC_OVERLAY = 'GenericOverlay';
export const CHANNEL_BANNER = 'ChannelBanner';
export default {
ABOUT,
@ -162,6 +163,7 @@ export default {
THREAD_OPTIONS,
USER_PROFILE,
GENERIC_OVERLAY,
CHANNEL_BANNER,
} as const;
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([

View file

@ -31,6 +31,7 @@ export const CALL_NOTIFICATION_BAR_HEIGHT = 40;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;
export const BOOKMARKS_BAR_HEIGHT = 48;
export const CHANNEL_BANNER_HEIGHT = 40;
export const HOME_PADDING = {
paddingLeft: 18,

View file

@ -8,7 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CurrentCallBar from '@calls/components/current_call_bar';
import {IncomingCallsContainer} from '@calls/components/incoming_calls_container';
import JoinCallBanner from '@calls/components/join_call_banner';
import {BOOKMARKS_BAR_HEIGHT, DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view';
import {BOOKMARKS_BAR_HEIGHT, CHANNEL_BANNER_HEIGHT, DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
@ -29,6 +29,7 @@ type Props = {
threadScreen?: boolean;
channelsScreen?: boolean;
includeBookmarkBar?: boolean;
includeChannelBanner?: boolean;
}
const FloatingCallContainer = ({
@ -39,6 +40,7 @@ const FloatingCallContainer = ({
threadScreen,
channelsScreen,
includeBookmarkBar,
includeChannelBanner,
}: Props) => {
const serverUrl = useServerUrl();
const insets = useSafeAreaInsets();
@ -47,7 +49,7 @@ const FloatingCallContainer = ({
const topBarForTablet = (isTablet && !threadScreen) ? TABLET_HEADER_HEIGHT : 0;
const topBarChannel = (!isTablet && !threadScreen) ? DEFAULT_HEADER_HEIGHT : 0;
const wrapperTop = {
top: insets.top + topBarForTablet + topBarChannel + (includeBookmarkBar ? BOOKMARKS_BAR_HEIGHT : 0),
top: insets.top + topBarForTablet + topBarChannel + (includeBookmarkBar ? BOOKMARKS_BAR_HEIGHT : 0) + (includeChannelBanner ? CHANNEL_BANNER_HEIGHT : 0),
};
const wrapperBottom = {
bottom: 8,

View file

@ -40,6 +40,7 @@ type ChannelProps = {
channelType: ChannelType;
hasGMasDMFeature: boolean;
includeBookmarkBar?: boolean;
includeChannelBanner: boolean;
};
const edges: Edge[] = ['left', 'right'];
@ -64,6 +65,7 @@ const Channel = ({
currentUserId,
hasGMasDMFeature,
includeBookmarkBar,
includeChannelBanner,
}: ChannelProps) => {
useGMasDMNotice(currentUserId, channelType, dismissedGMasDMNotice, hasGMasDMFeature);
const isTablet = useIsTablet();
@ -126,6 +128,7 @@ const Channel = ({
groupCallsAllowed={groupCallsAllowed}
isTabletView={isTabletView}
shouldRenderBookmarks={shouldRender}
shouldRenderChannelBanner={includeChannelBanner}
/>
{shouldRender &&
<ExtraKeyboardProvider>
@ -151,6 +154,7 @@ const Channel = ({
showIncomingCalls={showIncomingCalls}
isInACall={isInACall}
includeBookmarkBar={includeBookmarkBar}
includeChannelBanner={includeChannelBanner}
/>
}
</SafeAreaView>

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, License} from '@constants';
import {shouldShowChannelBanner} from '@screens/channel/channel_feature_checks';
describe('shouldShowChannelBanner', () => {
const validLicense = {
SkuShortName: License.SKU_SHORT_NAME.Premium,
} as ClientLicense;
const validBannerInfo = {
enabled: true,
text: 'Banner text',
background_color: '#FF0000',
};
it('should return false when license is not provided', () => {
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, undefined, validBannerInfo)).toBe(false);
});
it('should return false when banner info is not provided', () => {
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, validLicense, undefined)).toBe(false);
});
it('should return false when license is not professional', () => {
const nonPremiumLicense = {
SkuShortName: License.SKU_SHORT_NAME.Professional,
} as ClientLicense;
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, nonPremiumLicense, validBannerInfo)).toBe(false);
});
it('should return false when license is not enterprise', () => {
const nonPremiumLicense = {
SkuShortName: License.SKU_SHORT_NAME.Enterprise,
} as ClientLicense;
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, nonPremiumLicense, validBannerInfo)).toBe(false);
});
it('should return false when banner info is incomplete', () => {
const incompleteBannerInfo = {
enabled: true,
text: 'Banner text',
background_color: '',
};
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, validLicense, incompleteBannerInfo)).toBe(false);
});
it('should return false when banner is not enabled', () => {
const disabledBannerInfo = {
enabled: false,
text: 'Banner text',
background_color: '#FF0000',
};
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, validLicense, disabledBannerInfo)).toBe(false);
});
it('should return false for DM and GM channels', () => {
expect(shouldShowChannelBanner(General.DM_CHANNEL, validLicense, validBannerInfo)).toBe(false);
expect(shouldShowChannelBanner(General.GM_CHANNEL, validLicense, validBannerInfo)).toBe(false);
});
it('should return true for valid open channel with complete banner info and premium license', () => {
expect(shouldShowChannelBanner(General.OPEN_CHANNEL, validLicense, validBannerInfo)).toBe(true);
});
it('should return true for valid private channel with complete banner info and premium license', () => {
expect(shouldShowChannelBanner(General.PRIVATE_CHANNEL, validLicense, validBannerInfo)).toBe(true);
});
});

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, License} from '@constants';
export function shouldShowChannelBanner(channelType?: ChannelType, license?: ClientLicense, bannerInfo?: ChannelBannerInfo): boolean {
if (!license || !bannerInfo || !channelType) {
return false;
}
const isPremiumLicense = license.SkuShortName === License.SKU_SHORT_NAME.Premium;
const bannerInfoComplete = Boolean(bannerInfo.enabled && bannerInfo.text && bannerInfo.background_color);
const isValidChannelType = channelType === General.OPEN_CHANNEL || channelType === General.PRIVATE_CHANNEL;
return isPremiumLicense && bannerInfoComplete && isValidChannelType;
}

View file

@ -0,0 +1,216 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react-native';
import React from 'react';
import {General, License} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {bottomSheet} from '@screens/navigation';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelBanner from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type Database from '@nozbe/watermelondb/Database';
jest.mock('@screens/navigation', () => ({
bottomSheet: jest.fn(),
}));
jest.mock('@hooks/header', () => ({
useDefaultHeaderHeight: jest.fn(() => 50),
}));
describe('ChannelBanner', () => {
let database: Database;
let operator: ServerDataOperator;
beforeAll(async () => {
const serverUrl = 'baseHandler.test.com';
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
beforeEach(async () => {
jest.clearAllMocks();
const channel = await getChannelById(database, TestHelper.basicChannel!.id);
await database.write(async () => {
await channel?.update(() => {
channel.bannerInfo = {
enabled: true,
text: 'Test Banner Text',
background_color: '#FF0000',
};
});
});
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Premium}}], prepareRecordsOnly: false});
});
it('renders correctly with valid props', () => {
renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
expect(screen.getByText('Test Banner Text')).toBeVisible();
});
it('does not render when banner info is missing', async () => {
const channel = await getChannelById(database, TestHelper.basicChannel!.id);
await database.write(async () => {
await channel?.update(() => {
channel.bannerInfo = undefined;
});
});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render when banner is not enabled', async () => {
const channel = await getChannelById(database, TestHelper.basicChannel!.id);
await database.write(async () => {
await channel?.update(() => {
channel.bannerInfo = {
enabled: false,
text: 'Test Banner Text',
background_color: '#FF0000',
};
});
});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render when banner text is missing', async () => {
const channel = await getChannelById(database, TestHelper.basicChannel!.id);
await database.write(async () => {
await channel?.update(() => {
channel.bannerInfo = {
enabled: false,
text: undefined,
background_color: '#FF0000',
};
});
});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render when banner background color is missing', async () => {
const channel = await getChannelById(database, TestHelper.basicChannel!.id);
await database.write(async () => {
await channel?.update(() => {
channel.bannerInfo = {
enabled: false,
text: 'Banner text',
background_color: undefined,
};
});
});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render for DM channel type', async () => {
const dmChannel = TestHelper.fakeDmChannel(TestHelper.basicUser!.id, TestHelper.basicUser!.id) as Channel;
await operator.handleChannel({channels: [dmChannel], prepareRecordsOnly: false});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={dmChannel!.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render for GM channel type', async () => {
const gmChannel = TestHelper.fakeChannelWithId('');
gmChannel.type = General.GM_CHANNEL;
await operator.handleChannel({channels: [gmChannel], prepareRecordsOnly: false});
const {queryByText} = renderWithEverything(
<ChannelBanner channelId={gmChannel.id}/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('renders for private channel type', async () => {
const privateChannel = TestHelper.fakeChannelWithId(TestHelper.basicTeam!.id);
privateChannel.type = General.PRIVATE_CHANNEL;
privateChannel.banner_info = {
enabled: true,
text: 'Test Banner Text',
background_color: '#FF0000',
};
await operator.handleChannel({channels: [privateChannel], prepareRecordsOnly: false});
renderWithEverything(
<ChannelBanner channelId={privateChannel.id}/>,
{database},
);
expect(screen.getByText('Test Banner Text')).toBeVisible();
});
it('opens bottom sheet when banner is pressed', async () => {
renderWithEverything(
<ChannelBanner channelId={TestHelper.basicChannel!.id}/>,
{database},
);
const banner = screen.getByText('Test Banner Text');
fireEvent.press(banner);
expect(bottomSheet).toHaveBeenCalledWith(expect.objectContaining({
title: 'Channel Banner',
closeButtonId: 'channel-banner-close',
}));
});
it('does not render when channel ID is empty', async () => {
const {queryByText} = renderWithEverything(
<ChannelBanner channelId=''/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
it('does not render when channel ID is of non existent channel', async () => {
const {queryByText} = renderWithEverything(
<ChannelBanner channelId='non_existent_channel_id'/>,
{database},
);
expect(queryByText('Test Banner Text')).not.toBeVisible();
});
});

View file

@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, TouchableOpacity, View} from 'react-native';
import ExpandedAnnouncementBanner from '@components/announcement_banner/expanded_announcement_banner';
import RemoveMarkdown from '@components/remove_markdown';
import {useTheme} from '@context/theme';
import {useDefaultHeaderHeight} from '@hooks/header';
import {bottomSheet} from '@screens/navigation';
import {getContrastingSimpleColor} from '@utils/general';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {getMarkdownTextStyles} from '@utils/markdown';
import {typography} from '@utils/typography';
const BUTTON_HEIGHT = 48; // From /app/utils/buttonStyles.ts, lg button
const TITLE_HEIGHT = 30 + 12; // typography 600 line height
const MARGINS = 12 + 24 + 10; // (after title + after text + after content)
const SNAP_POINT = TITLE_HEIGHT + BUTTON_HEIGHT + MARGINS + BUTTON_HEIGHT + 10;
const MAX_TEXT_CONTAINER_HEIGHT = 500;
const MIN_TEXT_CONTAINER_HEIGHT = 40;
const CLOSE_BUTTON_ID = 'channel-banner-close';
const getStyleSheet = (bannerTextColor: string) => ({
container: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 16,
paddingRight: 16,
height: 40,
},
containerTopItem: {
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
baseTextStyle: {
borderWidth: 2,
borderColor: 'red',
...typography('Body', 100, 'Regular'),
color: bannerTextColor,
},
bannerTextContainer: {
flex: 1,
flexGrow: 1,
},
bannerText: {
textAlign: 'center' as const,
},
});
type Props = {
bannerInfo?: ChannelBannerInfo;
isTopItem?: Boolean;
}
export function ChannelBanner({bannerInfo, isTopItem}: Props) {
const intl = useIntl();
const theme = useTheme();
const bannerTextColor = getContrastingSimpleColor(bannerInfo?.background_color || '');
const style = useMemo(() => {
return getStyleSheet(bannerTextColor);
}, [bannerTextColor]);
const defaultHeight = useDefaultHeaderHeight();
const containerStyle = useMemo(() => ({
...style.container,
backgroundColor: bannerInfo?.background_color,
top: defaultHeight,
zIndex: 1,
}), [bannerInfo?.background_color, defaultHeight, style.container]);
const markdownTextStyle = useMemo(() => {
const textStyle = getMarkdownTextStyles(theme);
// channel banner colors are theme independent.
// If we let the link color being set by the theme, it will be unreadable in some cases.
// So we set the link color to the banner text color explicitly. This, with the controlled
// background color, ensures the banner text is always readable.
textStyle.link = {
...textStyle.link,
color: bannerTextColor,
};
return textStyle;
}, [bannerTextColor, theme]);
const handlePress = useCallback(() => {
// set snap point based on text length, with a defined
// minimum and maximum height for the text container
const length = bannerInfo!.text!.length / 100;
const snapPoint = SNAP_POINT + Math.min(Math.max(bottomSheetSnapPoint(length, 100), MIN_TEXT_CONTAINER_HEIGHT), MAX_TEXT_CONTAINER_HEIGHT);
const expandedChannelBannerTitle = intl.formatMessage({
id: 'channel.banner.bottom_sheet.title',
defaultMessage: 'Channel Banner',
});
const renderContent = () => (
<ExpandedAnnouncementBanner
allowDismissal={false}
bannerText={bannerInfo!.text || ''}
headingText={expandedChannelBannerTitle}
/>
);
bottomSheet({
closeButtonId: CLOSE_BUTTON_ID,
title: expandedChannelBannerTitle,
snapPoints: [1, snapPoint],
renderContent,
theme,
});
}, [bannerInfo, intl, theme]);
// banner info will be complete when this component renders,
// but this check is still here to avoid having to use non-null assertion everywhere.
if (!bannerInfo || !bannerInfo.enabled || !bannerInfo.text || !bannerInfo.background_color) {
return null;
}
return (
<View style={[containerStyle, isTopItem && style.containerTopItem]}>
<TouchableOpacity
onPress={handlePress}
style={style.bannerTextContainer}
>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.bannerText}
>
<RemoveMarkdown
value={bannerInfo.text}
textStyle={markdownTextStyle}
baseStyle={style.baseTextStyle}
/>
</Text>
</TouchableOpacity>
</View>
);
}

View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {ChannelBanner} from '@screens/channel/header/channel_banner/channel_banner';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const bannerInfo = channel.pipe(switchMap((c) => of$(c?.bannerInfo)));
return {
bannerInfo,
};
});
export default withDatabase(enhanced(ChannelBanner));

View file

@ -19,6 +19,7 @@ import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {BOTTOM_SHEET_ANDROID_OFFSET} from '@screens/bottom_sheet';
import ChannelBanner from '@screens/channel/header/channel_banner';
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
import {bottomSheetSnapPoint} from '@utils/helpers';
@ -51,6 +52,7 @@ type ChannelProps = {
groupCallsAllowed: boolean;
isTabletView?: boolean;
shouldRenderBookmarks: boolean;
shouldRenderChannelBanner: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -81,7 +83,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ChannelHeader = ({
canAddBookmarks, channelId, channelType, componentId, customStatus, displayName, hasBookmarks,
isBookmarksEnabled, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount,
searchTerm, teamId, callsEnabledInChannel, groupCallsAllowed, isTabletView, shouldRenderBookmarks,
searchTerm, teamId, callsEnabledInChannel, groupCallsAllowed, isTabletView, shouldRenderBookmarks, shouldRenderChannelBanner,
}: ChannelProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
@ -244,6 +246,8 @@ const ChannelHeader = ({
return undefined;
}, [memberCount, customStatus, isCustomStatusExpired]);
const showBookmarkBar = isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks;
return (
<>
<NavigationHeader
@ -260,12 +264,19 @@ const ChannelHeader = ({
<View style={contextStyle}>
<RoundedHeaderContext/>
</View>
{isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks &&
{showBookmarkBar &&
<ChannelHeaderBookmarks
canAddBookmarks={canAddBookmarks}
channelId={channelId}
/>
}
{
shouldRenderChannelBanner &&
<ChannelBanner
channelId={channelId}
isTopItem={!showBookmarkBar}
/>
}
</>
);
};

View file

@ -8,11 +8,17 @@ import {observeCallStateInChannel, observeIsCallsEnabledInChannel} from '@calls/
import {observeCallsConfig} from '@calls/state';
import {Preferences} from '@constants';
import {withServerUrl} from '@context/server';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeChannel, observeCurrentChannel} from '@queries/servers/channel';
import {queryBookmarks} from '@queries/servers/channel_bookmark';
import {observeHasGMasDMFeature} from '@queries/servers/features';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
import {
observeConfigBooleanValue,
observeCurrentChannelId,
observeCurrentUserId,
observeLicense,
} from '@queries/servers/system';
import {shouldShowChannelBanner} from '@screens/channel/channel_feature_checks';
import Channel from './channel';
@ -49,6 +55,19 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
distinctUntilChanged(),
);
const license = observeLicense(database);
const bannerInfo = channelId.pipe(
switchMap((cId) => observeChannel(database, cId)),
switchMap((channel) => of$(channel?.bannerInfo)),
);
const includeChannelBanner = channelType.pipe(
combineLatestWith(license, bannerInfo),
switchMap(([channelTypeValue, licenseValue, bannerInfoValue]) =>
of$(shouldShowChannelBanner(channelTypeValue, licenseValue, bannerInfoValue)),
),
);
return {
channelId,
...observeCallStateInChannel(serverUrl, database, channelId),
@ -59,6 +78,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
currentUserId,
hasGMasDMFeature,
includeBookmarkBar,
includeChannelBanner,
};
});

View file

@ -3,7 +3,16 @@
import ReactNativeHapticFeedback, {HapticFeedbackTypes} from 'react-native-haptic-feedback';
import {getIntlShape, emptyFunction, generateId, hapticFeedback, sortByNewest, isBetaApp, type SortByCreatAt} from './';
import {
getIntlShape,
emptyFunction,
generateId,
hapticFeedback,
sortByNewest,
isBetaApp,
type SortByCreatAt,
getContrastingSimpleColor,
} from './';
// Mock necessary modules
jest.mock('expo-application', () => ({
@ -102,3 +111,81 @@ describe('isBetaApp', () => {
expect(isBetaApp).toBe(true);
});
});
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');
});
});

View file

@ -50,3 +50,37 @@ export const sortByNewest = (a: SortByCreatAt, b: SortByCreatAt) => {
};
export const isBetaApp = applicationId && applicationId.includes('rnbeta');
// getContrastingSimpleColor returns a contrasting color - either black or white, depending on the luminance
// of the supplied color. Both input and output colors are in hexadecimal color code.
// This function is copied from Mattermost webapp -
// https://github.com/mattermost/mattermost/blob/03d724b6a64dbb7bb41a9491f60d277244a8c488/webapp/channels/src/packages/mattermost-redux/src/utils/theme_utils.ts#L176
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
// 0.179 is the threshold for black and white contrast - https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio,
// The exact value was derived empirically by testing the contrast of black and white on various colors
return colorLuminance > 0.179 ? '#000000' : '#FFFFFF';
}

View file

@ -232,6 +232,7 @@
"channel_notification_preferences.reset_default": "Reset to default",
"channel_notification_preferences.thread_replies": "Thread replies",
"channel_notification_preferences.unmute_content": "Unmute channel",
"channel.banner.bottom_sheet.title": "Channel Banner",
"combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.",
"combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.",
"combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.",