[Gekidou] Channel Info screen (#6330)

* Channel Info screen

* Delete the channel & related data when archiving while viewing archived channels is off

* feedback review

* UX feedback

* Add missing isOptionItem prop
This commit is contained in:
Elias Nahum
2022-06-02 16:09:12 -04:00
committed by GitHub
parent 62d2e20441
commit a0f25f0e3b
69 changed files with 2329 additions and 168 deletions

View File

@@ -6,7 +6,7 @@ import {Model} from '@nozbe/watermelondb';
import {IntlShape} from 'react-intl';
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
import {removeCurrentUserFromChannel, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {switchToGlobalThreads} from '@actions/local/thread';
import {General, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
@@ -1234,3 +1234,97 @@ export const toggleMuteChannel = async (serverUrl: string, channelId: string, sh
return {error};
}
};
export const archiveChannel = async (serverUrl: string, channelId: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const {database} = operator;
const config = await getConfig(database);
EphemeralStore.addArchivingChannel(channelId);
await client.deleteChannel(channelId);
if (config?.ExperimentalViewArchivedChannels === 'true') {
await setChannelDeleteAt(serverUrl, channelId, Date.now());
} else {
removeCurrentUserFromChannel(serverUrl, channelId);
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeArchivingChannel(channelId);
}
};
export const unarchiveChannel = async (serverUrl: string, channelId: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
EphemeralStore.addArchivingChannel(channelId);
await client.unarchiveChannel(channelId);
await setChannelDeleteAt(serverUrl, channelId, Date.now());
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeArchivingChannel(channelId);
}
};
export const convertChannelToPrivate = async (serverUrl: string, channelId: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const {database} = operator;
const channel = await getChannelById(database, channelId);
if (channel) {
EphemeralStore.addConvertingChannel(channelId);
}
await client.convertChannelToPrivate(channelId);
if (channel) {
channel.prepareUpdate((c) => {
c.type = General.PRIVATE_CHANNEL;
});
await operator.batchRecords([channel]);
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeConvertingChannel(channelId);
}
};

View File

@@ -68,6 +68,10 @@ export async function handleChannelCreatedEvent(serverUrl: string, msg: any) {
export async function handleChannelUnarchiveEvent(serverUrl: string, msg: any) {
try {
if (EphemeralStore.isArchivingChannel(msg.data.channel_id)) {
return;
}
await setChannelDeleteAt(serverUrl, msg.data.channel_id, 0);
} catch {
// do nothing
@@ -82,6 +86,10 @@ export async function handleChannelConvertedEvent(serverUrl: string, msg: any) {
try {
const channelId = msg.data.channel_id;
if (EphemeralStore.isConvertingChannel(channelId)) {
return;
}
const {channel} = await fetchChannelById(serverUrl, channelId);
if (channel) {
operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
@@ -394,7 +402,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke
try {
const {database} = operator;
const {channel_id: channelId, delete_at: deleteAt} = msg.data;
if (EphemeralStore.isLeavingChannel(channelId)) {
if (EphemeralStore.isLeavingChannel(channelId) || EphemeralStore.isArchivingChannel(channelId)) {
return;
}

View File

@@ -7,22 +7,27 @@ import {StyleProp, ViewStyle} from 'react-native';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {dismissBottomSheet, showModal} from '@screens/navigation';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
type Props = {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
inModal?: boolean;
testID?: string;
}
const AddPeopleBox = ({channelId, containerStyle, testID}: Props) => {
const AddPeopleBox = ({channelId, containerStyle, inModal, testID}: Props) => {
const intl = useIntl();
const onAddPeople = useCallback(async () => {
await dismissBottomSheet();
const title = intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'});
if (inModal) {
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
return;
}
await dismissBottomSheet();
showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
}, [intl, channelId]);
}, [intl, channelId, inModal]);
return (
<OptionBox

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';
import AddPeopleBox from '@components/channel_actions/add_people_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import MutedBox from '@components/channel_actions/mute_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
import {General} from '@constants';
import {dismissBottomSheet} from '@screens/navigation';
type Props = {
channelId: string;
channelType?: string;
inModal?: boolean;
}
const OPTIONS_HEIGHT = 62;
const DIRECT_CHANNELS: string[] = [General.DM_CHANNEL, General.GM_CHANNEL];
const styles = StyleSheet.create({
wrapper: {
flexDirection: 'row',
height: OPTIONS_HEIGHT,
},
separator: {
width: 8,
},
});
const ChannelActions = ({channelId, channelType, inModal = false}: Props) => {
const onCopyLinkAnimationEnd = useCallback(() => {
if (!inModal) {
requestAnimationFrame(async () => {
await dismissBottomSheet();
});
}
}, [inModal]);
return (
<View style={styles.wrapper}>
<FavoriteBox
channelId={channelId}
showSnackBar={!inModal}
/>
<View style={styles.separator}/>
<MutedBox
channelId={channelId}
showSnackBar={!inModal}
/>
<View style={styles.separator}/>
{channelType && DIRECT_CHANNELS.includes(channelType) &&
<SetHeaderBox
channelId={channelId}
inModal={inModal}
/>
}
{channelType && !DIRECT_CHANNELS.includes(channelType) &&
<>
<AddPeopleBox
channelId={channelId}
inModal={inModal}
/>
<View style={styles.separator}/>
<CopyChannelLinkBox
channelId={channelId}
onAnimationEnd={onCopyLinkAnimationEnd}
/>
</>
}
</View>
);
};
export default ChannelActions;

View File

@@ -1,9 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// 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$} from 'rxjs';
@@ -11,7 +8,7 @@ import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import ChannelQuickAction from './quick_actions';
import ChannelActions from './channel_actions';
import type {WithDatabaseArgs} from '@typings/database/database';
@@ -29,4 +26,4 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
};
});
export default withDatabase(enhanced(ChannelQuickAction));
export default withDatabase(enhanced(ChannelActions));

View File

@@ -5,9 +5,11 @@ import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleProp, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import OptionBox from '@components/option_box';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, showModal} from '@screens/navigation';
type Props = {
@@ -19,12 +21,25 @@ type Props = {
const InfoBox = ({channelId, containerStyle, showAsLabel = false, testID}: Props) => {
const intl = useIntl();
const theme = useTheme();
const onViewInfo = useCallback(async () => {
await dismissBottomSheet();
const title = intl.formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel Info'});
showModal(Screens.CHANNEL_INFO, title, {channelId});
}, [intl, channelId]);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-channel-info';
const options = {
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
},
};
showModal(Screens.CHANNEL_INFO, title, {channelId, closeButtonId}, options);
}, [intl, channelId, theme]);
if (showAsLabel) {
return (

View File

@@ -7,9 +7,11 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observeCurrentUser} from '@queries/servers/user';
import LeaveChannelLabel from './leave_channel_label';
@@ -20,7 +22,16 @@ type OwnProps = WithDatabaseArgs & {
}
const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => {
const currentUser = observeCurrentUser(database);
const channel = observeChannel(database, channelId);
const canLeave = channel.pipe(
combineLatestWith(currentUser),
switchMap(([ch, u]) => {
const isDefaultChannel = ch?.name === General.DEFAULT_CHANNEL;
return of$(!isDefaultChannel || (isDefaultChannel && u?.isGuest));
}),
);
const displayName = channel.pipe(
switchMap((c) => of$(c?.displayName)),
);
@@ -29,6 +40,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
);
return {
canLeave,
displayName,
type,
};

View File

@@ -7,6 +7,7 @@ import {Alert} from 'react-native';
import {leaveChannel} from '@actions/remote/channel';
import {setDirectChannelVisible} from '@actions/remote/preference';
import OptionItem from '@components/option_item';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
@@ -14,13 +15,15 @@ import {useIsTablet} from '@hooks/device';
import {dismissAllModals, dismissBottomSheet, popToRoot} from '@screens/navigation';
type Props = {
isOptionItem?: boolean;
canLeave: boolean;
channelId: string;
displayName?: string;
type?: string;
testID?: string;
}
const LeaveChanelLabel = ({channelId, displayName, type, testID}: Props) => {
const LeaveChanelLabel = ({canLeave, channelId, displayName, isOptionItem, type, testID}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
@@ -134,27 +137,45 @@ const LeaveChanelLabel = ({channelId, displayName, type, testID}: Props) => {
}
};
if (!displayName || !type) {
if (!displayName || !type || !canLeave) {
return null;
}
let leaveText = '';
let leaveText;
let icon;
switch (type) {
case General.DM_CHANNEL:
leaveText = intl.formatMessage({id: 'channel_info.close_dm', defaultMessage: 'Close direct message'});
icon = 'close';
break;
case General.GM_CHANNEL:
leaveText = intl.formatMessage({id: 'channel_info.close_gm', defaultMessage: 'Close group message'});
icon = 'close';
break;
default:
leaveText = intl.formatMessage({id: 'channel_info.leave_channel', defaultMessage: 'Leave channel'});
icon = 'exit-to-app';
break;
}
if (isOptionItem) {
return (
<OptionItem
action={onLeave}
destructive={true}
icon={icon}
label={leaveText}
testID={testID}
type='default'
/>
);
}
return (
<SlideUpPanelItem
destructive={true}
icon='close'
icon={icon}
onPress={onLeave}
text={leaveText}
testID={testID}

View File

@@ -7,21 +7,27 @@ import {StyleProp, ViewStyle} from 'react-native';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {dismissBottomSheet, showModal} from '@screens/navigation';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
type Props = {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
isHeaderSet: boolean;
inModal?: boolean;
testID?: string;
}
const SetHeaderBox = ({channelId, containerStyle, isHeaderSet, testID}: Props) => {
const SetHeaderBox = ({channelId, containerStyle, isHeaderSet, inModal, testID}: Props) => {
const intl = useIntl();
const onSetHeader = useCallback(async () => {
await dismissBottomSheet();
const title = intl.formatMessage({id: 'screens.channel_edit_header', defaultMessage: 'Edit Channel Header'});
if (inModal) {
goToScreen(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, headerOnly: true});
return;
}
await dismissBottomSheet();
showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, headerOnly: true});
}, [intl, channelId]);

View File

@@ -16,6 +16,7 @@ import FormattedTime from '@components/formatted_time';
import {Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeCurrentUser} from '@queries/servers/user';
import {getCurrentMomentForTimezone} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -61,6 +62,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
<FormattedText
id='custom_status.expiry_time.today'
defaultMessage='Today'
style={[styles.text, textStyles]}
/>
);
} else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) {
@@ -68,6 +70,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
<FormattedText
id='custom_status.expiry_time.tomorrow'
defaultMessage='Tomorrow'
style={[styles.text, textStyles]}
/>
);
} else if (expiryMomentTime.isAfter(tomorrowEndTime)) {
@@ -83,11 +86,12 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
format={format}
timezone={timezone}
value={expiryMomentTime.toDate()}
style={[styles.text, textStyles]}
/>
);
}
const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime));
const useTime = showTimeCompulsory ?? !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime));
return (
<Text
@@ -99,6 +103,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
<FormattedText
id='custom_status.expiry.until'
defaultMessage='Until'
style={[styles.text, textStyles]}
/>
)}
{showPrefix && ' '}
@@ -109,6 +114,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
<FormattedText
id='custom_status.expiry.at'
defaultMessage='at'
style={[styles.text, textStyles]}
/>
{' '}
</>
@@ -118,6 +124,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
isMilitaryTime={isMilitaryTime}
timezone={timezone || ''}
value={expiryMomentTime.toDate()}
style={[styles.text, textStyles]}
/>
)}
{withinBrackets && ')'}
@@ -126,6 +133,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: observeCurrentUser(database),
isMilitaryTime: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']).pipe(
switchMap(

View File

@@ -132,8 +132,8 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
testID,
...props
}: FloatingTextInputProps, ref) => {
const [focused, setIsFocused] = useState(Boolean(value) && editable);
const [focusedLabel, setIsFocusLabel] = useState<boolean | undefined>(Boolean(placeholder || value));
const [focused, setIsFocused] = useState(false);
const [focusedLabel, setIsFocusLabel] = useState<boolean | undefined>();
const inputRef = useRef<TextInput>(null);
const debouncedOnFocusTextInput = debounce(setIsFocusLabel, 500, {leading: true, trailing: false});
const styles = getStyleSheet(theme);

View File

@@ -41,15 +41,21 @@ type MarkdownProps = {
channelMentions?: ChannelMentions;
disableAtChannelMentionHighlight?: boolean;
disableAtMentions?: boolean;
disableBlockQuote?: boolean;
disableChannelLink?: boolean;
disableCodeBlock?: boolean;
disableGallery?: boolean;
disableHashtags?: boolean;
disableHeading?: boolean;
disableQuotes?: boolean;
disableTables?: boolean;
enableLatex: boolean;
enableInlineLatex: boolean;
imagesMetadata?: Record<string, PostImage>;
isEdited?: boolean;
isReplyPost?: boolean;
isSearchResult?: boolean;
layoutHeight?: number;
layoutWidth?: number;
location?: string;
mentionKeys?: UserMentionKey[];
@@ -87,6 +93,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
atMentionOpacity: {
opacity: 1,
},
bold: {
fontWeight: '600',
},
};
});
@@ -116,9 +125,10 @@ const computeTextStyle = (textStyles: MarkdownTextStyles, baseStyle: StyleProp<T
const Markdown = ({
autolinkedUrlSchemes, baseTextStyle, blockStyles, channelMentions,
disableAtChannelMentionHighlight = false, disableAtMentions = false, disableChannelLink = false,
disableGallery = false, disableHashtags = false, enableInlineLatex, enableLatex,
imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutWidth,
disableAtChannelMentionHighlight, disableAtMentions, disableBlockQuote, disableChannelLink,
disableCodeBlock, disableGallery, disableHashtags, disableHeading, disableTables,
enableInlineLatex, enableLatex,
imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutHeight, layoutWidth,
location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns,
textStyles = {}, theme, value = '',
}: MarkdownProps) => {
@@ -148,6 +158,10 @@ const Markdown = ({
};
const renderBlockQuote = ({children, ...otherProps}: any) => {
if (disableBlockQuote) {
return null;
}
return (
<MarkdownBlockQuote
iconStyle={blockStyles?.quoteBlockIcon}
@@ -191,6 +205,10 @@ const Markdown = ({
};
const renderCodeBlock = (props: any) => {
if (disableCodeBlock) {
return null;
}
// These sometimes include a trailing newline
const content = props.literal.replace(/\n$/, '');
@@ -264,11 +282,20 @@ const Markdown = ({
};
const renderHeading = ({children, level}: {children: ReactElement; level: string}) => {
if (disableHeading) {
return (
<Text style={style.bold}>
{children}
</Text>
);
}
const containerStyle = [
style.block,
textStyles[`heading${level}`],
];
const textStyle = textStyles[`heading${level}Text`];
return (
<View style={containerStyle}>
<Text style={textStyle}>
@@ -314,6 +341,7 @@ const Markdown = ({
<MarkdownImage
disabled={disableGallery ?? Boolean(!location)}
errorTextStyle={[computeTextStyle(textStyles, baseTextStyle, context), textStyles.error]}
layoutHeight={layoutHeight}
layoutWidth={layoutWidth}
linkDestination={linkDestination}
imagesMetadata={imagesMetadata}
@@ -396,6 +424,9 @@ const Markdown = ({
};
const renderTable = ({children, numColumns}: {children: ReactElement; numColumns: number}) => {
if (disableTables) {
return null;
}
return (
<MarkdownTable
numColumns={numColumns}
@@ -425,7 +456,12 @@ const Markdown = ({
}
// Construct the text style based off of the parents of this node since RN's inheritance is limited
const styles = computeTextStyle(textStyles, baseTextStyle, context);
let styles;
if (disableHeading) {
styles = computeTextStyle(textStyles, baseTextStyle, context.filter((c) => !c.startsWith('heading')));
} else {
styles = computeTextStyle(textStyles, baseTextStyle, context);
}
return (
<Text
@@ -497,7 +533,7 @@ const Markdown = ({
};
const parser = useRef(new Parser({urlFilter, minimumHashtagLength})).current;
const renderer = useMemo(() => createRenderer(), [theme]);
const renderer = useMemo(createRenderer, [theme, textStyles]);
let ast = parser.parse(value.toString());
ast = combineTextNodes(ast);

View File

@@ -37,6 +37,7 @@ type MarkdownImageProps = {
imagesMetadata: Record<string, PostImage>;
isReplyPost?: boolean;
linkDestination?: string;
layoutHeight?: number;
layoutWidth?: number;
location?: string;
postId: string;
@@ -67,7 +68,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const MarkdownImage = ({
disabled, errorTextStyle, imagesMetadata, isReplyPost = false,
layoutWidth, linkDestination, location, postId, source, sourceSize,
layoutWidth, layoutHeight, linkDestination, location, postId, source, sourceSize,
}: MarkdownImageProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
@@ -78,7 +79,7 @@ const MarkdownImage = ({
const genericFileId = useRef(generateId('uid')).current;
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0];
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth);
const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth, layoutHeight);
const serverUrl = useServerUrl();
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
const uri = source.startsWith('/') ? serverUrl + source : source;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {Pressable, StyleSheet, Text} from 'react-native';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import Animated, {Easing, interpolate, interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
@@ -28,12 +28,14 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
borderRadius: 4,
flex: 1,
maxHeight: OPTIONS_HEIGHT,
minWidth: 80,
},
background: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
center: {
alignItems: 'center',
justifyContent: 'center',
@@ -115,10 +117,11 @@ const AnimatedOptionBox = ({
<AnimatedPressable
onPress={handleOnPress}
disabled={activated}
style={styles.container}
testID={testID}
>
{({pressed}) => (
<Animated.View style={[styles.container, pressed && {backgroundColor: changeOpacity(theme.buttonBg, 0.16)}]}>
<View style={[styles.container, styles.background, pressed && {backgroundColor: changeOpacity(theme.buttonBg, 0.16)}]}>
<Animated.View style={[styles.container, backgroundStyle]}>
<Animated.View style={[StyleSheet.absoluteFill, styles.center, optionStyle]}>
<CompassIcon
@@ -149,7 +152,7 @@ const AnimatedOptionBox = ({
</Text>
</Animated.View>
</Animated.View>
</Animated.View>
</View>
)}
</AnimatedPressable>
);

View File

@@ -35,7 +35,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
text: {
color: changeOpacity(theme.centerChannelColor, 0.56),
paddingHorizontal: 5,
textTransform: 'capitalize',
...typography('Body', 50, 'SemiBold'),
},
}));

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Switch, Text, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
action: (value: string | boolean) => void;
description?: string;
destructive?: boolean;
icon?: string;
info?: string;
label: string;
selected?: boolean;
testID?: string;
type: OptionType;
value?: string;
}
const OptionType = {
ARROW: 'arrow',
DEFAULT: 'default',
TOGGLE: 'toggle',
SELECT: 'select',
} as const;
type OptionType = typeof OptionType[keyof typeof OptionType];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
actionContainer: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 16,
},
container: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 48,
},
destructive: {
color: theme.dndIndicator,
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
marginTop: 2,
},
iconContainer: {marginRight: 16},
infoContainer: {marginRight: 2},
info: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 100),
},
label: {
flexShrink: 1,
justifyContent: 'center',
},
labelContainer: {
flexDirection: 'row',
alignItems: 'center',
},
labelText: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
row: {
flex: 1,
flexDirection: 'row',
},
};
});
const OptionItem = ({
action, description, destructive, icon,
info, label, selected,
testID = 'optionItem', type, value,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
let actionComponent;
if (type === OptionType.SELECT && selected) {
actionComponent = (
<CompassIcon
color={theme.linkColor}
name='check'
size={24}
testID={`${testID}.selected`}
/>
);
} else if (type === OptionType.TOGGLE) {
actionComponent = (
<Switch
onValueChange={action}
value={selected}
testID={`${testID}.toggled.${selected}`}
/>
);
} else if (type === OptionType.ARROW) {
actionComponent = (
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.32)}
name='chevron-right'
size={24}
/>
);
}
const onPress = useCallback(() => {
action(value || '');
}, [value, action]);
const component = (
<View
testID={testID}
style={styles.container}
>
<View style={styles.row}>
<View style={styles.labelContainer}>
{Boolean(icon) && (
<View style={styles.iconContainer}>
<CompassIcon
name={icon!}
size={24}
color={destructive ? theme.dndIndicator : changeOpacity(theme.centerChannelColor, 0.64)}
/>
</View>
)}
<View style={styles.label}>
<Text
style={[styles.labelText, destructive && styles.destructive]}
testID={`${testID}.label`}
>
{label}
</Text>
{Boolean(description) &&
<Text
style={[styles.description, destructive && styles.destructive]}
testID={`${testID}.description`}
>
{description}
</Text>
}
</View>
</View>
</View>
{Boolean(actionComponent) &&
<View style={styles.actionContainer}>
{Boolean(info) &&
<View style={styles.infoContainer}>
<Text style={styles.info}>{info}</Text>
</View>
}
{actionComponent}
</View>
}
</View>
);
if (type === OptionType.DEFAULT || type === OptionType.SELECT || type === OptionType.ARROW) {
return (
<TouchableOpacity onPress={onPress}>
{component}
</TouchableOpacity>
);
}
return component;
};
export default OptionItem;

View File

@@ -58,7 +58,14 @@ const Image = ({author, iconSize, size, source}: Props) => {
}
if (author && client) {
const lastPictureUpdate = ('lastPictureUpdate' in author) ? author.lastPictureUpdate : author.last_picture_update;
let lastPictureUpdate = 0;
const isBot = ('isBot' in author) ? author.isBot : author.is_bot;
if (isBot) {
lastPictureUpdate = ('isBot' in author) ? author.props?.bot_last_icon_update : author.bot_last_icon_update || 0;
} else {
lastPictureUpdate = ('lastPictureUpdate' in author) ? author.lastPictureUpdate : author.last_picture_update;
}
const pictureUrl = client.getProfilePictureUrl(author.id, lastPictureUpdate);
const imgSource = source ?? {uri: `${serverUrl}${pictureUrl}`};
return (

View File

@@ -23,7 +23,7 @@ type SlideUpPanelProps = {
text: string;
}
export const ITEM_HEIGHT = 51;
export const ITEM_HEIGHT = 48;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
@@ -40,12 +40,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flexDirection: 'row',
},
iconContainer: {
height: 50,
height: ITEM_HEIGHT,
justifyContent: 'center',
marginRight: 10,
},
noIconContainer: {
height: 50,
height: ITEM_HEIGHT,
width: 18,
},
icon: {
@@ -54,7 +54,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
textContainer: {
justifyContent: 'center',
flex: 1,
height: 50,
height: ITEM_HEIGHT,
marginRight: 5,
},
text: {

View File

@@ -3,8 +3,14 @@
export const MIN_CHANNEL_NAME_LENGTH = 1;
export const MAX_CHANNEL_NAME_LENGTH = 64;
export const IGNORE_CHANNEL_MENTIONS_ON = 'on';
export const IGNORE_CHANNEL_MENTIONS_OFF = 'off';
export const IGNORE_CHANNEL_MENTIONS_DEFAULT = 'default';
export default {
IGNORE_CHANNEL_MENTIONS_ON,
IGNORE_CHANNEL_MENTIONS_OFF,
IGNORE_CHANNEL_MENTIONS_DEFAULT,
MAX_CHANNEL_NAME_LENGTH,
MIN_CHANNEL_NAME_LENGTH,
};

View File

@@ -19,6 +19,7 @@ import Integrations from './integrations';
import List from './list';
import Navigation from './navigation';
import Network from './network';
import NotificationLevel from './notification_level';
import Permissions from './permissions';
import Post from './post';
import PostDraft from './post_draft';
@@ -52,6 +53,7 @@ export {
List,
Navigation,
Network,
NotificationLevel,
Permissions,
Post,
PostDraft,

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const ALL = 'all';
export const DEFAULT = 'default';
export const MENTION = 'mention';
export const NONE = 'none';
export default {
ALL,
DEFAULT,
MENTION,
NONE,
};

View File

@@ -10,6 +10,7 @@ export const CHANNEL = 'Channel';
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
export const CHANNEL_EDIT = 'ChannelEdit';
export const CHANNEL_INFO = 'ChannelInfo';
export const CHANNEL_MENTION = 'ChannelMention';
export const CODE = 'Code';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
@@ -33,6 +34,7 @@ export const LOGIN = 'Login';
export const MENTIONS = 'Mentions';
export const MFA = 'MFA';
export const PERMALINK = 'Permalink';
export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions';
export const REACTIONS = 'Reactions';
export const SAVED_POSTS = 'SavedPosts';
@@ -58,8 +60,8 @@ export default {
BROWSE_CHANNELS,
CHANNEL,
CHANNEL_ADD_PEOPLE,
CHANNEL_EDIT,
CHANNEL_INFO,
CHANNEL_MENTION,
CODE,
CREATE_DIRECT_MESSAGE,
CREATE_OR_EDIT_CHANNEL,
@@ -83,6 +85,7 @@ export default {
MENTIONS,
MFA,
PERMALINK,
PINNED_MESSAGES,
POST_OPTIONS,
REACTIONS,
SAVED_POSTS,
@@ -120,9 +123,10 @@ export const MODAL_SCREENS_WITHOUT_BACK = [
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_INFO,
CHANNEL_MENTION,
CREATE_TEAM,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
PINNED_MESSAGES,
USER_PROFILE,
];

View File

@@ -40,13 +40,28 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs):
user.roles = raw.roles;
user.username = raw.username;
user.notifyProps = raw.notify_props;
user.props = raw.props || null;
user.timezone = raw.timezone || null;
user.isBot = raw.is_bot;
user.remoteId = raw?.remote_id ?? null;
if (raw.status) {
user.status = raw.status;
}
if (raw.bot_description) {
raw.props = {
...raw.props,
bot_description: raw.bot_description,
};
}
if (raw.bot_last_icon_update) {
raw.props = {
...raw.props,
bot_last_icon_update: raw.bot_last_icon_update,
};
}
user.props = raw.props || null;
};
return prepareBaseRecord({

View File

@@ -10,21 +10,23 @@ import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Navigation, Screens} from '@constants';
import {General, Navigation, Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import OtherMentionsBadge from './other_mentions_badge';
import ChannelQuickAction from './quick_actions';
import QuickActions from './quick_actions';
import type {HeaderRightButton} from '@components/navigation_header/header';
type ChannelProps = {
channelId: string;
channelType: ChannelType;
customStatus?: UserCustomStatus;
isCustomStatusExpired: boolean;
componentId?: string;
@@ -58,7 +60,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const ChannelHeader = ({
channelId, componentId, customStatus, displayName,
channelId, channelType, componentId, customStatus, displayName,
isCustomStatusExpired, isOwnDirectMessage, memberCount,
searchTerm, teamId,
}: ChannelProps) => {
@@ -86,10 +88,34 @@ const ChannelHeader = ({
popTopScreen(componentId);
}, []);
const onTitlePress = useCallback(() => {
const title = intl.formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel Info'});
showModal(Screens.CHANNEL_INFO, title, {channelId});
}, [channelId, intl]);
const onTitlePress = useCallback(preventDoubleTap(() => {
let title;
switch (channelType) {
case General.DM_CHANNEL:
title = intl.formatMessage({id: 'screens.channel_info.dm', defaultMessage: 'Direct message info'});
break;
case General.GM_CHANNEL:
title = intl.formatMessage({id: 'screens.channel_info.gm', defaultMessage: 'Group message info'});
break;
default:
title = intl.formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel info'});
break;
}
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-channel-info';
const options = {
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
},
};
showModal(Screens.CHANNEL_INFO, title, {channelId, closeButtonId}, options);
}), [channelId, channelType, intl, theme]);
const onChannelQuickAction = useCallback(() => {
if (isTablet) {
@@ -98,7 +124,9 @@ const ChannelHeader = ({
}
const renderContent = () => {
return <ChannelQuickAction channelId={channelId}/>;
return (
<QuickActions channelId={channelId}/>
);
};
bottomSheet({
@@ -108,7 +136,7 @@ const ChannelHeader = ({
theme,
closeButtonId: 'close-channel-quick-actions',
});
}, [channelId, isTablet, onTitlePress, theme]);
}, [channelId, channelType, isTablet, onTitlePress, theme]);
const rightButtons: HeaderRightButton[] = useMemo(() => ([{
iconName: 'magnify',

View File

@@ -25,6 +25,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((id) => observeChannel(database, id)),
);
const channelType = channel.pipe(switchMap((c) => of$(c?.type)));
const channelInfo = channelId.pipe(
switchMap((id) => observeChannelInfo(database, id)),
);
@@ -74,6 +75,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
channelId,
channelType,
customStatus,
displayName,
isCustomStatusExpired,

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import ChannelActions from '@components/channel_actions';
import InfoBox from '@components/channel_actions/info_box';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
channelId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
minHeight: 270,
},
line: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
height: 1,
marginVertical: 8,
},
wrapper: {
marginBottom: 8,
},
separator: {
width: 8,
},
}));
const ChannelQuickAction = ({channelId}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<View style={styles.wrapper}>
<ChannelActions channelId={channelId}/>
</View>
<InfoBox
channelId={channelId}
showAsLabel={true}
/>
<View style={styles.line}/>
<LeaveChannelLabel channelId={channelId}/>
</View>
);
};
export default ChannelQuickAction;

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';
import AddPeopleBox from '@components/channel_actions/add_people_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import InfoBox from '@components/channel_actions/info_box';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
import MutedBox from '@components/channel_actions/mute_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet} from '@screens/navigation';
import {changeOpacity} from '@utils/theme';
type Props = {
channelId: string;
channelType?: string;
}
const OPTIONS_HEIGHT = 62;
const DIRECT_CHANNELS: string[] = [General.DM_CHANNEL, General.GM_CHANNEL];
const styles = StyleSheet.create({
container: {
minHeight: 270,
},
wrapper: {
flexDirection: 'row',
height: OPTIONS_HEIGHT,
marginBottom: 8,
},
separator: {
width: 8,
},
});
const ChannelQuickAction = ({channelId, channelType}: Props) => {
const theme = useTheme();
const onCopyLinkAnimationEnd = useCallback(() => {
requestAnimationFrame(async () => {
await dismissBottomSheet();
});
}, []);
return (
<View style={styles.container}>
<View style={styles.wrapper}>
<FavoriteBox
channelId={channelId}
showSnackBar={true}
/>
<View style={styles.separator}/>
<MutedBox
channelId={channelId}
showSnackBar={true}
/>
<View style={styles.separator}/>
{channelType && DIRECT_CHANNELS.includes(channelType) &&
<SetHeaderBox channelId={channelId}/>
}
{channelType && !DIRECT_CHANNELS.includes(channelType) &&
<>
<AddPeopleBox channelId={channelId}/>
<View style={styles.separator}/>
<CopyChannelLinkBox
channelId={channelId}
onAnimationEnd={onCopyLinkAnimationEnd}
/>
</>
}
</View>
<InfoBox
channelId={channelId}
showAsLabel={true}
/>
<View style={{backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), height: 1, marginVertical: 8}}/>
<LeaveChannelLabel channelId={channelId}/>
</View>
);
};
export default ChannelQuickAction;

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {ScrollView, View} from 'react-native';
import {Navigation} from 'react-native-navigation';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import ChannelActions from '@components/channel_actions';
import {useTheme} from '@context/theme';
import {dismissModal} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DestructiveOptions from './destructive_options';
import Extra from './extra';
import Options from './options';
import Title from './title';
type Props = {
channelId: string;
closeButtonId: string;
componentId: string;
type?: ChannelType;
}
const edges: Edge[] = ['bottom', 'left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
content: {
paddingHorizontal: 20,
paddingBottom: 16,
},
flex: {
flex: 1,
},
separator: {
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginVertical: 8,
},
}));
const ChannelInfo = ({channelId, closeButtonId, componentId, type}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
useEffect(() => {
const update = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: {buttonId: string}) => {
if (buttonId === closeButtonId) {
dismissModal({componentId});
}
},
}, componentId);
return () => {
update.remove();
};
}, []);
return (
<SafeAreaView
edges={edges}
style={styles.flex}
>
<ScrollView
bounces={true}
alwaysBounceVertical={false}
contentContainerStyle={styles.content}
>
<Title
channelId={channelId}
type={type}
/>
<ChannelActions
channelId={channelId}
inModal={true}
/>
<Extra channelId={channelId}/>
<View style={styles.separator}/>
<Options
channelId={channelId}
type={type}
/>
<View style={styles.separator}/>
<DestructiveOptions
channelId={channelId}
componentId={componentId}
type={type}
/>
</ScrollView>
</SafeAreaView>
);
};
export default ChannelInfo;

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {MessageDescriptor, useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {archiveChannel, unarchiveChannel} from '@actions/remote/channel';
import OptionItem from '@components/option_item';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import {dismissModal, popToRoot} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {preventDoubleTap} from '@utils/tap';
type Props = {
canArchive: boolean;
canUnarchive: boolean;
canViewArchivedChannels: boolean;
channelId: string;
componentId: string;
displayName: string;
type?: ChannelType;
}
const Archive = ({
canArchive, canUnarchive, canViewArchivedChannels,
channelId, componentId, displayName, type,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const close = async (pop: boolean) => {
await dismissModal({componentId});
if (pop) {
popToRoot();
}
};
const alertAndHandleYesAction = (title: MessageDescriptor, message: MessageDescriptor, onPressAction: () => void) => {
const {formatMessage} = intl;
let term: string;
if (type === General.OPEN_CHANNEL) {
term = formatMessage({id: 'channel_info.public_channel', defaultMessage: 'Public Channel'});
} else {
term = formatMessage({id: 'channel_info.private_channel', defaultMessage: 'Private Channel'});
}
Alert.alert(
formatMessage(title, {term}),
formatMessage(message, {term: term.toLowerCase(), name: displayName}),
[{
text: formatMessage({id: 'channel_info.alertNo', defaultMessage: 'No'}),
}, {
text: formatMessage({id: 'channel_info.alertYes', defaultMessage: 'Yes'}),
onPress: onPressAction,
}],
);
};
const onArchive = preventDoubleTap(() => {
const title = {id: t('channel_info.archive_title'), defaultMessage: 'Archive {term}'};
const message = {
id: t('channel_info.archive_description'),
defaultMessage: 'Are you sure you want to archive the {term} {name}?',
};
const onPressAction = async () => {
const result = await archiveChannel(serverUrl, channelId);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: t('channel_info.archive_failed'),
defaultMessage: 'An error occurred trying to archive the channel {displayName}',
}, {displayName},
);
} else {
close(!canViewArchivedChannels);
}
};
alertAndHandleYesAction(title, message, onPressAction);
});
const onUnarchive = preventDoubleTap(() => {
const title = {id: t('channel_info.unarchive_title'), defaultMessage: 'Unarchive {term}'};
const message = {
id: t('channel_info.unarchive_description'),
defaultMessage: 'Are you sure you want to unarchive the {term} {name}?',
};
const onPressAction = async () => {
const result = await unarchiveChannel(serverUrl, channelId);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: t('channel_info.unarchive_failed'),
defaultMessage: 'An error occurred trying to unarchive the channel {displayName}',
}, {displayName},
);
} else {
close(false);
}
};
alertAndHandleYesAction(title, message, onPressAction);
});
if (!canArchive && !canUnarchive) {
return null;
}
if (canUnarchive) {
return (
<OptionItem
action={onUnarchive}
label={intl.formatMessage({id: 'channel_info.unarchive', defaultMessage: 'Unarchive Channel'})}
icon='archive-arrow-up-outline'
destructive={true}
type='default'
/>
);
}
return (
<OptionItem
action={onArchive}
label={intl.formatMessage({id: 'channel_info.archive', defaultMessage: 'Archive Channel'})}
icon='archive-outline'
destructive={true}
type='default'
/>
);
};
export default Archive;

View File

@@ -0,0 +1,79 @@
// 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$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePermissionForChannel, observePermissionForTeam} from '@queries/servers/role';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentTeam} from '@queries/servers/team';
import {observeCurrentUser} from '@queries/servers/user';
import Archive from './archive';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
type?: string;
}
const enhanced = withObservables(['channelId', 'type'], ({channelId, database, type}: Props) => {
const team = observeCurrentTeam(database);
const currentUser = observeCurrentUser(database);
const channel = observeChannel(database, channelId);
const canViewArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels');
const isArchived = channel.pipe(switchMap((c) => of$((c?.deleteAt || 0) > 0)));
const canLeave = channel.pipe(
combineLatestWith(currentUser),
switchMap(([ch, u]) => {
const isDefaultChannel = ch?.name === General.DEFAULT_CHANNEL;
return of$(!isDefaultChannel || (isDefaultChannel && u?.isGuest));
}),
);
const canArchive = channel.pipe(
combineLatestWith(currentUser, canLeave, isArchived),
switchMap(([ch, u, leave, archived]) => {
if (
type === General.DM_CHANNEL || type === General.GM_CHANNEL ||
!ch || !u || !leave || archived
) {
return of$(false);
}
if (type === General.OPEN_CHANNEL) {
return observePermissionForChannel(ch, u, Permissions.DELETE_PUBLIC_CHANNEL, true);
}
return observePermissionForChannel(ch, u, Permissions.DELETE_PRIVATE_CHANNEL, true);
}),
);
const canUnarchive = team.pipe(
combineLatestWith(currentUser, isArchived),
switchMap(([t, u, archived]) => {
if (
type === General.DM_CHANNEL || type === General.GM_CHANNEL ||
!t || !u || !archived
) {
return of$(false);
}
return observePermissionForTeam(t, u, Permissions.MANAGE_TEAM, false);
}),
);
return {
canArchive,
canUnarchive,
canViewArchivedChannels,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName))),
};
});
export default withDatabase(enhanced(Archive));

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {convertChannelToPrivate} from '@actions/remote/channel';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import {alertErrorWithFallback} from '@utils/draft';
import {preventDoubleTap} from '@utils/tap';
type Props = {
canConvert: boolean;
channelId: string;
displayName: string;
}
const ConvertPrivate = ({canConvert, channelId, displayName}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const onConfirmConvertToPrivate = () => {
const {formatMessage} = intl;
const title = {id: t('channel_info.convert_private_title'), defaultMessage: 'Convert {displayName} to a private channel?'};
const message = {
id: t('channel_info.convert_private_description'),
defaultMessage: 'When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?',
};
Alert.alert(
formatMessage(title, {displayName}),
formatMessage(message, {displayName}),
[{
text: formatMessage({id: 'channel_info.alertNo', defaultMessage: 'No'}),
}, {
text: formatMessage({id: 'channel_info.alertYes', defaultMessage: 'Yes'}),
onPress: convertToPrivate,
}],
);
};
const convertToPrivate = preventDoubleTap(async () => {
const result = await convertChannelToPrivate(serverUrl, channelId);
const {formatMessage} = intl;
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: t('channel_info.convert_failed'),
defaultMessage: 'We were unable to convert {displayName} to a private channel.',
}, {displayName},
[{
text: formatMessage({id: 'channel_info.error_close', defaultMessage: 'Close'}),
}, {
text: formatMessage({id: 'channel_info.alert_retry', defaultMessage: 'Try Again'}),
onPress: convertToPrivate,
}],
);
} else {
Alert.alert(
'',
formatMessage({id: t('channel_info.convert_private_success'), defaultMessage: '{displayName} is now a private channel.'}, {displayName}),
);
}
});
if (!canConvert) {
return null;
}
return (
<OptionItem
action={onConfirmConvertToPrivate}
label={intl.formatMessage({id: 'channel_info.convert_private', defaultMessage: 'Convert to private channel'})}
icon='lock-outline'
type='default'
/>
);
};
export default ConvertPrivate;

