diff --git a/app/actions/local/category.ts b/app/actions/local/category.ts index f1f9eee7e..9d853daf4 100644 --- a/app/actions/local/category.ts +++ b/app/actions/local/category.ts @@ -3,12 +3,13 @@ import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories'; import DatabaseManager from '@database/manager'; -import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; +import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels, queryCategoryChannelsByChannelId} from '@queries/servers/categories'; import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; import {isDMorGM} from '@utils/channel'; -import {logError} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; +import type {Database, Model} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; export const deleteCategory = async (serverUrl: string, categoryId: string) => { @@ -91,11 +92,8 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch categoriesWithChannels.push(cwc); } } else { - const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); - const channelCategory = categories.find((c) => c.type === CHANNELS_CATEGORY); - if (channelCategory) { - const cwc = await channelCategory.toCategoryWithChannels(); - cwc.channel_ids.unshift(channel.id); + const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, teamId, channel.id); + if (cwc) { categoriesWithChannels.push(cwc); } } @@ -108,6 +106,61 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch return {models}; } catch (error) { + logError('Failed to add channel to default category', error); + return {error}; + } +} + +async function prepareAddNonGMDMChannelToDefaultCategory(database: Database, teamId: string, channelId: string): Promise { + const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); + const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY); + if (channelCategory) { + const cwc = await channelCategory.toCategoryWithChannels(); + if (cwc.channel_ids.indexOf(channelId) < 0) { + cwc.channel_ids.unshift(channelId); + return cwc; + } + } + + return undefined; +} + +export async function handleConvertedGMCategories(serverUrl: string, channelId: string, targetTeamID: string, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const categoryChannels = await queryCategoryChannelsByChannelId(database, channelId).fetch(); + + const categories = await queryCategoriesByTeamIds(database, [targetTeamID]).fetch(); + const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY); + + if (!channelCategory) { + logError('Failed to find default category when handling category of converted GM'); + return {}; + } + + const models: Model[] = []; + + categoryChannels.forEach((categoryChannel) => { + if (categoryChannel.categoryId !== channelCategory.id) { + models.push(categoryChannel.prepareDestroyPermanently()); + } + }); + + const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, targetTeamID, channelId); + if (cwc) { + const model = await prepareCategoryChannels(operator, [cwc]); + models.push(...model); + } else { + logDebug('handleConvertedGMCategories: could not find channel category of target team'); + } + + if (models.length > 0 && !prepareRecordsOnly) { + await operator.batchRecords(models, 'putGMInCorrectCategory'); + } + + return {models}; + } catch (error) { + logError('Failed to handle category update for GM converted to channel', error); return {error}; } } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 7f2ffb191..4eda3e3bf 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ import {DeviceEventEmitter} from 'react-native'; -import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category'; +import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category'; import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; @@ -231,6 +231,7 @@ export async function createChannel(serverUrl: string, displayName: string, purp const resolvedModels = await Promise.all(channelModels); models.push(...resolvedModels.flat()); } + const categoriesModels = await addChannelToDefaultCategory(serverUrl, channelData, true); if (categoriesModels.models?.length) { models.push(...categoriesModels.models); @@ -1262,3 +1263,56 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string logDebug('cannot kick user from channel', error); } }; + +export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, channelId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const teams = await client.getGroupMessageMembersCommonTeams(channelId); + return {teams}; + } catch (error) { + logDebug('error on getGroupMessageMembersCommonTeams', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => { + try { + const name = generateChannelNameFromDisplayName(displayName); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const existingChannel = await getChannelById(database, channelId); + if (existingChannel) { + EphemeralStore.addConvertingChannel(channelId); + } + + const client = NetworkManager.getClient(serverUrl); + const updatedChannel = await client.convertGroupMessageToPrivateChannel(channelId, targetTeamId, displayName, name); + + if (existingChannel) { + existingChannel.prepareUpdate((channel) => { + channel.type = General.PRIVATE_CHANNEL; + channel.displayName = displayName; + channel.name = name; + channel.teamId = targetTeamId; + }); + + const models: Model[] = [existingChannel]; + + const {models: categoryUpdateModels} = await handleConvertedGMCategories(serverUrl, channelId, targetTeamId, true); + if (categoryUpdateModels) { + models.push(...categoryUpdateModels); + } + + await operator.batchRecords(models, 'convertGroupMessageToPrivateChannel'); + } + + return {updatedChannel}; + } catch (error) { + logError('error on convertGroupMessageToPrivateChannel', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } finally { + EphemeralStore.removeConvertingChannel(channelId); + } +}; diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 088920cbc..bb5d79902 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {addChannelToDefaultCategory} from '@actions/local/category'; +import {addChannelToDefaultCategory, handleConvertedGMCategories} from '@actions/local/category'; import { markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, @@ -12,10 +12,10 @@ import {fetchPostsForChannel} from '@actions/remote/post'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'; import {loadCallForChannel} from '@calls/actions/calls'; -import {Events} from '@constants'; +import {Events, General} from '@constants'; import DatabaseManager from '@database/manager'; import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel'; -import {getConfig, getCurrentChannelId} from '@queries/servers/system'; +import {getConfig, getCurrentChannelId, getCurrentTeamId, setCurrentTeamId} from '@queries/servers/system'; import {getCurrentUser, getTeammateNameDisplay, getUserById} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -93,14 +93,35 @@ export async function handleChannelConvertedEvent(serverUrl: string, msg: any) { export async function handleChannelUpdatedEvent(serverUrl: string, msg: any) { try { const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const updatedChannel = JSON.parse(msg.data.channel) as Channel; + + if (EphemeralStore.isConvertingChannel(updatedChannel.id)) { + return; + } + + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const existingChannel = await getChannelById(database, updatedChannel.id); + const existingChannelType = existingChannel?.type; - const updatedChannel = JSON.parse(msg.data.channel); const models: Model[] = await operator.handleChannel({channels: [updatedChannel], prepareRecordsOnly: true}); const infoModel = await updateChannelInfoFromChannel(serverUrl, updatedChannel, true); if (infoModel.model) { models.push(...infoModel.model); } operator.batchRecords(models, 'handleChannelUpdatedEvent'); + + // This indicates a GM was converted to a private channel + if (existingChannelType === General.GM_CHANNEL && updatedChannel.type === General.PRIVATE_CHANNEL) { + await handleConvertedGMCategories(serverUrl, updatedChannel.id, updatedChannel.team_id); + + const currentChannelId = await getCurrentChannelId(database); + const currentTeamId = await getCurrentTeamId(database); + + // Making sure user is in the correct team + if (currentChannelId === updatedChannel.id && currentTeamId !== updatedChannel.team_id) { + await setCurrentTeamId(operator, updatedChannel.team_id); + } + } } catch { // Do nothing } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 1fd390b60..80577f973 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -44,6 +44,8 @@ export interface ClientChannelsMix { searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise; updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise; getMemberInChannel: (channelId: string, userId: string) => Promise; + getGroupMessageMembersCommonTeams: (channelId: string) => Promise; + convertGroupMessageToPrivateChannel: (channelId: string, teamId: string, displayName: string, name: string) => Promise; } const ClientChannels = >(superclass: TBase) => class extends superclass { @@ -350,6 +352,27 @@ const ClientChannels = >(superclass: TBase {method: 'get'}, ); }; + + getGroupMessageMembersCommonTeams = (channelId: string) => { + return this.doFetch( + `${this.getChannelRoute(channelId)}/common_teams`, + {method: 'get'}, + ); + }; + + convertGroupMessageToPrivateChannel = (channelId: string, teamId: string, displayName: string, name: string) => { + const body = { + channel_id: channelId, + team_id: teamId, + display_name: displayName, + name, + }; + + return this.doFetch( + `${this.getChannelRoute(channelId)}/convert_to_channel?team-id=${teamId}`, + {method: 'post', body}, + ); + }; }; export default ClientChannels; diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index 9aa6c60d4..eb8bde1c3 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useMemo} from 'react'; +import React, {useMemo, type ReactNode} from 'react'; import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import RNButton from 'react-native-button'; @@ -21,6 +21,7 @@ type Props = ConditionalProps & { testID?: string; onPress: () => void; text: string; + iconComponent?: ReactNode; } const styles = StyleSheet.create({ @@ -41,6 +42,7 @@ const Button = ({ testID, iconName, iconSize, + iconComponent, }: Props) => { const bgStyle = useMemo(() => [ buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState), @@ -61,6 +63,21 @@ const Button = ({ [iconSize], ); + let icon: ReactNode; + + if (iconComponent) { + icon = iconComponent; + } else if (iconName) { + icon = ( + + ); + } + return ( - {Boolean(iconName) && - - } + {icon} { + const {formatMessage} = useIntl(); + + const goToConvertToPrivateChannel = useCallback(preventDoubleTap(async () => { + await dismissBottomSheet(); + const title = formatMessage({id: 'channel_info.convert_gm_to_channel.screen_title', defaultMessage: 'Convert to Private Channel'}); + goToScreen(Screens.CONVERT_GM_TO_CHANNEL, title, {channelId}); + }), [channelId]); + + return ( + + ); +}; + +export default ConvertToChannelLabel; diff --git a/app/components/loading/index.tsx b/app/components/loading/index.tsx index f0636c854..21b2f3169 100644 --- a/app/components/loading/index.tsx +++ b/app/components/loading/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import {ActivityIndicator, type StyleProp, View, type ViewStyle} from 'react-native'; +import {ActivityIndicator, type StyleProp, View, type ViewStyle, Text, type TextStyle} from 'react-native'; import {useTheme} from '@context/theme'; @@ -11,9 +11,18 @@ type LoadingProps = { size?: number | 'small' | 'large'; color?: string; themeColor?: keyof Theme; + footerText?: string; + footerTextStyles?: TextStyle; } -const Loading = ({containerStyle, size, color, themeColor}: LoadingProps) => { +const Loading = ({ + containerStyle, + size, + color, + themeColor, + footerText, + footerTextStyles, +}: LoadingProps) => { const theme = useTheme(); const indicatorColor = themeColor ? theme[themeColor] : color; @@ -23,6 +32,10 @@ const Loading = ({containerStyle, size, color, themeColor}: LoadingProps) => { color={indicatorColor} size={size} /> + { + footerText && + {footerText} + } ); }; diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 641ade7ec..28c59adf3 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -43,10 +43,14 @@ const hitSlop = {top: 11, bottom: 11, left: 11, right: 11}; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { actionContainer: { + flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 16, }, + actionSubContainer: { + marginLeft: 'auto', + }, container: { flexDirection: 'row', alignItems: 'center', @@ -61,8 +65,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginTop: DESCRIPTION_MARGIN_TOP, }, iconContainer: {marginRight: 16}, - infoContainer: {marginRight: 2}, info: { + flex: 1, + textAlign: 'right', color: changeOpacity(theme.centerChannelColor, 0.56), ...typography('Body', 100), }, @@ -99,7 +104,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { ...typography('Body', 200), }, row: { - flex: 1, + flex: 3, flexDirection: 'row', }, }; @@ -265,6 +270,7 @@ const OptionItem = ({ {label} @@ -284,16 +290,17 @@ const OptionItem = ({ { Boolean(info) && - - - {info} - - + + {info} + } - {actionComponent} + + {actionComponent} + } diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 358bdf236..2c0014171 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -13,6 +13,7 @@ export const CHANNEL_FILES = 'ChannelFiles'; export const CHANNEL_INFO = 'ChannelInfo'; export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences'; export const CODE = 'Code'; +export const CONVERT_GM_TO_CHANNEL = 'ConvertGMToChannel'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; export const CREATE_TEAM = 'CreateTeam'; @@ -65,6 +66,7 @@ export const SHARE_FEEDBACK = 'ShareFeedback'; export const SNACK_BAR = 'SnackBar'; export const SSO = 'SSO'; export const TABLE = 'Table'; +export const TEAM_SELECTOR_LIST = 'TeamSelectorList'; export const TERMS_OF_SERVICE = 'TermsOfService'; export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; @@ -84,6 +86,7 @@ export default { CHANNEL_INFO, CHANNEL_NOTIFICATION_PREFERENCES, CODE, + CONVERT_GM_TO_CHANNEL, CREATE_DIRECT_MESSAGE, CREATE_OR_EDIT_CHANNEL, CREATE_TEAM, @@ -136,6 +139,7 @@ export default { SNACK_BAR, SSO, TABLE, + TEAM_SELECTOR_LIST, TERMS_OF_SERVICE, THREAD, THREAD_FOLLOW_BUTTON, diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts index 39810710d..5ca22c999 100644 --- a/app/constants/server_errors.ts +++ b/app/constants/server_errors.ts @@ -7,4 +7,5 @@ export default { PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post', SEND_EMAIL_WITH_DEFAULTS_ERROR: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error', TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied', + DUPLICATE_CHANNEL_NAME: 'store.sql_channel.save_channel.exists.app_error', }; diff --git a/app/i18n/index.ts b/app/i18n/index.ts index 57d4adc5f..875b88968 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -23,6 +23,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/bg'); require('@formatjs/intl-numberformat/locale-data/bg'); require('@formatjs/intl-datetimeformat/locale-data/bg'); + require('@formatjs/intl-listformat/locale-data/bg'); translations = require('@assets/i18n/bg.json'); break; @@ -30,6 +31,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/de'); require('@formatjs/intl-numberformat/locale-data/de'); require('@formatjs/intl-datetimeformat/locale-data/de'); + require('@formatjs/intl-listformat/locale-data/de'); translations = require('@assets/i18n/de.json'); break; @@ -37,6 +39,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/en'); require('@formatjs/intl-numberformat/locale-data/en'); require('@formatjs/intl-datetimeformat/locale-data/en'); + require('@formatjs/intl-listformat/locale-data/en'); translations = require('@assets/i18n/en_AU.json'); break; @@ -44,6 +47,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/es'); require('@formatjs/intl-numberformat/locale-data/es'); require('@formatjs/intl-datetimeformat/locale-data/es'); + require('@formatjs/intl-listformat/locale-data/es'); translations = require('@assets/i18n/es.json'); break; @@ -51,6 +55,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/fa'); require('@formatjs/intl-numberformat/locale-data/fa'); require('@formatjs/intl-datetimeformat/locale-data/fa'); + require('@formatjs/intl-listformat/locale-data/fa'); translations = require('@assets/i18n/fa.json'); break; @@ -58,6 +63,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/fr'); require('@formatjs/intl-numberformat/locale-data/fr'); require('@formatjs/intl-datetimeformat/locale-data/fr'); + require('@formatjs/intl-listformat/locale-data/fr'); translations = require('@assets/i18n/fr.json'); break; @@ -65,6 +71,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/hu'); require('@formatjs/intl-numberformat/locale-data/hu'); require('@formatjs/intl-datetimeformat/locale-data/hu'); + require('@formatjs/intl-listformat/locale-data/hu'); translations = require('@assets/i18n/hu.json'); break; @@ -72,6 +79,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/it'); require('@formatjs/intl-numberformat/locale-data/it'); require('@formatjs/intl-datetimeformat/locale-data/it'); + require('@formatjs/intl-listformat/locale-data/it'); translations = require('@assets/i18n/it.json'); break; @@ -79,6 +87,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ja'); require('@formatjs/intl-numberformat/locale-data/ja'); require('@formatjs/intl-datetimeformat/locale-data/ja'); + require('@formatjs/intl-listformat/locale-data/ja'); translations = require('@assets/i18n/ja.json'); break; @@ -86,6 +95,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ko'); require('@formatjs/intl-numberformat/locale-data/ko'); require('@formatjs/intl-datetimeformat/locale-data/ko'); + require('@formatjs/intl-listformat/locale-data/ko'); translations = require('@assets/i18n/ko.json'); break; @@ -93,6 +103,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/nl'); require('@formatjs/intl-numberformat/locale-data/nl'); require('@formatjs/intl-datetimeformat/locale-data/nl'); + require('@formatjs/intl-listformat/locale-data/nl'); translations = require('@assets/i18n/nl.json'); break; @@ -100,6 +111,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/pl'); require('@formatjs/intl-numberformat/locale-data/pl'); require('@formatjs/intl-datetimeformat/locale-data/pl'); + require('@formatjs/intl-listformat/locale-data/pl'); translations = require('@assets/i18n/pl.json'); break; @@ -107,6 +119,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/pt'); require('@formatjs/intl-numberformat/locale-data/pt'); require('@formatjs/intl-datetimeformat/locale-data/pt'); + require('@formatjs/intl-listformat/locale-data/pt'); translations = require('@assets/i18n/pt-BR.json'); break; @@ -114,6 +127,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ro'); require('@formatjs/intl-numberformat/locale-data/ro'); require('@formatjs/intl-datetimeformat/locale-data/ro'); + require('@formatjs/intl-listformat/locale-data/ro'); translations = require('@assets/i18n/ro.json'); break; @@ -121,6 +135,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/ru'); require('@formatjs/intl-numberformat/locale-data/ru'); require('@formatjs/intl-datetimeformat/locale-data/ru'); + require('@formatjs/intl-listformat/locale-data/ru'); translations = require('@assets/i18n/ru.json'); break; @@ -128,6 +143,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/sv'); require('@formatjs/intl-numberformat/locale-data/sv'); require('@formatjs/intl-datetimeformat/locale-data/sv'); + require('@formatjs/intl-listformat/locale-data/sv'); translations = require('@assets/i18n/sv.json'); break; @@ -135,6 +151,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/tr'); require('@formatjs/intl-numberformat/locale-data/tr'); require('@formatjs/intl-datetimeformat/locale-data/tr'); + require('@formatjs/intl-listformat/locale-data/tr'); translations = require('@assets/i18n/tr.json'); break; @@ -142,6 +159,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/uk'); require('@formatjs/intl-numberformat/locale-data/uk'); require('@formatjs/intl-datetimeformat/locale-data/uk'); + require('@formatjs/intl-listformat/locale-data/uk'); translations = require('@assets/i18n/uk.json'); break; @@ -149,6 +167,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/vi'); require('@formatjs/intl-numberformat/locale-data/vi'); require('@formatjs/intl-datetimeformat/locale-data/vi'); + require('@formatjs/intl-listformat/locale-data/vi'); translations = require('@assets/i18n/uk.json'); break; @@ -164,6 +183,7 @@ function loadTranslation(locale?: string): {[x: string]: string} { require('@formatjs/intl-pluralrules/locale-data/en'); require('@formatjs/intl-numberformat/locale-data/en'); require('@formatjs/intl-datetimeformat/locale-data/en'); + require('@formatjs/intl-listformat/locale-data/en'); translations = en; break; @@ -180,6 +200,7 @@ function loadChinesePolyfills() { require('@formatjs/intl-pluralrules/locale-data/zh'); require('@formatjs/intl-numberformat/locale-data/zh'); require('@formatjs/intl-datetimeformat/locale-data/zh'); + require('@formatjs/intl-listformat/locale-data/zh'); } export function getLocaleFromLanguage(lang: string) { diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index c3bbc778f..5d7a1deec 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -34,6 +34,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) return database.get(CATEGORY).query(Q.where('team_id', Q.oneOf(teamIds))); }; +export const queryCategoryChannelsByChannelId = (database: Database, channelId: string) => { + return database.get(CATEGORY_CHANNEL).query(Q.where('channel_id', Q.eq(channelId))); +}; + export async function prepareCategoriesAndCategoriesChannels(operator: ServerDataOperator, categories: CategoryWithChannels[], prune = false) { try { const {database} = operator; @@ -45,20 +49,34 @@ export async function prepareCategoriesAndCategoriesChannels(operator: ServerDat const models = await Promise.all(modelPromises); const flattenedModels = models.flat(); + const teamIdToChannelIds = new Map>(); + categories.forEach((category) => { + const value = teamIdToChannelIds.get(category.team_id) || new Set(); + category.channel_ids.forEach(value.add, value); + teamIdToChannelIds.set(category.team_id, value); + }); + if (prune && categories.length) { const remoteCategoryIds = new Set(categories.map((cat) => cat.id)); // If the passed categories have more than one team, we want to update across teams const teamIds = pluckUnique('team_id')(categories) as string[]; const localCategories = await queryCategoriesByTeamIds(database, teamIds).fetch(); - const customCategories = localCategories.filter((c) => c.type === 'custom'); - for await (const custom of customCategories) { - if (!remoteCategoryIds.has(custom.id)) { - const categoryChannels = await custom.categoryChannels.fetch(); - for (const cc of categoryChannels) { + + for await (const localCategory of localCategories) { + const localCategoryChannels = await localCategory.categoryChannels.fetch(); + + if (remoteCategoryIds.has(localCategory.id)) { + for (const localCC of localCategoryChannels) { + if (!teamIdToChannelIds.get(localCategory.teamId)?.has(localCC.channelId)) { + flattenedModels.push(localCC.prepareDestroyPermanently()); + } + } + } else { + for (const cc of localCategoryChannels) { flattenedModels.push(cc.prepareDestroyPermanently()); } - flattenedModels.push(custom.prepareDestroyPermanently()); + flattenedModels.push(localCategory.prepareDestroyPermanently()); } } } diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 7e7babc35..3ad4ad075 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -9,6 +9,7 @@ import {Preferences} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy'; import {isMinimumServerVersion} from '@utils/helpers'; +import {logError} from '@utils/log'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type ConfigModel from '@typings/database/models/servers/config'; @@ -434,6 +435,22 @@ export async function setCurrentChannelId(operator: ServerDataOperator, channelI } } +export async function setCurrentTeamId(operator: ServerDataOperator, teamId: string) { + try { + const models = await prepareCommonSystemValues(operator, { + currentTeamId: teamId, + }); + if (models) { + await operator.batchRecords(models, 'setCurrentTeamId'); + } + + return {currentTeamId: teamId}; + } catch (error) { + logError(error); + return {error}; + } +} + export async function setCurrentTeamAndChannelId(operator: ServerDataOperator, teamId?: string, channelId?: string) { try { const models = await prepareCommonSystemValues(operator, { diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 8298427b9..5a63ac29c 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -7,6 +7,8 @@ import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls'; import ChannelActions from '@components/channel_actions'; +import ConvertToChannelLabel from '@components/channel_actions/convert_to_channel/convert_to_channel_label'; +import {General} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; @@ -32,6 +34,8 @@ type Props = { canManageMembers: boolean; isCRTEnabled: boolean; canManageSettings: boolean; + isGuestUser: boolean; + isConvertGMFeatureAvailable: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -61,6 +65,8 @@ const ChannelInfo = ({ isCallsEnabledInChannel, canManageMembers, canManageSettings, + isGuestUser, + isConvertGMFeatureAvailable, }: Props) => { const theme = useTheme(); const serverUrl = useServerUrl(); @@ -77,6 +83,8 @@ const ChannelInfo = ({ useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]); useAndroidHardwareBackHandler(componentId, onPressed); + const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser; + return ( + {convertGMOptionAvailable && + <> + + + + } {canEnableDisableCalls && <> { distinctUntilChanged(), ); + const isGuestUser = currentUser.pipe( + switchMap((u) => (u ? of$(u.isGuest) : of$(false))), + distinctUntilChanged(), + ); + + const isConvertGMFeatureAvailable = observeConfigValue(database, 'Version').pipe( + switchMap((version) => of$(isMinimumServerVersion(version || '', 9, 1))), + ); + return { type, canEnableDisableCalls, @@ -120,6 +129,8 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { canManageMembers, isCRTEnabled: observeIsCRTEnabled(database), canManageSettings, + isGuestUser, + isConvertGMFeatureAvailable, }; }); diff --git a/app/screens/convert_gm_to_channel/channel_name_input.tsx b/app/screens/convert_gm_to_channel/channel_name_input.tsx new file mode 100644 index 000000000..fb159819d --- /dev/null +++ b/app/screens/convert_gm_to_channel/channel_name_input.tsx @@ -0,0 +1,44 @@ +// 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 FloatingTextInput from '@components/floating_text_input_label'; +import {Channel} from '@constants'; +import {useTheme} from '@context/theme'; +import {getKeyboardAppearanceFromTheme} from '@utils/theme'; + +type Props = { + error?: string; + onChange: (text: string) => void; +} + +export const ChannelNameInput = ({error, onChange}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + + const labelDisplayName = formatMessage({id: 'channel_modal.name', defaultMessage: 'Name'}); + const placeholder = formatMessage({id: 'channel_modal.name', defaultMessage: 'Channel Name'}); + + return ( + + ); +}; diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx new file mode 100644 index 000000000..e1eb4f933 --- /dev/null +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {fetchChannelMemberships, fetchGroupMessageMembersCommonTeams} from '@actions/remote/channel'; +import {PER_PAGE_DEFAULT} from '@client/rest/constants'; +import Loading from '@components/loading'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import ConvertGMToChannelForm from './convert_gm_to_channel_form'; + +type Props = { + channelId: string; + currentUserId?: string; +} + +const loadingIndicatorTimeout = 1200; + +const matchUserProfiles = (users: UserProfile[], members: ChannelMembership[], currentUserId: string) => { + // Gotta make sure we use profiles that are in members. + // See comment in fetchChannelMemberships for more details. + + const usersById: {[id: string]: UserProfile} = {}; + users.forEach((profile) => { + if (profile.id !== currentUserId) { + usersById[profile.id] = profile; + } + }); + + const filteredUsers: UserProfile[] = []; + members.forEach((member) => { + if (usersById[member.user_id]) { + filteredUsers.push(usersById[member.user_id]); + } + }); + + return filteredUsers; +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + flex: 1, + gap: 24, + }, + text: { + color: changeOpacity(theme.centerChannelColor, 0.56), + ...typography('Body', 300, 'SemiBold'), + }, + container: { + paddingVertical: 24, + paddingHorizontal: 20, + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + }; +}); + +const ConvertGMToChannel = ({ + channelId, + currentUserId, +}: Props) => { + const theme = useTheme(); + const styles = getStyleFromTheme(theme); + + const {formatMessage} = useIntl(); + + const [loadingAnimationTimeout, setLoadingAnimationTimeout] = useState(false); + const [commonTeamsFetched, setCommonTeamsFetched] = useState(false); + const [channelMembersFetched, setChannelMembersFetched] = useState(false); + const [commonTeams, setCommonTeams] = useState([]); + const [profiles, setProfiles] = useState([]); + + const serverUrl = useServerUrl(); + const mounted = useRef(false); + + const loadingAnimationTimeoutRef = useRef(); + + useEffect(() => { + loadingAnimationTimeoutRef.current = setTimeout(() => setLoadingAnimationTimeout(true), loadingIndicatorTimeout); + async function work() { + const {teams} = await fetchGroupMessageMembersCommonTeams(serverUrl, channelId); + if (!teams || !mounted.current) { + return; + } + setCommonTeams(teams); + setCommonTeamsFetched(true); + } + + work(); + + return () => { + clearTimeout(loadingAnimationTimeoutRef.current); + }; + }, []); + + useEffect(() => { + mounted.current = true; + + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (!currentUserId) { + return; + } + + const options: GetUsersOptions = {sort: 'admin', active: true, per_page: PER_PAGE_DEFAULT}; + fetchChannelMemberships(serverUrl, channelId, options, true).then(({users, members}) => { + if (!mounted.current) { + return; + } + + if (users.length) { + setProfiles(matchUserProfiles(users, members, currentUserId)); + } + + setChannelMembersFetched(true); + }); + }, [serverUrl, channelId, currentUserId]); + + const showLoader = !loadingAnimationTimeout || !commonTeamsFetched || !channelMembersFetched; + + if (showLoader) { + return ( + + ); + } + + return ( + + ); +}; + +export default ConvertGMToChannel; diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx new file mode 100644 index 000000000..a38f0392e --- /dev/null +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel_form/convert_gm_to_channel_form.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {convertGroupMessageToPrivateChannel, switchToChannelById} from '@actions/remote/channel'; +import Button from '@components/button'; +import Loading from '@components/loading'; +import {ServerErrors} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {isErrorWithMessage, isServerError} from '@utils/errors'; +import {logError} from '@utils/log'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {displayUsername} from '@utils/user'; + +import {ChannelNameInput} from '../channel_name_input'; +import MessageBox from '../message_box/message_box'; +import {TeamSelector} from '../team_selector'; + +import {NoCommonTeamForm} from './no_common_teams_form'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + paddingVertical: 24, + paddingHorizontal: 20, + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + errorMessage: { + color: theme.dndIndicator, + }, + loadingContainerStyle: { + marginRight: 10, + padding: 0, + top: -2, + }, + }; +}); + +type Props = { + channelId: string; + commonTeams: Team[]; + profiles: UserProfile[]; + locale?: string; + teammateNameDisplay?: string; +} + +export const ConvertGMToChannelForm = ({ + channelId, + commonTeams, + profiles, + locale, + teammateNameDisplay, +}: Props) => { + const theme = useTheme(); + const styles = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + const {formatList, formatMessage} = useIntl(); + + const [selectedTeam, setSelectedTeam] = useState(commonTeams[0]); + const [newChannelName, setNewChannelName] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [channelNameErrorMessage, setChannelNameErrorMessage] = useState(''); + const [conversionInProgress, setConversionInProgress] = useState(false); + + const userDisplayNames = useMemo(() => profiles.map((profile) => displayUsername(profile, locale, teammateNameDisplay)), [profiles, teammateNameDisplay, locale]); + const submitButtonEnabled = !conversionInProgress && selectedTeam && newChannelName.trim(); + + const handleOnPress = useCallback(preventDoubleTap(async () => { + if (!submitButtonEnabled) { + return; + } + + setConversionInProgress(true); + + const {updatedChannel, error} = await convertGroupMessageToPrivateChannel(serverUrl, channelId, selectedTeam.id, newChannelName); + if (error) { + if (isServerError(error) && error.server_error_id === ServerErrors.DUPLICATE_CHANNEL_NAME && isErrorWithMessage(error)) { + setChannelNameErrorMessage(error.message); + } else if (isErrorWithMessage(error)) { + setErrorMessage(error.message); + } else { + setErrorMessage(formatMessage({id: 'channel_info.convert_gm_to_channel.conversion_error', defaultMessage: 'Something went wrong. Failed to convert Group Message to Private Channel.'})); + } + + setConversionInProgress(false); + return; + } + + if (!updatedChannel) { + logError('No updated channel received from server when converting GM to private channel'); + setErrorMessage(formatMessage({id: 'channel_info.convert_gm_to_channel.conversion_error', defaultMessage: 'Something went wrong. Failed to convert Group Message to Private Channel.'})); + setConversionInProgress(false); + return; + } + + setErrorMessage(''); + switchToChannelById(serverUrl, updatedChannel.id, selectedTeam.id); + setConversionInProgress(false); + }), [selectedTeam, newChannelName, submitButtonEnabled]); + + if (commonTeams.length === 0) { + return ( + + ); + } + + const messageBoxHeader = formatMessage({ + id: 'channel_info.convert_gm_to_channel.warning.header', + defaultMessage: 'Conversation history will be visible to any channel members', + }); + + const textConvert = formatMessage({ + id: 'channel_info.convert_gm_to_channel.button_text', + defaultMessage: 'Convert to Private Channel', + }); + + const textConverting = formatMessage({ + id: 'channel_info.convert_gm_to_channel.button_text_converting', + defaultMessage: 'Converting...', + }); + + const confirmButtonText = conversionInProgress ? textConverting : textConvert; + const defaultUserDisplayNames = formatMessage({id: 'channel_info.convert_gm_to_channel.warning.body.yourself', defaultMessage: 'yourself'}); + const memberNames = profiles.length > 0 ? formatList(userDisplayNames) : defaultUserDisplayNames; + const messageBoxBody = formatMessage({ + id: 'channel_info.convert_gm_to_channel.warning.bodyXXXX', + defaultMessage: 'You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.', + }, { + memberNames, + }); + + const buttonIcon = conversionInProgress ? ( + + ) : null; + + return ( + + + { + commonTeams.length > 1 && + + } + +