From 738c7e6b1d238df04417084e042a14fe4bb9b7f2 Mon Sep 17 00:00:00 2001
From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Date: Tue, 8 Apr 2025 11:15:18 +0530
Subject: [PATCH] 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
---
.../expanded_announcement_banner.tsx | 12 +-
.../channel_list_row/index.test.tsx | 1 +
app/constants/license.ts | 1 +
app/constants/screens.ts | 2 +
app/constants/view.ts | 1 +
.../components/floating_call_container.tsx | 6 +-
app/screens/channel/channel.tsx | 4 +
.../channel/channel_feature_checks.test.tsx | 70 ++++++
app/screens/channel/channel_feature_checks.ts | 16 ++
.../channel_banner/channel_banner.test.tsx | 216 ++++++++++++++++++
.../header/channel_banner/channel_banner.tsx | 145 ++++++++++++
.../channel/header/channel_banner/index.ts | 26 +++
app/screens/channel/header/header.tsx | 15 +-
app/screens/channel/index.tsx | 24 +-
app/utils/general/index.test.ts | 89 +++++++-
app/utils/general/index.ts | 34 +++
assets/base/i18n/en.json | 1 +
17 files changed, 652 insertions(+), 11 deletions(-)
create mode 100644 app/screens/channel/channel_feature_checks.test.tsx
create mode 100644 app/screens/channel/channel_feature_checks.ts
create mode 100644 app/screens/channel/header/channel_banner/channel_banner.test.tsx
create mode 100644 app/screens/channel/header/channel_banner/channel_banner.tsx
create mode 100644 app/screens/channel/header/channel_banner/index.ts
diff --git a/app/components/announcement_banner/expanded_announcement_banner.tsx b/app/components/announcement_banner/expanded_announcement_banner.tsx
index e455328f3..50e8defe4 100644
--- a/app/components/announcement_banner/expanded_announcement_banner.tsx
+++ b/app/components/announcement_banner/expanded_announcement_banner.tsx
@@ -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 (
{!isTablet && (
- {intl.formatMessage({
- id: 'mobile.announcement_banner.title',
- defaultMessage: 'Announcement',
- })}
+ {heading}
)}
{
group_constrained: null,
shared: true,
};
+
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
diff --git a/app/constants/license.ts b/app/constants/license.ts
index 36edb975e..e62e2967b 100644
--- a/app/constants/license.ts
+++ b/app/constants/license.ts
@@ -8,6 +8,7 @@ export default {
Starter: 'starter',
Professional: 'professional',
Enterprise: 'enterprise',
+ Premium: 'premium',
},
SelfHostedProducts: {
STARTER: 'starter',
diff --git a/app/constants/screens.ts b/app/constants/screens.ts
index 59b107974..fdc49b1a2 100644
--- a/app/constants/screens.ts
+++ b/app/constants/screens.ts
@@ -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([
diff --git a/app/constants/view.ts b/app/constants/view.ts
index 5e77b1996..6a33c9816 100644
--- a/app/constants/view.ts
+++ b/app/constants/view.ts
@@ -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,
diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx
index 3ab1741f5..25151cd81 100644
--- a/app/products/calls/components/floating_call_container.tsx
+++ b/app/products/calls/components/floating_call_container.tsx
@@ -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,
diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx
index 9b1b2fc80..058844f38 100644
--- a/app/screens/channel/channel.tsx
+++ b/app/screens/channel/channel.tsx
@@ -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 &&
@@ -151,6 +154,7 @@ const Channel = ({
showIncomingCalls={showIncomingCalls}
isInACall={isInACall}
includeBookmarkBar={includeBookmarkBar}
+ includeChannelBanner={includeChannelBanner}
/>
}
diff --git a/app/screens/channel/channel_feature_checks.test.tsx b/app/screens/channel/channel_feature_checks.test.tsx
new file mode 100644
index 000000000..d1dab0f36
--- /dev/null
+++ b/app/screens/channel/channel_feature_checks.test.tsx
@@ -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);
+ });
+});
diff --git a/app/screens/channel/channel_feature_checks.ts b/app/screens/channel/channel_feature_checks.ts
new file mode 100644
index 000000000..8d9deb521
--- /dev/null
+++ b/app/screens/channel/channel_feature_checks.ts
@@ -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;
+}
diff --git a/app/screens/channel/header/channel_banner/channel_banner.test.tsx b/app/screens/channel/header/channel_banner/channel_banner.test.tsx
new file mode 100644
index 000000000..9d991e2ca
--- /dev/null
+++ b/app/screens/channel/header/channel_banner/channel_banner.test.tsx
@@ -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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ expect(screen.getByText('Test Banner Text')).toBeVisible();
+ });
+
+ it('opens bottom sheet when banner is pressed', async () => {
+ renderWithEverything(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ expect(queryByText('Test Banner Text')).not.toBeVisible();
+ });
+
+ it('does not render when channel ID is of non existent channel', async () => {
+ const {queryByText} = renderWithEverything(
+ ,
+ {database},
+ );
+
+ expect(queryByText('Test Banner Text')).not.toBeVisible();
+ });
+});
diff --git a/app/screens/channel/header/channel_banner/channel_banner.tsx b/app/screens/channel/header/channel_banner/channel_banner.tsx
new file mode 100644
index 000000000..8b5d3534b
--- /dev/null
+++ b/app/screens/channel/header/channel_banner/channel_banner.tsx
@@ -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 = () => (
+
+ );
+
+ 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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/screens/channel/header/channel_banner/index.ts b/app/screens/channel/header/channel_banner/index.ts
new file mode 100644
index 000000000..f84a89e71
--- /dev/null
+++ b/app/screens/channel/header/channel_banner/index.ts
@@ -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));
diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx
index 391b34a9d..7f57d12ea 100644
--- a/app/screens/channel/header/header.tsx
+++ b/app/screens/channel/header/header.tsx
@@ -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 (
<>
- {isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks &&
+ {showBookmarkBar &&
}
+ {
+ shouldRenderChannelBanner &&
+
+ }
>
);
};
diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx
index f7b20dc1d..1fac0b2f9 100644
--- a/app/screens/channel/index.tsx
+++ b/app/screens/channel/index.tsx
@@ -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,
};
});
diff --git a/app/utils/general/index.test.ts b/app/utils/general/index.test.ts
index 0051de6ee..ef606edaf 100644
--- a/app/utils/general/index.test.ts
+++ b/app/utils/general/index.test.ts
@@ -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');
+ });
+});
diff --git a/app/utils/general/index.ts b/app/utils/general/index.ts
index 5e4b99a2a..8bcd319c3 100644
--- a/app/utils/general/index.ts
+++ b/app/utils/general/index.ts
@@ -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';
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 6366c33ad..ee2a7fbee 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -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}.",