View File

@@ -0,0 +1,42 @@
// 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$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeCurrentUser} from '@queries/servers/user';
import ConvertPrivate from './convert_private';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUser = observeCurrentUser(database);
const channel = observeChannel(database, channelId);
const canConvert = channel.pipe(
combineLatestWith(currentUser),
switchMap(([ch, u]) => {
if (!ch || !u || ch.name === General.DEFAULT_CHANNEL) {
return of$(false);
}
return observePermissionForChannel(ch, u, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE, false);
}),
);
return {
canConvert,
displayName: channel.pipe(switchMap((c) => of$(c?.displayName))),
};
});
export default withDatabase(enhanced(ConvertPrivate));

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
import {General} from '@constants';
import Archive from './archive';
import ConvertPrivate from './convert_private';
type Props = {
channelId: string;
componentId: string;
type?: ChannelType;
}
const DestructiveOptions = ({channelId, componentId, type}: Props) => {
return (
<>
{type === General.OPEN_CHANNEL &&
<ConvertPrivate channelId={channelId}/>
}
<LeaveChannelLabel
channelId={channelId}
isOptionItem={true}
/>
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
<Archive
channelId={channelId}
componentId={componentId}
type={type}
/>
}
</>
);
};
export default DestructiveOptions;

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {useMemo} from 'react';
import {Text, View} from 'react-native';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import Emoji from '@components/emoji';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {useTheme} from '@context/theme';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
createdAt: number;
createdBy: string;
customStatus?: UserCustomStatus;
header?: string;
}
const headerMetadata = {header: {width: 1, height: 1}};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
marginBottom: 20,
},
item: {
marginTop: 16,
},
extraHeading: {
color: changeOpacity(theme.centerChannelColor, 0.56),
marginBottom: 8,
...typography('Body', 75),
},
header: {
color: theme.centerChannelColor,
...typography('Body', 200),
fontWeight: undefined,
},
created: {
color: changeOpacity(theme.centerChannelColor, 0.48),
...typography('Body', 75),
},
customStatus: {
alignItems: 'center',
flexDirection: 'row',
},
customStatusEmoji: {
marginRight: 10,
},
customStatusLabel: {
color: theme.centerChannelColor,
marginRight: 8,
...typography('Body', 200),
},
customStatusExpiry: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
}));
const Extra = ({createdAt, createdBy, customStatus, header}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const created = useMemo(() => ({
user: createdBy,
date: (
<FormattedDate
style={styles.created}
value={createdAt}
/>
),
}), [createdAt, createdBy, theme]);
return (
<View style={styles.container}>
{Boolean(customStatus) &&
<View style={styles.item}>
<FormattedText
id='channel_info.custom_status'
defaultMessage='Custom status:'
style={styles.extraHeading}
/>
<View style={styles.customStatus}>
{Boolean(customStatus?.emoji) &&
<View style={styles.customStatusEmoji}>
<Emoji
emojiName={customStatus!.emoji!}
size={24}
/>
</View>
}
{Boolean(customStatus?.text) &&
<Text style={styles.customStatusLabel}>
{customStatus?.text}
</Text>
}
{Boolean(customStatus?.duration) &&
<CustomStatusExpiry
time={moment(customStatus?.expires_at)}
theme={theme}
textStyles={styles.customStatusExpiry}
withinBrackets={false}
showPrefix={true}
showToday={true}
showTimeCompulsory={false}
/>
}
</View>
</View>
}
{Boolean(header) &&
<View style={styles.item}>
<FormattedText
id='channel_info.header'
defaultMessage='Header:'
style={styles.extraHeading}
/>
<Markdown
baseTextStyle={styles.header}
blockStyles={blockStyles}
disableBlockQuote={true}
disableCodeBlock={true}
disableGallery={true}
disableHeading={true}
disableTables={true}
textStyles={textStyles}
layoutHeight={48}
layoutWidth={100}
theme={theme}
imagesMetadata={headerMetadata}
value={header}
/>
</View>
}
{Boolean(createdAt && createdBy) &&
<View style={styles.item}>
<FormattedText
id='channel_intro.createdBy'
defaultMessage='Created by {user} on {date}'
style={styles.created}
values={created}
/>
</View>
}
{Boolean(createdAt && !createdBy) &&
<View style={styles.item}>
<FormattedText
id='channel_intro.createdOn'
defaultMessage='Created on {date}'
style={styles.created}
values={created}
/>
</View>
}
</View>
);
};
export default Extra;

