Merge pull request #7654 from mattermost/gm_to_channel

GM to channel conversion
This commit is contained in:
Harshil Sharma 2023-11-22 15:44:40 +05:30 committed by GitHub
commit 908e4c08d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1218 additions and 50 deletions

View file

@ -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<CategoryWithChannels | undefined> {
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};
}
}

View file

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

View file

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

View file

@ -44,6 +44,8 @@ export interface ClientChannelsMix {
searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise<Channel[]>;
updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise<any>;
getMemberInChannel: (channelId: string, userId: string) => Promise<ChannelMembership>;
getGroupMessageMembersCommonTeams: (channelId: string) => Promise<Team[]>;
convertGroupMessageToPrivateChannel: (channelId: string, teamId: string, displayName: string, name: string) => Promise<Channel>;
}
const ClientChannels = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -350,6 +352,27 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(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;

View file

@ -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 = (
<CompassIcon
name={iconName!}
size={iconSize}
color={StyleSheet.flatten(txtStyle).color}
style={styles.icon}
/>
);
}
return (
<RNButton
containerStyle={bgStyle}
@ -68,14 +85,7 @@ const Button = ({
testID={testID}
>
<View style={containerStyle}>
{Boolean(iconName) &&
<CompassIcon
name={iconName!}
size={iconSize}
color={StyleSheet.flatten(txtStyle).color}
style={styles.icon}
/>
}
{icon}
<Text
style={txtStyle}
numberOfLines={1}

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {dismissBottomSheet, goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
}
const ConvertToChannelLabel = ({channelId}: Props) => {
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 (
<OptionItem
action={goToConvertToPrivateChannel}
icon='lock-outline'
label={formatMessage({id: 'channel_info.convert_gm_to_channel', defaultMessage: 'Convert to a Private Channel'})}
type='default'
/>
);
};
export default ConvertToChannelLabel;

View file

@ -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 &&
<Text style={footerTextStyles}>{footerText}</Text>
}
</View>
);
};

View file

@ -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 = ({
<Text
style={[labelTextStyle, optionLabelTextStyle]}
testID={`${testID}.label`}
numberOfLines={1}
>
{label}
</Text>
@ -284,16 +290,17 @@ const OptionItem = ({
<View style={styles.actionContainer}>
{
Boolean(info) &&
<View style={styles.infoContainer}>
<Text
style={[styles.info, !actionComponent && styles.iconContainer, destructive && {color: theme.dndIndicator}]}
testID={`${testID}.info`}
>
{info}
</Text>
</View>
<Text
style={[styles.info, !actionComponent && styles.iconContainer, destructive && {color: theme.dndIndicator}]}
testID={`${testID}.info`}
numberOfLines={1}
>
{info}
</Text>
}
{actionComponent}
<View style={styles.actionSubContainer}>
{actionComponent}
</View>
</View>
}
</View>

View file

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

View file

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

View file

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

View file

@ -34,6 +34,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[])
return database.get<CategoryModel>(CATEGORY).query(Q.where('team_id', Q.oneOf(teamIds)));
};
export const queryCategoryChannelsByChannelId = (database: Database, channelId: string) => {
return database.get<CategoryChannelModel>(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<String, Set<String>>();
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());
}
}
}

View file

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

View file

@ -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 (
<SafeAreaView
edges={edges}
@ -111,6 +119,12 @@ const ChannelInfo = ({
canManageSettings={canManageSettings}
/>
<View style={styles.separator}/>
{convertGMOptionAvailable &&
<>
<ConvertToChannelLabel channelId={channelId}/>
<View style={styles.separator}/>
</>
}
{canEnableDisableCalls &&
<>
<ChannelInfoEnableCalls

View file

@ -113,6 +113,15 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
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,
};
});

View file

@ -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 (
<FloatingTextInput
autoCorrect={false}
autoCapitalize='none'
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelDisplayName}
placeholder={placeholder}
maxLength={Channel.MAX_CHANNEL_NAME_LENGTH}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={true}
spellCheck={false}
testID='gonvert_gm_to_channel.channel_display_name.input'
theme={theme}
error={error}
onChangeText={onChange}
/>
);
};

View file

