[Gekidou MM-39733] Port Create Channel from V1 (#6067)

* copy directly from v1. will get working and then convert class to functional components

* screen showing up correctly.  Need to convert lifestyle methods

* create channel button working

* save before refactor in prep for bringing in edit_channel functionality

* change function naming

* clean up lint

* clean up for PR review

* clean up for PR review

* remove hoardcoded styles

* add edit_channel screen

* add handlePatchChannel

* add custom useFormInput hook. use edit screen for both create and edit screens. edit or create screen mode determined by channel prop passed in as a channel or null

* rename edit_channel to create_or_edit_channel

* displayname, header, and purpose are now an object with value and onChange props, created from the useFormInput hook. Now only need to pass this new FormInput Prop to the edit_channel_info component and deconstruct there to get the onChange and value

* fix some lint errors

* fix some lint errors

* remove empty line

* pass intl into utils validate functions because they are not Hooks.  add validation for displayName including translations.

* Move useFormInput hook to its own hooks file and import

* simplify

* remove editing prop.  Was used to determine if the right button was enabled. It was always true for edit_channel screen and always false for create channel screen.  The enableRightButton prop call back is was also used for the same reason.

* remove channeUrl editing references.  This was not implemented on v1

* pass editing prop back into component and add back logic. When editing one field must change.  when creating, just need to check that name is provided

* lint fixes

* fix typing issue for channel types

* scrolling ref should be fixed.  Linting should pass now

* Linting should pass now

* require id field in partial Channel. fixes tsc

* remove everything related to renaming the channel URL.  This has never been requred for mobile

* manage state with useReducer so that all actions/state in one location. This also removes the number of onXXX functions and reduces the number of functions in the component

* reorganize code. useEffects are at top.  Move type and interfaces outside of function component

* Fix lint

* nit: invert if statement checking a negative

* use cneterChannelColor. in figma this is center channel text, but I verified theme color by comparing to SSO login text color

* Simple snapshot tests as a start

* Add more tests

* update snapshot

* add snapshot tests. Add tests for button enabling and disabling

* simplify test with destructuring.

* PR feedback. formatting changes. get user and teamid from one call

* remove FormInput hook and use value/setvalue convention for controlled components

* no need to setChannelDisplayName after creating/updating channel

* Just pass the setXXX function.  Don't need to create as separate callback

* modify floatingTextInput component to allow placeholder text

* remove InteractionManager. PR nits

* mv EditChannelComponent into create_or_edit screen.  Rename component from EditChannelInfo to ChannelInfoForm

* correct import path

* add IntlShape Type to function input. Wrap screen with withServerDatabase, not withIntl

* remove state setting function calls from inside the reducer.  move close function outside of the component. remove setRightButton and rightButton and place rightbutton in initial appState

* move editing const after useX oneliners and before useCallback, useEffect, and useReducers

* rightButton
  - useMemo to memoize an object with dependencies
  - move out of the appState
emitCanSaveChannel
  - wrap with useCallback
onCreateChannel
  - wrap with useCallback
onUpdateChannel
  - wrap with useCallback
useEffect Navigation
  - use the callbacks as dependencies in stead of the depencies of those
    callbacks.

* wrap all formatted message with useMemo()
wrap all onXXXChangeText with useCallback and add deps
move all oneliner derived constants directly after useState useMemo

* remove useMemo from formatted text

* switchToCHannel is still not working.  failing at
  const channel: ChannelModel = await member.channel.fetch();

* use prepareMyChannelsForTeam to update db tables for new channel

* add placeholder text color

* Attach open edit channel screen to `Set Header` button in channel intro view
port SectionItem from V1 and us to add a Switch for setting private/public channel
hook up the plus icon in the channel list header to create a channel (temporary fix to allow debugging)
add new queryChannelsInfoById and queryCurrentChannelInfo query functions
update text for create screen text inputs

* Fix styles and fix actions

* Add autocomplete, fix patch, and address design feedback

* Address feedback

* Add margin between icon and label on Make Private

* Address feedback

* Address feedback

* Address feedback and fix channel list not updating when the channel gets created

* Address feedback and directly add the channel to the default category

* Render at-mentions as Members if no channelId is set

* Display autocomplete on iOS

Co-authored-by: Jason Frerich <jason.frerich@mattermost.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-03-31 10:06:02 +02:00
committed by GitHub
parent 328f029a93
commit e2e54b3bca
28 changed files with 1141 additions and 96 deletions

View File

@@ -39,12 +39,12 @@ export const storeCategories = async (serverUrl: string, categories: CategoryWit
const modelPromises: Array<Promise<Model[]>> = [];
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);

View File

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

View File

@@ -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<any> = {
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',
});
};

View File

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

View File