View File

@@ -0,0 +1,59 @@
// 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$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeChannel, observeChannelInfo} from '@queries/servers/channel';
import {observeCurrentUser, observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
import {displayUsername, getUserCustomStatus, getUserIdFromChannelName, isCustomStatusExpired as checkCustomStatusIsExpired} from '@utils/user';
import Extra from './extra';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUser = observeCurrentUser(database);
const teammateNameDisplay = observeTeammateNameDisplay(database);
const channel = observeChannel(database, channelId);
const channelInfo = observeChannelInfo(database, channelId);
const createdAt = channel.pipe(switchMap((c) => of$(c?.type === General.DM_CHANNEL ? 0 : c?.createAt)));
const header = channelInfo.pipe(switchMap((ci) => of$(ci?.header)));
const dmUser = currentUser.pipe(
combineLatestWith(channel),
switchMap(([user, c]) => {
if (c?.type === General.DM_CHANNEL && user) {
const teammateId = getUserIdFromChannelName(user.id, c.name);
return observeUser(database, teammateId);
}
return of$(undefined);
}),
);
const createdBy = channel.pipe(
switchMap((ch) => (ch?.creatorId ? ch.creator.observe() : of$(undefined))),
combineLatestWith(currentUser, teammateNameDisplay),
switchMap(([creator, me, disaplySetting]) => of$(displayUsername(creator, me?.locale, disaplySetting, false))),
);
const customStatus = dmUser.pipe(
switchMap((dm) => of$(checkCustomStatusIsExpired(dm) ? undefined : getUserCustomStatus(dm))),
);
return {
createdAt,
createdBy,
customStatus,
header,
};
});
export default withDatabase(enhanced(Extra));