@ -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<Team[]>([]);
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const serverUrl = useServerUrl();
const mounted = useRef(false);
const loadingAnimationTimeoutRef = useRef<NodeJS.Timeout>();
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 (
<Loading
containerStyle={styles.loadingContainer}
size='large'
color={theme.buttonBg}
footerText={formatMessage({id: 'channel_info.convert_gm_to_channel.loading.footer', defaultMessage: 'Fetching details...'})}
footerTextStyles={styles.text}
/>
);
}
return (
<ConvertGMToChannelForm
commonTeams={commonTeams}
profiles={profiles}
channelId={channelId}
/>
);
};
export default ConvertGMToChannel;

View file

@ -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<Team>(commonTeams[0]);
const [newChannelName, setNewChannelName] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [channelNameErrorMessage, setChannelNameErrorMessage] = useState<string>('');
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 (
<NoCommonTeamForm containerStyles={styles.container}/>
);
}
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 ? (
<Loading
containerStyle={styles.loadingContainerStyle}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
) : null;
return (
<View style={styles.container}>
<MessageBox
header={messageBoxHeader}
body={messageBoxBody}
/>
{
commonTeams.length > 1 &&
<TeamSelector
commonTeams={commonTeams}
onSelectTeam={setSelectedTeam}
selectedTeamId={selectedTeam?.id}
/>
}
<ChannelNameInput
onChange={setNewChannelName}
error={channelNameErrorMessage}
/>
<Button
onPress={handleOnPress}
text={confirmButtonText}
theme={theme}
buttonType={submitButtonEnabled ? 'destructive' : 'disabled'}
size='lg'
iconComponent={buttonIcon}
/>
{
errorMessage &&
<Text style={styles.errorMessage}>
{errorMessage}
</Text>
}
</View>
);
};

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import {switchMap, distinctUntilChanged} from '@nozbe/watermelondb/utils/rx';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {observeCurrentUser, observeTeammateNameDisplay} from '@queries/servers/user';
import {ConvertGMToChannelForm} from './convert_gm_to_channel_form';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const locale = observeCurrentUser(database).pipe(
switchMap((user) => of$(user?.locale)),
distinctUntilChanged(),
);
const teammateNameDisplay = observeTeammateNameDisplay(database);
return {
locale,
teammateNameDisplay,
};
});
export default withDatabase(enhanced(ConvertGMToChannelForm));

View file

@ -0,0 +1,59 @@
// 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 {View, type ViewStyle} from 'react-native';
import Button from '@components/button';
import {useTheme} from '@context/theme';
import {popTopScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import MessageBox from '../message_box/message_box';
type Props = {
containerStyles: ViewStyle;
}
const handleOnPress = preventDoubleTap(() => {
popTopScreen();
});
export const NoCommonTeamForm = ({
containerStyles,
}: Props) => {
const theme = useTheme();
const {formatMessage} = useIntl();
const header = formatMessage({
id: 'channel_info.convert_gm_to_channel.warning.no_teams.header',
defaultMessage: 'Unable to convert to a channel because group members are part of different teams',
});
const body = formatMessage({
id: 'channel_info.convert_gm_to_channel.warning.no_teams.body',
defaultMessage: 'Group Message cannot be converted to a channel because members are not a part of the same team. Add all members to a single team to convert this group message to a channel.',
});
const buttonText = formatMessage({
id: 'generic.back',
defaultMessage: 'Back',
});
return (
<View style={containerStyles}>
<MessageBox
header={header}
body={body}
type='danger'
/>
<Button
onPress={handleOnPress}
text={buttonText}
theme={theme}
size='lg'
/>
</View>
);
};

View file

@ -0,0 +1,24 @@
// 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 {observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ConvertGMToChannel from './convert_gm_to_channel';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentUserId = observeCurrentUserId(database);
const teammateNameDisplay = observeTeammateNameDisplay(database);
return {
currentUserId,
teammateNameDisplay,
};
});
export default withDatabase(enhance(ConvertGMToChannel));

View file

