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));