View File

@@ -0,0 +1,28 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import ChannelInfo from './channel_info';
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 type = channel.pipe(switchMap((c) => of$(c?.type)));
return {
type,
};
});
export default withDatabase(enhanced(ChannelInfo));

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
}
const EditChannel = ({channelId}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'});
const goToEditChannel = preventDoubleTap(async () => {
goToScreen(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, isModal: false});
});
return (
<OptionItem
action={goToEditChannel}
label={title}
icon='pencil-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
/>
);
};
export default EditChannel;

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {useIntl} from 'react-intl';
import {updateChannelNotifyProps} from '@actions/remote/channel';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
ignoring: boolean;
}
const IgnoreMentions = ({channelId, ignoring}: Props) => {
const [ignored, setIgnored] = useState(ignoring);
const serverUrl = useServerUrl();
const {formatMessage} = useIntl();
const toggleIgnore = preventDoubleTap(() => {
const props: Partial<ChannelNotifyProps> = {
ignore_channel_mentions: ignoring ? 'off' : 'on',
};
setIgnored(!ignored);
updateChannelNotifyProps(serverUrl, channelId, props);
});
return (
<OptionItem
action={toggleIgnore}
label={formatMessage({id: 'channel_info.ignore_mentions', defaultMessage: 'Ignore @channel, @here, @all'})}
icon='at'
type='toggle'
selected={ignored}
/>
);
};
export default IgnoreMentions;

