diff --git a/app/actions/local/systems.ts b/app/actions/local/systems.ts
index ea2f15f2b8..12dc681ac8 100644
--- a/app/actions/local/systems.ts
+++ b/app/actions/local/systems.ts
@@ -68,3 +68,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
}
return [];
}
+
+export async function dismissAnnouncement(serverUrl: string, announcementText: string) {
+ try {
+ const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LAST_DISMISSED_BANNER, value: announcementText}], prepareRecordsOnly: false});
+ } catch (error) {
+ logError('An error occurred while dismissing an announcement', error);
+ }
+}
diff --git a/app/components/announcement_banner/announcement_banner.tsx b/app/components/announcement_banner/announcement_banner.tsx
new file mode 100644
index 0000000000..f390664232
--- /dev/null
+++ b/app/components/announcement_banner/announcement_banner.tsx
@@ -0,0 +1,187 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {useIntl} from 'react-intl';
+import {
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+
+import {dismissAnnouncement} from '@actions/local/systems';
+import CompassIcon from '@components/compass_icon';
+import RemoveMarkdown from '@components/remove_markdown';
+import {ANNOUNCEMENT_BAR_HEIGHT} from '@constants/view';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {bottomSheet} from '@screens/navigation';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+import ExpandedAnnouncementBanner from './expanded_announcement_banner';
+
+type Props = {
+ bannerColor: string;
+ bannerDismissed: boolean;
+ bannerEnabled: boolean;
+ bannerText?: string;
+ bannerTextColor?: string;
+ allowDismissal: boolean;
+}
+
+const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
+ background: {
+ backgroundColor: theme.sidebarBg,
+ },
+ bannerContainer: {
+ flex: 1,
+ paddingHorizontal: 10,
+ overflow: 'hidden',
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginHorizontal: 8,
+ borderRadius: 7,
+ },
+ wrapper: {
+ flexDirection: 'row',
+ flex: 1,
+ overflow: 'hidden',
+ },
+ bannerTextContainer: {
+ flex: 1,
+ flexGrow: 1,
+ marginRight: 5,
+ textAlign: 'center',
+ },
+ bannerText: {
+ ...typography('Body', 100, 'SemiBold'),
+ },
+}));
+
+const CLOSE_BUTTON_ID = 'announcement-close';
+
+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) from ./expanded_announcement_banner.tsx
+const TEXT_CONTAINER_HEIGHT = 150;
+const DISMISS_BUTTON_HEIGHT = BUTTON_HEIGHT + 10; // Top margin from ./expanded_announcement_banner.tsx
+
+const SNAP_POINT_WITHOUT_DISMISS = TITLE_HEIGHT + BUTTON_HEIGHT + MARGINS + TEXT_CONTAINER_HEIGHT;
+
+const AnnouncementBanner = ({
+ bannerColor,
+ bannerDismissed,
+ bannerEnabled,
+ bannerText = '',
+ bannerTextColor = '#000',
+ allowDismissal,
+}: Props) => {
+ const intl = useIntl();
+ const serverUrl = useServerUrl();
+ const height = useSharedValue(0);
+ const theme = useTheme();
+ const [visible, setVisible] = useState(false);
+ const style = getStyle(theme);
+
+ const renderContent = useCallback(() => (
+
+ ), [allowDismissal, bannerText]);
+
+ const handlePress = useCallback(() => {
+ const title = intl.formatMessage({
+ id: 'mobile.announcement_banner.title',
+ defaultMessage: 'Announcement',
+ });
+
+ let snapPoint = SNAP_POINT_WITHOUT_DISMISS;
+ if (allowDismissal) {
+ snapPoint += DISMISS_BUTTON_HEIGHT;
+ }
+
+ bottomSheet({
+ closeButtonId: CLOSE_BUTTON_ID,
+ title,
+ renderContent,
+ snapPoints: [snapPoint, 10],
+ theme,
+ });
+ }, [theme.sidebarHeaderTextColor, intl.locale, renderContent, allowDismissal]);
+
+ const handleDismiss = useCallback(() => {
+ dismissAnnouncement(serverUrl, bannerText);
+ }, [serverUrl, bannerText]);
+
+ useEffect(() => {
+ const showBanner = bannerEnabled && !bannerDismissed && Boolean(bannerText);
+ setVisible(showBanner);
+ }, [bannerDismissed, bannerEnabled, bannerText]);
+
+ useEffect(() => {
+ height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, {
+ duration: 200,
+ });
+ }, [visible]);
+
+ const bannerStyle = useAnimatedStyle(() => ({
+ height: height.value,
+ }));
+
+ const bannerTextContainerStyle = useMemo(() => [style.bannerTextContainer, {
+ color: bannerTextColor,
+ }], [style, bannerTextColor]);
+
+ return (
+
+
+ {visible &&
+ <>
+
+
+
+ {' '}
+
+
+
+ {allowDismissal && (
+
+
+
+ )
+ }
+ >
+ }
+
+
+ );
+};
+
+export default AnnouncementBanner;
diff --git a/app/components/announcement_banner/expanded_announcement_banner.tsx b/app/components/announcement_banner/expanded_announcement_banner.tsx
new file mode 100644
index 0000000000..22de72d027
--- /dev/null
+++ b/app/components/announcement_banner/expanded_announcement_banner.tsx
@@ -0,0 +1,136 @@
+// 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, View} from 'react-native';
+import Button from 'react-native-button';
+import {ScrollView} from 'react-native-gesture-handler';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+import {dismissAnnouncement} from '@actions/local/systems';
+import FormattedText from '@components/formatted_text';
+import Markdown from '@components/markdown';
+import {Screens} from '@constants';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {useIsTablet} from '@hooks/device';
+import {dismissBottomSheet} from '@screens/navigation';
+import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
+import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+type Props = {
+ allowDismissal: boolean;
+ bannerText: string;
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ flex: 1,
+ },
+ scrollContainer: {
+ flex: 1,
+ marginTop: 12,
+ marginBottom: 24,
+ },
+ baseTextStyle: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 100, 'Regular'),
+ },
+ title: {
+ color: theme.centerChannelColor,
+ ...typography('Heading', 600, 'SemiBold'),
+ },
+ };
+});
+
+const close = () => {
+ dismissBottomSheet();
+};
+
+const ExpandedAnnouncementBanner = ({
+ allowDismissal,
+ bannerText,
+}: Props) => {
+ const theme = useTheme();
+ const style = getStyleSheet(theme);
+ const serverUrl = useServerUrl();
+ const isTablet = useIsTablet();
+ const intl = useIntl();
+ const insets = useSafeAreaInsets();
+
+ const dismissBanner = useCallback(() => {
+ dismissAnnouncement(serverUrl, bannerText);
+ close();
+ }, [bannerText]);
+
+ const buttonStyles = useMemo(() => {
+ return {
+ okay: {
+ button: buttonBackgroundStyle(theme, 'lg', 'primary'),
+ text: buttonTextStyle(theme, 'lg', 'primary'),
+ },
+ dismiss: {
+ button: [{marginTop: 10}, buttonBackgroundStyle(theme, 'lg', 'link')],
+ text: buttonTextStyle(theme, 'lg', 'link'),
+ },
+ };
+ }, [theme]);
+
+ const containerStyle = useMemo(() => {
+ return [style.container, {marginBottom: insets.bottom + 10}];
+ }, [style, insets.bottom]);
+
+ return (
+
+ {!isTablet && (
+
+ {intl.formatMessage({
+ id: 'mobile.announcement_banner.title',
+ defaultMessage: 'Announcement',
+ })}
+
+ )}
+
+
+
+
+ {allowDismissal && (
+
+ )}
+
+ );
+};
+
+export default ExpandedAnnouncementBanner;
diff --git a/app/components/announcement_banner/index.ts b/app/components/announcement_banner/index.ts
new file mode 100644
index 0000000000..fd53e13433
--- /dev/null
+++ b/app/components/announcement_banner/index.ts
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import {of as of$, combineLatest} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import {observeConfigBooleanValue, observeConfigValue, observeLastDismissedAnnouncement, observeLicense} from '@queries/servers/system';
+
+import AnnouncementBanner from './announcement_banner';
+
+import type {WithDatabaseArgs} from '@typings/database/database';
+
+const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
+ const lastDismissed = observeLastDismissedAnnouncement(database);
+ const bannerText = observeConfigValue(database, 'BannerText');
+ const allowDismissal = observeConfigBooleanValue(database, 'AllowBannerDismissal');
+
+ const bannerDismissed = combineLatest([lastDismissed, bannerText, allowDismissal]).pipe(
+ switchMap(([ld, bt, abd]) => of$(abd && (ld === bt))),
+ );
+
+ const license = observeLicense(database);
+ const enableBannerConfig = observeConfigBooleanValue(database, 'EnableBanner');
+ const bannerEnabled = combineLatest([license, enableBannerConfig]).pipe(
+ switchMap(([lcs, cfg]) => of$(cfg && lcs?.IsLicensed === 'true')),
+ );
+ return {
+ bannerColor: observeConfigValue(database, 'BannerColor'),
+ bannerEnabled,
+ bannerText,
+ bannerTextColor: observeConfigValue(database, 'BannerTextColor'),
+ bannerDismissed,
+ allowDismissal,
+ };
+});
+
+export default withDatabase(enhanced(AnnouncementBanner));
diff --git a/app/components/remove_markdown/index.tsx b/app/components/remove_markdown/index.tsx
index b677fafcde..bcd1f4f4b6 100644
--- a/app/components/remove_markdown/index.tsx
+++ b/app/components/remove_markdown/index.tsx
@@ -3,7 +3,7 @@
import {Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
-import React, {ReactElement, useCallback, useRef} from 'react';
+import React, {ReactElement, useCallback, useMemo, useRef} from 'react';
import {StyleProp, Text, TextStyle} from 'react-native';
import Emoji from '@components/emoji';
@@ -34,9 +34,9 @@ const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, textStyl
return '\n';
}, []);
- const renderText = ({literal}: {literal: string}) => {
+ const renderText = useCallback(({literal}: {literal: string}) => {
return {literal};
- };
+ }, [textStyle]);
const renderNull = () => {
return null;
@@ -85,7 +85,7 @@ const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, textStyl
};
const parser = useRef(new Parser()).current;
- const renderer = useRef(createRenderer()).current;
+ const renderer = useMemo(createRenderer, [renderText, renderEmoji]);
const ast = parser.parse(value);
return renderer.render(ast) as ReactElement;
diff --git a/app/constants/database.ts b/app/constants/database.ts
index 1d3a72369a..4e0f802ec1 100644
--- a/app/constants/database.ts
+++ b/app/constants/database.ts
@@ -58,6 +58,7 @@ export const SYSTEM_IDENTIFIERS = {
CURRENT_USER_ID: 'currentUserId',
DATA_RETENTION_POLICIES: 'dataRetentionPolicies',
EXPANDED_LINKS: 'expandedLinks',
+ LAST_DISMISSED_BANNER: 'lastDismissedBanner',
LICENSE: 'license',
ONLY_UNREADS: 'onlyUnreads',
PUSH_VERIFICATION_STATUS: 'pushVerificationStatus',
diff --git a/app/constants/view.ts b/app/constants/view.ts
index c1ef40d8a0..fbc4aa8025 100644
--- a/app/constants/view.ts
+++ b/app/constants/view.ts
@@ -27,6 +27,8 @@ export const CALL_ERROR_BAR_HEIGHT = 62;
export const QUICK_OPTIONS_HEIGHT = 270;
+export const ANNOUNCEMENT_BAR_HEIGHT = 40;
+
export default {
BOTTOM_TAB_HEIGHT,
BOTTOM_TAB_ICON_SIZE,
diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts
index 562ed9ed10..455af01a4c 100644
--- a/app/queries/servers/system.ts
+++ b/app/queries/servers/system.ts
@@ -447,6 +447,12 @@ export const getExpiredSession = async (database: Database) => {
}
};
+export const observeLastDismissedAnnouncement = (database: Database) => {
+ return querySystemValue(database, SYSTEM_IDENTIFIERS.LAST_DISMISSED_BANNER).observeWithColumns(['value']).pipe(
+ switchMap((list) => of$(list[0]?.value)),
+ );
+};
+
export const observeCanUploadFiles = (database: Database) => {
const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments');
const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload');
diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx
index e82543506b..56c353af31 100644
--- a/app/screens/home/channel_list/channel_list.tsx
+++ b/app/screens/home/channel_list/channel_list.tsx
@@ -5,10 +5,11 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
-import {BackHandler, DeviceEventEmitter, StyleSheet, ToastAndroid} from 'react-native';
+import {BackHandler, DeviceEventEmitter, StyleSheet, ToastAndroid, View} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
+import AnnouncementBanner from '@components/announcement_banner';
import FreezeScreen from '@components/freeze_screen';
import TeamSidebar from '@components/team_sidebar';
import {Navigation as NavigationConstants, Screens} from '@constants';
@@ -28,6 +29,7 @@ type ChannelProps = {
isCRTEnabled: boolean;
teamsCount: number;
time?: number;
+ isLicensed: boolean;
};
const edges: Edge[] = ['bottom', 'left', 'right'];
@@ -37,6 +39,9 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row',
},
+ flex: {
+ flex: 1,
+ },
});
let backPressedCount = 0;
@@ -120,31 +125,36 @@ const ChannelListScreen = (props: ChannelProps) => {
return (
- {}
+
- {canAddOtherServers && }
-
-
-
- {isTablet &&
-
- }
-
+ {props.isLicensed &&
+
+ }
+
+ {canAddOtherServers && }
+
+
+
+ {isTablet &&
+
+ }
+
+
);
diff --git a/app/screens/home/channel_list/index.ts b/app/screens/home/channel_list/index.ts
index f11d0cfac0..00342c881b 100644
--- a/app/screens/home/channel_list/index.ts
+++ b/app/screens/home/channel_list/index.ts
@@ -7,7 +7,7 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryAllMyChannelsForTeam} from '@queries/servers/channel';
-import {observeCurrentTeamId} from '@queries/servers/system';
+import {observeCurrentTeamId, observeLicense} from '@queries/servers/system';
import {queryMyTeams} from '@queries/servers/team';
import {observeIsCRTEnabled} from '@queries/servers/thread';
@@ -21,6 +21,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
channelsCount: observeCurrentTeamId(database).pipe(
switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount() : of$(0))),
),
+ isLicensed: observeLicense(database).pipe(
+ switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))),
+ ),
}));
export default withDatabase(enhanced(ChannelsList));