From a0f25f0e3b6b02629a82f7ea4b7c7b3b4cc40e9a Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 2 Jun 2022 16:09:12 -0400 Subject: [PATCH] [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 --- app/actions/remote/channel.ts | 96 +++++++++- app/actions/websocket/channel.ts | 10 +- .../channel_actions/add_people_box/index.tsx | 13 +- .../channel_actions/channel_actions.tsx | 78 ++++++++ .../channel_actions}/index.ts | 7 +- .../channel_actions/info_box/index.tsx | 19 +- .../leave_channel_label/index.ts | 14 +- .../leave_channel_label.tsx | 29 ++- .../set_header_box/set_header.tsx | 12 +- .../custom_status/custom_status_expiry.tsx | 10 +- .../floating_text_input_label/index.tsx | 4 +- app/components/markdown/markdown.tsx | 46 ++++- .../markdown/markdown_image/index.tsx | 5 +- app/components/option_box/animated.tsx | 11 +- app/components/option_box/index.tsx | 1 - app/components/option_item/index.tsx | 177 ++++++++++++++++++ app/components/profile_picture/image.tsx | 9 +- app/components/slide_up_panel_item/index.tsx | 8 +- app/constants/channel.ts | 6 + app/constants/index.ts | 2 + app/constants/notification_level.ts | 14 ++ app/constants/screens.ts | 8 +- .../server_data_operator/transformers/user.ts | 17 +- app/screens/channel/header/header.tsx | 46 ++++- app/screens/channel/header/index.ts | 2 + .../channel/header/quick_actions/index.tsx | 53 ++++++ .../header/quick_actions/quick_actions.tsx | 87 --------- app/screens/channel_info/channel_info.tsx | 96 ++++++++++ .../destructive_options/archive/archive.tsx | 137 ++++++++++++++ .../destructive_options/archive/index.ts | 79 ++++++++ .../convert_private/convert_private.tsx | 85 +++++++++ .../convert_private/index.ts | 42 +++++ .../destructive_options/index.tsx | 39 ++++ app/screens/channel_info/extra/extra.tsx | 166 ++++++++++++++++ app/screens/channel_info/extra/index.ts | 59 ++++++ app/screens/channel_info/index.ts | 28 +++ .../options/edit_channel/index.tsx | 35 ++++ .../ignore_mentions/ignore_mentions.tsx | 41 ++++ .../options/ignore_mentions/index.ts | 49 +++++ app/screens/channel_info/options/index.tsx | 37 ++++ .../channel_info/options/members/index.ts | 30 +++ .../channel_info/options/members/members.tsx | 37 ++++ .../options/notification_preference/index.ts | 30 +++ .../notification_preference.tsx | 66 +++++++ .../options/pinned_messages/index.ts | 30 +++ .../pinned_messages/pinned_messages.tsx | 37 ++++ .../title/direct_message/direct_message.tsx | 99 ++++++++++ .../title/direct_message/index.ts | 42 +++++ .../title/group_message/avatars/avatars.tsx | 66 +++++++ .../title/group_message/avatars/index.ts | 17 ++ .../title/group_message/group_message.tsx | 50 +++++ .../channel_info/title/group_message/index.ts | 31 +++ app/screens/channel_info/title/index.ts | 28 +++ .../title/public_private/index.ts | 28 +++ .../title/public_private/public_private.tsx | 46 +++++ app/screens/channel_info/title/title.tsx | 63 +++++++ .../create_or_edit_channel.tsx | 24 ++- .../custom_status/components/clear_after.tsx | 5 +- app/screens/custom_status/index.tsx | 3 +- .../components/clear_after_menu_item.tsx | 1 - .../options/custom_status/custom_label.tsx | 6 +- .../options/custom_status/index.tsx | 1 - app/screens/index.tsx | 3 + app/store/ephemeral_store.ts | 28 +++ app/utils/markdown/index.ts | 3 +- app/utils/user/index.ts | 2 +- assets/base/i18n/en.json | 34 ++++ types/api/channels.d.ts | 8 +- types/api/users.d.ts | 2 + 69 files changed, 2329 insertions(+), 168 deletions(-) create mode 100644 app/components/channel_actions/channel_actions.tsx rename app/{screens/channel/header/quick_actions => components/channel_actions}/index.ts (77%) create mode 100644 app/components/option_item/index.tsx create mode 100644 app/constants/notification_level.ts create mode 100644 app/screens/channel/header/quick_actions/index.tsx delete mode 100644 app/screens/channel/header/quick_actions/quick_actions.tsx create mode 100644 app/screens/channel_info/channel_info.tsx create mode 100644 app/screens/channel_info/destructive_options/archive/archive.tsx create mode 100644 app/screens/channel_info/destructive_options/archive/index.ts create mode 100644 app/screens/channel_info/destructive_options/convert_private/convert_private.tsx create mode 100644 app/screens/channel_info/destructive_options/convert_private/index.ts create mode 100644 app/screens/channel_info/destructive_options/index.tsx create mode 100644 app/screens/channel_info/extra/extra.tsx create mode 100644 app/screens/channel_info/extra/index.ts create mode 100644 app/screens/channel_info/index.ts create mode 100644 app/screens/channel_info/options/edit_channel/index.tsx create mode 100644 app/screens/channel_info/options/ignore_mentions/ignore_mentions.tsx create mode 100644 app/screens/channel_info/options/ignore_mentions/index.ts create mode 100644 app/screens/channel_info/options/index.tsx create mode 100644 app/screens/channel_info/options/members/index.ts create mode 100644 app/screens/channel_info/options/members/members.tsx create mode 100644 app/screens/channel_info/options/notification_preference/index.ts create mode 100644 app/screens/channel_info/options/notification_preference/notification_preference.tsx create mode 100644 app/screens/channel_info/options/pinned_messages/index.ts create mode 100644 app/screens/channel_info/options/pinned_messages/pinned_messages.tsx create mode 100644 app/screens/channel_info/title/direct_message/direct_message.tsx create mode 100644 app/screens/channel_info/title/direct_message/index.ts create mode 100644 app/screens/channel_info/title/group_message/avatars/avatars.tsx create mode 100644 app/screens/channel_info/title/group_message/avatars/index.ts create mode 100644 app/screens/channel_info/title/group_message/group_message.tsx create mode 100644 app/screens/channel_info/title/group_message/index.ts create mode 100644 app/screens/channel_info/title/index.ts create mode 100644 app/screens/channel_info/title/public_private/index.ts create mode 100644 app/screens/channel_info/title/public_private/public_private.tsx create mode 100644 app/screens/channel_info/title/title.tsx diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 97b6db09d2..88a5be719c 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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); + } +}; diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 920fc4f98e..a57facae43 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -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; } diff --git a/app/components/channel_actions/add_people_box/index.tsx b/app/components/channel_actions/add_people_box/index.tsx index 7be4b322e5..d3b106be80 100644 --- a/app/components/channel_actions/add_people_box/index.tsx +++ b/app/components/channel_actions/add_people_box/index.tsx @@ -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; + 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 ( { + const onCopyLinkAnimationEnd = useCallback(() => { + if (!inModal) { + requestAnimationFrame(async () => { + await dismissBottomSheet(); + }); + } + }, [inModal]); + + return ( + + + + + + {channelType && DIRECT_CHANNELS.includes(channelType) && + + } + {channelType && !DIRECT_CHANNELS.includes(channelType) && + <> + + + + + } + + ); +}; + +export default ChannelActions; diff --git a/app/screens/channel/header/quick_actions/index.ts b/app/components/channel_actions/index.ts similarity index 77% rename from app/screens/channel/header/quick_actions/index.ts rename to app/components/channel_actions/index.ts index 9dafd00df5..89d01bac5b 100644 --- a/app/screens/channel/header/quick_actions/index.ts +++ b/app/components/channel_actions/index.ts @@ -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)); diff --git a/app/components/channel_actions/info_box/index.tsx b/app/components/channel_actions/info_box/index.tsx index 73b9d43025..4eb377fc1e 100644 --- a/app/components/channel_actions/info_box/index.tsx +++ b/app/components/channel_actions/info_box/index.tsx @@ -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 ( diff --git a/app/components/channel_actions/leave_channel_label/index.ts b/app/components/channel_actions/leave_channel_label/index.ts index 9370b118e1..d1d39d4d8f 100644 --- a/app/components/channel_actions/leave_channel_label/index.ts +++ b/app/components/channel_actions/leave_channel_label/index.ts @@ -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, }; diff --git a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx index 5160829a3f..e4544209f9 100644 --- a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx +++ b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx @@ -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 ( + + ); + } + return ( ; 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]); diff --git a/app/components/custom_status/custom_status_expiry.tsx b/app/components/custom_status/custom_status_expiry.tsx index 62d1c3a4f7..c3e484db81 100644 --- a/app/components/custom_status/custom_status_expiry.tsx +++ b/app/components/custom_status/custom_status_expiry.tsx @@ -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 ); } else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) { @@ -68,6 +70,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo ); } 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 ( )} {showPrefix && ' '} @@ -109,6 +114,7 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo {' '} @@ -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( diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index f38baac551..1b886d34af 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -132,8 +132,8 @@ const FloatingTextInput = forwardRef { - const [focused, setIsFocused] = useState(Boolean(value) && editable); - const [focusedLabel, setIsFocusLabel] = useState(Boolean(placeholder || value)); + const [focused, setIsFocused] = useState(false); + const [focusedLabel, setIsFocusLabel] = useState(); const inputRef = useRef(null); const debouncedOnFocusTextInput = debounce(setIsFocusLabel, 500, {leading: true, trailing: false}); const styles = getStyleSheet(theme); diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 8c63b7ec26..ac12ecea0b 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -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; 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 { @@ -148,6 +158,10 @@ const Markdown = ({ }; const renderBlockQuote = ({children, ...otherProps}: any) => { + if (disableBlockQuote) { + return null; + } + return ( { + 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 ( + + {children} + + ); + } + const containerStyle = [ style.block, textStyles[`heading${level}`], ]; const textStyle = textStyles[`heading${level}Text`]; + return ( @@ -314,6 +341,7 @@ const Markdown = ({ { + if (disableTables) { + return null; + } return ( !c.startsWith('heading'))); + } else { + styles = computeTextStyle(textStyles, baseTextStyle, context); + } return ( createRenderer(), [theme]); + const renderer = useMemo(createRenderer, [theme, textStyles]); let ast = parser.parse(value.toString()); ast = combineTextNodes(ast); diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index 58db85a5d3..bbfefdc1f4 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -37,6 +37,7 @@ type MarkdownImageProps = { imagesMetadata: Record; 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; diff --git a/app/components/option_box/animated.tsx b/app/components/option_box/animated.tsx index 6cf5b3a28d..7e5880bcef 100644 --- a/app/components/option_box/animated.tsx +++ b/app/components/option_box/animated.tsx @@ -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 = ({ {({pressed}) => ( - + - + )} ); diff --git a/app/components/option_box/index.tsx b/app/components/option_box/index.tsx index 5b711dee59..131f3422fc 100644 --- a/app/components/option_box/index.tsx +++ b/app/components/option_box/index.tsx @@ -35,7 +35,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ text: { color: changeOpacity(theme.centerChannelColor, 0.56), paddingHorizontal: 5, - textTransform: 'capitalize', ...typography('Body', 50, 'SemiBold'), }, })); diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx new file mode 100644 index 0000000000..5102575168 --- /dev/null +++ b/app/components/option_item/index.tsx @@ -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 = ( + + ); + } else if (type === OptionType.TOGGLE) { + actionComponent = ( + + ); + } else if (type === OptionType.ARROW) { + actionComponent = ( + + ); + } + + const onPress = useCallback(() => { + action(value || ''); + }, [value, action]); + + const component = ( + + + + {Boolean(icon) && ( + + + + )} + + + {label} + + {Boolean(description) && + + {description} + + } + + + + {Boolean(actionComponent) && + + {Boolean(info) && + + {info} + + } + {actionComponent} + + } + + ); + + if (type === OptionType.DEFAULT || type === OptionType.SELECT || type === OptionType.ARROW) { + return ( + + {component} + + ); + } + + return component; +}; + +export default OptionItem; diff --git a/app/components/profile_picture/image.tsx b/app/components/profile_picture/image.tsx index 90aa11f7fd..6ffcb954c2 100644 --- a/app/components/profile_picture/image.tsx +++ b/app/components/profile_picture/image.tsx @@ -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 ( diff --git a/app/components/slide_up_panel_item/index.tsx b/app/components/slide_up_panel_item/index.tsx index 24342f99f7..840d8ec5e0 100644 --- a/app/components/slide_up_panel_item/index.tsx +++ b/app/components/slide_up_panel_item/index.tsx @@ -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: { diff --git a/app/constants/channel.ts b/app/constants/channel.ts index 79f3b897ea..2c94cb3e92 100644 --- a/app/constants/channel.ts +++ b/app/constants/channel.ts @@ -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, }; diff --git a/app/constants/index.ts b/app/constants/index.ts index 0f6c9a2006..88c04fb2fe 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -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, diff --git a/app/constants/notification_level.ts b/app/constants/notification_level.ts new file mode 100644 index 0000000000..e5d7711157 --- /dev/null +++ b/app/constants/notification_level.ts @@ -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, +}; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 5078e82034..ac8c6a66f9 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -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, ]; diff --git a/app/database/operator/server_data_operator/transformers/user.ts b/app/database/operator/server_data_operator/transformers/user.ts index 5d00723514..6c10cd93ce 100644 --- a/app/database/operator/server_data_operator/transformers/user.ts +++ b/app/database/operator/server_data_operator/transformers/user.ts @@ -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({ diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 88008aafd3..940d33ea1c 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -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 ; + return ( + + ); }; 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', diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index 64faafa5e2..f4e9724f57 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -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, diff --git a/app/screens/channel/header/quick_actions/index.tsx b/app/screens/channel/header/quick_actions/index.tsx new file mode 100644 index 0000000000..cf2c482075 --- /dev/null +++ b/app/screens/channel/header/quick_actions/index.tsx @@ -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 ( + + + + + + + + + ); +}; + +export default ChannelQuickAction; diff --git a/app/screens/channel/header/quick_actions/quick_actions.tsx b/app/screens/channel/header/quick_actions/quick_actions.tsx deleted file mode 100644 index c870465a2d..0000000000 --- a/app/screens/channel/header/quick_actions/quick_actions.tsx +++ /dev/null @@ -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 ( - - - - - - - {channelType && DIRECT_CHANNELS.includes(channelType) && - - } - {channelType && !DIRECT_CHANNELS.includes(channelType) && - <> - - - - - } - - - - - - ); -}; - -export default ChannelQuickAction; diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx new file mode 100644 index 0000000000..91bc7a4b36 --- /dev/null +++ b/app/screens/channel_info/channel_info.tsx @@ -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 ( + + + + <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; diff --git a/app/screens/channel_info/destructive_options/archive/archive.tsx b/app/screens/channel_info/destructive_options/archive/archive.tsx new file mode 100644 index 0000000000..067447d8e0 --- /dev/null +++ b/app/screens/channel_info/destructive_options/archive/archive.tsx @@ -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; diff --git a/app/screens/channel_info/destructive_options/archive/index.ts b/app/screens/channel_info/destructive_options/archive/index.ts new file mode 100644 index 0000000000..aa3079fec6 --- /dev/null +++ b/app/screens/channel_info/destructive_options/archive/index.ts @@ -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)); diff --git a/app/screens/channel_info/destructive_options/convert_private/convert_private.tsx b/app/screens/channel_info/destructive_options/convert_private/convert_private.tsx new file mode 100644 index 0000000000..63a08dc2b6 --- /dev/null +++ b/app/screens/channel_info/destructive_options/convert_private/convert_private.tsx @@ -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; diff --git a/app/screens/channel_info/destructive_options/convert_private/index.ts b/app/screens/channel_info/destructive_options/convert_private/index.ts new file mode 100644 index 0000000000..1a58b54e57 --- /dev/null +++ b/app/screens/channel_info/destructive_options/convert_private/index.ts @@ -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)); diff --git a/app/screens/channel_info/destructive_options/index.tsx b/app/screens/channel_info/destructive_options/index.tsx new file mode 100644 index 0000000000..dddad4d759 --- /dev/null +++ b/app/screens/channel_info/destructive_options/index.tsx @@ -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; diff --git a/app/screens/channel_info/extra/extra.tsx b/app/screens/channel_info/extra/extra.tsx new file mode 100644 index 0000000000..1608e533af --- /dev/null +++ b/app/screens/channel_info/extra/extra.tsx @@ -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; diff --git a/app/screens/channel_info/extra/index.ts b/app/screens/channel_info/extra/index.ts new file mode 100644 index 0000000000..f09aa45879 --- /dev/null +++ b/app/screens/channel_info/extra/index.ts @@ -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)); diff --git a/app/screens/channel_info/index.ts b/app/screens/channel_info/index.ts new file mode 100644 index 0000000000..5004c9d859 --- /dev/null +++ b/app/screens/channel_info/index.ts @@ -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)); diff --git a/app/screens/channel_info/options/edit_channel/index.tsx b/app/screens/channel_info/options/edit_channel/index.tsx new file mode 100644 index 0000000000..669b1602f0 --- /dev/null +++ b/app/screens/channel_info/options/edit_channel/index.tsx @@ -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; diff --git a/app/screens/channel_info/options/ignore_mentions/ignore_mentions.tsx b/app/screens/channel_info/options/ignore_mentions/ignore_mentions.tsx new file mode 100644 index 0000000000..6c79aaba53 --- /dev/null +++ b/app/screens/channel_info/options/ignore_mentions/ignore_mentions.tsx @@ -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; diff --git a/app/screens/channel_info/options/ignore_mentions/index.ts b/app/screens/channel_info/options/ignore_mentions/index.ts new file mode 100644 index 0000000000..e56cc1d133 --- /dev/null +++ b/app/screens/channel_info/options/ignore_mentions/index.ts @@ -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)); diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx new file mode 100644 index 0000000000..482b3d87fa --- /dev/null +++ b/app/screens/channel_info/options/index.tsx @@ -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; diff --git a/app/screens/channel_info/options/members/index.ts b/app/screens/channel_info/options/members/index.ts new file mode 100644 index 0000000000..5ca0559fa0 --- /dev/null +++ b/app/screens/channel_info/options/members/index.ts @@ -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)); diff --git a/app/screens/channel_info/options/members/members.tsx b/app/screens/channel_info/options/members/members.tsx new file mode 100644 index 0000000000..9f615575b3 --- /dev/null +++ b/app/screens/channel_info/options/members/members.tsx @@ -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; diff --git a/app/screens/channel_info/options/notification_preference/index.ts b/app/screens/channel_info/options/notification_preference/index.ts new file mode 100644 index 0000000000..77f52b75c1 --- /dev/null +++ b/app/screens/channel_info/options/notification_preference/index.ts @@ -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)); diff --git a/app/screens/channel_info/options/notification_preference/notification_preference.tsx b/app/screens/channel_info/options/notification_preference/notification_preference.tsx new file mode 100644 index 0000000000..b98abcacbf --- /dev/null +++ b/app/screens/channel_info/options/notification_preference/notification_preference.tsx @@ -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; diff --git a/app/screens/channel_info/options/pinned_messages/index.ts b/app/screens/channel_info/options/pinned_messages/index.ts new file mode 100644 index 0000000000..df4ec24e00 --- /dev/null +++ b/app/screens/channel_info/options/pinned_messages/index.ts @@ -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)); diff --git a/app/screens/channel_info/options/pinned_messages/pinned_messages.tsx b/app/screens/channel_info/options/pinned_messages/pinned_messages.tsx new file mode 100644 index 0000000000..dc7c5ff6c8 --- /dev/null +++ b/app/screens/channel_info/options/pinned_messages/pinned_messages.tsx @@ -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; diff --git a/app/screens/channel_info/title/direct_message/direct_message.tsx b/app/screens/channel_info/title/direct_message/direct_message.tsx new file mode 100644 index 0000000000..bc576019d2 --- /dev/null +++ b/app/screens/channel_info/title/direct_message/direct_message.tsx @@ -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; diff --git a/app/screens/channel_info/title/direct_message/index.ts b/app/screens/channel_info/title/direct_message/index.ts new file mode 100644 index 0000000000..c25103974a --- /dev/null +++ b/app/screens/channel_info/title/direct_message/index.ts @@ -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)); diff --git a/app/screens/channel_info/title/group_message/avatars/avatars.tsx b/app/screens/channel_info/title/group_message/avatars/avatars.tsx new file mode 100644 index 0000000000..ed9f4786cc --- /dev/null +++ b/app/screens/channel_info/title/group_message/avatars/avatars.tsx @@ -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; diff --git a/app/screens/channel_info/title/group_message/avatars/index.ts b/app/screens/channel_info/title/group_message/avatars/index.ts new file mode 100644 index 0000000000..bce9edb233 --- /dev/null +++ b/app/screens/channel_info/title/group_message/avatars/index.ts @@ -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)); diff --git a/app/screens/channel_info/title/group_message/group_message.tsx b/app/screens/channel_info/title/group_message/group_message.tsx new file mode 100644 index 0000000000..6d5b35cca6 --- /dev/null +++ b/app/screens/channel_info/title/group_message/group_message.tsx @@ -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; diff --git a/app/screens/channel_info/title/group_message/index.ts b/app/screens/channel_info/title/group_message/index.ts new file mode 100644 index 0000000000..0a7db43326 --- /dev/null +++ b/app/screens/channel_info/title/group_message/index.ts @@ -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)); diff --git a/app/screens/channel_info/title/index.ts b/app/screens/channel_info/title/index.ts new file mode 100644 index 0000000000..4fe752bb56 --- /dev/null +++ b/app/screens/channel_info/title/index.ts @@ -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)); diff --git a/app/screens/channel_info/title/public_private/index.ts b/app/screens/channel_info/title/public_private/index.ts new file mode 100644 index 0000000000..b9448cae5d --- /dev/null +++ b/app/screens/channel_info/title/public_private/index.ts @@ -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)); diff --git a/app/screens/channel_info/title/public_private/public_private.tsx b/app/screens/channel_info/title/public_private/public_private.tsx new file mode 100644 index 0000000000..4e5d55f9db --- /dev/null +++ b/app/screens/channel_info/title/public_private/public_private.tsx @@ -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; diff --git a/app/screens/channel_info/title/title.tsx b/app/screens/channel_info/title/title.tsx new file mode 100644 index 0000000000..353bc757f9 --- /dev/null +++ b/app/screens/channel_info/title/title.tsx @@ -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; diff --git a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx index 81e7612fd9..12a125bf0d 100644 --- a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx +++ b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx @@ -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 diff --git a/app/screens/custom_status/components/clear_after.tsx b/app/screens/custom_status/components/clear_after.tsx index be4dfa0ba9..9b637266c3 100644 --- a/app/screens/custom_status/components/clear_after.tsx +++ b/app/screens/custom_status/components/clear_after.tsx @@ -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()} diff --git a/app/screens/custom_status/index.tsx b/app/screens/custom_status/index.tsx index a7d78bc98c..18e360912b 100644 --- a/app/screens/custom_status/index.tsx +++ b/app/screens/custom_status/index.tsx @@ -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} diff --git a/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx index 47e402b434..8a6b25a859 100644 --- a/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx +++ b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx @@ -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} diff --git a/app/screens/home/account/components/options/custom_status/custom_label.tsx b/app/screens/home/account/components/options/custom_status/custom_label.tsx index e169324206..a430c85e4a 100644 --- a/app/screens/home/account/components/options/custom_status/custom_label.tsx +++ b/app/screens/home/account/components/options/custom_status/custom_label.tsx @@ -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} diff --git a/app/screens/home/account/components/options/custom_status/index.tsx b/app/screens/home/account/components/options/custom_status/index.tsx index 2df8d338f8..4e9b845824 100644 --- a/app/screens/home/account/components/options/custom_status/index.tsx +++ b/app/screens/home/account/components/options/custom_status/index.tsx @@ -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} diff --git a/app/screens/index.tsx b/app/screens/index.tsx index a4e0118f4e..488e59ab2c 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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; diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 96565321c8..dff03f2f23 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -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); diff --git a/app/utils/markdown/index.ts b/app/utils/markdown/index.ts index 865faae08d..612f4dd2a7 100644 --- a/app/utils/markdown/index.ts +++ b/app/utils/markdown/index.ts @@ -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}; }; diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index ca5ddbfe44..773d283d58 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -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) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 3a5805d57b..f51fa2d754 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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.", diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts index 1f14f292a4..32d8a6bf89 100644 --- a/types/api/channels.d.ts +++ b/types/api/channels.d.ts @@ -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 = { diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 09be2f8073..413e389e2b 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -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 = {