View File

@@ -0,0 +1,49 @@
// 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$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {Channel} from '@constants';
import {observeChannelSettings} from '@queries/servers/channel';
import {observeCurrentUser} from '@queries/servers/user';
import IgnoreMentions from './ignore_mentions';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const isChannelMentionsIgnored = (channelNotifyProps?: Partial<ChannelNotifyProps>, userNotifyProps?: UserNotifyProps | null) => {
let ignoreChannelMentionsDefault = Channel.IGNORE_CHANNEL_MENTIONS_OFF;
if (userNotifyProps?.channel && userNotifyProps.channel === 'false') {
ignoreChannelMentionsDefault = Channel.IGNORE_CHANNEL_MENTIONS_ON;
}
let ignoreChannelMentions = channelNotifyProps?.ignore_channel_mentions;
if (!ignoreChannelMentions || ignoreChannelMentions === Channel.IGNORE_CHANNEL_MENTIONS_DEFAULT) {
ignoreChannelMentions = ignoreChannelMentionsDefault as any;
}
return ignoreChannelMentions !== Channel.IGNORE_CHANNEL_MENTIONS_OFF;
};
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUser = observeCurrentUser(database);
const settings = observeChannelSettings(database, channelId);
const ignoring = currentUser.pipe(
combineLatestWith(settings),
switchMap(([u, s]) => of$(isChannelMentionsIgnored(s?.notifyProps, u?.notifyProps))),
);
return {
ignoring,
};
});
export default withDatabase(enhanced(IgnoreMentions));

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {General} from '@constants';
import EditChannel from './edit_channel';
import IgnoreMentions from './ignore_mentions';
import Members from './members';
import NotificationPreference from './notification_preference';
import PinnedMessages from './pinned_messages';
type Props = {
channelId: string;
type?: ChannelType;
}
const Options = ({channelId, type}: Props) => {
return (
<>
{type !== General.DM_CHANNEL &&
<IgnoreMentions channelId={channelId}/>
}
<NotificationPreference channelId={channelId}/>
<PinnedMessages channelId={channelId}/>
{type !== General.DM_CHANNEL &&
<Members channelId={channelId}/>
}
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
<EditChannel channelId={channelId}/>
}
</>
);
};
export default Options;