@ -0,0 +1,136 @@
// 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 CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type MessageBoxTypes = 'default' | 'danger'
type Props = {
header: string;
body: string;
type?: MessageBoxTypes;
}
const getBaseStyles = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.08),
borderWidth: 1,
borderRadius: 8,
borderColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
display: 'flex',
flexDirection: 'row',
padding: 16,
gap: 12,
},
icon: {
marginTop: 5,
fontSize: 20,
width: 28,
height: 28,
borderWidth: 3,
color: theme.sidebarTextActiveBorder,
borderColor: theme.sidebarTextActiveBorder,
borderRadius: 14,
textAlign: 'center',
},
iconContainer: {},
textContainer: {
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
},
heading: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
},
body: {
color: theme.centerChannelColor,
...typography('Body', 100),
},
};
});
const getDefaultStylesFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.08),
borderColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
},
icon: {
color: theme.sidebarTextActiveBorder,
borderColor: theme.sidebarTextActiveBorder,
},
};
});
const getDangerStylesFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.dndIndicator, 0.08),
borderColor: changeOpacity(theme.dndIndicator, 0.16),
},
icon: {
color: theme.dndIndicator,
borderColor: theme.dndIndicator,
},
};
});
const getStyleFromTheme = (theme: Theme, kind: MessageBoxTypes | undefined) => {
let kindStyles;
switch (kind) {
case 'danger': {
kindStyles = getDangerStylesFromTheme(theme);
break;
}
default: {
kindStyles = getDefaultStylesFromTheme(theme);
break;
}
}
return kindStyles;
};
const MessageBox = ({
header,
body,
type,
}: Props) => {
const theme = useTheme();
const baseStyle = getBaseStyles(theme);
const kindStyle = getStyleFromTheme(theme, type);
return (
<View style={[baseStyle.container, kindStyle.container]}>
<View style={baseStyle.iconContainer}>
<CompassIcon
name='exclamation-thick'
style={[baseStyle.icon, kindStyle.icon]}
/>
</View>
<View style={baseStyle.textContainer}>
<View>
<Text style={baseStyle.heading}>
{header}
</Text>
</View>
<View>
<Text style={baseStyle.body}>
{body}
</Text>
</View>
</View>
</View>
);
};
export default MessageBox;

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
teamSelector: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
},
labelContainerStyle: {
flexShrink: 0,
},
};
});
type Props = {
commonTeams: Team[];
onSelectTeam: (team: Team) => void;
selectedTeamId?: string;
}
export const TeamSelector = ({commonTeams, onSelectTeam, selectedTeamId}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const label = formatMessage({id: 'channel_into.convert_gm_to_channel.team_selector.label', defaultMessage: 'Team'});
const placeholder = formatMessage({id: 'channel_into.convert_gm_to_channel.team_selector.placeholder', defaultMessage: 'Select a Team'});
const selectedTeam = useMemo(() => commonTeams.find((t) => t.id === selectedTeamId), [commonTeams, selectedTeamId]);
const selectTeam = useCallback((teamId: string) => {
const team = commonTeams.find((t) => t.id === teamId);
if (team) {
onSelectTeam(team);
}
}, []);
const goToTeamSelectorList = useCallback(preventDoubleTap(async () => {
await dismissBottomSheet();
const title = formatMessage({id: 'channel_info.convert_gm_to_channel.team_selector_list.title', defaultMessage: 'Select Team'});
goToScreen(Screens.TEAM_SELECTOR_LIST, title, {teams: commonTeams, selectTeam, selectedTeamId});
}), [commonTeams, selectTeam, selectedTeamId]);
return (
<OptionItem
action={goToTeamSelectorList}
containerStyle={styles.teamSelector}
label={label}
type={Platform.select({ios: 'arrow', default: 'default'})}
info={selectedTeam ? selectedTeam.display_name : placeholder}
labelContainerStyle={styles.labelContainerStyle}
/>
);
};

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useMemo, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import SearchBar from '@components/search';
import TeamList from '@components/team_list';
import {useTheme} from '@context/theme';
import {popTopScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
const styles = StyleSheet.create({
container: {
padding: 12,
},
listContainer: {
marginTop: 12,
},
});
type Props = {
teams: Team[];
selectTeam: (teamId: string) => void;
}
const TeamSelectorList = ({teams, selectTeam}: Props) => {
const theme = useTheme();
const [filteredTeams, setFilteredTeam] = useState(teams);
const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]);
const handleOnChangeSearchText = useCallback(debounce((searchTerm: string) => {
if (searchTerm === '') {
setFilteredTeam(teams);
} else {
setFilteredTeam(teams.filter((team) => team.display_name.includes(searchTerm) || team.name.includes(searchTerm)));
}
}, 200), [teams]);
const handleOnPress = useCallback(preventDoubleTap((teamId: string) => {
selectTeam(teamId);
popTopScreen();
}), []);
return (
<View style={styles.container}>
<SearchBar
autoCapitalize='none'
autoFocus={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
placeholderTextColor={color}
searchIconColor={color}
testID='convert_gm_to_channel_team_search_bar'
onChangeText={handleOnChangeSearchText}
/>
<View style={styles.listContainer}>
<TeamList
teams={filteredTeams}
onPress={handleOnPress}
/>
</View>
</View>
);
};
export default TeamSelectorList;

View file

@ -90,6 +90,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CODE:
screen = withServerDatabase(require('@screens/code').default);
break;
case Screens.CONVERT_GM_TO_CHANNEL:
screen = withServerDatabase(require('@screens/convert_gm_to_channel').default);
break;
case Screens.CREATE_OR_EDIT_CHANNEL:
screen = withServerDatabase(require('@screens/create_or_edit_channel').default);
break;
@ -242,6 +245,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.TABLE:
screen = withServerDatabase(require('@screens/table').default);
break;
case Screens.TEAM_SELECTOR_LIST:
screen = withServerDatabase(require('@screens/convert_gm_to_channel/team_selector_list').default);
break;
case Screens.TERMS_OF_SERVICE:
screen = withServerDatabase(require('@screens/terms_of_service').default);
break;

View file

@ -121,6 +121,18 @@
"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_gm_to_channel": "Convert to a Private Channel",
"channel_info.convert_gm_to_channel.button_text": "Convert to Private Channel",
"channel_info.convert_gm_to_channel.button_text_converting": "Converting...",
"channel_info.convert_gm_to_channel.conversion_error": "Something went wrong. Failed to convert Group Message to Private Channel.",
"channel_info.convert_gm_to_channel.loading.footer": "Fetching details...",
"channel_info.convert_gm_to_channel.screen_title": "Convert to Private Channel",
"channel_info.convert_gm_to_channel.team_selector_list.title": "Select Team",
"channel_info.convert_gm_to_channel.warning.body.yourself": "yourself",
"channel_info.convert_gm_to_channel.warning.bodyXXXX": "You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.",
"channel_info.convert_gm_to_channel.warning.header": "Conversation history will be visible to any channel members",
"channel_info.convert_gm_to_channel.warning.no_teams.body": "Group Message cannot be converted to a channel because members are not a part of the same team. Add all members to a single team to convert this group message to a channel.",
"channel_info.convert_gm_to_channel.warning.no_teams.header": "Unable to convert to a channel because group members are part of different teams",
"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.",
@ -160,6 +172,8 @@
"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_into.convert_gm_to_channel.team_selector.label": "Team",
"channel_into.convert_gm_to_channel.team_selector.placeholder": "Select a Team",
"channel_intro.createdBy": "Created by {user} on {date}",
"channel_intro.createdOn": "Created on {date}",
"channel_list.channels_category": "Channels",
@ -322,6 +336,7 @@
"general_settings.help": "Help",
"general_settings.notifications": "Notifications",
"general_settings.report_problem": "Report a problem",
"generic.back": "Back",
"get_post_link_modal.title": "Copy Link",
"global_threads.allThreads": "All Your Threads",
"global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.",

View file

@ -47,6 +47,7 @@ if (global.HermesInternal) {
require('@formatjs/intl-numberformat/polyfill');
require('@formatjs/intl-datetimeformat/polyfill');
require('@formatjs/intl-datetimeformat/add-golden-tz');
require('@formatjs/intl-listformat/polyfill');
}
if (Platform.OS === 'android') {

101
package-lock.json generated
View file

@ -12,6 +12,7 @@
"dependencies": {
"@formatjs/intl-datetimeformat": "6.10.0",
"@formatjs/intl-getcanonicallocales": "2.2.1",
"@formatjs/intl-listformat": "7.5.0",
"@formatjs/intl-locale": "3.3.2",
"@formatjs/intl-numberformat": "8.7.0",
"@formatjs/intl-pluralrules": "5.2.4",
@ -2508,12 +2509,29 @@
}
},
"node_modules/@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.5.0.tgz",
"integrity": "sha512-n9FsXGl1T2ZbX6wSyrzCDJHrbJR0YJ9ZNsAqUvHXfbY3nsOmGnSTf5+bkuIp1Xiywu7m1X1Pfm/Ngp/yK1H84A==",
"dependencies": {
"@formatjs/ecma402-abstract": "1.17.2",
"@formatjs/intl-localematcher": "0.4.2",
"tslib": "^2.4.0"
}
},
"node_modules/@formatjs/intl-listformat/node_modules/@formatjs/ecma402-abstract": {
"version": "1.17.2",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.2.tgz",
"integrity": "sha512-k2mTh0m+IV1HRdU0xXM617tSQTi53tVR2muvYOsBeYcUgEAyxV1FOC7Qj279th3fBVQ+Dj6muvNJZcHSPNdbKg==",
"dependencies": {
"@formatjs/intl-localematcher": "0.4.2",
"tslib": "^2.4.0"
}
},
"node_modules/@formatjs/intl-listformat/node_modules/@formatjs/intl-localematcher": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.2.tgz",
"integrity": "sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==",
"dependencies": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"tslib": "^2.4.0"
}
},
@ -2566,6 +2584,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@formatjs/intl/node_modules/@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"dependencies": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"tslib": "^2.4.0"
}
},
"node_modules/@gorhom/bottom-sheet": {
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.4.7.tgz",
@ -19105,6 +19133,16 @@
}
}
},
"node_modules/react-intl/node_modules/@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"dependencies": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"tslib": "^2.4.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -24666,6 +24704,18 @@
"@formatjs/intl-listformat": "7.4.0",
"intl-messageformat": "10.5.0",
"tslib": "^2.4.0"
},
"dependencies": {
"@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"requires": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"tslib": "^2.4.0"
}
}
}
},
"@formatjs/intl-datetimeformat": {
@ -24705,13 +24755,32 @@
}
},
"@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.5.0.tgz",
"integrity": "sha512-n9FsXGl1T2ZbX6wSyrzCDJHrbJR0YJ9ZNsAqUvHXfbY3nsOmGnSTf5+bkuIp1Xiywu7m1X1Pfm/Ngp/yK1H84A==",
"requires": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"@formatjs/ecma402-abstract": "1.17.2",
"@formatjs/intl-localematcher": "0.4.2",
"tslib": "^2.4.0"
},
"dependencies": {
"@formatjs/ecma402-abstract": {
"version": "1.17.2",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.2.tgz",
"integrity": "sha512-k2mTh0m+IV1HRdU0xXM617tSQTi53tVR2muvYOsBeYcUgEAyxV1FOC7Qj279th3fBVQ+Dj6muvNJZcHSPNdbKg==",
"requires": {
"@formatjs/intl-localematcher": "0.4.2",
"tslib": "^2.4.0"
}
},
"@formatjs/intl-localematcher": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.2.tgz",
"integrity": "sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==",
"requires": {
"tslib": "^2.4.0"
}
}
}
},
"@formatjs/intl-locale": {
@ -37055,6 +37124,18 @@
"hoist-non-react-statics": "^3.3.2",
"intl-messageformat": "10.5.0",
"tslib": "^2.4.0"
},
"dependencies": {
"@formatjs/intl-listformat": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.4.0.tgz",
"integrity": "sha512-ifupb+balZUAF/Oh3QyGRqPRWGSKwWoMPR0cYZEG7r61SimD+m38oFQqVx/3Fp7LfQFF11m7IS+MlxOo2sKINA==",
"requires": {
"@formatjs/ecma402-abstract": "1.17.0",
"@formatjs/intl-localematcher": "0.4.0",
"tslib": "^2.4.0"
}
}
}
},
"react-is": {

View file

@ -13,6 +13,7 @@
"dependencies": {
"@formatjs/intl-datetimeformat": "6.10.0",
"@formatjs/intl-getcanonicallocales": "2.2.1",
"@formatjs/intl-listformat": "7.5.0",
"@formatjs/intl-locale": "3.3.2",
"@formatjs/intl-numberformat": "8.7.0",
"@formatjs/intl-pluralrules": "5.2.4",