forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
e729ffce7a
commit
e2bd4fbf51
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
187
app/components/announcement_banner/announcement_banner.tsx
Normal file
187
app/components/announcement_banner/announcement_banner.tsx
Normal 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;
|
||||
@@ -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;
|
||||
39
app/components/announcement_banner/index.ts
Normal file
39
app/components/announcement_banner/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user