View File

@@ -0,0 +1,30 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelInfo} from '@queries/servers/channel';
import Members from './members';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const info = observeChannelInfo(database, channelId);
const count = info.pipe(
switchMap((i) => of$(i?.memberCount || 0)),
);
return {
count,
};
});
export default withDatabase(enhanced(Members));

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
count: number;
}
const Members = ({channelId, count}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'channel_info.members', defaultMessage: 'Members'});
const goToChannelMembers = preventDoubleTap(() => {
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
});
return (
<OptionItem
action={goToChannelMembers}
label={title}
icon='account-multiple-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
info={count.toString()}
/>
);
};
export default Members;

View File

@@ -0,0 +1,30 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelSettings} from '@queries/servers/channel';
import NotificationPreference from './notification_preference';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const settings = observeChannelSettings(database, channelId);
const notifyLevel = settings.pipe(
switchMap((s) => of$(s?.notifyProps.push)),
);
return {
notifyLevel,
};
});
export default withDatabase(enhanced(NotificationPreference));

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {t} from '@app/i18n';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {NotificationLevel, Screens} from '@constants';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
notifyLevel: NotificationLevel;
}
const NotificationPreference = ({channelId, notifyLevel}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'channel_info.mobile_notifications', defaultMessage: 'Mobile Notifications'});
const goToMentions = preventDoubleTap(() => {
goToScreen(Screens.CHANNEL_MENTION, title, {channelId});
});
const notificationLevelToText = () => {
let id = '';
let defaultMessage = '';
switch (notifyLevel) {
case NotificationLevel.ALL: {
id = t('channel_info.notification.all');
defaultMessage = 'All';
break;
}
case NotificationLevel.MENTION: {
id = t('channel_info.notification.mention');
defaultMessage = 'Mentions';
break;
}
case NotificationLevel.NONE: {
id = t('channel_info.notification.none');
defaultMessage = 'Never';
break;
}
default:
id = t('channel_info.notification.default');
defaultMessage = 'Default';
break;
}
return formatMessage({id, defaultMessage});
};
return (
<OptionItem
action={goToMentions}
label={title}
icon='cellphone'
type={Platform.select({ios: 'arrow', default: 'default'})}
info={notificationLevelToText()}
/>
);
};
export default NotificationPreference;

View File

@@ -0,0 +1,30 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelInfo} from '@queries/servers/channel';
import PinnedMessages from './pinned_messages';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const info = observeChannelInfo(database, channelId);
const count = info.pipe(
switchMap((i) => of$(i?.pinnedPostCount || 0)),
);
return {
count,
};
});
export default withDatabase(enhanced(PinnedMessages));

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
count: number;
}
const PinnedMessages = ({channelId, count}: Props) => {
const {formatMessage} = useIntl();
const title = formatMessage({id: 'channel_info.pinned_messages', defaultMessage: 'Pinned Messages'});
const goToPinnedMessages = preventDoubleTap(() => {
goToScreen(Screens.PINNED_MESSAGES, title, {channelId});
});
return (
<OptionItem
action={goToPinnedMessages}
label={title}
icon='pin-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
info={count.toString()}
/>
);
};
export default PinnedMessages;

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import {BotTag, GuestTag} from '@app/components/tag';
import ProfilePicture from '@components/profile_picture';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
displayName?: string;
user?: UserModel;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
alignItems: 'center',
flexDirection: 'row',
},
displayName: {
flexDirection: 'row',
},
position: {
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 200),
},
tagContainer: {
marginLeft: 12,
},
tag: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
},
titleContainer: {
flex: 1,
marginLeft: 16,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
flexShrink: 1,
},
}));
const DirectMessage = ({displayName, user}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<ProfilePicture
author={user}
size={64}
iconSize={64}
showStatus={true}
statusSize={24}
/>
<View style={styles.titleContainer}>
<View style={styles.displayName}>
<Text
numberOfLines={1}
style={styles.title}
>
{displayName}
</Text>
{user?.isGuest &&
<GuestTag
textStyle={styles.tag}
style={styles.tagContainer}
/>
}
{user?.isBot &&
<BotTag
textStyle={styles.tag}
style={styles.tagContainer}
/>
}
</View>
{Boolean(user?.position) &&
<Text style={styles.position}>
{user?.position}
</Text>
}
{Boolean(user?.isBot && user.props?.bot_description) &&
<Text style={styles.position}>
{user?.props?.bot_description}
</Text>
}
</View>
</View>
);
};
export default DirectMessage;

View File

@@ -0,0 +1,42 @@
// 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$} from 'rxjs';
import {combineLatestWith, switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import {getUserIdFromChannelName} from '@utils/user';
import DirectMessage from './direct_message';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUserId = observeCurrentUserId(database);
const channel = observeChannel(database, channelId);
const user = currentUserId.pipe(
combineLatestWith(channel),
switchMap(([uId, ch]) => {
if (!ch) {
return of$(undefined);
}
const otherUserId = getUserIdFromChannelName(uId, ch.name);
return observeUser(database, otherUserId);
}),
);
return {
currentUserId,
user,
};
});
export default withDatabase(enhanced(DirectMessage));

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import NetworkManager from '@managers/network_manager';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {Client} from '@client/rest';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
users: UserModel[];
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
alignItems: 'center',
flexDirection: 'row',
marginBottom: 8,
},
profile: {
borderColor: theme.centerChannelBg,
borderRadius: 24,
borderWidth: 2,
height: 48,
width: 48,
},
}));
const GroupAvatars = ({users}: Props) => {
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
return null;
}
const group = users.map((u, i) => {
const pictureUrl = client!.getProfilePictureUrl(u.id, u.lastPictureUpdate);
return (
<FastImage
key={pictureUrl + i.toString()}
style={[styles.profile, {transform: [{translateX: -(i * 12)}]}]}
source={{uri: `${serverUrl}${pictureUrl}`}}
/>
);
});
return (
<View style={[styles.container]}>
{group}
</View>
);
};
export default GroupAvatars;

View File

@@ -0,0 +1,17 @@
// 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 {queryUsersById} from '@queries/servers/user';
import GroupAvatars from './avatars';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({userIds, database}: {userIds: string[]} & WithDatabaseArgs) => ({
users: queryUsersById(database, userIds).observeWithColumns(['last_picture_update']),
}));
export default withDatabase(enhanced(GroupAvatars));

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Text} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import GroupAvatars from './avatars';
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
type Props = {
currentUserId: string;
displayName?: string;
members: ChannelMembershipModel[];
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
avatars: {
left: 0,
marginBottom: 8,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
}));
const GroupMessage = ({currentUserId, displayName, members}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const userIds = useMemo(() => members.map((cm) => cm.userId).filter((id) => id !== currentUserId),
[members.length, currentUserId]);
return (
<>
<GroupAvatars
userIds={userIds}
/>
<Text style={styles.title}>
{displayName}
</Text>
</>
);
};
export default GroupMessage;

View File

@@ -0,0 +1,31 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeCurrentUserId} from '@queries/servers/system';
import GroupMessage from './group_message';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUserId = observeCurrentUserId(database);
const channel = observeChannel(database, channelId);
const members = channel.pipe(switchMap((c) => (c ? c.members.observe() : of$([]))));
return {
currentUserId,
members,
};
});
export default withDatabase(enhanced(GroupMessage));

View File

@@ -0,0 +1,28 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import Title from './title';
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 displayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
return {
displayName,
};
});
export default withDatabase(enhanced(Title));

View File

@@ -0,0 +1,28 @@
// 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$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannelInfo} from '@queries/servers/channel';
import PublicPrivate from './public_private';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channelInfo = observeChannelInfo(database, channelId);
const purpose = channelInfo.pipe(switchMap((ci) => of$(ci?.purpose)));
return {
purpose,
};
});
export default withDatabase(enhanced(PublicPrivate));

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
displayName?: string;
purpose?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
},
purpose: {
color: changeOpacity(theme.centerChannelColor, 0.72),
marginTop: 8,
...typography('Body', 200),
},
}));
const PublicPrivate = ({displayName, purpose}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<>
<Text style={styles.title}>
{displayName}
</Text>
{Boolean(purpose) &&
<Text style={styles.purpose}>
{purpose}
</Text>
}
</>
);
};
export default PublicPrivate;

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {General} from '@constants';
import DirectMessage from './direct_message';
import GroupMessage from './group_message';
import PublicPrivate from './public_private';
type Props = {
channelId: string;
displayName?: string;
type?: ChannelType;
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
marginTop: 24,
},
});
const Title = ({channelId, displayName, type}: Props) => {
let component;
switch (type) {
case General.DM_CHANNEL:
component = (
<DirectMessage
channelId={channelId}
displayName={displayName}
/>
);
break;
case General.GM_CHANNEL:
component = (
<GroupMessage
channelId={channelId}
displayName={displayName}
/>
);
break;
default:
component = (
<PublicPrivate
channelId={channelId}
displayName={displayName}
/>
);
break;
}
return (
<View style={styles.container}>
{component}
</View>
);
};
export default Title;

View File

