Add members (#7220)

* Add "Add members" modal

* Refactor into server user list

* Renaming and fixes

* Address feedback

* Add missing change

* Styling fixes for iOS
This commit is contained in:
Daniel Espino García 2023-03-30 16:58:50 +02:00 committed by GitHub
parent 05469207d7
commit 77095b1034
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 826 additions and 277 deletions

View file

@ -156,6 +156,7 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
noBorder: true,
scrollEdgeAppearance: {
noBorder: true,
active: true,
},
rightButtons,
},

View file

@ -164,10 +164,12 @@ export async function addMembersToChannel(serverUrl: string, channelId: string,
if (!fetchOnly) {
const modelPromises: Array<Promise<Model[]>> = [];
modelPromises.push(operator.handleUsers({
users,
prepareRecordsOnly: true,
}));
if (users?.length) {
modelPromises.push(operator.handleUsers({
users,
prepareRecordsOnly: true,
}));
}
modelPromises.push(operator.handleChannelMembership({
channelMemberships,
prepareRecordsOnly: true,

View file

@ -485,10 +485,12 @@ export const fetchProfiles = async (serverUrl: string, page = 0, perPage: number
if (!fetchOnly) {
const currentUserId = await getCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
if (toStore.length) {
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
}
return {users};
@ -517,7 +519,38 @@ export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, pag
if (!fetchOnly) {
const currentUserId = await getCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
if (toStore.length) {
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchProfilesNotInChannel = async (
serverUrl: string,
teamId: string,
channelId: string,
groupConstrained = false,
page = 0,
perPage: number = General.PROFILE_CHUNK_SIZE,
fetchOnly = false,
) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const users = await client.getProfilesNotInChannel(teamId, channelId, groupConstrained, page, perPage);
if (!fetchOnly && users.length) {
const currentUserId = await getCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,

View file

@ -52,13 +52,22 @@ const Button = ({
textStyle,
], [theme, textStyle, size, emphasis, buttonType]);
const containerStyle = useMemo(
() =>
(iconSize ? [
styles.container,
{minHeight: iconSize},
] : styles.container),
[iconSize],
);
return (
<RNButton
containerStyle={bgStyle}
onPress={onPress}
testID={testID}
>
<View style={styles.container}>
<View style={containerStyle}>
{Boolean(iconName) &&
<CompassIcon
name={iconName!}

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {getHeaderOptions} from '@screens/channel_add_members/channel_add_members';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
import type {StyleProp, ViewStyle} from 'react-native';
type Props = {
channelId: string;
displayName: string;
inModal?: boolean;
containerStyle?: StyleProp<ViewStyle>;
testID: string;
}
const AddMembersBox = ({
channelId,
displayName,
inModal,
containerStyle,
testID,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const onAddMembers = useCallback(async () => {
const title = intl.formatMessage({id: 'intro.add_members', defaultMessage: 'Add members'});
const options = await getHeaderOptions(theme, displayName, inModal);
if (inModal) {
goToScreen(Screens.CHANNEL_ADD_MEMBERS, title, {channelId, inModal}, options);
return;
}
await dismissBottomSheet();
showModal(Screens.CHANNEL_ADD_MEMBERS, title, {channelId, inModal}, options);
}, [intl, channelId, inModal, testID, displayName]);
return (
<OptionBox
containerStyle={containerStyle}
iconName='account-plus-outline'
onPress={onAddMembers}
testID={testID}
text={intl.formatMessage({id: 'intro.add_members', defaultMessage: 'Add members'})}
/>
);
};
export default AddMembersBox;

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import AddMembersBox from './add_members_box';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const displayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
return {
displayName,
};
});
export default withDatabase(enhanced(AddMembersBox));

View file

@ -1,44 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
import type {StyleProp, ViewStyle} from 'react-native';
type Props = {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
inModal?: boolean;
testID?: string;
}
const AddPeopleBox = ({channelId, containerStyle, inModal, testID}: Props) => {
const intl = useIntl();
const onAddPeople = useCallback(async () => {
const title = intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'});
if (inModal) {
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
return;
}
await dismissBottomSheet();
showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
}, [intl, channelId, inModal]);
return (
<OptionBox
containerStyle={containerStyle}
iconName='account-plus-outline'
onPress={onAddPeople}
testID={testID}
text={intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'})}
/>
);
};
export default AddPeopleBox;

View file

@ -5,6 +5,7 @@ import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';
import ChannelInfoStartButton from '@calls/components/channel_info_start';
import AddMembersBox from '@components/channel_actions/add_members_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import MutedBox from '@components/channel_actions/mute_box';
@ -13,8 +14,6 @@ import {useServerUrl} from '@context/server';
import {dismissBottomSheet} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
type Props = {
channelId: string;
channelType?: ChannelType;
@ -22,6 +21,7 @@ type Props = {
dismissChannelInfo: () => void;
callsEnabled: boolean;
testID?: string;
canManageMembers: boolean;
}
export const CHANNEL_ACTIONS_OPTIONS_HEIGHT = 62;
@ -36,7 +36,15 @@ const styles = StyleSheet.create({
},
});
const ChannelActions = ({channelId, channelType, inModal = false, dismissChannelInfo, callsEnabled, testID}: Props) => {
const ChannelActions = ({
channelId,
channelType,
inModal = false,
dismissChannelInfo,
callsEnabled,
canManageMembers,
testID,
}: Props) => {
const serverUrl = useServerUrl();
const onCopyLinkAnimationEnd = useCallback(() => {
@ -70,15 +78,13 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel
testID={`${testID}.set_header.action`}
/>
}
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{!isDM &&
<AddPeopleBox
{canManageMembers &&
<AddMembersBox
channelId={channelId}
inModal={inModal}
testID={`${testID}.add_people.action`}
testID={`${testID}.add_members.action`}
/>
}
*/}
{!isDM && !callsEnabled &&
<>
<View style={styles.separator}/>

View file

@ -7,6 +7,8 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeCanManageChannelMembers} from '@queries/servers/role';
import {observeCurrentUser} from '@queries/servers/user';
import ChannelActions from './channel_actions';
@ -21,8 +23,12 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
switchMap((c) => of$(c?.type)),
);
const canManageMembers = observeCurrentUser(database).pipe(
switchMap((u) => (u ? observeCanManageChannelMembers(database, channelId, u) : of$(false))),
);
return {
channelType,
canManageMembers,
};
});

View file

@ -41,6 +41,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
text: {
color: changeOpacity(theme.centerChannelColor, 0.56),
paddingHorizontal: 5,
width: '100%',
textAlign: 'center',
...typography('Body', 50, 'SemiBold'),
},
}));

View file

@ -6,12 +6,11 @@ import {LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View} from
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Button from '@components/button';
import {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from '@components/selected_chip';
import Toast from '@components/toast';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser from './selected_user';
@ -56,12 +55,12 @@ type Props = {
/**
* callback to set the value of showToast
*/
setShowToast: (show: boolean) => void;
setShowToast?: (show: boolean) => void;
/**
* show the toast
*/
showToast: boolean;
showToast?: boolean;
/**
* How to display the names of users.
@ -81,15 +80,23 @@ type Props = {
/**
* toast Message
*/
toastMessage: string;
toastMessage?: string;
/**
* Max number of users in the list
*/
maxUsers?: number;
}
const BUTTON_HEIGHT = 48;
const CHIP_HEIGHT_WITH_MARGIN = USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN;
const EXPOSED_CHIP_HEIGHT = 0.33 * USER_CHIP_HEIGHT;
const MAX_CHIP_ROWS = 2;
const SCROLL_PADDING_TOP = 20;
const PANEL_MAX_HEIGHT = SCROLL_PADDING_TOP + (CHIP_HEIGHT_WITH_MARGIN * MAX_CHIP_ROWS) + EXPOSED_CHIP_HEIGHT;
const SCROLL_MARGIN_TOP = 20;
const SCROLL_MARGIN_BOTTOM = 12;
const USERS_CHIPS_MAX_HEIGHT = (CHIP_HEIGHT_WITH_MARGIN * MAX_CHIP_ROWS) + EXPOSED_CHIP_HEIGHT;
const SCROLL_MAX_HEIGHT = USERS_CHIPS_MAX_HEIGHT + SCROLL_MARGIN_TOP + SCROLL_MARGIN_BOTTOM;
const PANEL_MAX_HEIGHT = SCROLL_MAX_HEIGHT + BUTTON_HEIGHT;
const TABLET_MARGIN_BOTTOM = 20;
const TOAST_BOTTOM_MARGIN = 24;
@ -102,7 +109,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderWidth: 1,
maxHeight: PANEL_MAX_HEIGHT + BUTTON_HEIGHT,
maxHeight: PANEL_MAX_HEIGHT,
overflow: 'hidden',
paddingHorizontal: 20,
shadowColor: theme.centerChannelColor,
@ -116,9 +123,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
toast: {
backgroundColor: theme.centerChannelColor,
},
usersScroll: {
marginTop: SCROLL_MARGIN_TOP,
marginBottom: SCROLL_MARGIN_BOTTOM,
},
users: {
paddingTop: SCROLL_PADDING_TOP,
paddingBottom: 12,
flexDirection: 'row',
flexGrow: 1,
flexWrap: 'wrap',
@ -134,10 +143,20 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
});
export default function SelectedUsers({
buttonIcon, buttonText, containerHeight = 0,
modalPosition = 0, onPress, onRemove,
selectedIds, setShowToast, showToast = false,
teammateNameDisplay, testID, toastIcon, toastMessage,
buttonIcon,
buttonText,
containerHeight = 0,
modalPosition = 0,
onPress,
onRemove,
selectedIds,
setShowToast,
showToast = false,
teammateNameDisplay,
testID,
toastIcon,
toastMessage,
maxUsers,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
@ -146,11 +165,10 @@ export default function SelectedUsers({
const insets = useSafeAreaInsets();
const dimensions = useWindowDimensions();
const panelHeight = useSharedValue(0);
const usersChipsHeight = useSharedValue(0);
const [isVisible, setIsVisible] = useState(false);
const numberSelectedIds = Object.keys(selectedIds).length;
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
const bottomPaddingBottom = isTablet ? CHIP_HEIGHT_WITH_MARGIN : 0;
const users = useMemo(() => {
const u = [];
@ -173,13 +191,15 @@ export default function SelectedUsers({
}, [selectedIds, teammateNameDisplay, onRemove]);
const totalPanelHeight = useDerivedValue(() => (
isVisible ? panelHeight.value + BUTTON_HEIGHT + bottomPaddingBottom : 0
), [isVisible, isTablet, bottomPaddingBottom]);
isVisible ?
usersChipsHeight.value + SCROLL_MARGIN_BOTTOM + SCROLL_MARGIN_TOP + BUTTON_HEIGHT :
0
), [isVisible]);
const marginBottom = useMemo(() => {
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
if (isTablet) {
margin = keyboard.height ? (keyboard.height - bottomSpace - insets.bottom) : 0;
margin = keyboard.height ? Math.max((keyboard.height - bottomSpace - insets.bottom), 0) : 0;
}
return margin;
}, [keyboard, isTablet, insets.bottom, bottomSpace]);
@ -209,7 +229,10 @@ export default function SelectedUsers({
}, [onPress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
panelHeight.value = Math.min(PANEL_MAX_HEIGHT + bottomPaddingBottom, e.nativeEvent.layout.height);
usersChipsHeight.value = Math.min(
USERS_CHIPS_MAX_HEIGHT,
e.nativeEvent.layout.height,
);
}, []);
const androidMaxHeight = Platform.select({
@ -234,10 +257,10 @@ export default function SelectedUsers({
}, [showToast, keyboard]);
const animatedViewStyle = useAnimatedStyle(() => ({
height: withTiming(totalPanelHeight.value + insets.bottom, {duration: 250}),
height: withTiming(totalPanelHeight.value, {duration: 250}),
borderWidth: isVisible ? 1 : 0,
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + bottomPaddingBottom + insets.bottom : 0,
}), [isVisible, insets, bottomPaddingBottom]);
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT : 0,
}), [isVisible]);
const animatedButtonStyle = useAnimatedStyle(() => ({
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),
@ -252,13 +275,14 @@ export default function SelectedUsers({
let timer: NodeJS.Timeout;
if (showToast) {
timer = setTimeout(() => {
setShowToast(false);
setShowToast?.(false);
}, 4000);
}
return () => clearTimeout(timer);
}, [showToast]);
const isDisabled = Boolean(maxUsers && (numberSelectedIds > maxUsers));
return (
<Animated.View style={animatedContainerStyle}>
{showToast &&
@ -270,7 +294,7 @@ export default function SelectedUsers({
/>
}
<Animated.View style={[style.container, animatedViewStyle]}>
<ScrollView>
<ScrollView style={style.usersScroll}>
<View
style={style.users}
onLayout={onLayout}
@ -281,9 +305,13 @@ export default function SelectedUsers({
<Animated.View style={animatedButtonStyle}>
<Button
onPress={handlePress}
icon={buttonIcon}
iconName={buttonIcon}
text={buttonText}
disabled={numberSelectedIds > General.MAX_USERS_IN_GM}
iconSize={20}
theme={theme}
buttonType={isDisabled ? 'disabled' : 'default'}
emphasis={'primary'}
size={'lg'}
testID={`${testID}.start.button`}
/>
</Animated.View>

View file

@ -3,7 +3,6 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
@ -11,26 +10,33 @@ import {debounce} from '@helpers/api/general';
import {filterProfilesMatchingTerm} from '@utils/user';
type Props = {
currentTeamId: string;
currentUserId: string;
teammateNameDisplay: string;
tutorialWatched: boolean;
handleSelectProfile: (user: UserProfile) => void;
term: string;
selectedIds: {[id: string]: UserProfile};
fetchFunction: (page: number) => Promise<UserProfile[]>;
searchFunction: (term: string) => Promise<UserProfile[]>;
createFilter: (exactMatches: UserProfile[], term: string) => ((p: UserProfile) => boolean);
testID: string;
}
export default function ServerUserList({
currentTeamId,
currentUserId,
teammateNameDisplay,
tutorialWatched,
handleSelectProfile,
term,
selectedIds,
fetchFunction,
searchFunction,
createFilter,
testID,
}: Props) {
const serverUrl = useServerUrl();
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
@ -38,13 +44,12 @@ export default function ServerUserList({
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
const loadedProfiles = (users: UserProfile[]) => {
if (mounted.current) {
if (users && !users.length) {
if (!users.length) {
next.current = false;
}
@ -63,30 +68,29 @@ export default function ServerUserList({
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
fetchFunction(page.current + 1).then(loadedProfiles);
}
}, 100), [loading, isSearch, serverUrl, currentTeamId]);
const onHandleSelectProfile = useCallback((user: UserProfile) => {
handleSelectProfile(user);
}, [handleSelectProfile]);
}, 100), [loading, isSearch, serverUrl]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
const results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
const data = await searchFunction(searchTerm);
setSearchResults(data);
setLoading(false);
}, [serverUrl, currentTeamId]);
}, [serverUrl, searchFunction]);
useEffect(() => {
searchUsers(term);
if (term) {
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(term);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
setSearchResults([]);
}
}, [term]);
useEffect(() => {
@ -98,31 +102,21 @@ export default function ServerUserList({
}, []);
const data = useMemo(() => {
if (term) {
if (isSearch) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
const filterByTerm = createFilter(exactMatches, term);
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
const profilesToFilter = searchResults.length ? searchResults : profiles;
const results = filterProfilesMatchingTerm(profilesToFilter, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
}, [term, isSearch, isSearch && searchResults, profiles]);
return (
<UserList
currentUserId={currentUserId}
handleSelectProfile={onHandleSelectProfile}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
@ -130,7 +124,7 @@ export default function ServerUserList({
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
testID={testID}
tutorialWatched={tutorialWatched}
/>
);

View file

@ -8,7 +8,7 @@ export const BOTTOM_SHEET = 'BottomSheet';
export const BROWSE_CHANNELS = 'BrowseChannels';
export const CALL = 'Call';
export const CHANNEL = 'Channel';
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
export const CHANNEL_ADD_MEMBERS = 'ChannelAddMembers';
export const CHANNEL_INFO = 'ChannelInfo';
export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences';
export const CODE = 'Code';
@ -77,7 +77,7 @@ export default {
BROWSE_CHANNELS,
CALL,
CHANNEL,
CHANNEL_ADD_PEOPLE,
CHANNEL_ADD_MEMBERS,
CHANNEL_INFO,
CHANNEL_NOTIFICATION_PREFERENCES,
CODE,
@ -142,6 +142,7 @@ export default {
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
BROWSE_CHANNELS,
CHANNEL_INFO,
CHANNEL_ADD_MEMBERS,
CREATE_DIRECT_MESSAGE,
CREATE_TEAM,
CUSTOM_STATUS,
@ -171,6 +172,5 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
]);
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CREATE_TEAM,
];

View file

@ -5,6 +5,7 @@ import {t} from '@i18n';
import keyMirror from '@utils/key_mirror';
export const SNACK_BAR_TYPE = keyMirror({
ADD_CHANNEL_MEMBERS: null,
FAVORITE_CHANNEL: null,
LINK_COPIED: null,
MESSAGE_COPIED: null,
@ -22,6 +23,12 @@ type SnackBarConfig = {
};
export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
ADD_CHANNEL_MEMBERS: {
id: t('snack.bar.channel.members.added'),
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
iconName: 'check',
canUndo: false,
},
FAVORITE_CHANNEL: {
id: t('snack.bar.favorited.channel'),
defaultMessage: 'This channel was favorited',

View file

@ -110,7 +110,7 @@ export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrac
return height;
}
export function useModalPosition(viewRef: RefObject<View>, deps?: React.DependencyList) {
export function useModalPosition(viewRef: RefObject<View>, deps: React.DependencyList = []) {
const [modalPosition, setModalPosition] = useState(0);
const isTablet = useIsTablet();
const height = useKeyboardHeight();
@ -123,7 +123,7 @@ export function useModalPosition(viewRef: RefObject<View>, deps?: React.Dependen
}
});
}
}, [...(deps || []), isTablet, height]);
}, [...deps, isTablet, height]);
return modalPosition;
}

View file

@ -153,7 +153,7 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
channelId={channel.id}
header={true}
favorite={true}
people={false}
canAddMembers={false}
/>
</View>
);

View file

@ -4,7 +4,7 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
import AddMembersBox from '@components/channel_actions/add_members_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import InfoBox from '@components/channel_actions/info_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
@ -13,7 +13,7 @@ type Props = {
channelId: string;
header?: boolean;
favorite?: boolean;
people?: boolean;
canAddMembers?: boolean;
}
const styles = StyleSheet.create({
@ -39,18 +39,18 @@ const styles = StyleSheet.create({
},
});
const IntroOptions = ({channelId, header, favorite}: Props) => {
const IntroOptions = ({channelId, header, favorite, canAddMembers}: Props) => {
return (
<View style={styles.container}>
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{people &&
<AddPeopleBox
{canAddMembers &&
<AddMembersBox
channelId={channelId}
containerStyle={[styles.item, styles.margin]}
testID='channel_post_list.intro_options.add_people.action'
testID='channel_post_list.intro_options.add_members.action'
inModal={false}
/>
}
*/}
{header &&
<SetHeaderBox
channelId={channelId}

View file

@ -139,7 +139,7 @@ const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => {
<IntroOptions
channelId={channel.id}
header={canSetHeader}
people={canManagePeople}
canAddMembers={canManagePeople}
/>
</View>
);

View file

@ -61,6 +61,7 @@ const TownSquare = ({channelId, displayName, roles, theme}: Props) => {
<IntroOptions
channelId={channelId}
header={hasPermission(roles, Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES)}
canAddMembers={false}
/>
</View>
);

View file

@ -0,0 +1,298 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, LayoutChangeEvent, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {addMembersToChannel} from '@actions/remote/channel';
import {fetchProfilesNotInChannel, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import ServerUserList from '@components/server_user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useModalPosition} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {mergeNavigationOptions} from '@utils/navigation';
import {showAddChannelMembersSnackbar} from '@utils/snack_bar';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type ChannelModel from '@typings/database/models/servers/channel';
import type {AvailableScreens} from '@typings/screens/navigation';
const CLOSE_BUTTON_ID = 'close-add-member';
const TEST_ID = 'add_members';
const CLOSE_BUTTON_TEST_ID = 'close.button';
export const getHeaderOptions = async (theme: Theme, displayName: string, inModal = false) => {
let leftButtons;
if (!inModal) {
const closeButton = await CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
leftButtons = [{
id: CLOSE_BUTTON_ID,
icon: closeButton,
testID: `${TEST_ID}.${CLOSE_BUTTON_TEST_ID}`,
}];
}
return {
topBar: {
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: displayName,
},
leftButtons,
backButton: inModal ? {
color: theme.sidebarHeaderTextColor,
} : undefined,
},
};
};
type Props = {
componentId: AvailableScreens;
channel?: ChannelModel;
currentUserId: string;
teammateNameDisplay: string;
tutorialWatched: boolean;
inModal?: boolean;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
...typography('Body', 600, 'Regular'),
},
};
});
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
Reflect.deleteProperty(newSelectedIds, id);
return newSelectedIds;
}
export default function ChannelAddMembers({
componentId,
channel,
currentUserId,
teammateNameDisplay,
tutorialWatched,
inModal,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const [term, setTerm] = useState('');
const [addingMembers, setAddingMembers] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const [containerHeight, setContainerHeight] = useState(0);
const clearSearch = useCallback(() => {
setTerm('');
}, []);
const handleRemoveProfile = useCallback((id: string) => {
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const addMembers = useCallback(async () => {
if (!channel) {
return;
}
if (addingMembers) {
return;
}
const idsToUse = Object.keys(selectedIds);
if (!idsToUse.length) {
return;
}
setAddingMembers(true);
const result = await addMembersToChannel(serverUrl, channel.id, idsToUse);
if (result.error) {
alertErrorWithFallback(intl, result.error, {id: t('mobile.channel_add_members.error'), defaultMessage: 'There has been an error and we could not add those users to the channel.'});
setAddingMembers(false);
} else {
close();
showAddChannelMembersSnackbar(idsToUse.length);
}
}, [channel, addingMembers, selectedIds, serverUrl, intl]);
const handleSelectProfile = useCallback((user: UserProfile) => {
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const newSelectedIds = Object.assign({}, current);
newSelectedIds[user.id] = user;
return newSelectedIds;
});
}, [currentUserId, clearSearch]);
const onTextChange = useCallback((searchTerm: string) => {
setTerm(searchTerm);
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const updateNavigationButtons = useCallback(async () => {
const options = await getHeaderOptions(theme, channel?.displayName || '', inModal);
mergeNavigationOptions(componentId, options);
}, [theme, channel?.displayName, inModal, componentId]);
const userFetchFunction = useCallback(async (page: number) => {
if (!channel) {
return [];
}
const result = await fetchProfilesNotInChannel(serverUrl, channel.teamId, channel.id, channel.isGroupConstrained, page, General.PROFILE_CHUNK_SIZE);
if (result.users?.length) {
return result.users;
}
return [];
}, [serverUrl, channel]);
const userSearchFunction = useCallback(async (searchTerm: string) => {
if (!channel) {
return [];
}
const lowerCasedTerm = searchTerm.toLowerCase();
const results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: channel.teamId, not_in_channel_id: channel.id, allow_inactive: true});
if (results.data) {
return results.data;
}
return [];
}, [serverUrl, channel]);
const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
return (p: UserProfile) => {
if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
exactMatches.push(p);
return false;
}
return true;
};
}, []);
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
useAndroidHardwareBackHandler(componentId, close);
useEffect(() => {
updateNavigationButtons();
}, [updateNavigationButtons]);
if (addingMembers) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID={`${TEST_ID}.screen`}
onLayout={onLayout}
ref={mainView}
edges={['top', 'left', 'right']}
>
<View style={style.searchBar}>
<Search
testID={`${TEST_ID}.search_bar`}
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onTextChange}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<ServerUserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
teammateNameDisplay={teammateNameDisplay}
term={term}
testID={`${TEST_ID}.user_list`}
tutorialWatched={tutorialWatched}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
/>
<SelectedUsers
containerHeight={containerHeight}
modalPosition={modalPosition}
selectedIds={selectedIds}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={addMembers}
buttonIcon={'account-plus-outline'}
buttonText={formatMessage({id: 'channel_add_members.add_members.button', defaultMessage: 'Add Members'})}
testID={`${TEST_ID}.selected`}
/>
</SafeAreaView>
);
}

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import {observeChannel} from '@queries/servers/channel';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ChannelAddMembers from './channel_add_members';
import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
channelId: string;
} & WithDatabaseArgs;
const enhanced = withObservables(['channelId'], ({database, channelId}: OwnProps) => {
const channel = observeChannel(database, channelId);
return {
channel,
teammateNameDisplay: observeTeammateNameDisplay(database),
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
};
});
export default withDatabase(enhanced(ChannelAddMembers));

View file

@ -29,6 +29,7 @@ type Props = {
type?: ChannelType;
canEnableDisableCalls: boolean;
isCallsEnabledInChannel: boolean;
canManageMembers: boolean;
}
const edges: Edge[] = ['bottom', 'left', 'right'];
@ -55,6 +56,7 @@ const ChannelInfo = ({
type,
canEnableDisableCalls,
isCallsEnabledInChannel,
canManageMembers,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
@ -100,6 +102,7 @@ const ChannelInfo = ({
channelId={channelId}
type={type}
callsEnabled={callsAvailable}
canManageMembers={canManageMembers}
/>
<View style={styles.separator}/>
{canEnableDisableCalls &&

View file

@ -4,12 +4,13 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators';
import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeCallsConfig} from '@calls/state';
import {withServerUrl} from '@context/server';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeCanManageChannelMembers} from '@queries/servers/role';
import {
observeConfigValue,
observeCurrentChannelId,
@ -98,10 +99,16 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
);
const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, observeCurrentChannelId(database));
const canManageMembers = observeCurrentUser(database).pipe(
combineLatestWith(channelId),
switchMap(([u, cId]) => (u ? observeCanManageChannelMembers(database, cId, u) : of$(false))),
distinctUntilChanged(),
);
return {
type,
canEnableDisableCalls,
isCallsEnabledInChannel,
canManageMembers,
};
});

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {getHeaderOptions} from '@app/screens/channel_add_members/channel_add_members';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {
channelId: string;
displayName: string;
}
const AddMembers = ({displayName, channelId}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const title = formatMessage({id: 'channel_info.add_members', defaultMessage: 'Add members'});
const goToAddMembers = preventDoubleTap(async () => {
const options = await getHeaderOptions(theme, displayName, true);
goToScreen(Screens.CHANNEL_ADD_MEMBERS, title, {channelId, inModal: true}, options);
});
return (
<OptionItem
action={goToAddMembers}
label={title}
icon='account-plus-outline'
type={Platform.select({ios: 'arrow', default: 'default'})}
testID='channel_info.options.add_members.option'
/>
);
};
export default AddMembers;

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import AddMembers from './add_members';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const displayName = observeChannel(database, channelId).pipe(
switchMap((c) => of$(c?.displayName)),
);
return {
displayName,
};
});
export default withDatabase(enhanced(AddMembers));

View file

@ -7,6 +7,7 @@ import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link
import {General} from '@constants';
import {isTypeDMorGM} from '@utils/channel';
import AddMembers from './add_members';
import EditChannel from './edit_channel';
import IgnoreMentions from './ignore_mentions';
import Members from './members';
@ -17,9 +18,15 @@ type Props = {
channelId: string;
type?: ChannelType;
callsEnabled: boolean;
canManageMembers: boolean;
}
const Options = ({channelId, type, callsEnabled}: Props) => {
const Options = ({
channelId,
type,
callsEnabled,
canManageMembers,
}: Props) => {
const isDMorGM = isTypeDMorGM(type);
return (
@ -32,6 +39,9 @@ const Options = ({channelId, type, callsEnabled}: Props) => {
{type !== General.DM_CHANNEL &&
<Members channelId={channelId}/>
}
{canManageMembers &&
<AddMembers channelId={channelId}/>
}
{callsEnabled && !isDMorGM && // if calls is not enabled, copy link will show in the channel actions
<CopyChannelLinkOption
channelId={channelId}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, LayoutChangeEvent, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
@ -12,11 +12,10 @@ import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import UserList from '@components/user_list';
import ServerUserList from '@components/server_user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useModalPosition} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
@ -25,7 +24,7 @@ import {dismissModal, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import {displayUsername} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -96,13 +95,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
@ -124,16 +116,9 @@ export default function CreateDirectMessage({
const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
@ -141,58 +126,10 @@ export default function CreateDirectMessage({
const [containerHeight, setContainerHeight] = useState(0);
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
}
};
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
@ -274,49 +211,10 @@ export default function CreateDirectMessage({
}
}, [currentUserId, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let searchData: UserProfile[] = [];
if (results.data) {
searchData = results.data;
}
setSearchResults(searchData);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async () => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
@ -328,17 +226,62 @@ export default function CreateDirectMessage({
});
}, [intl.locale, theme]);
const onChangeText = useCallback((searchTerm: string) => {
setTerm(searchTerm);
}, []);
const userFetchFunction = useCallback(async (page: number) => {
let results;
if (restrictDirectMessage) {
results = await fetchProfilesInTeam(serverUrl, currentTeamId, page, General.PROFILE_CHUNK_SIZE);
} else {
results = await fetchProfiles(serverUrl, page, General.PROFILE_CHUNK_SIZE);
}
if (results.users?.length) {
return results.users;
}
return [];
}, [serverUrl, currentTeamId, restrictDirectMessage]);
const userSearchFunction = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
if (results.data) {
return results.data;
}
return [];
}, [serverUrl, currentTeamId, restrictDirectMessage]);
const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
return (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
exactMatches.push(p);
return false;
}
return true;
};
}, [selectedCount > 0, currentUserId]);
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
useAndroidHardwareBackHandler(componentId, close);
useEffect(() => {
mounted.current = true;
updateNavigationButtons();
getProfiles();
return () => {
mounted.current = false;
};
}, []);
}, [updateNavigationButtons]);
useEffect(() => {
setShowToast(selectedCount >= General.MAX_USERS_IN_GM);
@ -366,26 +309,24 @@ export default function CreateDirectMessage({
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
onSubmitEditing={search}
onChangeText={onChangeText}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<UserList
<ServerUserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
/>
<SelectedUsers
containerHeight={containerHeight}
@ -401,6 +342,7 @@ export default function CreateDirectMessage({
buttonIcon={'forum-outline'}
buttonText={formatMessage(messages.buttonText)}
testID='create_direct_message'
maxUsers={General.MAX_USERS_IN_GM}
/>
</SafeAreaView>
);

View file

@ -100,6 +100,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase(require('@screens/create_direct_message').default);
break;
case Screens.CHANNEL_ADD_MEMBERS:
screen = withServerDatabase(require('@screens/channel_add_members').default);
break;
case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default);
break;

View file

@ -7,6 +7,7 @@ import {View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {fetchChannels, searchChannels} from '@actions/remote/channel';
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
import FormattedText from '@components/formatted_text';
import SearchBar from '@components/search';
import ServerUserList from '@components/server_user_list';
@ -516,18 +517,52 @@ function IntegrationSelector(
);
}, [multiselectSelected, selectedIds, style, theme]);
const userFetchFunction = useCallback(async (userFetchPage: number) => {
const result = await fetchProfiles(serverUrl, userFetchPage, General.PROFILE_CHUNK_SIZE);
if (result.users?.length) {
return result.users;
}
return [];
}, [serverUrl]);
const userSearchFunction = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
const results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: false});
if (results.data) {
return results.data;
}
return [];
}, [serverUrl]);
const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
return (p: UserProfile) => {
if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
exactMatches.push(p);
return false;
}
return true;
};
}, []);
const renderDataTypeList = () => {
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS:
return (
<ServerUserList
currentTeamId={currentTeamId}
currentUserId={currentUserId}
teammateNameDisplay={teammateNameDisplay}
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds as {[id: string]: UserProfile}}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
testID={'integration_selector.user_list'}
/>
);
default:

View file

@ -441,7 +441,7 @@ export function resetToTeams() {
});
}
export function goToScreen(name: AvailableScreens, title: string, passProps = {}, options = {}) {
export function goToScreen(name: AvailableScreens, title: string, passProps = {}, options: Options = {}) {
if (!isScreenRegistered(name)) {
return '';
}

View file

@ -28,13 +28,12 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {ShowSnackBarArgs} from '@utils/snack_bar';
type SnackBarProps = {
componentId: AvailableScreens;
onAction?: () => void;
barType: keyof typeof SNACK_BAR_TYPE;
sourceScreen: AvailableScreens;
}
} & ShowSnackBarArgs;
const SNACK_BAR_WIDTH = 96;
const SNACK_BAR_HEIGHT = 56;
@ -81,7 +80,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const SnackBar = ({barType, componentId, onAction, sourceScreen}: SnackBarProps) => {
const SnackBar = ({
barType,
messageValues,
componentId,
onAction,
sourceScreen,
}: SnackBarProps) => {
const [showSnackBar, setShowSnackBar] = useState<boolean | undefined>();
const intl = useIntl();
const theme = useTheme();
@ -245,7 +250,10 @@ const SnackBar = ({barType, componentId, onAction, sourceScreen}: SnackBarProps)
<Toast
animatedStyle={snackBarStyle}
iconName={config.iconName}
message={intl.formatMessage({id: config.id, defaultMessage: config.defaultMessage})}
message={intl.formatMessage(
{id: config.id, defaultMessage: config.defaultMessage},
messageValues,
)}
style={[styles.toast, barType === SNACK_BAR_TYPE.LINK_COPIED && {backgroundColor: theme.onlineIndicator}]}
textStyle={styles.text}
>

View file

@ -5,11 +5,13 @@ import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {showOverlay} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {PrimitiveType} from 'react-intl';
type ShowSnackBarArgs = {
export type ShowSnackBarArgs = {
barType: keyof typeof SNACK_BAR_TYPE;
onAction?: () => void;
sourceScreen?: AvailableScreens;
messageValues?: Record<string, PrimitiveType>;
};
export const showSnackBar = (passProps: ShowSnackBarArgs) => {
@ -31,6 +33,14 @@ export const showFavoriteChannelSnackbar = (favorited: boolean, onAction: () =>
});
};
export const showAddChannelMembersSnackbar = (count: number) => {
return showSnackBar({
barType: SNACK_BAR_TYPE.ADD_CHANNEL_MEMBERS,
sourceScreen: Screens.CHANNEL_ADD_MEMBERS,
messageValues: {numMembers: count},
});
};
export const showRemoveChannelUserSnackbar = () => {
return showSnackBar({
barType: SNACK_BAR_TYPE.REMOVE_CHANNEL_USER,

View file

@ -95,9 +95,11 @@
"camera_type.photo.option": "Capture Photo",
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel_add_members.add_members.button": "Add Members",
"channel_header.directchannel.you": "{displayName} (you)",
"channel_header.info": "View info",
"channel_header.member_count": "{count, plural, one {# member} other {# members}}",
"channel_info.add_members": "Add members",
"channel_info.alert_retry": "Try Again",
"channel_info.alertNo": "No",
"channel_info.alertYes": "Yes",
@ -329,7 +331,7 @@
"home.header.plus_menu": "Options",
"integration_selector.multiselect.submit": "Done",
"interactive_dialog.submit": "Submit",
"intro.add_people": "Add People",
"intro.add_members": "Add members",
"intro.channel_info": "Info",
"intro.created_by": "created by {creator} on {date}.",
"intro.direct_message": "This is the start of your conversation with {teammate}. Messages and files shared here are not shown to anyone else.",
@ -476,6 +478,7 @@
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.camera_type.title": "Camera options",
"mobile.channel_add_members.error": "There has been an error and we could not add those users to the channel.",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
"mobile.channel_list.recent": "Recent",
@ -921,6 +924,7 @@
"skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.",
"skintone_selector.tooltip.title": "Choose your default skin tone",
"smobile.search.recent_title": "Recent searches in {teamName}",
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
"snack.bar.favorited.channel": "This channel was favorited",
"snack.bar.link.copied": "Link copied to clipboard",
"snack.bar.message.copied": "Text copied to clipboard",

View file

@ -30,12 +30,12 @@ class ChannelScreen {
muteQuickAction: 'channel.quick_actions.mute.action',
unmuteQuickAction: 'channel.quick_actions.unmute.action',
setHeaderQuickAction: 'channel.quick_actions.set_header.action',
addPeopleQuickAction: 'channel.quick_actions.add_people.action',
addMembersQuickAction: 'channel.quick_actions.add_members.action',
copyChannelLinkQuickAction: 'channel.quick_actions.copy_channel_link.action',
channelInfoQuickAction: 'channel.quick_actions.channel_info.action',
leaveChannelQuickAction: 'channel.quick_actions.leave_channel.action',
introDisplayName: 'channel_post_list.intro.display_name',
introAddPeopleAction: 'channel_post_list.intro_options.add_people.action',
introAddMembersAction: 'channel_post_list.intro_options.add_members.action',
introSetHeaderAction: 'channel_post_list.intro_options.set_header.action',
introFavoriteAction: 'channel_post_list.intro_options.favorite.action',
introUnfavoriteAction: 'channel_post_list.intro_options.unfavorite.action',
@ -50,12 +50,12 @@ class ChannelScreen {
muteQuickAction = element(by.id(this.testID.muteQuickAction));
unmuteQuickAction = element(by.id(this.testID.unmuteQuickAction));
setHeaderQuickAction = element(by.id(this.testID.setHeaderQuickAction));
addPeopleQuickAction = element(by.id(this.testID.addPeopleQuickAction));
addMembersQuickAction = element(by.id(this.testID.addMembersQuickAction));
copyChannelLinkQuickAction = element(by.id(this.testID.copyChannelLinkQuickAction));
channelInfoQuickAction = element(by.id(this.testID.channelInfoQuickAction));
leaveChannelQuickAction = element(by.id(this.testID.leaveChannelQuickAction));
introDisplayName = element(by.id(this.testID.introDisplayName));
introAddPeopleAction = element(by.id(this.testID.introAddPeopleAction));
introAddMembersAction = element(by.id(this.testID.introAddMembersAction));
introSetHeaderAction = element(by.id(this.testID.introSetHeaderAction));
introFavoriteAction = element(by.id(this.testID.introFavoriteAction));
introUnfavoriteAction = element(by.id(this.testID.introUnfavoriteAction));

View file

@ -23,7 +23,7 @@ class ChannelInfoScreen {
muteAction: 'channel_info.channel_actions.mute.action',
unmuteAction: 'channel_info.channel_actions.unmute.action',
setHeaderAction: 'channel_info.channel_actions.set_header.action',
addPeopleAction: 'channel_info.channel_actions.add_people.action',
addMembersAction: 'channel_info.channel_actions.add_members.action',
copyChannelLinkAction: 'channel_info.channel_actions.copy_channel_link.action',
joinStartCallAction: 'channel_info.channel_actions.join_start_call.action',
extraHeader: 'channel_info.extra.header',
@ -53,7 +53,7 @@ class ChannelInfoScreen {
muteAction = element(by.id(this.testID.muteAction));
unmuteAction = element(by.id(this.testID.unmuteAction));
setHeaderAction = element(by.id(this.testID.setHeaderAction));
addPeopleAction = element(by.id(this.testID.addPeopleAction));
addMembersAction = element(by.id(this.testID.addMembersAction));
copyChannelLinkAction = element(by.id(this.testID.copyChannelLinkAction));
joinStartCallAction = element(by.id(this.testID.joinStartCallAction));
extraHeader = element(by.id(this.testID.extraHeader));

View file

@ -103,6 +103,7 @@ type SearchUserOptions = {
team_id?: string;
not_in_team?: string;
in_channel_id?: string;
not_in_channel_id?: string;
in_group_id?: string;
group_constrained?: boolean;
allow_inactive?: boolean;