Compare commits

...

4 Commits

Author SHA1 Message Date
Jason Frerich
b261d0dc16 don't close modal after adding members, pop to previous screen 2022-11-16 14:29:48 -06:00
Jason Frerich
f54bb59aef remove close button from navbar 2022-11-16 14:23:15 -06:00
Jason Frerich
2d10f99a94 pass channelId in to the AddPeopleBox component 2022-11-16 10:27:19 -06:00
Jason Frerich
a6b51436ac implement add members to channel screen
add channel name to add people screen nav bar
2022-11-15 14:08:07 -06:00
12 changed files with 485 additions and 38 deletions

View File

@@ -530,6 +530,46 @@ export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, pag
}
};
export const fetchProfilesNotInChannel = async (
serverUrl: string,
teamId: string,
channelId: string,
groupConstrained: boolean,
page = 0,
perPage: number = General.PROFILE_CHUNK_SIZE,
fetchOnly = false,
) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const users = await client.getProfilesNotInChannel(teamId, channelId, groupConstrained, page, perPage);
if (!fetchOnly) {
const currentUserId = await getCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const searchProfiles = async (serverUrl: string, term: string, options: any = {}, fetchOnly = false) => {
let client: Client;
try {

View File

@@ -0,0 +1,57 @@
// 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 {StyleProp, ViewStyle} from 'react-native';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {ChannelModel} from '@database/models/server';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
import {changeOpacity} from '@utils/theme';
type Props = {
channel: ChannelModel;
containerStyle?: StyleProp<ViewStyle>;
inModal?: boolean;
testID?: string;
}
const AddPeopleBox = ({channel, containerStyle, inModal, testID}: Props) => {
const intl = useIntl();
const theme = useTheme();
const channelId = channel.id;
const displayName = channel.displayName;
const onAddPeople = useCallback(async () => {
const title = intl.formatMessage({id: 'mobile.channel_add_people.title', defaultMessage: 'Add Members'});
const options = {
topBar: {
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: displayName,
},
},
};
if (inModal) {
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId}, options);
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

@@ -1,43 +1,23 @@
// 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 {StyleProp, ViewStyle} from 'react-native';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
import {observeChannel} from '@queries/servers/channel';
type Props = {
import AddPeopleBox from './add_people_box';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
inModal?: boolean;
testID?: string;
}
const AddPeopleBox = ({channelId, containerStyle, inModal, testID}: Props) => {
const intl = useIntl();
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
return {
channel: observeChannel(database, channelId),
};
});
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;
export default withDatabase(enhanced(AddPeopleBox));

View File

@@ -126,6 +126,7 @@ export default {
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
BROWSE_CHANNELS,
CHANNEL_INFO,
CHANNEL_ADD_PEOPLE,
CREATE_DIRECT_MESSAGE,
CREATE_TEAM,
CUSTOM_STATUS,
@@ -156,7 +157,6 @@ export const OVERLAY_SCREENS = new Set<string>([
]);
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
INTEGRATION_SELECTOR,

View File

@@ -0,0 +1,340 @@
// 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 {defineMessages, useIntl} from 'react-intl';
import {Keyboard, Platform, StyleSheet, 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 Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users_panel';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {ChannelModel} from '@database/models/server';
import {debounce} from '@helpers/api/general';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import {popTopScreen, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
import {filterProfilesMatchingTerm} from '@utils/user';
const ADD_BUTTON = 'add-button';
const close = () => {
Keyboard.dismiss();
popTopScreen();
};
const style = StyleSheet.create({
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
});
const messages = defineMessages({
error: {
id: t('mobile.channel_add_people.error'),
defaultMessage: 'We could not add those users to the channel. Please check your connection and try again.',
},
button: {
id: t('mobile.channel_add_people.title'),
defaultMessage: 'Add Members',
},
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
type Props = {
componentId: string;
currentChannel: ChannelModel;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
export default function ChannelAddPeople({
componentId,
currentChannel,
currentTeamId,
currentUserId,
restrictDirectMessage,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const intl = useIntl();
const theme = useTheme();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
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}>({});
const selectedCount = Object.keys(selectedIds).length;
const groupConstrained = currentChannel.isGroupConstrained;
const currentChannelId = currentChannel.id;
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 getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
fetchProfilesNotInChannel(serverUrl,
currentTeamId,
currentChannelId,
groupConstrained,
page.current + 1,
General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}, 100), [loading, isSearch, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const addPeopleToChannel = useCallback(async (ids: string[]): Promise<boolean> => {
const result = await addMembersToChannel(serverUrl, currentChannelId, ids, '', false);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.error);
}
return !result.error;
}, [serverUrl]);
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingConversation) {
return;
}
setStartingConversation(true);
const idsToUse = selectedId ? Object.keys(selectedId) : Object.keys(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else {
success = await addPeopleToChannel(idsToUse);
}
if (success) {
close();
} else {
setStartingConversation(false);
}
}, [startingConversation, selectedIds, addPeopleToChannel]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (selectedIds[user.id]) {
handleRemoveProfile(user.id);
return;
}
if (user.id === currentUserId) {
const selectedId = {
[currentUserId]: true,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
const results = await searchProfiles(serverUrl, lowerCasedTerm, {
team_id: currentTeamId,
allow_inactive: true,
});
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
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 (startEnabled: boolean) => {
setButtons(componentId, {
rightButtons: [{
color: theme.sidebarHeaderTextColor,
id: ADD_BUTTON,
text: formatMessage({id: 'mobile.channel_add_people.button', defaultMessage: 'Add'}),
showAsAction: 'always',
enabled: startEnabled,
testID: 'add_members.start.button',
}],
});
}, [intl.locale, theme]);
useNavButtonPressed(ADD_BUTTON, componentId, startConversation, [startConversation]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons(false);
getProfiles();
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
const canStart = selectedCount > 0 && !startingConversation;
updateNavigationButtons(canStart);
}, [selectedCount > 0, startingConversation, updateNavigationButtons]);
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]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID='add_members.screen'
>
<View style={style.searchBar}>
<Search
testID='add_members.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
onSubmitEditing={search}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={General.MAX_USERS_IN_GM - 2}
maxCount={General.MAX_USERS_IN_GM}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
/>
}
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='add_members.user_list'
tutorialWatched={tutorialWatched}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,25 @@
// 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 {observeCurrentTeamId} from '@app/queries/servers/system';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ChannelAddPeople from './channel_add_people';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentChannel: observeCurrentChannel(database),
currentTeamId: observeCurrentTeamId(database),
teammateNameDisplay: observeTeammateNameDisplay(database),
tutorialWatched: observeProfileLongPresTutorial(),
};
});
export default withDatabase(enhanced(ChannelAddPeople));

View File

@@ -11,6 +11,8 @@ import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remot
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users_panel';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@@ -22,9 +24,6 @@ import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users';
import UserList from './user_list';
const START_BUTTON = 'start-conversation';
const CLOSE_BUTTON = 'close-dms';

View File

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

View File

@@ -398,6 +398,9 @@
"mobile.calls_you": "(you)",
"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.channel_add_people.error": "We could not add those users to the channel. Please check your connection and try again.",
"mobile.channel_add_people.title": "Add Members",
"mobile.channel_add_people.button": "Add",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
"mobile.channel_list.recent": "Recent",