@@ -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 && (
<AppSlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
@@ -195,9 +197,10 @@ const Autocomplete = ({
nestedScrollEnabled={nestedScrollEnabled}
rootId={rootId}
hasFilesAttached={hasFilesAttached}
inPost={inPost}
/>
}
{showCommands &&
{showCommands && channelId &&
<SlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}

View File

@@ -70,11 +70,12 @@ type Props = {
maxListHeight: number;
updateValue: (v: string) => 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);

View File

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

View File

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

View File

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

View File

@@ -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<TargetedEvent>) => void;
onFocus?: (e: NativeSyntheticEvent<TargetedEvent>) => 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<FloatingTextInputRef, FloatingTextInputProp
onPress = undefined,
onFocus,
onBlur,
onLayout,
showErrorIcon = true,
placeholder,
multiline,
theme,
value = '',
textInputStyle,
@@ -171,86 +199,106 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
[value],
);
const focusStyle = {
top: interpolateNode(animation, {
inputRange: [0, 1],
outputRange: [...getLabelPositions(styles.textInput, styles.label, styles.smallLabel)],
}),
fontSize: interpolateNode(animation, {
inputRange: [0, 1],
outputRange: [styles.textInput.fontSize, styles.smallLabel.fontSize],
}),
backgroundColor: (
focusedLabel ? theme.centerChannelBg : 'transparent'
),
paddingHorizontal: focusedLabel ? 4 : 0,
color: styles.label.color,
};
const onTextInputBlur = (e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
() => {
setIsFocusLabel(Boolean(value));
setIsFocused(false);
},
onBlur,
);
), [onBlur]);
const onTextInputFocus = (e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
const onTextInputFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => 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<TextStyle> = [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 (
<TouchableWithoutFeedback
onPress={onPressAction}
onLayout={onLayout}
>
<View style={[styles.container, containerStyle]}>
{
<Animated.Text
onPress={onAnimatedTextPress}
style={textAnimatedTextStyle}
suppressHighlighting={true}
>
{label}
</Animated.Text>
}
<View style={combinedContainerStyle}>
<Animated.Text
onPress={onAnimatedTextPress}
style={textAnimatedTextStyle}
suppressHighlighting={true}
>
{label}
</Animated.Text>
<TextInput
{...props}
editable={isKeyboardInput && editable}
style={combinedTextInputStyle}
placeholder=''
placeholderTextColor='transparent'
placeholder={placeholder}
placeholderTextColor={styles.label.color}
multiline={multiline}
value={value}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
onFocus={onTextInputFocus}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {
Switch,
Text,
TouchableOpacity,
View,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const ActionTypes = {
ARROW: 'arrow',
DEFAULT: 'default',
TOGGLE: 'toggle',
SELECT: 'select',
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
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 = (
<CompassIcon
name='check'
style={selectStyle}
testID={`${testID}.selected`}
/>
);
} else if (actionType === ActionTypes.TOGGLE) {
actionComponent = (
<Switch
onValueChange={action}
value={selected}
/>
);
} else if (actionType === ActionTypes.ARROW) {
actionComponent = (
<CompassIcon
name='chevron-right'
style={style.arrow}
/>
);
}
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 = (
<View
testID={testID}
style={style.container}
>
<View style={description ? style.doubleContainer : style.singleContainer}>
<View style={style.labelContainer}>
{Boolean(icon) && (
<CompassIcon
name={icon!}
size={24}
color={changeOpacity(theme.centerChannelColor, 0.6)}
/>
)}
<Text
style={labelStyle}
testID={`${testID}.label`}
>
{label}
</Text>
</View>
<Text
style={style.description}
testID={`${testID}.description`}
>
{description}
</Text>
</View>
{actionComponent}
</View>
);
if (actionType === ActionTypes.DEFAULT || actionType === ActionTypes.SELECT || actionType === ActionTypes.ARROW) {
return (
<TouchableOpacity onPress={onPress}>
{component}
</TouchableOpacity>
);
}
return component;
};
export default SectionItem;

10
app/constants/channel.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -184,6 +184,15 @@ export const queryChannelsById = (database: Database, channelIds: string[]) => {
return database.get<ChannelModel>(CHANNEL).query(Q.where('id', Q.oneOf(channelIds)));
};
export const getChannelInfo = async (database: Database, channelId: string) => {
try {
const info = await database.get<ChannelInfoModel>(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<ChannelModel>(CHANNEL).query(Q.where('id', id), Q.take(1)).observe().pipe(

View File

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

View File

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

View File

@@ -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<TextInput>(null);
const purposeInput = useRef<TextInput>(null);
const headerInput = useRef<TextInput>(null);
const scrollViewRef = useRef<KeyboardAwareScrollView>();
const updateScrollTimeout = useRef<NodeJS.Timeout>();
const [keyboardVisible, setKeyBoardVisible] = useState<boolean>(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [scrollPosition, setScrollPosition] = useState(0);
const [headerPosition, setHeaderPosition] = useState<number>(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<NativeScrollEvent>) => {
const pos = e.nativeEvent.contentOffset.y;
if (updateScrollTimeout.current) {
clearTimeout(updateScrollTimeout.current);
}
updateScrollTimeout.current = setTimeout(() => {
setScrollPosition(pos);
updateScrollTimeout.current = undefined;
}, 200);
}, []);
if (saving) {
return (
<View style={styles.container}>
<StatusBar/>
<Loading
style={styles.loading}
/>
</View>
);
}
let displayError;
if (error) {
displayError = (
<SafeAreaView
edges={['bottom', 'left', 'right']}
style={styles.errorContainer}
>
<View style={styles.errorWrapper}>
<ErrorText
testID='edit_channel_info.error.text'
error={error}
/>
</View>
</SafeAreaView>
);
}
const platformHeaderHeight = headerHeight.defaultHeight + Platform.select({ios: 10, default: headerHeight.defaultHeight + 10});
const postInputTop = (headerPosition + scrollPosition + platformHeaderHeight) - keyboardHeight;
return (
<SafeAreaView
edges={['bottom', 'left', 'right']}
style={styles.container}
>
<KeyboardAwareScrollView
testID={'edit_channel_info.scrollview'}
// @ts-expect-error legacy ref
ref={scrollViewRef}
keyboardShouldPersistTaps={'always'}
onKeyboardDidShow={onKeyboardDidShow}
onKeyboardDidHide={onKeyboardDidHide}
enableAutomaticScroll={!keyboardVisible}
contentContainerStyle={styles.scrollView}
onScroll={onScroll}
>
{displayError}
<TouchableWithoutFeedback
onPress={blur}
>
<View>
{showSelector && (
<SectionItem
testID='makePrivate'
label={makePrivateLabel}
description={makePrivateDescription}
action={handlePress}
actionType={'toggle'}
selected={isPrivate}
icon={'lock-outline'}
/>
)}
{!displayHeaderOnly && (
<>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelDisplayName}
placeholder={placeholderDisplayName}
onChangeText={onDisplayNameChange}
maxLength={Channel.MAX_CHANNEL_NAME_LENGTH}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='edit_channel_info.displayname.input'
value={displayName}
ref={nameInput}
containerStyle={styles.textInput}
theme={theme}
/>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelPurpose}
placeholder={placeholderPurpose}
onChangeText={onPurposeChange}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='edit_channel_info.purpose.input'
value={purpose}
ref={purposeInput}
containerStyle={styles.textInput}
theme={theme}
/>
<FormattedText
style={styles.helpText}
id='channel_modal.descriptionHelp'
defaultMessage='Describe how this channel should be used.'
/>
</>
)}
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelHeader}
placeholder={placeholderHeader}
onChangeText={onHeaderChange}
multiline={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='edit_channel_info.header.input'
value={header}
onLayout={onHeaderLayout}
ref={headerInput}
containerStyle={styles.textInput}
theme={theme}
/>
<FormattedText
style={styles.helpText}
id='channel_modal.headerHelp'
defaultMessage={'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).'}
/>
</View>
</TouchableWithoutFeedback>
</KeyboardAwareScrollView>
<View>
<Autocomplete
postInputTop={postInputTop}
updateValue={onHeaderChange}
cursorPosition={header.length}
value={header}
nestedScrollEnabled={true}
maxHeightOverride={isTablet ? 200 : undefined}
inPost={false}
fixedBottomPosition={false}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -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<ChannelType>(channel?.type as ChannelType || General.OPEN_CHANNEL);
const [canSave, setCanSave] = useState(false);
const [displayName, setDisplayName] = useState<string>(channel?.displayName || '');
const [purpose, setPurpose] = useState<string>(channelInfo?.purpose || '');
const [header, setHeader] = useState<string>(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 (
<ChannelInfoForm
error={appState.error}
saving={appState.saving}
channelType={channel?.type}
editing={editing}
onTypeChange={setType}
type={type}
displayName={displayName}
onDisplayNameChange={setDisplayName}
header={header}
onHeaderChange={setHeader}
purpose={purpose}
onPurposeChange={setPurpose}
/>
);
};
export default CreateOrEditChannel;

View File

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

View File

@@ -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
</View>
</SafeAreaView>
<Animated.View style={animatedStyle}>
<AutoComplete
<Autocomplete
channelId={post.channelId}
hasFilesAttached={hasFilesAttached}
nestedScrollEnabled={true}
@@ -298,6 +298,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
postInputTop={1}
fixedBottomPosition={true}
maxHeightOverride={isTablet ? 200 : undefined}
inPost={false}
/>
</Animated.View>

View File

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

View File

@@ -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: []}) {

View File

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

View File

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

View File

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

View File

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