@@ -12,7 +12,7 @@ import {General} from '@constants';
import {MIN_CHANNEL_NAME_LENGTH} from '@constants/channel';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {buildNavigationButton, dismissModal, popTopScreen, setButtons} from '@screens/navigation';
import {validateDisplayName} from '@utils/channel';
import ChannelInfoForm from './channel_info_form';
@@ -48,9 +48,13 @@ interface RequestAction {
error?: string;
}
const close = (componentId: string): void => {
const close = (componentId: string, isModal: boolean): void => {
Keyboard.dismiss();
dismissModal({componentId});
if (isModal) {
dismissModal({componentId});
} else {
popTopScreen(componentId);
}
};
const isDirect = (channel?: ChannelModel): boolean => {
@@ -181,9 +185,9 @@ const CreateOrEditChannel = ({
}
dispatch({type: RequestActions.COMPLETE});
close(componentId);
close(componentId, isModal);
switchToChannelById(serverUrl, createdChannel.channel!.id, createdChannel.channel!.team_id);
}, [serverUrl, type, displayName, header, purpose, isValidDisplayName]);
}, [serverUrl, type, displayName, header, isModal, purpose, isValidDisplayName]);
const onUpdateChannel = useCallback(async () => {
if (!channel) {
@@ -198,7 +202,7 @@ const CreateOrEditChannel = ({
const patchChannel = {
id: channel.id,
type: channel.type,
display_name: isDirect(channel) ? '' : displayName,
display_name: isDirect(channel) ? channel.displayName : displayName,
purpose,
header,
} as Channel;
@@ -213,15 +217,15 @@ const CreateOrEditChannel = ({
return;
}
dispatch({type: RequestActions.COMPLETE});
close(componentId);
}, [channel?.id, channel?.type, displayName, header, purpose, isValidDisplayName]);
close(componentId, isModal);
}, [channel?.id, channel?.type, displayName, header, isModal, purpose, isValidDisplayName]);
useEffect(() => {
const update = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: {buttonId: string}) => {
switch (buttonId) {
case CLOSE_BUTTON_ID:
close(componentId);
close(componentId, isModal);
break;
case CREATE_BUTTON_ID:
onCreateChannel();
@@ -236,7 +240,7 @@ const CreateOrEditChannel = ({
return () => {
update.remove();
};
}, [onCreateChannel, onUpdateChannel]);
}, [onCreateChannel, onUpdateChannel, isModal]);
return (
<ChannelInfoForm

View File

@@ -11,11 +11,9 @@ import FormattedText from '@components/formatted_text';
import {CustomStatusDuration, CST} from '@constants/custom_status';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
import type {Moment} from 'moment-timezone';
type Props = {
currentUser: UserModel;
duration: CustomStatusDuration;
onOpenClearAfterModal: () => void;
theme: Theme;
@@ -51,7 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ClearAfter = ({currentUser, duration, expiresAt, onOpenClearAfterModal, theme}: Props) => {
const ClearAfter = ({duration, expiresAt, onOpenClearAfterModal, theme}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
@@ -60,7 +58,6 @@ const ClearAfter = ({currentUser, duration, expiresAt, onOpenClearAfterModal, th
return (
<View style={style.expiryTime}>
<CustomStatusExpiry
currentUser={currentUser}
textStyles={style.customStatusExpiry}
theme={theme}
time={expiresAt.toDate()}

View File

@@ -313,7 +313,7 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
render() {
const {duration, emoji, expires_at, text} = this.state;
const {customStatusExpirySupported, currentUser, intl, recentCustomStatuses, theme} = this.props;
const {customStatusExpirySupported, intl, recentCustomStatuses, theme} = this.props;
const isStatusSet = Boolean(emoji || text);
const style = getStyleSheet(theme);
@@ -356,7 +356,6 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
/>
{isStatusSet && customStatusExpirySupported && (
<ClearAfter
currentUser={currentUser}
duration={duration}
expiresAt={expires_at}
onOpenClearAfterModal={this.openClearAfterModal}

View File

@@ -116,7 +116,6 @@ const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemC
{showExpiryTime && expiryTime !== '' && (
<View style={style.rightPosition}>
<CustomStatusExpiry
currentUser={currentUser}
theme={theme}
time={moment(expiryTime).toDate()}
textStyles={style.customStatusExpiry}

View File

@@ -12,15 +12,12 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CustomStatusText from './custom_status_text';
import type UserModel from '@typings/database/models/servers/user';
type CustomLabelProps = {
customStatus: UserCustomStatus;
isCustomStatusExpirySupported: boolean;
isStatusSet: boolean;
showRetryMessage: boolean;
theme: Theme;
currentUser: UserModel;
onClearCustomStatus: () => void;
};
@@ -46,7 +43,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
};
});
const CustomLabel = ({currentUser, customStatus, isCustomStatusExpirySupported, isStatusSet, onClearCustomStatus, showRetryMessage, theme}: CustomLabelProps) => {
const CustomLabel = ({customStatus, isCustomStatusExpirySupported, isStatusSet, onClearCustomStatus, showRetryMessage, theme}: CustomLabelProps) => {
const style = getStyleSheet(theme);
return (
@@ -59,7 +56,6 @@ const CustomLabel = ({currentUser, customStatus, isCustomStatusExpirySupported,
/>
{Boolean(isStatusSet && isCustomStatusExpirySupported && customStatus?.duration) && (
<CustomStatusExpiry
currentUser={currentUser}
time={moment(customStatus?.expires_at)}
theme={theme}
textStyles={style.customStatusExpiryText}

View File

@@ -72,7 +72,6 @@ const CustomStatus = ({isCustomStatusExpirySupported, isTablet, currentUser}: Cu
testID='settings.sidebar.custom_status.action'
labelComponent={
<CustomLabel
currentUser={currentUser}
theme={theme}
customStatus={customStatus!}
isCustomStatusExpirySupported={isCustomStatusExpirySupported}

View File

@@ -73,6 +73,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CHANNEL:
screen = withServerDatabase(require('@screens/channel').default);
break;
case Screens.CHANNEL_INFO:
screen = withServerDatabase(require('@screens/channel_info').default);
break;
case Screens.CODE:
screen = withServerDatabase(require('@screens/code').default);
break;

View File

@@ -19,6 +19,8 @@ class EphemeralStore {
private addingTeam = new Set<string>();
private joiningChannels = new Set<string>();
private leavingChannels = new Set<string>();
private archivingChannels = new Set<string>();
private convertingChannels = new Set<string>();
addNavigationComponentId = (componentId: string) => {
this.addToNavigationComponentIdStack(componentId);
@@ -134,6 +136,32 @@ class EphemeralStore {
}
};
// Ephemeral control when (un)archiving a channel locally
addArchivingChannel = (channelId: string) => {
this.archivingChannels.add(channelId);
};
isArchivingChannel = (channelId: string) => {
return this.archivingChannels.has(channelId);
};
removeArchivingChannel = (channelId: string) => {
this.archivingChannels.delete(channelId);
};
// Ephemeral control when converting a channel to private locally
addConvertingChannel = (channelId: string) => {
this.convertingChannels.add(channelId);
};
isConvertingChannel = (channelId: string) => {
return this.convertingChannels.has(channelId);
};
removeConvertingChannel = (channelId: string) => {
this.convertingChannels.delete(channelId);
};
// Ephemeral control when leaving a channel locally
addLeavingChannel = (channelId: string) => {
this.leavingChannels.add(channelId);

View File

@@ -242,6 +242,7 @@ export const getMarkdownImageSize = (
sourceSize?: SourceSize,
knownSize?: PostImage,
layoutWidth?: number,
layoutHeight?: number,
) => {
let ratioW;
let ratioH;
@@ -277,5 +278,5 @@ export const getMarkdownImageSize = (
// When no metadata and source size is not specified (full size svg's)
const width = layoutWidth || getViewPortWidth(isReplyPost, isTablet);
return {width, height: width};
return {width, height: layoutHeight || width};
};

View File

@@ -12,7 +12,7 @@ import {toTitleCase} from '@utils/helpers';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
export function displayUsername(user?: UserProfile | UserModel | null, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, t('channel_loader.someone'), 'Someone') : '';
if (user) {

View File

@@ -99,22 +99,53 @@
"channel_header.directchannel.you": "{displayName} (you)",
"channel_header.info": "View info",
"channel_header.member_count": "{count, plural, one {# member} other {# members}}",
"channel_info.alert_retry": "Try Again",
"channel_info.alertNo": "No",
"channel_info.alertYes": "Yes",
"channel_info.archive": "Archive Channel",
"channel_info.archive_description": "Are you sure you want to archive the {term} {name}?",
"channel_info.archive_failed": "An error occurred trying to archive the channel {displayName}",
"channel_info.archive_title": "Archive {term}",
"channel_info.close": "Close",
"channel_info.close_dm": "Close direct message",
"channel_info.close_dm_channel": "Are you sure you want to close this direct message? This will remove it from your home screen, but you can always open it again.",
"channel_info.close_gm": "Close group message",
"channel_info.close_gm_channel": "Are you sure you want to close this group message? This will remove it from your home screen, but you can always open it again.",
"channel_info.convert_failed": "We were unable to convert {displayName} to a private channel.",
"channel_info.convert_private": "Convert to private channel",
"channel_info.convert_private_description": "When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?",
"channel_info.convert_private_success": "{displayName} is now a private channel.",
"channel_info.convert_private_title": "Convert {displayName} to a private channel?",
"channel_info.copied": "Copied",
"channel_info.copy_link": "Copy Link",
"channel_info.custom_status": "Custom status:",
"channel_info.edit_header": "Edit Header",
"channel_info.error_close": "Close",
"channel_info.favorite": "Favorite",
"channel_info.favorited": "Favorited",
"channel_info.header": "Header:",
"channel_info.ignore_mentions": "Ignore @channel, @here, @all",
"channel_info.leave": "Leave",
"channel_info.leave_channel": "Leave channel",
"channel_info.leave_private_channel": "Are you sure you want to leave the private channel {displayName}? You cannot rejoin the channel unless you're invited again.",
"channel_info.leave_public_channel": "Are you sure you want to leave the public channel {displayName}? You can always rejoin.",
"channel_info.members": "Members",
"channel_info.mobile_notifications": "Mobile Notifications",
"channel_info.muted": "Mute",
"channel_info.notification.all": "All",
"channel_info.notification.default": "Default",
"channel_info.notification.mention": "Mentions",
"channel_info.notification.none": "Never",
"channel_info.pinned_messages": "Pinned Messages",
"channel_info.private_channel": "Private Channel",
"channel_info.public_channel": "Public Channel",
"channel_info.set_header": "Set Header",
"channel_info.unarchive": "Unarchive Channel",
"channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?",
"channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}",
"channel_info.unarchive_title": "Unarchive {term}",
"channel_intro.createdBy": "Created by {user} on {date}",
"channel_intro.createdOn": "Created on {date}",
"channel_list.channels_category": "Channels",
"channel_list.dms_category": "Direct messages",
"channel_list.favorites_category": "Favorites",
@@ -579,8 +610,11 @@
"screen.search.header.messages": "Messages",
"screen.search.placeholder": "Search messages & files",
"screen.search.title": "Search",
"screens.channel_edit": "Edit Channel",
"screens.channel_edit_header": "Edit Channel Header",
"screens.channel_info": "Channel Info",
"screens.channel_info.dm": "Direct message info",
"screens.channel_info.gm": "Group message info",
"search_bar.search": "Search",
"select_team.description": "You are not yet a member of any teams. Select one below to get started.",
"select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.",

View File

@@ -8,11 +8,13 @@ type ChannelStats = {
pinnedpost_count: number;
};
type NotificationLevel = 'default' | 'all' | 'mention' | 'none';
type ChannelNotifyProps = {
desktop: 'default' | 'all' | 'mention' | 'none';
email: 'default' | 'all' | 'mention' | 'none';
desktop: NotificationLevel;
email: NotificationLevel;
mark_unread: 'all' | 'mention';
push: 'default' | 'all' | 'mention' | 'none';
push: NotificationLevel;
ignore_channel_mentions: 'default' | 'off' | 'on';
};
type Channel = {

View File

@@ -43,6 +43,8 @@ type UserProfile = {
last_picture_update: number;
remote_id?: string;
status?: string;
bot_description?: string;
bot_last_icon_update?: number;
};
type UsersState = {