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:
parent
52330847e2
commit
738c7e6b1d
17 changed files with 652 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ describe('components/channel_list_row', () => {
|
|||
group_constrained: null,
|
||||
shared: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export default {
|
|||
Starter: 'starter',
|
||||
Professional: 'professional',
|
||||
Enterprise: 'enterprise',
|
||||
Premium: 'premium',
|
||||
},
|
||||
SelfHostedProducts: {
|
||||
STARTER: 'starter',
|
||||
|
|
|
|||
|
|
@ -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>([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
70
app/screens/channel/channel_feature_checks.test.tsx
Normal file
70
app/screens/channel/channel_feature_checks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
16
app/screens/channel/channel_feature_checks.ts
Normal file
16
app/screens/channel/channel_feature_checks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
145
app/screens/channel/header/channel_banner/channel_banner.tsx
Normal file
145
app/screens/channel/header/channel_banner/channel_banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
app/screens/channel/header/channel_banner/index.ts
Normal file
26
app/screens/channel/header/channel_banner/index.ts
Normal 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));
|
||||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue