diff --git a/app/actions/local/category.ts b/app/actions/local/category.ts index 50b52ce3e2..c55da2ef19 100644 --- a/app/actions/local/category.ts +++ b/app/actions/local/category.ts @@ -39,12 +39,12 @@ export const storeCategories = async (serverUrl: string, categories: CategoryWit const modelPromises: Array> = []; const preparedCategories = prepareCategories(operator, categories); if (preparedCategories) { - modelPromises.push(...preparedCategories); + modelPromises.push(preparedCategories); } const preparedCategoryChannels = prepareCategoryChannels(operator, categories); if (preparedCategoryChannels) { - modelPromises.push(...preparedCategoryChannels); + modelPromises.push(preparedCategoryChannels); } const models = await Promise.all(modelPromises); diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index ff77fe6317..357e47a041 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -12,12 +12,12 @@ import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; import NetworkManager from '@init/network_manager'; -import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel} from '@queries/servers/channel'; +import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; import {prepareMyTeams, getNthLastChannelFromTeam, getMyTeamById, getTeamById, getTeamByName} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; -import {getDirectChannelName} from '@utils/channel'; +import {generateChannelNameFromDisplayName, getDirectChannelName} from '@utils/channel'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; @@ -107,6 +107,104 @@ export const fetchChannelByName = async (serverUrl: string, teamId: string, chan } }; +export const createChannel = async (serverUrl: string, displayName: string, purpose: string, header: string, type: ChannelType) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const {database} = operator; + const {currentUserId, currentTeamId} = await getCommonSystemValues(database); + const name = generateChannelNameFromDisplayName(displayName); + const channel = { + creator_id: currentUserId, + team_id: currentTeamId, + display_name: displayName, + header, + name, + purpose, + type, + } as Channel; + + const channelData = await client.createChannel(channel); + + const member = await client.getChannelMember(channelData.id, currentUserId); + + const models: Model[] = []; + const channelModels = await prepareMyChannelsForTeam(operator, channelData.team_id, [channelData], [member]); + if (channelModels?.length) { + const resolvedModels = await Promise.all(channelModels); + models.push(...resolvedModels.flat()); + } + const categoriesModels = await operator.handleCategoryChannels({categoryChannels: [{ + category_id: `channels_${currentUserId}_${currentTeamId}`, + channel_id: channelData.id, + sort_order: 0, + id: `${currentTeamId}_${channelData.id}`, + }], + prepareRecordsOnly: true}); + if (categoriesModels?.length) { + models.push(...categoriesModels); + } + if (models.length) { + await operator.batchRecords(models.flat()); + } + fetchChannelStats(serverUrl, channelData.id, false); + return {channel: channelData}; + } catch (error) { + return {error}; + } +}; + +export const patchChannel = async (serverUrl: string, channelPatch: Partial & {id: string}) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channelData = await client.patchChannel(channelPatch.id, channelPatch); + const models = []; + const channelInfo = (await getChannelInfo(operator.database, channelData.id)); + if (channelInfo && (channelInfo.purpose !== channelData.purpose || channelInfo.header !== channelData.header)) { + channelInfo.prepareUpdate((v) => { + v.purpose = channelData.purpose; + v.header = channelData.header; + }); + models.push(channelInfo); + } + const channel = await getChannelById(operator.database, channelData.id); + if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) { + channel.prepareUpdate((v) => { + v.displayName = channelData.display_name; + v.type = channelData.type; + }); + models.push(channel); + } + if (models?.length) { + await operator.batchRecords(models.flat()); + } + return {channel: channelData}; + } catch (error) { + return {error}; + } +}; + export const fetchChannelCreator = async (serverUrl: string, channelId: string, fetchOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index 07f33f3b30..47d1a92643 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -323,12 +323,17 @@ const ClientUsers = (superclass: any) => class extends superclass { autocompleteUsers = async (name: string, teamId: string, channelId?: string, options = { limit: General.AUTOCOMPLETE_LIMIT_DEFAULT, }) => { - return this.doFetch(`${this.getUsersRoute()}/autocomplete${buildQueryString({ + const query: Dictionary = { in_team: teamId, - in_channel: channelId, name, - limit: options.limit, - })}`, { + }; + if (channelId) { + query.in_channel = channelId; + } + if (options.limit) { + query.limit = options.limit; + } + return this.doFetch(`${this.getUsersRoute()}/autocomplete${buildQueryString(query)}`, { method: 'get', }); }; diff --git a/app/components/autocomplete/at_mention/at_mention.tsx b/app/components/autocomplete/at_mention/at_mention.tsx index 148500c06e..6f4f47f680 100644 --- a/app/components/autocomplete/at_mention/at_mention.tsx +++ b/app/components/autocomplete/at_mention/at_mention.tsx @@ -187,6 +187,7 @@ const AtMention = ({ setUsersInChannel(receivedUsers.users.length ? receivedUsers.users : emptyProfileList); setUsersOutOfChannel(receivedUsers.out_of_channel?.length ? receivedUsers.out_of_channel : emptyProfileList); } + setLoading(false); }, 200), []); @@ -314,7 +315,8 @@ const AtMention = ({ useEffect(() => { const showSpecialMentions = useChannelMentions && matchTerm != null && checkSpecialMentions(matchTerm); - const newSections = makeSections(teamMembers, usersInChannel, usersOutOfChannel, groups, showSpecialMentions, isSearch); + const buildMemberSection = isSearch || (!channelId && teamMembers.length > 0); + const newSections = makeSections(teamMembers, usersInChannel, usersOutOfChannel, groups, showSpecialMentions, buildMemberSection); const nSections = newSections.length; if (!loading && !nSections && noResultsTerm == null) { @@ -322,7 +324,7 @@ const AtMention = ({ } setSections(nSections ? newSections : empytSectionList); onShowingChange(Boolean(nSections)); - }, [usersInChannel, usersOutOfChannel, teamMembers, groups, loading]); + }, [usersInChannel, usersOutOfChannel, teamMembers, groups, loading, channelId]); if (sections.length === 0 || noResultsTerm != null) { // If we are not in an active state or the mention has been completed return null so nothing is rendered diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 440a6f2028..c6cfbf25fd 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -57,8 +57,8 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { type Props = { cursorPosition: number; postInputTop: number; - rootId: string; - channelId: string; + rootId?: string; + channelId?: string; fixedBottomPosition?: boolean; isSearch?: boolean; value: string; @@ -66,8 +66,9 @@ type Props = { isAppsEnabled: boolean; nestedScrollEnabled?: boolean; updateValue: (v: string) => void; - hasFilesAttached: boolean; + hasFilesAttached?: boolean; maxHeightOverride?: number; + inPost?: boolean; } const Autocomplete = ({ @@ -85,6 +86,7 @@ const Autocomplete = ({ nestedScrollEnabled = false, updateValue, hasFilesAttached, + inPost = false, }: Props) => { const theme = useTheme(); const isTablet = useIsTablet(); @@ -154,7 +156,7 @@ const Autocomplete = ({ testID='autocomplete' style={containerStyles} > - {isAppsEnabled && ( + {isAppsEnabled && channelId && ( } - {showCommands && + {showCommands && channelId && void; onShowingChange: (c: boolean) => void; - rootId: string; + rootId?: string; value: string; nestedScrollEnabled: boolean; skinTone: string; - hasFilesAttached: boolean; + hasFilesAttached?: boolean; + inPost?: boolean; } const EmojiSuggestion = ({ cursorPosition, @@ -86,7 +87,8 @@ const EmojiSuggestion = ({ value, nestedScrollEnabled, skinTone, - hasFilesAttached, + hasFilesAttached = false, + inPost = true, }: Props) => { const insets = useSafeAreaInsets(); const theme = useTheme(); @@ -121,7 +123,7 @@ const EmojiSuggestion = ({ const showingElements = Boolean(data.length); const completeSuggestion = useCallback((emoji: string) => { - if (!hasFilesAttached) { + if (!hasFilesAttached && inPost) { const match = value.match(REACTION_REGEX); if (match) { handleReactionToLatestPost(serverUrl, emoji, match[1] === '+', rootId); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index 5b96de6a28..f3af50c68f 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -10,10 +10,10 @@ import { } from 'react-native'; import {fetchSuggestions} from '@actions/remote/command'; -import IntegrationsManager from '@app/init/integrations_manager'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import analytics from '@init/analytics'; +import IntegrationsManager from '@init/integrations_manager'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {AppCommandParser} from './app_command_parser/app_command_parser'; diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index 66219bbfba..a049b7ced6 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -8,7 +8,7 @@ import FastImage from 'react-native-fast-image'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {SvgXml} from 'react-native-svg'; -import CompassIcon from '@app/components/compass_icon'; +import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {COMMAND_SUGGESTION_ERROR} from '@constants/apps'; import {useTheme} from '@context/theme'; diff --git a/app/components/channel_list/header/plus_menu/index.tsx b/app/components/channel_list/header/plus_menu/index.tsx index 846ff39fba..d31f5ed932 100644 --- a/app/components/channel_list/header/plus_menu/index.tsx +++ b/app/components/channel_list/header/plus_menu/index.tsx @@ -32,8 +32,11 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => { }, [intl, theme]); const createNewChannel = useCallback(async () => { - // To be added - }, [intl, theme]); + await dismissBottomSheet(); + + const title = intl.formatMessage({id: 'mobile.create_channel.title', defaultMessage: 'New channel'}); + showModal(Screens.CREATE_OR_EDIT_CHANNEL, title); + }, [intl]); const openDirectMessage = useCallback(async () => { await dismissBottomSheet(); diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 170ac2d752..3fb363449e 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -4,8 +4,8 @@ // Note: This file has been adapted from the library https://github.com/csath/react-native-reanimated-text-input import {debounce} from 'lodash'; -import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef} from 'react'; -import {GestureResponderEvent, NativeSyntheticEvent, Platform, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native'; +import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef, useMemo, useCallback} from 'react'; +import {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, Platform, StyleProp, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native'; import Animated, {useCode, interpolateNode, EasingNode, Value, set, Clock} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -17,6 +17,28 @@ const DEFAULT_INPUT_HEIGHT = 48; const BORDER_DEFAULT_WIDTH = 1; const BORDER_FOCUSED_WIDTH = 2; +const getTopStyle = (styles: any, animation: Value<0|1>, inputText?: string) => { + if (inputText) { + return getLabelPositions(styles.textInput, styles.label, styles.smallLabel)[1]; + } + + return interpolateNode(animation, { + inputRange: [0, 1], + outputRange: [...getLabelPositions(styles.textInput, styles.label, styles.smallLabel)], + }); +}; + +const getFontSize = (styles: any, animation: Value<0|1>, inputText?: string) => { + if (inputText) { + return styles.smallLabel.fontSize; + } + + return interpolateNode(animation, { + inputRange: [0, 1], + outputRange: [styles.textInput.fontSize, styles.smallLabel.fontSize], + }); +}; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { height: DEFAULT_INPUT_HEIGHT + (2 * BORDER_DEFAULT_WIDTH), @@ -101,12 +123,15 @@ type FloatingTextInputProps = TextInputProps & { errorIcon?: string; isKeyboardInput?: boolean; label: string; + multiline?: boolean; onBlur?: (event: NativeSyntheticEvent) => void; onFocus?: (e: NativeSyntheticEvent) => void; onPress?: (e: GestureResponderEvent) => void; + onLayout?: (e: LayoutChangeEvent) => void; + placeholder?: string; showErrorIcon?: boolean; - theme: Theme; testID?: string; + theme: Theme; value: string; } @@ -120,7 +145,10 @@ const FloatingTextInput = forwardRef) => onExecution(e, + const onTextInputBlur = useCallback((e: NativeSyntheticEvent) => onExecution(e, () => { setIsFocusLabel(Boolean(value)); setIsFocused(false); }, onBlur, - ); + ), [onBlur]); - const onTextInputFocus = (e: NativeSyntheticEvent) => onExecution(e, + const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => onExecution(e, () => { setIsFocusLabel(true); setIsFocused(true); }, onFocus, - ); + ), [onFocus]); - const onAnimatedTextPress = () => { + const onAnimatedTextPress = useCallback(() => { return focused ? null : inputRef?.current?.focus(); - }; + }, [focused]); const shouldShowError = (!focused && error); const onPressAction = !isKeyboardInput && editable && onPress ? onPress : undefined; - let textInputColorStyles; - let labelColorStyles; + const combinedContainerStyle = useMemo(() => { + const res = [styles.container]; + if (multiline) { + res.push({height: 100 + (2 * BORDER_DEFAULT_WIDTH)}); + } + res.push(containerStyle); + return res; + }, [styles, containerStyle, multiline]); - if (focused) { - textInputColorStyles = {borderColor: theme.buttonBg}; - labelColorStyles = {color: theme.buttonBg}; - } else if (shouldShowError) { - textInputColorStyles = {borderColor: theme.errorTextColor}; - } + const combinedTextInputStyle = useMemo(() => { + const res: StyleProp = [styles.textInput]; + res.push({ + borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH, + height: DEFAULT_INPUT_HEIGHT + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2), + }); - const textInputBorder = { - borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH, - height: DEFAULT_INPUT_HEIGHT + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2), - }; - const combinedTextInputStyle = [styles.textInput, textInputBorder, textInputColorStyles, textInputStyle]; - const textAnimatedTextStyle = [styles.label, focusStyle, labelColorStyles, labelTextStyle]; + if (focused) { + res.push({borderColor: theme.buttonBg}); + } else if (shouldShowError) { + res.push({borderColor: theme.errorTextColor}); + } - if (error && !focused) { - textAnimatedTextStyle.push({color: theme.errorTextColor}); - } + res.push(textInputStyle); + + if (multiline) { + res.push({height: 100, textAlignVertical: 'top'}); + } + + return res; + }, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline]); + + const textAnimatedTextStyle = useMemo(() => { + const res = [styles.label]; + const inputText = value || placeholder; + res.push({ + top: getTopStyle(styles, animation, inputText), + fontSize: getFontSize(styles, animation, inputText), + backgroundColor: ( + focusedLabel || inputText ? theme.centerChannelBg : 'transparent' + ), + paddingHorizontal: focusedLabel || inputText ? 4 : 0, + color: styles.label.color, + }); + + if (focused) { + res.push({color: theme.buttonBg}); + } + + res.push(labelTextStyle); + + if (shouldShowError) { + res.push({color: theme.errorTextColor}); + } + + return res; + }, [theme, styles, focused, shouldShowError, labelTextStyle, placeholder, animation, focusedLabel]); return ( - - { - - {label} - - } + + + {label} + { + return { + container: { + flexDirection: 'row', + alignItems: 'center', + }, + singleContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + height: 45, + }, + doubleContainer: { + flex: 1, + flexDirection: 'column', + height: 69, + justifyContent: 'center', + }, + label: { + color: theme.centerChannelColor, + ...typography('Body', 600, 'SemiBold'), + fontSize: 16, + lineHeight: 24, + }, + description: { + color: changeOpacity(theme.centerChannelColor, 0.6), + ...typography('Body', 400, 'Regular'), + fontSize: 12, + lineHeight: 16, + marginTop: 3, + }, + arrow: { + color: changeOpacity(theme.centerChannelColor, 0.25), + fontSize: 24, + }, + labelContainer: { + flex: 0, + flexDirection: 'row', + }, + }; +}); + +type Props = { + testID?: string; + action: (value: string | boolean) => void; + actionType: string; + actionValue?: string; + label: string; + selected: boolean; + description: string; + icon?: string; +} + +const SectionItem = ({testID = 'sectionItem', action, actionType, actionValue, label, selected, description, icon}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + let actionComponent; + if (actionType === ActionTypes.SELECT && selected) { + const selectStyle = [style.arrow, {color: theme.linkColor}]; + actionComponent = ( + + ); + } else if (actionType === ActionTypes.TOGGLE) { + actionComponent = ( + + ); + } else if (actionType === ActionTypes.ARROW) { + actionComponent = ( + + ); + } + + const onPress = useCallback(() => { + action(actionValue || ''); + }, [actionValue, action]); + + const labelStyle = useMemo(() => { + if (icon) { + return [style.label, {marginLeft: 4}]; + } + return style.label; + }, [Boolean(icon), style]); + + const component = ( + + + + {Boolean(icon) && ( + + )} + + {label} + + + + {description} + + + {actionComponent} + + ); + + if (actionType === ActionTypes.DEFAULT || actionType === ActionTypes.SELECT || actionType === ActionTypes.ARROW) { + return ( + + {component} + + ); + } + + return component; +}; + +export default SectionItem; diff --git a/app/constants/channel.ts b/app/constants/channel.ts new file mode 100644 index 0000000000..79f3b897ea --- /dev/null +++ b/app/constants/channel.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const MIN_CHANNEL_NAME_LENGTH = 1; +export const MAX_CHANNEL_NAME_LENGTH = 64; + +export default { + MAX_CHANNEL_NAME_LENGTH, + MIN_CHANNEL_NAME_LENGTH, +}; diff --git a/app/constants/index.ts b/app/constants/index.ts index 003486a88e..72198869ec 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -3,6 +3,7 @@ import ActionType from './action_type'; import Apps from './apps'; +import Channel from './channel'; import {CustomStatusDuration} from './custom_status'; import Database from './database'; import DeepLink from './deep_linking'; @@ -30,6 +31,7 @@ export { ActionType, Apps, CustomStatusDuration, + Channel, Database, DeepLink, Device, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 22dd7d37a0..2eb3747ac5 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -8,6 +8,7 @@ export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const BROWSE_CHANNELS = 'BrowseChannels'; export const CHANNEL = 'Channel'; +export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; export const CHANNEL_DETAILS = 'ChannelDetails'; export const CHANNEL_EDIT = 'ChannelEdit'; @@ -44,6 +45,7 @@ export default { BOTTOM_SHEET, BROWSE_CHANNELS, CHANNEL, + CREATE_OR_EDIT_CHANNEL, CHANNEL_ADD_PEOPLE, CHANNEL_EDIT, CHANNEL_DETAILS, diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index 1762c99112..6c7031e8bd 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -28,12 +28,7 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) }; export const prepareCategories = (operator: ServerDataOperator, categories: CategoryWithChannels[]) => { - try { - const categoryRecords = operator.handleCategories({categories, prepareRecordsOnly: true}); - return [categoryRecords]; - } catch { - return undefined; - } + return operator.handleCategories({categories, prepareRecordsOnly: true}); }; export const prepareCategoryChannels = ( @@ -55,11 +50,10 @@ export const prepareCategoryChannels = ( }); if (categoryChannels.length) { - const categoryChannelRecords = operator.handleCategoryChannels({categoryChannels, prepareRecordsOnly: true}); - return [categoryChannelRecords]; + return operator.handleCategoryChannels({categoryChannels, prepareRecordsOnly: true}); } - return []; + return undefined; } catch (e) { return undefined; } diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 4bce45aebc..c975ac4d70 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -184,6 +184,15 @@ export const queryChannelsById = (database: Database, channelIds: string[]) => { return database.get(CHANNEL).query(Q.where('id', Q.oneOf(channelIds))); }; +export const getChannelInfo = async (database: Database, channelId: string) => { + try { + const info = await database.get(CHANNEL_INFO).find(channelId); + return info; + } catch { + return undefined; + } +}; + export const getDefaultChannelForTeam = async (database: Database, teamId: string) => { let channel: ChannelModel|undefined; let canIJoinPublicChannelsInTeam = false; @@ -224,6 +233,16 @@ export const getCurrentChannel = async (database: Database) => { return undefined; }; +export const getCurrentChannelInfo = async (database: Database) => { + const currentChannelId = await getCurrentChannelId(database); + if (currentChannelId) { + const info = await getChannelInfo(database, currentChannelId); + return info; + } + + return undefined; +}; + export const observeCurrentChannel = (database: Database) => { return observeCurrentChannelId(database).pipe( switchMap((id) => database.get(CHANNEL).query(Q.where('id', id), Q.take(1)).observe().pipe( diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index 7906a40269..f44ce32712 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -53,12 +53,12 @@ export const prepareModels = async ({operator, initialTeamId, removeTeams, remov if (chData?.categories?.length) { const categoryModels = prepareCategories(operator, chData.categories); if (categoryModels) { - modelPromises.push(...categoryModels); + modelPromises.push(categoryModels); } const categoryChannelModels = prepareCategoryChannels(operator, chData.categories); if (categoryChannelModels) { - modelPromises.push(...categoryChannelModels); + modelPromises.push(categoryChannelModels); } } diff --git a/app/screens/channel/channel_post_list/intro/options/index.tsx b/app/screens/channel/channel_post_list/intro/options/index.tsx index 774791f611..b5c4c65f0a 100644 --- a/app/screens/channel/channel_post_list/intro/options/index.tsx +++ b/app/screens/channel/channel_post_list/intro/options/index.tsx @@ -39,7 +39,7 @@ const IntroOptions = ({channelId, header, favorite, people, theme}: Props) => { const onSetHeader = useCallback(() => { const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'}); - showModal(Screens.CHANNEL_EDIT, title, {channelId}); + showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId}); }, []); const onDetails = useCallback(() => { diff --git a/app/screens/create_or_edit_channel/channel_info_form.tsx b/app/screens/create_or_edit_channel/channel_info_form.tsx new file mode 100644 index 0000000000..b4f7da37ac --- /dev/null +++ b/app/screens/create_or_edit_channel/channel_info_form.tsx @@ -0,0 +1,345 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useRef, useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import { + LayoutChangeEvent, + TextInput, + TouchableWithoutFeedback, + StatusBar, + View, + NativeSyntheticEvent, + NativeScrollEvent, + Platform, +} from 'react-native'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import Autocomplete from '@components/autocomplete'; +import ErrorText from '@components/error_text'; +import FloatingTextInput from '@components/floating_text_input_label'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import SectionItem from '@components/section_item'; +import {General, Channel} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import useHeaderHeight from '@hooks/header'; +import {t} from '@i18n'; +import { + changeOpacity, + makeStyleSheetFromTheme, + getKeyboardAppearanceFromTheme, +} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + container: { + flex: 1, + }, + scrollView: { + paddingVertical: 30, + paddingHorizontal: 20, + }, + errorContainer: { + width: '100%', + }, + errorWrapper: { + justifyContent: 'center', + alignItems: 'center', + }, + input: { + color: theme.centerChannelColor, + fontSize: 14, + height: 40, + paddingHorizontal: 15, + }, + loading: { + height: 20, + width: 20, + color: theme.centerChannelBg, + }, + textInput: { + marginTop: 30, + }, + helpText: { + ...typography('Body', 400, 'Regular'), + fontSize: 12, + lineHeight: 16, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginTop: 10, + }, + headerHelpText: { + zIndex: -1, + }, +})); + +type Props = { + channelType?: string; + displayName: string; + onDisplayNameChange: (text: string) => void; + editing: boolean; + error?: string | object; + header: string; + onHeaderChange: (text: string) => void; + onTypeChange: (type: ChannelType) => void; + purpose: string; + onPurposeChange: (text: string) => void; + saving: boolean; + type?: string; +} + +export default function ChannelInfoForm({ + channelType, + displayName, + onDisplayNameChange, + editing, + error, + header, + onHeaderChange, + onTypeChange, + purpose, + onPurposeChange, + saving, + type, +}: Props) { + const intl = useIntl(); + const {formatMessage} = intl; + const isTablet = useIsTablet(); + const headerHeight = useHeaderHeight(false, false, false); + + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const nameInput = useRef(null); + const purposeInput = useRef(null); + const headerInput = useRef(null); + + const scrollViewRef = useRef(); + + const updateScrollTimeout = useRef(); + + const [keyboardVisible, setKeyBoardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [scrollPosition, setScrollPosition] = useState(0); + + const [headerPosition, setHeaderPosition] = useState(0); + + const optionalText = formatMessage({id: t('channel_modal.optional'), defaultMessage: '(optional)'}); + const labelDisplayName = formatMessage({id: t('channel_modal.name'), defaultMessage: 'Name'}); + const labelPurpose = formatMessage({id: t('channel_modal.purpose'), defaultMessage: 'Purpose'}) + ' ' + optionalText; + const labelHeader = formatMessage({id: t('channel_modal.header'), defaultMessage: 'Header'}) + ' ' + optionalText; + + const placeholderDisplayName = formatMessage({id: t('channel_modal.nameEx'), defaultMessage: 'Bugs, Marketing'}); + const placeholderPurpose = formatMessage({id: t('channel_modal.purposeEx'), defaultMessage: 'A channel to file bugs and improvements'}); + const placeholderHeader = formatMessage({id: t('channel_modal.headerEx'), defaultMessage: 'Use Markdown to format header text'}); + + const makePrivateLabel = formatMessage({id: t('channel_modal.makePrivate.label'), defaultMessage: 'Make Private'}); + const makePrivateDescription = formatMessage({id: t('channel_modal.makePrivate.description'), defaultMessage: 'When a channel is set to private, only invited team members can access and participate in that channel.'}); + + const displayHeaderOnly = channelType === General.DM_CHANNEL || channelType === General.GM_CHANNEL; + const showSelector = !displayHeaderOnly && !editing; + + const isPrivate = type === General.PRIVATE_CHANNEL; + + const handlePress = () => { + const chtype = isPrivate ? General.OPEN_CHANNEL : General.PRIVATE_CHANNEL; + onTypeChange(chtype); + }; + + const blur = useCallback(() => { + nameInput.current?.blur(); + purposeInput.current?.blur(); + headerInput.current?.blur(); + scrollViewRef.current?.scrollToPosition(0, 0, true); + }, []); + + const onHeaderLayout = useCallback(({nativeEvent}: LayoutChangeEvent) => { + setHeaderPosition(nativeEvent.layout.y); + }, []); + + const scrollHeaderToTop = useCallback(() => { + if (scrollViewRef?.current) { + scrollViewRef.current?.scrollToPosition(0, headerPosition); + } + }, []); + + const onKeyboardDidShow = useCallback((frames: any) => { + setKeyBoardVisible(true); + if (Platform.OS === 'android') { + setKeyboardHeight(frames.endCoordinates.height); + } + }, [scrollHeaderToTop]); + + const onKeyboardDidHide = useCallback(() => { + setKeyBoardVisible(false); + setKeyboardHeight(0); + }, []); + + const onScroll = useCallback((e: NativeSyntheticEvent) => { + const pos = e.nativeEvent.contentOffset.y; + if (updateScrollTimeout.current) { + clearTimeout(updateScrollTimeout.current); + } + updateScrollTimeout.current = setTimeout(() => { + setScrollPosition(pos); + updateScrollTimeout.current = undefined; + }, 200); + }, []); + + if (saving) { + return ( + + + + + ); + } + + let displayError; + if (error) { + displayError = ( + + + + + + ); + } + const platformHeaderHeight = headerHeight.defaultHeight + Platform.select({ios: 10, default: headerHeight.defaultHeight + 10}); + const postInputTop = (headerPosition + scrollPosition + platformHeaderHeight) - keyboardHeight; + + return ( + + + {displayError} + + + {showSelector && ( + + )} + {!displayHeaderOnly && ( + <> + + + + + )} + + + + + + + + + + ); +} diff --git a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx new file mode 100644 index 0000000000..ace06a2902 --- /dev/null +++ b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx @@ -0,0 +1,253 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useReducer, useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard} from 'react-native'; +import {ImageResource, Navigation} from 'react-native-navigation'; + +import {patchChannel as handlePatchChannel, createChannel, switchToChannelById} from '@actions/remote/channel'; +import CompassIcon from '@components/compass_icon'; +import {General} from '@constants'; +import {MIN_CHANNEL_NAME_LENGTH} from '@constants/channel'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; +import {validateDisplayName} from '@utils/channel'; + +import ChannelInfoForm from './channel_info_form'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; + +type Props = { + componentId: string; + channel?: ChannelModel; + channelInfo?: ChannelInfoModel; +} + +const CLOSE_BUTTON_ID = 'close-channel'; +const EDIT_BUTTON_ID = 'update-channel'; +const CREATE_BUTTON_ID = 'create-channel'; + +enum RequestActions { + START = 'Start', + COMPLETE = 'Complete', + FAILURE = 'Failure', +} + +interface RequestState { + error: string; + saving: boolean; +} + +interface RequestAction { + type: RequestActions; + error?: string; +} + +const close = (componentId: string): void => { + Keyboard.dismiss(); + dismissModal({componentId}); +}; + +const isDirect = (channel?: ChannelModel): boolean => { + return channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL; +}; + +const makeCloseButton = (icon: ImageResource) => { + return buildNavigationButton(CLOSE_BUTTON_ID, 'close.more_direct_messages.button', icon); +}; + +const CreateOrEditChannel = ({ + componentId, + channel, + channelInfo, +}: Props) => { + const intl = useIntl(); + const {formatMessage} = intl; + const theme = useTheme(); + const serverUrl = useServerUrl(); + + const editing = Boolean(channel); + + const [type, setType] = useState(channel?.type as ChannelType || General.OPEN_CHANNEL); + const [canSave, setCanSave] = useState(false); + + const [displayName, setDisplayName] = useState(channel?.displayName || ''); + const [purpose, setPurpose] = useState(channelInfo?.purpose || ''); + const [header, setHeader] = useState(channelInfo?.header || ''); + + const [appState, dispatch] = useReducer((state: RequestState, action: RequestAction) => { + switch (action.type) { + case RequestActions.START: + return { + error: '', + saving: true, + }; + case RequestActions.COMPLETE: + return { + error: '', + saving: false, + }; + case RequestActions.FAILURE: + return { + error: action.error, + saving: false, + }; + + default: + return state; + } + }, { + error: '', + saving: false, + }); + + const rightButton = useMemo(() => { + const base = buildNavigationButton( + editing ? EDIT_BUTTON_ID : CREATE_BUTTON_ID, + 'edit_channel.save.button', + undefined, + editing ? formatMessage({id: 'mobile.edit_channel', defaultMessage: 'Save'}) : formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}), + ); + base.enabled = canSave; + base.showAsAction = 'always'; + base.color = theme.sidebarHeaderTextColor; + return base; + }, [editing, theme.sidebarHeaderTextColor, intl, canSave]); + + useEffect(() => { + setButtons(componentId, { + rightButtons: [rightButton], + }); + }, [rightButton, componentId]); + + useEffect(() => { + const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + setButtons(componentId, { + leftButtons: [makeCloseButton(icon)], + }); + }, [theme]); + + useEffect(() => { + setCanSave( + displayName.length >= MIN_CHANNEL_NAME_LENGTH && ( + displayName !== channel?.displayName || + purpose !== channelInfo?.purpose || + header !== channelInfo?.header || + type !== channel.type + ), + ); + }, [channel, displayName, purpose, header, type]); + + const isValidDisplayName = useCallback((): boolean => { + if (isDirect(channel)) { + return true; + } + + const result = validateDisplayName(intl, displayName); + if (result.error) { + dispatch({ + type: RequestActions.FAILURE, + error: result.error, + }); + return false; + } + return true; + }, [channel, displayName]); + + const onCreateChannel = useCallback(async () => { + dispatch({type: RequestActions.START}); + Keyboard.dismiss(); + if (!isValidDisplayName()) { + return; + } + + setCanSave(false); + const createdChannel = await createChannel(serverUrl, displayName, purpose, header, type); + if (createdChannel.error) { + dispatch({ + type: RequestActions.FAILURE, + error: createdChannel.error as string, + }); + return; + } + + dispatch({type: RequestActions.COMPLETE}); + close(componentId); + switchToChannelById(serverUrl, createdChannel.channel!.id, createdChannel.channel!.team_id); + }, [serverUrl, type, displayName, header, purpose, isValidDisplayName]); + + const onUpdateChannel = useCallback(async () => { + if (!channel) { + return; + } + dispatch({type: RequestActions.START}); + Keyboard.dismiss(); + if (!isValidDisplayName()) { + return; + } + + const patchChannel = { + id: channel.id, + type: channel.type, + display_name: isDirect(channel) ? '' : displayName, + purpose, + header, + } as Channel; + + setCanSave(false); + const patchedChannel = await handlePatchChannel(serverUrl, patchChannel); + if (patchedChannel.error) { + dispatch({ + type: RequestActions.FAILURE, + error: patchedChannel.error as string, + }); + return; + } + dispatch({type: RequestActions.COMPLETE}); + close(componentId); + }, [channel?.id, channel?.type, displayName, header, purpose, isValidDisplayName]); + + useEffect(() => { + const update = Navigation.events().registerComponentListener({ + navigationButtonPressed: ({buttonId}: {buttonId: string}) => { + switch (buttonId) { + case CLOSE_BUTTON_ID: + close(componentId); + break; + case CREATE_BUTTON_ID: + onCreateChannel(); + break; + case EDIT_BUTTON_ID: + onUpdateChannel(); + break; + } + }, + }, componentId); + + return () => { + update.remove(); + }; + }, [onCreateChannel, onUpdateChannel]); + + return ( + + ); +}; + +export default CreateOrEditChannel; diff --git a/app/screens/create_or_edit_channel/index.tsx b/app/screens/create_or_edit_channel/index.tsx new file mode 100644 index 0000000000..47d02674dd --- /dev/null +++ b/app/screens/create_or_edit_channel/index.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; + +import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; + +import CreateOrEditChannel from './create_or_edit_channel'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = { + channelId?: string; +} + +const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & OwnProps) => { + const channel = channelId ? observeChannel(database, channelId) : of$(undefined); + const channelInfo = channelId ? observeChannelInfo(database, channelId) : of$(undefined); + return { + channel, + channelInfo, + }; +}); + +export default withDatabase(enhanced(CreateOrEditChannel)); diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index 1e38502112..405b578f23 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -8,7 +8,7 @@ import {Navigation} from 'react-native-navigation'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {deletePost, editPost} from '@actions/remote/post'; -import AutoComplete from '@components/autocomplete'; +import Autocomplete from '@components/autocomplete'; import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; @@ -287,7 +287,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach - diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 4fd6506f74..2cdf56d267 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -70,6 +70,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.CHANNEL: screen = withServerDatabase(require('@screens/channel').default); break; + case Screens.CREATE_OR_EDIT_CHANNEL: + screen = withServerDatabase(require('@screens/create_or_edit_channel').default); + break; case Screens.CUSTOM_STATUS: screen = withServerDatabase( require('@screens/custom_status').default, diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 1cefcc1968..8950f1b0ec 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -573,7 +573,7 @@ export async function dismissAllModals() { } } -export const buildNavigationButton = (id: string, testID: string, icon?: ImageResource): OptionsTopBarButton => ({ +export const buildNavigationButton = (id: string, testID: string, icon?: ImageResource, text?: string): OptionsTopBarButton => ({ fontSize: 16, fontFamily: 'OpenSans-SemiBold', fontWeight: '600', @@ -581,6 +581,7 @@ export const buildNavigationButton = (id: string, testID: string, icon?: ImageRe icon, showAsAction: 'always', testID, + text, }); export function setButtons(componentId: string, buttons: NavButtons = {leftButtons: [], rightButtons: []}) { diff --git a/app/screens/reactions/reactors_list/reactor/index.ts b/app/screens/reactions/reactors_list/reactor/index.ts index 148b5a56ac..5591b0a952 100644 --- a/app/screens/reactions/reactors_list/reactor/index.ts +++ b/app/screens/reactions/reactors_list/reactor/index.ts @@ -4,7 +4,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {observeUser} from '@app/queries/servers/user'; +import {observeUser} from '@queries/servers/user'; import {WithDatabaseArgs} from '@typings/database/database'; import Reactor from './reactor'; diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts index 309c8a89ad..5430ee874f 100644 --- a/app/utils/channel/index.ts +++ b/app/utils/channel/index.ts @@ -1,10 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl'; -import {General, Permissions} from '@constants'; -import {DEFAULT_LOCALE} from '@i18n'; +import {Channel, General, Permissions} from '@constants'; +import {t, DEFAULT_LOCALE} from '@i18n'; import {hasPermission} from '@utils/role'; +import {generateId} from '../general'; +import {cleanUpUrlable} from '../url'; + import type ChannelModel from '@typings/database/models/servers/channel'; export function getDirectChannelName(id: string, otherId: string): string { @@ -62,3 +66,49 @@ export function sortChannelsModelByDisplayName(locale: string, a: ChannelModel, return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true}); } + +const displayNameValidationMessages = { + display_name_required: { + id: t('mobile.rename_channel.display_name_required'), + defaultMessage: 'Channel name is required', + }, + display_name_maxLength: { + id: t('mobile.rename_channel.display_name_maxLength'), + defaultMessage: 'Channel name must be less than {maxLength, number} characters', + }, + display_name_minLength: { + id: t('mobile.rename_channel.display_name_minLength'), + defaultMessage: 'Channel name must be {minLength, number} or more characters', + }, +}; + +export const validateDisplayName = (intl: IntlShape, displayName: string): {error: string} => { + let errorMessage; + switch (true) { + case !displayName: + errorMessage = intl.formatMessage(displayNameValidationMessages.display_name_required); + break; + case displayName.length > Channel.MAX_CHANNEL_NAME_LENGTH: + errorMessage = intl.formatMessage( + displayNameValidationMessages.display_name_maxLength, + {maxLength: Channel.MAX_CHANNEL_NAME_LENGTH}); + break; + case displayName.length < Channel.MIN_CHANNEL_NAME_LENGTH: + errorMessage = intl.formatMessage( + displayNameValidationMessages.display_name_minLength, + {minLength: Channel.MIN_CHANNEL_NAME_LENGTH}); + break; + + default: + errorMessage = ''; + } + return {error: errorMessage}; +}; + +export function generateChannelNameFromDisplayName(displayName: string) { + let name = cleanUpUrlable(displayName); + if (name === '') { + name = generateId(); + } + return name; +} diff --git a/app/utils/role/index.ts b/app/utils/role/index.ts index 21a9feaca4..fad9acd726 100644 --- a/app/utils/role/index.ts +++ b/app/utils/role/index.ts @@ -75,7 +75,8 @@ export async function canManageChannelMembers(post: PostModel, user: UserModel) const rolesArray = [...user.roles.split(' ')]; const channel = await post.channel.fetch() as ChannelModel | undefined; - if (!channel || channel.deleteAt !== 0 || [General.DM_CHANNEL, General.GM_CHANNEL].includes(channel.type as any) || channel.name === General.DEFAULT_CHANNEL) { + const directTypes: string[] = [General.DM_CHANNEL, General.GM_CHANNEL]; + if (!channel || channel.deleteAt !== 0 || directTypes.includes(channel.type) || channel.name === General.DEFAULT_CHANNEL) { return false; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 63a1c31451..6cedcf8342 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -41,7 +41,17 @@ "channel": "{count, plural, one {# member} other {# members}}", "channel_header.directchannel.you": "{displayName} (you)", "channel_loader.someone": "Someone", + "channel_modal.descriptionHelp": "Describe how this channel should be used.", + "channel_modal.header": "Header", + "channel_modal.headerEx": "Use Markdown to format header text", + "channel_modal.headerHelp": "Specify text to appear in the channel header beside the channel name. For example, include frequently used links by typing link text [Link Title](http://example.com).", + "channel_modal.makePrivate.": "Make Private", + "channel_modal.makePrivate.description": "When a channel is set to private, only invited team members can access and participate in that channel", + "channel_modal.name": "Name", + "channel_modal.nameEx": "Bugs, Marketing", "channel_modal.optional": "(optional)", + "channel_modal.purpose": "Purpose", + "channel_modal.purposeEx": "A channel to file bugs and improvements", "combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.", @@ -213,6 +223,7 @@ "mobile.components.select_server_view.proceed": "Proceed", "mobile.create_channel": "Create", "mobile.create_channel.public": "New Public Channel", + "mobile.create_channel.title": "New channel", "mobile.create_direct_message.add_more": "You can add {remaining, number} more users", "mobile.create_direct_message.cannot_add_more": "You cannot add more users", "mobile.create_direct_message.one_more": "You can add 1 more user", @@ -230,6 +241,7 @@ "mobile.downloader.disabled_title": "Download disabled", "mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n", "mobile.downloader.failed_title": "Download failed", + "mobile.edit_channel": "Save", "mobile.edit_post.delete_question": "Are you sure you want to delete this Post?", "mobile.edit_post.delete_title": "Confirm Post Delete", "mobile.edit_post.error": "There was a problem editing this message. Please try again.", @@ -325,6 +337,9 @@ "mobile.push_notification_reply.button": "Send", "mobile.push_notification_reply.placeholder": "Write a reply...", "mobile.push_notification_reply.title": "Reply", + "mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters", + "mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters", + "mobile.rename_channel.display_name_required": "Channel name is required", "mobile.request.invalid_request_method": "Invalid request method", "mobile.request.invalid_response": "Received invalid response from the server.", "mobile.reset_status.alert_cancel": "Cancel",