Add announcement banner (#6728)

* Add announcement banner

* Move config to its own table

* Add new config behaviour to iOS share extensions

* Fix test

* Add style changes

* Minor style fixes

* Address design feedback

* Address feedback

* Only render the announcement banner if container if licensed

Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
This commit is contained in:
Daniel Espino García
2022-11-17 19:13:20 +01:00
committed by GitHub
parent e729ffce7a
commit e2bd4fbf51
10 changed files with 420 additions and 27 deletions

View File

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

View File

@@ -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(() => (
<ExpandedAnnouncementBanner
allowDismissal={allowDismissal}
bannerText={bannerText}
/>
), [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 (
<Animated.View
style={[style.background, bannerStyle]}
>
<View
style={[style.bannerContainer, {backgroundColor: bannerColor}]}
>
{visible &&
<>
<TouchableOpacity
onPress={handlePress}
style={style.wrapper}
>
<Text
style={bannerTextContainerStyle}
ellipsizeMode='tail'
numberOfLines={1}
>
<CompassIcon
color={bannerTextColor}
name='information-outline'
size={18}
/>
{' '}
<RemoveMarkdown
value={bannerText}
textStyle={style.bannerText}
/>
</Text>
</TouchableOpacity>
{allowDismissal && (
<TouchableOpacity
onPress={handleDismiss}
>
<CompassIcon
color={changeOpacity(bannerTextColor, 0.56)}
name='close'
size={18}
/>
</TouchableOpacity>
)
}
</>
}
</View>
</Animated.View>
);
};
export default AnnouncementBanner;

View File

@@ -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 (
<View style={containerStyle}>
{!isTablet && (
<Text style={style.title}>
{intl.formatMessage({
id: 'mobile.announcement_banner.title',
defaultMessage: 'Announcement',
})}
</Text>
)}
<ScrollView
style={style.scrollContainer}
>
<Markdown
baseTextStyle={style.baseTextStyle}
blockStyles={getMarkdownBlockStyles(theme)}
disableGallery={true}
textStyles={getMarkdownTextStyles(theme)}
value={bannerText}
theme={theme}
location={Screens.BOTTOM_SHEET}
/>
</ScrollView>
<Button
containerStyle={buttonStyles.okay.button}
onPress={close}
>
<FormattedText
id='announcment_banner.okay'
defaultMessage={'Okay'}
style={buttonStyles.okay.text}
/>
</Button>
{allowDismissal && (
<Button
containerStyle={buttonStyles.dismiss.button}
onPress={dismissBanner}
>
<FormattedText
id='announcment_banner.dismiss'
defaultMessage={'Dismiss announcement'}
style={buttonStyles.dismiss.text}
/>
</Button>
)}
</View>
);
};
export default ExpandedAnnouncementBanner;

View File

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

View File

@@ -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 <Text style={textStyle}>{literal}</Text>;
};
}, [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;

View File

@@ -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',

View File

@@ -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,

View File

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

View File

@@ -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 (
<FreezeScreen freeze={!isFocused}>
{<Animated.View style={top}/>}
<Animated.View style={top}/>
<SafeAreaView
style={styles.content}
style={styles.flex}
edges={edges}
testID='channel_list.screen'
>
{canAddOtherServers && <Servers/>}
<Animated.View
style={[styles.content, animated]}
>
<TeamSidebar
iconPad={canAddOtherServers}
teamsCount={props.teamsCount}
/>
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
isCRTEnabled={props.isCRTEnabled}
isTablet={isTablet}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
/>
{isTablet &&
<AdditionalTabletView/>
}
</Animated.View>
{props.isLicensed &&
<AnnouncementBanner/>
}
<View style={styles.content}>
{canAddOtherServers && <Servers/>}
<Animated.View
style={[styles.content, animated]}
>
<TeamSidebar
iconPad={canAddOtherServers}
teamsCount={props.teamsCount}
/>
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
isCRTEnabled={props.isCRTEnabled}
isTablet={isTablet}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
/>
{isTablet &&
<AdditionalTabletView/>
}
</Animated.View>
</View>
</SafeAreaView>
</FreezeScreen>
);

View File

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