forked from Ivasoft/mattermost-mobile
[Gekidou - MM-47653] Implement Manage Members Screen (#6771)
* move user_list to component
* start the modal from create_direct_message
* initial commit
* Add managing options to user profile
* s/showManage/showManageMode/
* simplify
* use helper functions
* add dependency
* fix togglling manage/done button
* remove close button in navbar
* remove close button. The only exit from this screen is the back button
* utilize LeaveChannelLabel component actions
* nit
* nit
* slight refactor
* return earlier if not showManageMode
* use defineMessages
* don't modify leave channel component
* add manage_members_label component
* rename variables to imply manage actions
* remove user from channel on server and locally in channel membership
* prevent managing yourself. In V1, this is done by not allowing you to
select yourself for channel removal
* remove useReducer
* - fix typography
- fix icon size
- don't allow tapping on yourself in manage mode
* sort props
* sort props
* sort props
* - combine try blocks
- use getServerDatabaseAndOperator function to get the operator
* fetchChannelStats after removing users from a channel
* currently, the UI does not provide a need to remove multiple members
from a channel, only one member. modify the function to only accept and
remove one user
* no need to pass the entire channel object. only need the channelId which
is already passed into the screen
* do not pass the entire user model, only the userid and if user canManage
(is sysadmin or channel admin)
* move members constants to its own file and out of general.ts file
* pass channel displayName instead of the entire channel object
* not need to store the user as it is already in the store from the
fetchProfilesInChannel call
* implement device emitter to notify the parent to remove to the user from
the user list
* rename constant in reveal removing a member from a channel. Might need for another team removal later.
* add snackbar after user is removed
* remove unnessary filter
* remove paging. Server response is not paginated
deconstruct intl
* create EMPTY const
* simplify getProfiles function
* move constants to top of file
* add function to remove the user from the server
* clean up dependencies
* remove @app/ prefix from imports
* add comment describing reason for switch / case
* rename varaible to be more intention revealing
* calculate isDefaultChannel and pass in as prop so don't need to
query for each user
* if user cannot manage, do not show the manage nav button
* move options const into function that uses it
* have the caller of handeRemoveMemberFromChannel fetch channel stats, not
the action
* nit formatting
* s/canManage/canManageMembers/
* use existing observeCanManageChannelMembers function
function only requires channel id
* move userInfo and manage user options to their own components
* calculate bottom sheet snap points when in manage mode
* implement correct permissions for managing users. For now, only channel
admins can manage users (including deleting members)
* working on section creation
* use map instead of arrays
* - handle user profile sections differently when in members are provided (manage mode)
- emit event when user role is changed
- modify the channelMembers in manage members modal after changing
user role
* remove commented code
* deconstruct options
* sort dependencies and add loading dependency
* - when removing a user, remove them from channelMembers state also
- don't add empty sections to the user list results
* user profile coming from ManageChannelMembers is UserProfile joined with
their ChannelMembership. Can now check for scheme_admin to see if the
user is a channel admin
* deconstruct locale from intl and remove intl const
* Add SearchUserOptions type to provide type checking when creating options for searchProfile
action and searchUsers client api
* correct comment
* deconstruct MANAGE_OPTIONS
* Remove unused event constant
* nits
* Push header title in to the UserProfileTitle component
* Put constants back so Diff of file is smaller
* Combine switch statements
Remove isOptionItem. These are always action items
* Wrap onAction in a usecallback
* Add help comments
* Add i18n to section titles
* Create RenderItemType for renderItem callback
* update testID
update snapshots
* CanManageMembers is deterimined by observeCanManageChannelMembers
* Add members chanenl option
* Update after merge
* Sort in order of options shown
* nit refactor
* Modify client getProfilesInChannel allow passing more options than sort.
- sort the profiles by admin
- do not show deactivated users in the manage members modal
* Profiles are now sorted by admin. We can maintain the alphabetical sort
also by iterating over the profiles instead of members which are not alphabetical
* Type the get users Api object
* Add type.
Active option is a boolean, not a string
* only initialize if needed. Moved inside the check for members
* Create type for Manage Member Options
* Remove one liners and call directly in the switch block
* Keys to the map do not need to be translated. Only translate the title
Place the Admins section always on top
* Add removeFromChannel as a dependency
* Remove manageMode option from the title component
- add imageSize prop
- add headerText prop
* Do not show deactivated users in search
* When users are showing and not in manage mode, allow the user to tap and
open the profile for the user (in non-manage mode)
* Add fetchOnly to getMemberInChannel function
Add fetchOnly to updateChannelMembersSchemeRoles function
Remove getMemberInChannel from handleUserChangeRole in manage_channel_members because it is already called via updateChannelMembersSchemeRoles
* Remove todo from comment
* Don't use state for defining action text, icon, and isDestructive. just
set them based on the prop value manageOption
* Added correct permission check for can user manage member roles
* Add can manage member roles prop
* Calculate snap points based on manageMemberRoles prop
* Calculate snap point based on if user can remove other users
* Do not show options if you cannot remove or manage members
* Fix post merge issues
* No need to batch because only manipulating a single model
* Remove comment
* Rename variable
* Split and sort props into multiple lines for readability
* Nit
* Make dependency more specific
* Remove comment. Doing this requires writing a custom search function in
the app that would need to guarantee the same results as a server call
* Add logError to functions with catch
* Add ticket reference
* Remove await from functions that are updating the database. Components
that observe models these modify will get the update based from the
observable change.
* Keep track of which section is first so that the tutorial highlight
selects the first user profile of the first section
* Add a second user that creates a new section for testing tutorial
* Remove unused prop
* Update snapshot to include second user
* Use getServerDatabaseAndOperator
* remove testID change. Added a ticket to fix later
* Revert tests to only one user to test if previous tests worked
* Add new test that has 2 users
* Add ticket context as comment
* Add channelId as dependency
* Use useCallback for updateChannelMemberSchemeRole
* Remove async
* mounted.current should only be used in an effect that executes on the
first render
when user has permission to manage members changed, there is no need to
get the profiles again
* Add await for function
* Always reset loading to false after getting profiles
* use !text instead of const value using Boolean()
* add dependency
* Add manage members ids back
* When fetching users for the channel, always store them in the database.
Otherwise tapping a user might not be in the database and tapping on
them will cause a crash
* Fetch the user profile from the server when opening the user profile
* Checking management permissions should be based on the current user, not
the user of the profile being opened
---------
Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>
This commit is contained in:
@@ -97,27 +97,37 @@ export const updateRecentCustomStatuses = async (serverUrl: string, customStatus
|
||||
export const updateLocalUser = async (
|
||||
serverUrl: string,
|
||||
userDetails: Partial<UserProfile> & { status?: string},
|
||||
userId?: string,
|
||||
) => {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const user = await getCurrentUser(database);
|
||||
|
||||
let user: UserModel | undefined;
|
||||
|
||||
if (userId) {
|
||||
user = await getUserById(database, userId);
|
||||
} else {
|
||||
user = await getCurrentUser(database);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
const u = user;
|
||||
await database.write(async () => {
|
||||
await user.update((userRecord: UserModel) => {
|
||||
userRecord.authService = userDetails.auth_service ?? user.authService;
|
||||
userRecord.email = userDetails.email ?? user.email;
|
||||
userRecord.firstName = userDetails.first_name ?? user.firstName;
|
||||
userRecord.lastName = userDetails.last_name ?? user.lastName;
|
||||
userRecord.lastPictureUpdate = userDetails.last_picture_update ?? user.lastPictureUpdate;
|
||||
userRecord.locale = userDetails.locale ?? user.locale;
|
||||
userRecord.nickname = userDetails.nickname ?? user.nickname;
|
||||
userRecord.notifyProps = userDetails.notify_props ?? user.notifyProps;
|
||||
userRecord.position = userDetails?.position ?? user.position;
|
||||
userRecord.props = userDetails.props ?? user.props;
|
||||
userRecord.roles = userDetails.roles ?? user.roles;
|
||||
userRecord.status = userDetails?.status ?? user.status;
|
||||
userRecord.timezone = userDetails.timezone ?? user.timezone;
|
||||
userRecord.username = userDetails.username ?? user.username;
|
||||
await u.update((userRecord: UserModel) => {
|
||||
userRecord.authService = userDetails.auth_service ?? u.authService;
|
||||
userRecord.email = userDetails.email ?? u.email;
|
||||
userRecord.firstName = userDetails.first_name ?? u.firstName;
|
||||
userRecord.lastName = userDetails.last_name ?? u.lastName;
|
||||
userRecord.lastPictureUpdate = userDetails.last_picture_update ?? u.lastPictureUpdate;
|
||||
userRecord.locale = userDetails.locale ?? u.locale;
|
||||
userRecord.nickname = userDetails.nickname ?? u.nickname;
|
||||
userRecord.notifyProps = userDetails.notify_props ?? u.notifyProps;
|
||||
userRecord.position = userDetails?.position ?? u.position;
|
||||
userRecord.props = userDetails.props ?? u.props;
|
||||
userRecord.roles = userDetails.roles ?? u.roles;
|
||||
userRecord.status = userDetails?.status ?? u.status;
|
||||
userRecord.timezone = userDetails.timezone ?? u.timezone;
|
||||
userRecord.username = userDetails.username ?? u.username;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {DeviceEventEmitter} from 'react-native';
|
||||
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
|
||||
import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
|
||||
import {switchToGlobalThreads} from '@actions/local/thread';
|
||||
import {updateLocalUser} from '@actions/local/user';
|
||||
import {loadCallForChannel} from '@calls/actions/calls';
|
||||
import {DeepLink, Events, General, Preferences, Screens} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
@@ -15,7 +16,7 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
||||
import AppsManager from '@managers/apps_manager';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {getActiveServer} from '@queries/app/servers';
|
||||
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, queryChannelsById} from '@queries/servers/channel';
|
||||
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, deleteChannelMembership, queryChannelsById} from '@queries/servers/channel';
|
||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||
import {getCommonSystemValues, getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getLicense, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team';
|
||||
@@ -35,9 +36,10 @@ import {setDirectChannelVisible} from './preference';
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team';
|
||||
import {fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user';
|
||||
import {fetchProfilesInChannel, fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type ClientError from '@client/rest/error';
|
||||
import type {Model} from '@nozbe/watermelondb';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
@@ -49,6 +51,99 @@ export type MyChannelsRequest = {
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export type ChannelMembersRequest = {
|
||||
members?: ChannelMembership[];
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export async function removeMemberFromChannel(serverUrl: string, channelId: string, userId: string) {
|
||||
try {
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
||||
await client.removeFromChannel(userId, channelId);
|
||||
await deleteChannelMembership(operator, userId, channelId);
|
||||
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
logError('removeMemberFromChannel', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelMembersByIds(serverUrl: string, channelId: string, userIds: string[], fetchOnly = false): Promise<ChannelMembersRequest> {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const members = await client.getChannelMembersByIds(channelId, userIds);
|
||||
|
||||
if (!fetchOnly) {
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
if (operator && members.length) {
|
||||
const memberships = members.map((u) => ({
|
||||
channel_id: channelId,
|
||||
user_id: u.user_id,
|
||||
scheme_admin: u.scheme_admin,
|
||||
}));
|
||||
await operator.handleChannelMembership({
|
||||
channelMemberships: memberships,
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {members};
|
||||
} catch (error) {
|
||||
logError('fetchChannelMembersByIds', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
export async function updateChannelMemberSchemeRoles(serverUrl: string, channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean, fetchOnly = false) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
await client.updateChannelMemberSchemeRoles(channelId, userId, isSchemeUser, isSchemeAdmin);
|
||||
|
||||
if (!fetchOnly) {
|
||||
return getMemberInChannel(serverUrl, channelId, userId);
|
||||
}
|
||||
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
logError('updateChannelMemberSchemeRoles', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMemberInChannel(serverUrl: string, channelId: string, userId: string, fetchOnly = false) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const member = await client.getMemberInChannel(channelId, userId);
|
||||
|
||||
if (!fetchOnly) {
|
||||
updateLocalUser(serverUrl, member, userId);
|
||||
}
|
||||
return {member, error: undefined};
|
||||
} catch (error) {
|
||||
logError('getMemberInChannel', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelMemberships(serverUrl: string, channelId: string, options: GetUsersOptions, fetchOnly = false) {
|
||||
const {users = []} = await fetchProfilesInChannel(serverUrl, channelId, undefined, options, fetchOnly);
|
||||
const userIds = users.map((u) => u.id);
|
||||
|
||||
// MM-49896 https://mattermost.atlassian.net/browse/MM-49896
|
||||
// We are not sure the getChannelMembers API returns the same members
|
||||
// from getProfilesInChannel. This guarantees a 1:1 match of the
|
||||
// user IDs
|
||||
const {members = []} = await fetchChannelMembersByIds(serverUrl, channelId, userIds, true);
|
||||
return {users, members};
|
||||
}
|
||||
|
||||
export async function addMembersToChannel(serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise<MyU
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchProfilesInChannel(serverUrl: string, channelId: string, excludeUserId?: string, fetchOnly = false): Promise<ProfilesInChannelRequest> {
|
||||
export async function fetchProfilesInChannel(serverUrl: string, channelId: string, excludeUserId?: string, options?: GetUsersOptions, fetchOnly = false): Promise<ProfilesInChannelRequest> {
|
||||
let client: Client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
@@ -83,7 +83,7 @@ export async function fetchProfilesInChannel(serverUrl: string, channelId: strin
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await client.getProfilesInChannel(channelId);
|
||||
const users = await client.getProfilesInChannel(channelId, options);
|
||||
const uniqueUsers = Array.from(new Set(users));
|
||||
const filteredUsers = uniqueUsers.filter((u) => u.id !== excludeUserId);
|
||||
if (!fetchOnly) {
|
||||
@@ -108,6 +108,7 @@ export async function fetchProfilesInChannel(serverUrl: string, channelId: strin
|
||||
|
||||
return {channelId, users: filteredUsers};
|
||||
} catch (error) {
|
||||
logError('fetchProfilesInChannel', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {channelId, error};
|
||||
}
|
||||
@@ -198,7 +199,7 @@ export async function fetchProfilesPerChannels(serverUrl: string, channelIds: st
|
||||
const data: ProfilesInChannelRequest[] = [];
|
||||
|
||||
for await (const cIds of channels) {
|
||||
const requests = cIds.map((id) => fetchProfilesInChannel(serverUrl, id, excludeUserId, true));
|
||||
const requests = cIds.map((id) => fetchProfilesInChannel(serverUrl, id, excludeUserId, undefined, true));
|
||||
const response = await Promise.all(requests);
|
||||
data.push(...response);
|
||||
}
|
||||
@@ -530,7 +531,7 @@ export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, pag
|
||||
}
|
||||
};
|
||||
|
||||
export const searchProfiles = async (serverUrl: string, term: string, options: any = {}, fetchOnly = false) => {
|
||||
export const searchProfiles = async (serverUrl: string, term: string, options: SearchUserOptions, fetchOnly = false) => {
|
||||
let client: Client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
@@ -563,6 +564,7 @@ export const searchProfiles = async (serverUrl: string, term: string, options: a
|
||||
|
||||
return {data: users};
|
||||
} catch (error) {
|
||||
logError('searchProfiles', error);
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ClientChannelsMix {
|
||||
searchChannels: (teamId: string, term: string) => Promise<Channel[]>;
|
||||
searchArchivedChannels: (teamId: string, term: string) => Promise<Channel[]>;
|
||||
searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise<Channel[]>;
|
||||
updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise<any>;
|
||||
getMemberInChannel: (channelId: string, userId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const ClientChannels = (superclass: any) => class extends superclass {
|
||||
@@ -327,6 +329,25 @@ const ClientChannels = (superclass: any) => class extends superclass {
|
||||
{method: 'post', body},
|
||||
);
|
||||
};
|
||||
|
||||
// Update a channel member's scheme_admin/scheme_user properties. Typically
|
||||
// this should either be scheme_admin=false, scheme_user=true for ordinary
|
||||
// channel member, or scheme_admin=true, scheme_user=true for a channel
|
||||
// admin.
|
||||
updateChannelMemberSchemeRoles = (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => {
|
||||
const body = {scheme_user: isSchemeUser, scheme_admin: isSchemeAdmin};
|
||||
return this.doFetch(
|
||||
`${this.getChannelMembersRoute(channelId)}/${userId}/schemeRoles`,
|
||||
{method: 'put', body},
|
||||
);
|
||||
};
|
||||
|
||||
getMemberInChannel = (channelId: string, userId: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getChannelMembersRoute(channelId)}/${userId}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default ClientChannels;
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ClientUsersMix {
|
||||
getProfilesInTeam: (teamId: string, page?: number, perPage?: number, sort?: string, options?: Record<string, any>) => Promise<UserProfile[]>;
|
||||
getProfilesNotInTeam: (teamId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise<UserProfile[]>;
|
||||
getProfilesWithoutTeam: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
|
||||
getProfilesInChannel: (channelId: string, page?: number, perPage?: number, sort?: string) => Promise<UserProfile[]>;
|
||||
getProfilesInChannel: (channelId: string, options?: GetUsersOptions) => Promise<UserProfile[]>;
|
||||
getProfilesInGroupChannels: (channelsIds: string[]) => Promise<{[x: string]: UserProfile[]}>;
|
||||
getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise<UserProfile[]>;
|
||||
getMe: () => Promise<UserProfile>;
|
||||
@@ -37,7 +37,7 @@ export interface ClientUsersMix {
|
||||
getSessions: (userId: string) => Promise<Session[]>;
|
||||
checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>;
|
||||
attachDevice: (deviceId: string) => Promise<any>;
|
||||
searchUsers: (term: string, options: any) => Promise<UserProfile[]>;
|
||||
searchUsers: (term: string, options: SearchUserOptions) => Promise<UserProfile[]>;
|
||||
getStatusesByIds: (userIds: string[]) => Promise<UserStatus[]>;
|
||||
getStatus: (userId: string) => Promise<UserStatus>;
|
||||
updateStatus: (status: UserStatus) => Promise<UserStatus>;
|
||||
@@ -250,10 +250,10 @@ const ClientUsers = (superclass: any) => class extends superclass {
|
||||
);
|
||||
};
|
||||
|
||||
getProfilesInChannel = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => {
|
||||
getProfilesInChannel = async (channelId: string, options: GetUsersOptions) => {
|
||||
this.analytics.trackAPI('api_profiles_get_in_channel', {channel_id: channelId});
|
||||
|
||||
const queryStringObj = {in_channel: channelId, page, per_page: perPage, sort};
|
||||
const queryStringObj = {in_channel: channelId, ...options};
|
||||
return this.doFetch(
|
||||
`${this.getUsersRoute()}${buildQueryString(queryStringObj)}`,
|
||||
{method: 'get'},
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const LeaveChanelLabel = ({canLeave, channelId, displayName, isOptionItem, type, testID}: Props) => {
|
||||
const LeaveChannelLabel = ({canLeave, channelId, displayName, isOptionItem, type, testID}: Props) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const isTablet = useIsTablet();
|
||||
@@ -183,4 +183,4 @@ const LeaveChanelLabel = ({canLeave, channelId, displayName, isOptionItem, type,
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaveChanelLabel;
|
||||
export default LeaveChannelLabel;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 {queryUsersById} from '@queries/servers/user';
|
||||
|
||||
import ManageMembersLabel from './manage_members_label';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
type OwnProps = WithDatabaseArgs & {
|
||||
isDefaultChannel: boolean;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const enhanced = withObservables(['isDefaultChannel', 'userId'], ({isDefaultChannel, userId, database}: OwnProps) => {
|
||||
const users = queryUsersById(database, [userId]).observe();
|
||||
const canRemoveUser = users.pipe(
|
||||
switchMap((u) => {
|
||||
return of$(!isDefaultChannel || (isDefaultChannel && u[0].isGuest));
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
canRemoveUser,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ManageMembersLabel));
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {Alert, DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {fetchChannelStats, removeMemberFromChannel, updateChannelMemberSchemeRoles} from '@actions/remote/channel';
|
||||
import OptionItem from '@components/option_item';
|
||||
import {Events, Members} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {alertErrorWithFallback} from '@utils/draft';
|
||||
|
||||
import type {ManageOptionsTypes} from '@constants/members';
|
||||
|
||||
const {MAKE_CHANNEL_ADMIN, MAKE_CHANNEL_MEMBER, REMOVE_USER} = Members.ManageOptions;
|
||||
|
||||
const messages = defineMessages({
|
||||
role_change_error: {
|
||||
id: t('mobile.manage_members.change_role.error'),
|
||||
defaultMessage: 'An error occurred while trying to update the role. Please check your connection and try again.',
|
||||
},
|
||||
make_channel_admin: {
|
||||
id: t('mobile.manage_members.make_channel_admin'),
|
||||
defaultMessage: 'Make Channel Admin',
|
||||
},
|
||||
make_channel_member: {
|
||||
id: t('mobile.manage_members.make_channel_member'),
|
||||
defaultMessage: 'Make Channel Member',
|
||||
},
|
||||
remove_title: {
|
||||
id: t('mobile.manage_members.remove_member'),
|
||||
defaultMessage: 'Remove From Channel',
|
||||
},
|
||||
remove_message: {
|
||||
id: t('mobile.manage_members.message'),
|
||||
defaultMessage: 'Are you sure you want to remove the selected member from the channel?',
|
||||
},
|
||||
remove_cancel: {
|
||||
id: t('mobile.manage_members.cancel'),
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
remove_confirm: {
|
||||
id: t('mobile.manage_members.remove'),
|
||||
defaultMessage: 'Remove',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
canRemoveUser: boolean;
|
||||
channelId: string;
|
||||
manageOption: ManageOptionsTypes;
|
||||
testID?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const ManageMembersLabel = ({canRemoveUser, channelId, manageOption, testID, userId}: Props) => {
|
||||
const intl = useIntl();
|
||||
const {formatMessage} = intl;
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const handleRemoveUser = useCallback(async () => {
|
||||
removeMemberFromChannel(serverUrl, channelId, userId);
|
||||
fetchChannelStats(serverUrl, channelId, false);
|
||||
await dismissBottomSheet();
|
||||
DeviceEventEmitter.emit(Events.REMOVE_USER_FROM_CHANNEL, userId);
|
||||
}, [channelId, serverUrl, userId]);
|
||||
|
||||
const removeFromChannel = useCallback(() => {
|
||||
Alert.alert(
|
||||
formatMessage(messages.remove_title),
|
||||
formatMessage(messages.remove_message),
|
||||
[{
|
||||
text: formatMessage(messages.remove_cancel),
|
||||
style: 'cancel',
|
||||
}, {
|
||||
text: formatMessage(messages.remove_confirm),
|
||||
style: 'destructive',
|
||||
onPress: handleRemoveUser,
|
||||
}], {cancelable: false},
|
||||
);
|
||||
}, [formatMessage, handleRemoveUser]);
|
||||
|
||||
const updateChannelMemberSchemeRole = useCallback(async (schemeAdmin: boolean) => {
|
||||
const result = await updateChannelMemberSchemeRoles(serverUrl, channelId, userId, true, schemeAdmin);
|
||||
if (result.error) {
|
||||
alertErrorWithFallback(intl, result.error, messages.role_change_error);
|
||||
}
|
||||
await dismissBottomSheet();
|
||||
DeviceEventEmitter.emit(Events.MANAGE_USER_CHANGE_ROLE, {userId, schemeAdmin});
|
||||
}, [channelId, userId, intl, serverUrl]);
|
||||
|
||||
const onAction = useCallback(() => {
|
||||
switch (manageOption) {
|
||||
case REMOVE_USER:
|
||||
removeFromChannel();
|
||||
break;
|
||||
case MAKE_CHANNEL_ADMIN:
|
||||
updateChannelMemberSchemeRole(true);
|
||||
break;
|
||||
case MAKE_CHANNEL_MEMBER:
|
||||
updateChannelMemberSchemeRole(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [manageOption, removeFromChannel, updateChannelMemberSchemeRole]);
|
||||
|
||||
let actionText;
|
||||
let icon;
|
||||
let isDestructive = false;
|
||||
switch (manageOption) {
|
||||
case REMOVE_USER:
|
||||
actionText = (formatMessage(messages.remove_title));
|
||||
icon = 'trash-can-outline';
|
||||
isDestructive = true;
|
||||
break;
|
||||
case MAKE_CHANNEL_ADMIN:
|
||||
actionText = formatMessage(messages.make_channel_admin);
|
||||
icon = 'account-outline';
|
||||
break;
|
||||
case MAKE_CHANNEL_MEMBER:
|
||||
actionText = formatMessage(messages.make_channel_member);
|
||||
icon = 'account-outline';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (manageOption === REMOVE_USER && !canRemoveUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!actionText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
action={onAction}
|
||||
destructive={isDestructive}
|
||||
icon={icon}
|
||||
label={actionText}
|
||||
testID={testID}
|
||||
type='default'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageMembersLabel;
|
||||
@@ -102,7 +102,7 @@ const withPost = withObservables(
|
||||
const isEphemeral = of$(isPostEphemeral(post));
|
||||
|
||||
if (post.props?.add_channel_member && isPostEphemeral(post)) {
|
||||
isPostAddChannelMember = observeCanManageChannelMembers(database, post, currentUser);
|
||||
isPostAddChannelMember = observeCanManageChannelMembers(database, post.channelId, currentUser);
|
||||
}
|
||||
|
||||
let highlightReplyBar = of$(false);
|
||||
|
||||
@@ -928,3 +928,506 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
|
||||
</View>
|
||||
</RCTScrollView>
|
||||
`;
|
||||
|
||||
exports[`components/channel_list_row should show results no tutorial 2 users 1`] = `
|
||||
<RCTScrollView
|
||||
ListEmptyComponent={[Function]}
|
||||
ListFooterComponent={[Function]}
|
||||
contentContainerStyle={
|
||||
{
|
||||
"flexGrow": 1,
|
||||
}
|
||||
}
|
||||
data={
|
||||
[
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"auth_service": "",
|
||||
"create_at": 1111,
|
||||
"delete_at": 0,
|
||||
"email": "john@doe.com",
|
||||
"first_name": "",
|
||||
"id": "1",
|
||||
"last_name": "",
|
||||
"locale": "",
|
||||
"nickname": "",
|
||||
"notify_props": {
|
||||
"channel": "true",
|
||||
"comments": "never",
|
||||
"desktop": "mention",
|
||||
"desktop_sound": "true",
|
||||
"email": "true",
|
||||
"first_name": "true",
|
||||
"mention_keys": "",
|
||||
"push": "mention",
|
||||
"push_status": "away",
|
||||
},
|
||||
"position": "",
|
||||
"roles": "",
|
||||
"update_at": 1111,
|
||||
"username": "johndoe",
|
||||
},
|
||||
],
|
||||
"first": true,
|
||||
"id": "J",
|
||||
},
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"auth_service": "",
|
||||
"create_at": 1111,
|
||||
"delete_at": 0,
|
||||
"email": "rocky@doe.com",
|
||||
"first_name": "",
|
||||
"id": "2",
|
||||
"last_name": "",
|
||||
"locale": "",
|
||||
"nickname": "",
|
||||
"notify_props": {
|
||||
"channel": "true",
|
||||
"comments": "never",
|
||||
"desktop": "mention",
|
||||
"desktop_sound": "true",
|
||||
"email": "true",
|
||||
"first_name": "true",
|
||||
"mention_keys": "",
|
||||
"push": "mention",
|
||||
"push_status": "away",
|
||||
},
|
||||
"position": "",
|
||||
"roles": "",
|
||||
"update_at": 1111,
|
||||
"username": "rocky",
|
||||
},
|
||||
],
|
||||
"first": false,
|
||||
"id": "R",
|
||||
},
|
||||
]
|
||||
}
|
||||
getItem={[Function]}
|
||||
getItemCount={[Function]}
|
||||
initialNumToRender={15}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={16}
|
||||
onContentSizeChange={[Function]}
|
||||
onEndReached={[Function]}
|
||||
onLayout={[Function]}
|
||||
onMomentumScrollBegin={[Function]}
|
||||
onMomentumScrollEnd={[Function]}
|
||||
onScroll={[Function]}
|
||||
onScrollBeginDrag={[Function]}
|
||||
onScrollEndDrag={[Function]}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={60}
|
||||
stickyHeaderIndices={[]}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#ffffff",
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
testID="UserListRow.section_list"
|
||||
>
|
||||
<View>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#ffffff",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "rgba(63,67,80,0.08)",
|
||||
"height": 24,
|
||||
"justifyContent": "center",
|
||||
"paddingLeft": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans-SemiBold",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
J
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
>
|
||||
<View
|
||||
onMoveShouldSetResponder={[Function]}
|
||||
onMoveShouldSetResponderCapture={[Function]}
|
||||
onResponderEnd={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderReject={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderStart={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
onStartShouldSetResponderCapture={[Function]}
|
||||
>
|
||||
<View
|
||||
accessibilityValue={
|
||||
{
|
||||
"max": undefined,
|
||||
"min": undefined,
|
||||
"now": undefined,
|
||||
"text": undefined,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
focusable={true}
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"height": 58,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 20,
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.1"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"color": "#3f4350",
|
||||
"flexDirection": "row",
|
||||
},
|
||||
{
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"borderRadius": 21.5,
|
||||
"height": 42,
|
||||
"width": 42,
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.1.profile_picture"
|
||||
>
|
||||
<Icon
|
||||
name="account-outline"
|
||||
size={24}
|
||||
style={
|
||||
{
|
||||
"color": "rgba(63,67,80,0.48)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "column",
|
||||
"justifyContent": "center",
|
||||
"paddingHorizontal": 10,
|
||||
},
|
||||
{
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"height": 24,
|
||||
"lineHeight": 24,
|
||||
"maxWidth": "80%",
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.1.display_name"
|
||||
>
|
||||
johndoe
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.32)"
|
||||
name="circle-outline"
|
||||
size={28}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
/>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#ffffff",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "rgba(63,67,80,0.08)",
|
||||
"height": 24,
|
||||
"justifyContent": "center",
|
||||
"paddingLeft": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans-SemiBold",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
R
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
>
|
||||
<View
|
||||
onMoveShouldSetResponder={[Function]}
|
||||
onMoveShouldSetResponderCapture={[Function]}
|
||||
onResponderEnd={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderReject={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderStart={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
onStartShouldSetResponderCapture={[Function]}
|
||||
>
|
||||
<View
|
||||
accessibilityValue={
|
||||
{
|
||||
"max": undefined,
|
||||
"min": undefined,
|
||||
"now": undefined,
|
||||
"text": undefined,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
focusable={true}
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"height": 58,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 20,
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.2"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"color": "#3f4350",
|
||||
"flexDirection": "row",
|
||||
},
|
||||
{
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"borderRadius": 21.5,
|
||||
"height": 42,
|
||||
"width": 42,
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.2.profile_picture"
|
||||
>
|
||||
<Icon
|
||||
name="account-outline"
|
||||
size={24}
|
||||
style={
|
||||
{
|
||||
"color": "rgba(63,67,80,0.48)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "column",
|
||||
"justifyContent": "center",
|
||||
"paddingHorizontal": 10,
|
||||
},
|
||||
{
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode="tail"
|
||||
numberOfLines={1}
|
||||
style={
|
||||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"height": 24,
|
||||
"lineHeight": 24,
|
||||
"maxWidth": "80%",
|
||||
}
|
||||
}
|
||||
testID="create_direct_message.user_list.user_item.2.display_name"
|
||||
>
|
||||
rocky
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.32)"
|
||||
name="circle-outline"
|
||||
size={28}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onFocusCapture={[Function]}
|
||||
onLayout={[Function]}
|
||||
style={null}
|
||||
/>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<ActivityIndicator
|
||||
color="#1c58d9"
|
||||
size="large"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</RCTScrollView>
|
||||
`;
|
||||
|
||||
@@ -38,6 +38,34 @@ describe('components/channel_list_row', () => {
|
||||
push_status: 'away',
|
||||
},
|
||||
};
|
||||
|
||||
const user2: UserProfile = {
|
||||
id: '2',
|
||||
create_at: 1111,
|
||||
update_at: 1111,
|
||||
delete_at: 0,
|
||||
username: 'rocky',
|
||||
auth_service: '',
|
||||
email: 'rocky@doe.com',
|
||||
nickname: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
position: '',
|
||||
roles: '',
|
||||
locale: '',
|
||||
notify_props: {
|
||||
channel: 'true',
|
||||
comments: 'never',
|
||||
desktop: 'mention',
|
||||
desktop_sound: 'true',
|
||||
email: 'true',
|
||||
first_name: 'true',
|
||||
mention_keys: '',
|
||||
push: 'mention',
|
||||
push_status: 'away',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
@@ -91,6 +119,30 @@ describe('components/channel_list_row', () => {
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show results no tutorial 2 users', () => {
|
||||
const wrapper = renderWithEverything(
|
||||
<UserList
|
||||
profiles={[user, user2]}
|
||||
testID='UserListRow'
|
||||
currentUserId={'1'}
|
||||
teammateNameDisplay={'johndoe'}
|
||||
handleSelectProfile={() => {
|
||||
// noop
|
||||
}}
|
||||
fetchMore={() => {
|
||||
// noop
|
||||
}}
|
||||
loading={true}
|
||||
selectedIds={{}}
|
||||
showNoResults={true}
|
||||
tutorialWatched={true}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show results and tutorial', () => {
|
||||
const wrapper = renderWithEverything(
|
||||
<UserList
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {defineMessages, IntlShape, useIntl} from 'react-intl';
|
||||
import {FlatList, Keyboard, ListRenderItemInfo, Platform, SectionList, SectionListData, Text, View} from 'react-native';
|
||||
|
||||
import {storeProfile} from '@actions/local/user';
|
||||
@@ -13,6 +13,7 @@ import {General, Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useKeyboardHeight} from '@hooks/device';
|
||||
import {t} from '@i18n';
|
||||
import {openAsBottomSheet} from '@screens/navigation';
|
||||
import {
|
||||
changeOpacity,
|
||||
@@ -20,9 +21,23 @@ import {
|
||||
} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type UserProfileWithChannelAdmin = UserProfile & {scheme_admin?: boolean}
|
||||
type RenderItemType = ListRenderItemInfo<UserProfileWithChannelAdmin> & {section?: SectionListData<UserProfileWithChannelAdmin>}
|
||||
|
||||
const INITIAL_BATCH_TO_RENDER = 15;
|
||||
const SCROLL_EVENT_THROTTLE = 60;
|
||||
|
||||
const messages = defineMessages({
|
||||
admins: {
|
||||
id: t('mobile.manage_members.section_title_admins'),
|
||||
defaultMessage: 'CHANNEL ADMINS',
|
||||
},
|
||||
members: {
|
||||
id: t('mobile.manage_members.section_title_members'),
|
||||
defaultMessage: 'MEMBERS',
|
||||
},
|
||||
});
|
||||
|
||||
const keyboardDismissProp = Platform.select({
|
||||
android: {
|
||||
onScrollBeginDrag: Keyboard.dismiss,
|
||||
@@ -41,29 +56,58 @@ const sectionKeyExtractor = (profile: UserProfile) => {
|
||||
return profile.username[0].toUpperCase();
|
||||
};
|
||||
|
||||
export function createProfilesSections(profiles: UserProfile[]) {
|
||||
const sections: {[key: string]: UserProfile[]} = {};
|
||||
const sectionKeys: string[] = [];
|
||||
for (const profile of profiles) {
|
||||
const sectionKey = sectionKeyExtractor(profile);
|
||||
const sectionRoleKeyExtractor = (cAdmin: boolean) => {
|
||||
// Group items by channel admin or channel member
|
||||
return cAdmin ? messages.admins : messages.members;
|
||||
};
|
||||
|
||||
if (!sections[sectionKey]) {
|
||||
sections[sectionKey] = [];
|
||||
sectionKeys.push(sectionKey);
|
||||
}
|
||||
|
||||
sections[sectionKey].push(profile);
|
||||
export function createProfilesSections(intl: IntlShape, profiles: UserProfile[], members?: ChannelMember[]) {
|
||||
if (!profiles.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
sectionKeys.sort();
|
||||
const sections = new Map();
|
||||
|
||||
return sectionKeys.map((sectionKey, index) => {
|
||||
return {
|
||||
id: sectionKey,
|
||||
first: index === 0,
|
||||
data: sections[sectionKey],
|
||||
};
|
||||
});
|
||||
if (members?.length) {
|
||||
// when channel members are provided, build the sections by admins and members
|
||||
const membersDictionary = new Map();
|
||||
const membersSections = new Map();
|
||||
const {formatMessage} = intl;
|
||||
members.forEach((m) => membersDictionary.set(m.user_id, m));
|
||||
profiles.forEach((p) => {
|
||||
const member = membersDictionary.get(p.id);
|
||||
const sectionKey = sectionRoleKeyExtractor(member.scheme_admin!);
|
||||
const sectionValue = membersSections.get(sectionKey) || [];
|
||||
|
||||
// combine UserProfile and ChannelMember objects so can get channel member scheme_admin permission
|
||||
const section = [...sectionValue, {...p, ...member}];
|
||||
membersSections.set(sectionKey, section);
|
||||
});
|
||||
sections.set(formatMessage(messages.admins), membersSections.get(messages.admins));
|
||||
sections.set(formatMessage(messages.members), membersSections.get(messages.members));
|
||||
} else {
|
||||
// when channel members are not provided, build the sections alphabetically
|
||||
profiles.forEach((p) => {
|
||||
const sectionKey = sectionKeyExtractor(p);
|
||||
const sectionValue = sections.get(sectionKey) || [];
|
||||
const section = [...sectionValue, p];
|
||||
sections.set(sectionKey, section);
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let index = 0;
|
||||
for (const [k, v] of sections) {
|
||||
if (v) {
|
||||
results.push({
|
||||
first: index === 0,
|
||||
id: k,
|
||||
data: v,
|
||||
});
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
@@ -103,11 +147,14 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
|
||||
type Props = {
|
||||
profiles: UserProfile[];
|
||||
channelMembers?: ChannelMember[];
|
||||
currentUserId: string;
|
||||
teammateNameDisplay: string;
|
||||
handleSelectProfile: (user: UserProfile) => void;
|
||||
fetchMore: () => void;
|
||||
loading: boolean;
|
||||
manageMode?: boolean;
|
||||
showManageMode?: boolean;
|
||||
showNoResults: boolean;
|
||||
selectedIds: {[id: string]: UserProfile};
|
||||
testID?: string;
|
||||
@@ -117,12 +164,15 @@ type Props = {
|
||||
|
||||
export default function UserList({
|
||||
profiles,
|
||||
channelMembers,
|
||||
selectedIds,
|
||||
currentUserId,
|
||||
teammateNameDisplay,
|
||||
handleSelectProfile,
|
||||
fetchMore,
|
||||
loading,
|
||||
manageMode = false,
|
||||
showManageMode = false,
|
||||
showNoResults,
|
||||
term,
|
||||
testID,
|
||||
@@ -139,11 +189,16 @@ export default function UserList({
|
||||
], [style, keyboardHeight]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (profiles.length === 0 && !loading) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (term) {
|
||||
return profiles;
|
||||
}
|
||||
return createProfilesSections(profiles);
|
||||
}, [term, profiles]);
|
||||
|
||||
return createProfilesSections(intl, profiles, channelMembers);
|
||||
}, [channelMembers, loading, profiles, term]);
|
||||
|
||||
const openUserProfile = useCallback(async (profile: UserProfile) => {
|
||||
const {user} = await storeProfile(serverUrl, profile);
|
||||
@@ -162,29 +217,34 @@ export default function UserList({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({item, index, section}: ListRenderItemInfo<UserProfile> & {section?: SectionListData<UserProfile>}) => {
|
||||
const renderItem = useCallback(({item, index, section}: RenderItemType) => {
|
||||
// The list will re-render when the selection changes because it's passed into the list as extraData
|
||||
const selected = Boolean(selectedIds[item.id]);
|
||||
const canAdd = Object.keys(selectedIds).length < General.MAX_USERS_IN_GM;
|
||||
|
||||
const isChAdmin = item.scheme_admin || false;
|
||||
|
||||
return (
|
||||
<UserListRow
|
||||
key={item.id}
|
||||
highlight={section?.first && index === 0}
|
||||
id={item.id}
|
||||
isChannelAdmin={isChAdmin}
|
||||
isMyUser={currentUserId === item.id}
|
||||
manageMode={manageMode}
|
||||
onPress={handleSelectProfile}
|
||||
onLongPress={openUserProfile}
|
||||
selectable={manageMode || canAdd}
|
||||
disabled={!canAdd}
|
||||
selectable={true}
|
||||
selected={selected}
|
||||
showManageMode={showManageMode}
|
||||
testID='create_direct_message.user_list.user_item'
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
tutorialWatched={tutorialWatched}
|
||||
user={item}
|
||||
/>
|
||||
);
|
||||
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]);
|
||||
}, [selectedIds, handleSelectProfile, showManageMode, manageMode, teammateNameDisplay, tutorialWatched]);
|
||||
|
||||
const renderLoading = useCallback(() => {
|
||||
if (!loading) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import {storeProfileLongPressTutorial} from '@actions/app/global';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {BotTag, GuestTag} from '@components/tag';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
@@ -19,23 +20,27 @@ import TutorialHighlight from '@components/tutorial_highlight';
|
||||
import TutorialLongPress from '@components/tutorial_highlight/long_press';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {t} from '@i18n';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {displayUsername, isGuest} from '@utils/user';
|
||||
|
||||
type Props = {
|
||||
highlight?: boolean;
|
||||
id: string;
|
||||
isMyUser: boolean;
|
||||
highlight?: boolean;
|
||||
user: UserProfile;
|
||||
teammateNameDisplay: string;
|
||||
testID: string;
|
||||
onPress?: (user: UserProfile) => void;
|
||||
isChannelAdmin: boolean;
|
||||
manageMode: boolean;
|
||||
onLongPress: (user: UserProfile) => void;
|
||||
onPress?: (user: UserProfile) => void;
|
||||
selectable: boolean;
|
||||
disabled?: boolean;
|
||||
selected: boolean;
|
||||
showManageMode: boolean;
|
||||
teammateNameDisplay: string;
|
||||
testID: string;
|
||||
tutorialWatched?: boolean;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
@@ -84,6 +89,15 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
selectorManage: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
manageText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 100, 'Regular'),
|
||||
},
|
||||
tutorial: {
|
||||
top: Platform.select({ios: -74, default: -94}),
|
||||
},
|
||||
@@ -100,24 +114,26 @@ function UserListRow({
|
||||
id,
|
||||
isMyUser,
|
||||
highlight,
|
||||
user,
|
||||
teammateNameDisplay,
|
||||
testID,
|
||||
isChannelAdmin,
|
||||
onPress,
|
||||
onLongPress,
|
||||
tutorialWatched = false,
|
||||
manageMode = false,
|
||||
selectable,
|
||||
disabled,
|
||||
selected,
|
||||
showManageMode = false,
|
||||
teammateNameDisplay,
|
||||
testID,
|
||||
tutorialWatched = false,
|
||||
user,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const isTablet = useIsTablet();
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const [itemBounds, setItemBounds] = useState<TutorialItemBounds>({startX: 0, startY: 0, endX: 0, endY: 0});
|
||||
const viewRef = useRef<View>(null);
|
||||
const style = getStyleFromTheme(theme);
|
||||
const {formatMessage} = intl;
|
||||
const {formatMessage, locale} = useIntl();
|
||||
const {username} = user;
|
||||
|
||||
const startTutorial = () => {
|
||||
@@ -152,13 +168,41 @@ function UserListRow({
|
||||
}, [highlight, tutorialWatched, isTablet]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isMyUser && manageMode) {
|
||||
return;
|
||||
}
|
||||
onPress?.(user);
|
||||
}, [onPress, user]);
|
||||
}, [onPress, isMyUser, manageMode, user]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
onLongPress?.(user);
|
||||
}, [onLongPress, user]);
|
||||
|
||||
const manageModeIcon = useMemo(() => {
|
||||
if (!showManageMode || isMyUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = changeOpacity(theme.centerChannelColor, 0.64);
|
||||
const i18nId = isChannelAdmin ? t('mobile.manage_members.admin') : t('mobile.manage_members.member');
|
||||
const defaultMessage = isChannelAdmin ? 'Admin' : 'Member';
|
||||
|
||||
return (
|
||||
<View style={style.selectorManage}>
|
||||
<FormattedText
|
||||
id={i18nId}
|
||||
style={style.manageText}
|
||||
defaultMessage={defaultMessage}
|
||||
/>
|
||||
<CompassIcon
|
||||
name={'chevron-down'}
|
||||
size={18}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [isChannelAdmin, showManageMode, theme]);
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
startTutorial();
|
||||
}, []);
|
||||
@@ -189,7 +233,7 @@ function UserListRow({
|
||||
}, {username});
|
||||
}
|
||||
|
||||
const teammateDisplay = displayUsername(user, intl.locale, teammateNameDisplay);
|
||||
const teammateDisplay = displayUsername(user, locale, teammateNameDisplay);
|
||||
const showTeammateDisplay = teammateDisplay !== username;
|
||||
|
||||
const userItemTestID = `${testID}.${id}`;
|
||||
@@ -257,7 +301,7 @@ function UserListRow({
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
{icon}
|
||||
{manageMode ? manageModeIcon : icon}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
{showTutorial &&
|
||||
@@ -267,7 +311,7 @@ function UserListRow({
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<TutorialLongPress
|
||||
message={intl.formatMessage({id: 'user.tutorial.long_press', defaultMessage: "Long-press on an item to view a user's profile"})}
|
||||
message={formatMessage({id: 'user.tutorial.long_press', defaultMessage: "Long-press on an item to view a user's profile"})}
|
||||
style={isTablet ? style.tutorialTablet : style.tutorial}
|
||||
/>
|
||||
</TutorialHighlight>
|
||||
|
||||
@@ -15,6 +15,8 @@ export default keyMirror({
|
||||
LEAVE_TEAM: null,
|
||||
LOADING_CHANNEL_POSTS: null,
|
||||
NOTIFICATION_ERROR: null,
|
||||
REMOVE_USER_FROM_CHANNEL: null,
|
||||
MANAGE_USER_CHANGE_ROLE: null,
|
||||
SERVER_LOGOUT: null,
|
||||
SERVER_VERSION_CHANGED: null,
|
||||
SESSION_EXPIRED: null,
|
||||
|
||||
@@ -19,6 +19,7 @@ import Integrations from './integrations';
|
||||
import Launch from './launch';
|
||||
import License from './license';
|
||||
import List from './list';
|
||||
import Members from './members';
|
||||
import Navigation from './navigation';
|
||||
import Network from './network';
|
||||
import NotificationLevel from './notification_level';
|
||||
@@ -57,6 +58,7 @@ export {
|
||||
Launch,
|
||||
License,
|
||||
List,
|
||||
Members,
|
||||
Navigation,
|
||||
Network,
|
||||
NotificationLevel,
|
||||
|
||||
16
app/constants/members.ts
Normal file
16
app/constants/members.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import keyMirror from '@utils/key_mirror';
|
||||
const ManageOptions = keyMirror({
|
||||
REMOVE_USER: null,
|
||||
MAKE_CHANNEL_ADMIN: null,
|
||||
MAKE_CHANNEL_MEMBER: null,
|
||||
});
|
||||
|
||||
export type ManageOptionsTypes = keyof typeof ManageOptions
|
||||
|
||||
export default {
|
||||
ManageOptions,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export const IN_APP_NOTIFICATION = 'InAppNotification';
|
||||
export const JOIN_TEAM = 'JoinTeam';
|
||||
export const LATEX = 'Latex';
|
||||
export const LOGIN = 'Login';
|
||||
export const MANAGE_CHANNEL_MEMBERS = 'ManageChannelMembers';
|
||||
export const MENTIONS = 'Mentions';
|
||||
export const MFA = 'MFA';
|
||||
export const ONBOARDING = 'Onboarding';
|
||||
@@ -101,6 +102,7 @@ export default {
|
||||
JOIN_TEAM,
|
||||
LATEX,
|
||||
LOGIN,
|
||||
MANAGE_CHANNEL_MEMBERS,
|
||||
MENTIONS,
|
||||
MFA,
|
||||
ONBOARDING,
|
||||
@@ -148,6 +150,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
|
||||
EDIT_SERVER,
|
||||
FIND_CHANNELS,
|
||||
GALLERY,
|
||||
MANAGE_CHANNEL_MEMBERS,
|
||||
INVITE,
|
||||
PERMALINK,
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SNACK_BAR_TYPE = keyMirror({
|
||||
LINK_COPIED: null,
|
||||
MESSAGE_COPIED: null,
|
||||
MUTE_CHANNEL: null,
|
||||
REMOVE_CHANNEL_USER: null,
|
||||
UNFAVORITE_CHANNEL: null,
|
||||
UNMUTE_CHANNEL: null,
|
||||
});
|
||||
@@ -45,6 +46,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||
iconName: 'bell-off-outline',
|
||||
canUndo: true,
|
||||
},
|
||||
REMOVE_CHANNEL_USER: {
|
||||
id: t('snack.bar.remove.user'),
|
||||
defaultMessage: '1 member was removed from the channel',
|
||||
iconName: 'check',
|
||||
canUndo: true,
|
||||
},
|
||||
UNFAVORITE_CHANNEL: {
|
||||
id: t('snack.bar.unfavorite.channel'),
|
||||
defaultMessage: 'This channel was unfavorited',
|
||||
|
||||
@@ -88,8 +88,8 @@ export function observePermissionForPost(database: Database, post: PostModel, us
|
||||
);
|
||||
}
|
||||
|
||||
export function observeCanManageChannelMembers(database: Database, post: PostModel, user: UserModel) {
|
||||
return observeChannel(database, post.channelId).pipe(
|
||||
export function observeCanManageChannelMembers(database: Database, channelId: string, user: UserModel) {
|
||||
return observeChannel(database, channelId).pipe(
|
||||
switchMap((c) => {
|
||||
if (!c || c.deleteAt !== 0 || isDMorGM(c) || c.name === General.DEFAULT_CHANNEL) {
|
||||
return of$(false);
|
||||
|
||||
@@ -74,7 +74,7 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
|
||||
useEffect(() => {
|
||||
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
|
||||
if (!channelMembers?.length) {
|
||||
fetchProfilesInChannel(serverUrl, channel.id, currentUserId, false);
|
||||
fetchProfilesInChannel(serverUrl, channel.id, currentUserId, undefined, false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ import {isTypeDMorGM} from '@utils/channel';
|
||||
|
||||
import EditChannel from './edit_channel';
|
||||
import IgnoreMentions from './ignore_mentions';
|
||||
import Members from './members';
|
||||
import PinnedMessages from './pinned_messages';
|
||||
|
||||
// import Members from './members';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
type?: ChannelType;
|
||||
@@ -29,11 +28,9 @@ const Options = ({channelId, type, callsEnabled}: Props) => {
|
||||
}
|
||||
{/*<NotificationPreference channelId={channelId}/>*/}
|
||||
<PinnedMessages channelId={channelId}/>
|
||||
{/* Add back in after MM-47653 is resolved. https://mattermost.atlassian.net/browse/MM-47653
|
||||
{type !== General.DM_CHANNEL &&
|
||||
<Members channelId={channelId}/>
|
||||
}
|
||||
*/}
|
||||
{callsEnabled && !isDMorGM && // if calls is not enabled, copy link will show in the channel actions
|
||||
<CopyChannelLinkOption
|
||||
channelId={channelId}
|
||||
|
||||
@@ -6,7 +6,7 @@ import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeChannelInfo} from '@queries/servers/channel';
|
||||
import {observeChannel, observeChannelInfo} from '@queries/servers/channel';
|
||||
|
||||
import Members from './members';
|
||||
|
||||
@@ -18,11 +18,16 @@ type Props = WithDatabaseArgs & {
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
|
||||
const info = observeChannelInfo(database, channelId);
|
||||
|
||||
const displayName = observeChannel(database, channelId).pipe(
|
||||
switchMap((c) => of$(c?.displayName)));
|
||||
|
||||
const count = info.pipe(
|
||||
switchMap((i) => of$(i?.memberCount || 0)),
|
||||
);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,20 +7,32 @@ import {Platform} from 'react-native';
|
||||
|
||||
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';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const Members = ({channelId, count}: Props) => {
|
||||
const Members = ({displayName, channelId, count}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const title = formatMessage({id: 'channel_info.members', defaultMessage: 'Members'});
|
||||
|
||||
const goToChannelMembers = preventDoubleTap(() => {
|
||||
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
|
||||
const options = {
|
||||
topBar: {
|
||||
subtitle: {
|
||||
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
|
||||
text: displayName,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
goToScreen(Screens.MANAGE_CHANNEL_MEMBERS, title, {channelId}, options);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -149,6 +149,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||
case Screens.LOGIN:
|
||||
screen = withIntl(require('@screens/login').default);
|
||||
break;
|
||||
case Screens.MANAGE_CHANNEL_MEMBERS:
|
||||
screen = withServerDatabase(require('@screens/manage_channel_members').default);
|
||||
break;
|
||||
case Screens.MFA:
|
||||
screen = withIntl(require('@screens/mfa').default);
|
||||
break;
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function Invite({
|
||||
return;
|
||||
}
|
||||
|
||||
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase());
|
||||
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {});
|
||||
const results: SearchResult[] = data ?? [];
|
||||
|
||||
if (!results.length && isEmail(searchTerm.trim())) {
|
||||
|
||||
40
app/screens/manage_channel_members/index.tsx
Normal file
40
app/screens/manage_channel_members/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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$, combineLatest, switchMap} from 'rxjs';
|
||||
|
||||
import {Permissions, Tutorial} from '@constants';
|
||||
import {observeTutorialWatched} from '@queries/app/global';
|
||||
import {observeCurrentChannel} from '@queries/servers/channel';
|
||||
import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role';
|
||||
import {observeCurrentChannelId, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
|
||||
import {observeCurrentUser, observeTeammateNameDisplay} from '@queries/servers/user';
|
||||
|
||||
import ManageChannelMembers from './manage_channel_members';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const currentUser = observeCurrentUser(database);
|
||||
const currentChannelId = observeCurrentChannelId(database);
|
||||
const currentChannel = observeCurrentChannel(database);
|
||||
|
||||
const canManageAndRemoveMembers = combineLatest([currentChannelId, currentUser]).pipe(
|
||||
switchMap(([cId, u]) => (cId && u ? observeCanManageChannelMembers(database, cId, u) : of$(false))));
|
||||
|
||||
const canChangeMemberRoles = combineLatest([currentChannel, currentUser, canManageAndRemoveMembers]).pipe(
|
||||
switchMap(([c, u, m]) => (of$(c) && of$(u) && of$(m) && observePermissionForChannel(database, c, u, Permissions.MANAGE_CHANNEL_ROLES, true))));
|
||||
|
||||
return {
|
||||
currentUserId: observeCurrentUserId(database),
|
||||
currentTeamId: observeCurrentTeamId(database),
|
||||
canManageAndRemoveMembers,
|
||||
teammateNameDisplay: observeTeammateNameDisplay(database),
|
||||
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
|
||||
canChangeMemberRoles,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ManageChannelMembers));
|
||||
281
app/screens/manage_channel_members/manage_channel_members.tsx
Normal file
281
app/screens/manage_channel_members/manage_channel_members.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Keyboard, Platform, StyleSheet, View} from 'react-native';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {fetchChannelMemberships} from '@actions/remote/channel';
|
||||
import {fetchUsersByIds, searchProfiles} from '@actions/remote/user';
|
||||
import Search from '@components/search';
|
||||
import UserList from '@components/user_list';
|
||||
import {Events, General, Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {debounce} from '@helpers/api/general';
|
||||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import {t} from '@i18n';
|
||||
import {openAsBottomSheet, setButtons} from '@screens/navigation';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {showRemoveChannelUserSnackbar} from '@utils/snack_bar';
|
||||
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
|
||||
import {filterProfilesMatchingTerm} from '@utils/user';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
canManageAndRemoveMembers: boolean;
|
||||
channelId: string;
|
||||
componentId: AvailableScreens;
|
||||
currentTeamId: string;
|
||||
currentUserId: string;
|
||||
teammateNameDisplay: string;
|
||||
tutorialWatched: boolean;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
searchBar: {
|
||||
marginLeft: 12,
|
||||
marginRight: Platform.select({ios: 4, default: 12}),
|
||||
marginVertical: 12,
|
||||
},
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
button_manage: {
|
||||
id: t('mobile.manage_members.manage'),
|
||||
defaultMessage: 'Manage',
|
||||
},
|
||||
button_done: {
|
||||
id: t('mobile.manage_members.done'),
|
||||
defaultMessage: 'Done',
|
||||
},
|
||||
});
|
||||
|
||||
const MANAGE_BUTTON = 'manage-button';
|
||||
const EMPTY: UserProfile[] = [];
|
||||
const EMPTY_MEMBERS: ChannelMembership[] = [];
|
||||
const EMPTY_IDS = {};
|
||||
const {USER_PROFILE} = Screens;
|
||||
const CLOSE_BUTTON_ID = 'close-user-profile';
|
||||
|
||||
export default function ManageChannelMembers({
|
||||
canManageAndRemoveMembers,
|
||||
channelId,
|
||||
componentId,
|
||||
currentTeamId,
|
||||
currentUserId,
|
||||
teammateNameDisplay,
|
||||
tutorialWatched,
|
||||
}: Props) {
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
const mounted = useRef(false);
|
||||
|
||||
const [isManageMode, setIsManageMode] = useState(false);
|
||||
const [profiles, setProfiles] = useState<UserProfile[]>(EMPTY);
|
||||
const [channelMembers, setChannelMembers] = useState<ChannelMembership[]>(EMPTY_MEMBERS);
|
||||
const [searchResults, setSearchResults] = useState<UserProfile[]>(EMPTY);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [term, setTerm] = useState('');
|
||||
|
||||
const loadedProfiles = (users: UserProfile[], members: ChannelMembership[]) => {
|
||||
if (mounted.current) {
|
||||
setLoading(false);
|
||||
setProfiles(users);
|
||||
setChannelMembers(members);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setTerm('');
|
||||
setSearchResults(EMPTY);
|
||||
}, []);
|
||||
|
||||
const getProfiles = useCallback(debounce(async () => {
|
||||
const hasTerm = Boolean(term);
|
||||
if (!loading && !hasTerm && mounted.current) {
|
||||
setLoading(true);
|
||||
const options = {sort: 'admin', active: true};
|
||||
const {users, members} = await fetchChannelMemberships(serverUrl, channelId, options, true);
|
||||
if (users.length) {
|
||||
loadedProfiles(users, members);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, 100), [channelId, loading, serverUrl, term]);
|
||||
|
||||
const handleSelectProfile = useCallback(async (profile: UserProfile) => {
|
||||
await fetchUsersByIds(serverUrl, [profile.id]);
|
||||
const title = formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
const props = {
|
||||
channelId,
|
||||
closeButtonId: CLOSE_BUTTON_ID,
|
||||
location: USER_PROFILE,
|
||||
manageMode: isManageMode,
|
||||
userId: profile.id,
|
||||
canManageAndRemoveMembers,
|
||||
};
|
||||
|
||||
Keyboard.dismiss();
|
||||
openAsBottomSheet({screen: USER_PROFILE, title, theme, closeButtonId: CLOSE_BUTTON_ID, props});
|
||||
}, [canManageAndRemoveMembers, channelId, isManageMode]);
|
||||
|
||||
const searchUsers = useCallback(async (searchTerm: string) => {
|
||||
const lowerCasedTerm = searchTerm.toLowerCase();
|
||||
setLoading(true);
|
||||
|
||||
const options: SearchUserOptions = {team_id: currentTeamId, in_channel_id: channelId, allow_inactive: false};
|
||||
const {data = EMPTY} = await searchProfiles(serverUrl, lowerCasedTerm, options);
|
||||
|
||||
setSearchResults(data);
|
||||
setLoading(false);
|
||||
}, [serverUrl, channelId, currentTeamId]);
|
||||
|
||||
const search = useCallback(() => {
|
||||
searchUsers(term);
|
||||
}, [searchUsers, term]);
|
||||
|
||||
const onSearch = useCallback((text: string) => {
|
||||
if (!text) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
setTerm(text);
|
||||
if (searchTimeoutId.current) {
|
||||
clearTimeout(searchTimeoutId.current);
|
||||
}
|
||||
|
||||
searchTimeoutId.current = setTimeout(() => {
|
||||
searchUsers(text);
|
||||
}, General.SEARCH_TIMEOUT_MILLISECONDS);
|
||||
}, [searchUsers, clearSearch]);
|
||||
|
||||
const updateNavigationButtons = useCallback((manage: boolean) => {
|
||||
setButtons(componentId, {
|
||||
rightButtons: [{
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
enabled: true,
|
||||
id: MANAGE_BUTTON,
|
||||
showAsAction: 'always',
|
||||
testID: 'manage_members.button',
|
||||
text: formatMessage(manage ? messages.button_done : messages.button_manage),
|
||||
}],
|
||||
});
|
||||
}, [theme.sidebarHeaderTextColor]);
|
||||
|
||||
const toggleManageEnabled = useCallback(() => {
|
||||
updateNavigationButtons(!isManageMode);
|
||||
setIsManageMode((prev) => !prev);
|
||||
}, [isManageMode, updateNavigationButtons]);
|
||||
|
||||
const handleRemoveUser = useCallback(async (userId: string) => {
|
||||
const pIndex = profiles.findIndex((user) => user.id === userId);
|
||||
const mIndex = channelMembers.findIndex((m) => m.user_id === userId);
|
||||
if (pIndex !== -1) {
|
||||
const newProfiles = [...profiles];
|
||||
newProfiles.splice(pIndex, 1);
|
||||
setProfiles(newProfiles);
|
||||
|
||||
const newMembers = [...channelMembers];
|
||||
newMembers.splice(mIndex, 1);
|
||||
setChannelMembers(newMembers);
|
||||
|
||||
await NavigationStore.waitUntilScreensIsRemoved(USER_PROFILE);
|
||||
showRemoveChannelUserSnackbar();
|
||||
}
|
||||
}, [profiles, channelMembers]);
|
||||
|
||||
const handleUserChangeRole = useCallback(async ({userId, schemeAdmin}: {userId: string; schemeAdmin: boolean}) => {
|
||||
const clone = channelMembers.map((m) => {
|
||||
if (m.user_id === userId) {
|
||||
m.scheme_admin = schemeAdmin;
|
||||
return m;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
setChannelMembers(clone);
|
||||
}, [channelMembers]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const isSearch = Boolean(term);
|
||||
if (isSearch) {
|
||||
return filterProfilesMatchingTerm(searchResults, term);
|
||||
}
|
||||
return profiles;
|
||||
}, [term, searchResults, profiles]);
|
||||
|
||||
useNavButtonPressed(MANAGE_BUTTON, componentId, toggleManageEnabled, [toggleManageEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageAndRemoveMembers) {
|
||||
updateNavigationButtons(false);
|
||||
}
|
||||
}, [canManageAndRemoveMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeUserListener = DeviceEventEmitter.addListener(Events.REMOVE_USER_FROM_CHANNEL, handleRemoveUser);
|
||||
const changeUserRoleListener = DeviceEventEmitter.addListener(Events.MANAGE_USER_CHANGE_ROLE, handleUserChangeRole);
|
||||
return (() => {
|
||||
removeUserListener?.remove();
|
||||
changeUserRoleListener?.remove();
|
||||
});
|
||||
}, [handleRemoveUser, handleUserChangeRole]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.container}
|
||||
testID='manage_members.screen'
|
||||
>
|
||||
<View style={styles.searchBar}>
|
||||
<Search
|
||||
autoCapitalize='none'
|
||||
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
onCancel={clearSearch}
|
||||
onChangeText={onSearch}
|
||||
onSubmitEditing={search}
|
||||
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
testID='manage_members.search_bar'
|
||||
value={term}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* TODO: https://mattermost.atlassian.net/browse/MM-48830 */}
|
||||
{/* fix flashing No Results page when results are present */}
|
||||
<UserList
|
||||
currentUserId={currentUserId}
|
||||
fetchMore={getProfiles}
|
||||
handleSelectProfile={handleSelectProfile}
|
||||
loading={loading}
|
||||
manageMode={true} // default true to change row select icon to a dropdown
|
||||
profiles={data}
|
||||
channelMembers={channelMembers}
|
||||
selectedIds={EMPTY_IDS}
|
||||
showManageMode={canManageAndRemoveMembers && isManageMode}
|
||||
showNoResults={!loading}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
term={term}
|
||||
testID='manage_members.user_list'
|
||||
tutorialWatched={tutorialWatched}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const SNACK_BAR_WIDTH = 96;
|
||||
const SNACK_BAR_HEIGHT = 56;
|
||||
const SNACK_BAR_BOTTOM_RATIO = 0.04;
|
||||
|
||||
const caseScreens: AvailableScreens[] = [Screens.PERMALINK, Screens.MENTIONS, Screens.SAVED_MESSAGES];
|
||||
const caseScreens: AvailableScreens[] = [Screens.PERMALINK, Screens.MANAGE_CHANNEL_MEMBERS, Screens.MENTIONS, Screens.SAVED_MESSAGES];
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {of as of$, combineLatest} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General, Preferences} from '@constants';
|
||||
import {General, Permissions, Preferences} from '@constants';
|
||||
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
|
||||
import {observeChannel} from '@queries/servers/channel';
|
||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||
import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role';
|
||||
import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
|
||||
import {observeTeammateNameDisplay, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user';
|
||||
import {observeTeammateNameDisplay, observeCurrentUser, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user';
|
||||
import {isSystemAdmin} from '@utils/user';
|
||||
|
||||
import UserProfile from './user_profile';
|
||||
@@ -24,11 +25,15 @@ type EnhancedProps = WithDatabaseArgs & {
|
||||
}
|
||||
|
||||
const enhanced = withObservables([], ({channelId, database, userId}: EnhancedProps) => {
|
||||
const currentUser = observeCurrentUser(database);
|
||||
const currentUserId = observeCurrentUserId(database);
|
||||
const channel = channelId ? observeChannel(database, channelId) : of$(undefined);
|
||||
const user = observeUser(database, userId);
|
||||
const teammateDisplayName = observeTeammateNameDisplay(database);
|
||||
const isChannelAdmin = channelId ? observeUserIsChannelAdmin(database, userId, channelId) : of$(false);
|
||||
const isDefaultChannel = channel ? channel.pipe(
|
||||
switchMap((c) => of$(c?.name === General.DEFAULT_CHANNEL)),
|
||||
) : of$(false);
|
||||
const isDirectMessage = channelId ? channel.pipe(
|
||||
switchMap((c) => of$(c?.type === General.DM_CHANNEL)),
|
||||
) : of$(false);
|
||||
@@ -42,12 +47,21 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
|
||||
const isMilitaryTime = preferences.pipe(map((prefs) => getDisplayNamePreferenceAsBool(prefs, Preferences.USE_MILITARY_TIME)));
|
||||
const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses');
|
||||
|
||||
// can remove member
|
||||
const canManageAndRemoveMembers = combineLatest([channel, currentUser]).pipe(
|
||||
switchMap(([c, u]) => (c && u ? observeCanManageChannelMembers(database, c.id, u) : of$(false))));
|
||||
|
||||
const canChangeMemberRoles = combineLatest([channel, currentUser, canManageAndRemoveMembers]).pipe(
|
||||
switchMap(([c, u, m]) => (of$(c?.id) && of$(u) && of$(m) && observePermissionForChannel(database, c, u, Permissions.MANAGE_CHANNEL_ROLES, true))));
|
||||
|
||||
return {
|
||||
canManageAndRemoveMembers,
|
||||
currentUserId,
|
||||
enablePostIconOverride,
|
||||
enablePostUsernameOverride,
|
||||
isChannelAdmin,
|
||||
isCustomStatusEnabled,
|
||||
isDefaultChannel,
|
||||
isDirectMessage,
|
||||
isMilitaryTime,
|
||||
isSystemAdmin: systemAdmin,
|
||||
@@ -55,6 +69,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
|
||||
teamId,
|
||||
teammateDisplayName,
|
||||
user,
|
||||
canChangeMemberRoles,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
62
app/screens/user_profile/manage_user_options.tsx
Normal file
62
app/screens/user_profile/manage_user_options.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import ManageMembersLabel from '@components/channel_actions/manage_members_label';
|
||||
import {Members} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
export const DIVIDER_MARGIN = 8;
|
||||
const {MAKE_CHANNEL_ADMIN, MAKE_CHANNEL_MEMBER, REMOVE_USER} = Members.ManageOptions;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
divider: {
|
||||
alignSelf: 'center',
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
height: 1,
|
||||
marginVertical: DIVIDER_MARGIN,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
isDefaultChannel: boolean;
|
||||
isChannelAdmin: boolean;
|
||||
canChangeMemberRoles: boolean;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const ManageUserOptions = ({channelId, isChannelAdmin, isDefaultChannel, userId, canChangeMemberRoles}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider}/>
|
||||
{canChangeMemberRoles &&
|
||||
<ManageMembersLabel
|
||||
channelId={channelId}
|
||||
isDefaultChannel={isDefaultChannel}
|
||||
manageOption={isChannelAdmin ? MAKE_CHANNEL_MEMBER : MAKE_CHANNEL_ADMIN}
|
||||
testID='channel.make_channel_admin'
|
||||
userId={userId}
|
||||
/>
|
||||
}
|
||||
<ManageMembersLabel
|
||||
channelId={channelId}
|
||||
isDefaultChannel={isDefaultChannel}
|
||||
manageOption={REMOVE_USER}
|
||||
testID='channel.remove_member'
|
||||
userId={userId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageUserOptions;
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import React, {useMemo} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import Animated from 'react-native-reanimated';
|
||||
@@ -18,19 +18,26 @@ const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
|
||||
type Props = {
|
||||
enablePostIconOverride: boolean;
|
||||
forwardRef?: React.RefObject<any>;
|
||||
imageSize?: number;
|
||||
user: UserModel;
|
||||
userIconOverride?: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatar: {
|
||||
borderRadius: 48,
|
||||
height: 96,
|
||||
width: 96,
|
||||
},
|
||||
});
|
||||
const DEFAULT_IMAGE_SIZE = 96;
|
||||
|
||||
const getStyles = (size?: number) => {
|
||||
return StyleSheet.create({
|
||||
avatar: {
|
||||
borderRadius: 48,
|
||||
height: size || DEFAULT_IMAGE_SIZE,
|
||||
width: size || DEFAULT_IMAGE_SIZE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const UserProfileAvatar = ({enablePostIconOverride, forwardRef, imageSize, user, userIconOverride}: Props) => {
|
||||
const styles = useMemo(() => getStyles(imageSize), [imageSize]);
|
||||
|
||||
const UserProfileAvatar = ({enablePostIconOverride, forwardRef, user, userIconOverride}: Props) => {
|
||||
if (enablePostIconOverride && userIconOverride) {
|
||||
return (
|
||||
<View style={styles.avatar}>
|
||||
@@ -48,7 +55,7 @@ const UserProfileAvatar = ({enablePostIconOverride, forwardRef, user, userIconOv
|
||||
author={user}
|
||||
forwardRef={forwardRef}
|
||||
showStatus={true}
|
||||
size={96}
|
||||
size={imageSize || DEFAULT_IMAGE_SIZE}
|
||||
statusSize={24}
|
||||
testID={`user_profile_avatar.${user.id}.profile_picture`}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
type Props = {
|
||||
enablePostIconOverride: boolean;
|
||||
enablePostUsernameOverride: boolean;
|
||||
headerText?: string;
|
||||
imageSize?: number;
|
||||
isChannelAdmin: boolean;
|
||||
isSystemAdmin: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
@@ -34,6 +36,8 @@ type Props = {
|
||||
usernameOverride?: string;
|
||||
}
|
||||
|
||||
export const HEADER_TEXT_HEIGHT = 30;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
@@ -52,14 +56,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 200),
|
||||
},
|
||||
heading: {
|
||||
height: HEADER_TEXT_HEIGHT,
|
||||
color: theme.centerChannelColor,
|
||||
marginBottom: 20,
|
||||
...typography('Heading', 600, 'SemiBold'),
|
||||
},
|
||||
tablet: {
|
||||
marginTop: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const UserProfileTitle = ({
|
||||
enablePostIconOverride, enablePostUsernameOverride,
|
||||
isChannelAdmin, isSystemAdmin, isTeamAdmin,
|
||||
enablePostIconOverride, enablePostUsernameOverride, headerText,
|
||||
imageSize, isChannelAdmin, isSystemAdmin, isTeamAdmin,
|
||||
teammateDisplayName, user, userIconOverride, usernameOverride,
|
||||
}: Props) => {
|
||||
const galleryIdentifier = `${user.id}-avatarPreview`;
|
||||
@@ -118,45 +128,56 @@ const UserProfileTitle = ({
|
||||
const prefix = hideUsername ? '@' : '';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isTablet && styles.tablet]}>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={galleryStyles}>
|
||||
<TouchableOpacity onPress={onGestureEvent}>
|
||||
<UserProfileAvatar
|
||||
forwardRef={ref}
|
||||
enablePostIconOverride={enablePostIconOverride}
|
||||
user={user}
|
||||
userIconOverride={userIconOverride}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
<View style={styles.details}>
|
||||
<UserProfileTag
|
||||
isBot={user.isBot || Boolean(userIconOverride || usernameOverride)}
|
||||
isChannelAdmin={isChannelAdmin}
|
||||
isGuest={user.isGuest}
|
||||
isSystemAdmin={isSystemAdmin}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
/>
|
||||
<>
|
||||
{headerText &&
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={styles.displayName}
|
||||
testID='user_profile.display_name'
|
||||
style={styles.heading}
|
||||
testID='user_profile.heading'
|
||||
>
|
||||
{`${prefix}${displayName}`}
|
||||
{headerText}
|
||||
</Text>
|
||||
{!hideUsername &&
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={styles.username}
|
||||
testID='user_profile.username'
|
||||
>
|
||||
{`@${user.username}`}
|
||||
</Text>
|
||||
}
|
||||
}
|
||||
<View style={[styles.container, isTablet && styles.tablet]}>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={galleryStyles}>
|
||||
<TouchableOpacity onPress={onGestureEvent}>
|
||||
<UserProfileAvatar
|
||||
forwardRef={ref}
|
||||
enablePostIconOverride={enablePostIconOverride}
|
||||
imageSize={imageSize || undefined}
|
||||
user={user}
|
||||
userIconOverride={userIconOverride}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
<View style={styles.details}>
|
||||
<UserProfileTag
|
||||
isBot={user.isBot || Boolean(userIconOverride || usernameOverride)}
|
||||
isChannelAdmin={isChannelAdmin}
|
||||
isGuest={user.isGuest}
|
||||
isSystemAdmin={isSystemAdmin}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={styles.displayName}
|
||||
testID='user_profile.display_name'
|
||||
>
|
||||
{`${prefix}${displayName}`}
|
||||
</Text>
|
||||
{!hideUsername &&
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={styles.username}
|
||||
testID='user_profile.username'
|
||||
>
|
||||
{`@${user.username}`}
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
54
app/screens/user_profile/user_info.tsx
Normal file
54
app/screens/user_profile/user_info.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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 {getUserCustomStatus} from '@utils/user';
|
||||
|
||||
import UserProfileCustomStatus from './custom_status';
|
||||
import UserProfileLabel from './label';
|
||||
|
||||
import type {UserModel} from '@database/models/server';
|
||||
|
||||
type Props = {
|
||||
localTime?: string;
|
||||
showCustomStatus: boolean;
|
||||
showLocalTime: boolean;
|
||||
showNickname: boolean;
|
||||
showPosition: boolean;
|
||||
user: UserModel;
|
||||
}
|
||||
|
||||
const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const customStatus = getUserCustomStatus(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCustomStatus && <UserProfileCustomStatus customStatus={customStatus!}/> }
|
||||
{showNickname &&
|
||||
<UserProfileLabel
|
||||
description={user.nickname}
|
||||
testID='user_profile.nickname'
|
||||
title={formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'})}
|
||||
/>
|
||||
}
|
||||
{showPosition &&
|
||||
<UserProfileLabel
|
||||
description={user.position}
|
||||
testID='user_profile.position'
|
||||
title={formatMessage({id: 'channel_info.position', defaultMessage: 'Position'})}
|
||||
/>
|
||||
}
|
||||
{showLocalTime &&
|
||||
<UserProfileLabel
|
||||
description={localTime!}
|
||||
testID='user_profile.local_time'
|
||||
title={formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'})}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfo;
|
||||
@@ -4,37 +4,41 @@
|
||||
import moment from 'moment';
|
||||
import mtz from 'moment-timezone';
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {fetchTeamAndChannelMembership} from '@actions/remote/user';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {getLocaleFromLanguage} from '@i18n';
|
||||
import {getLocaleFromLanguage, t} from '@i18n';
|
||||
import BottomSheet from '@screens/bottom_sheet';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user';
|
||||
|
||||
import UserProfileCustomStatus from './custom_status';
|
||||
import UserProfileLabel from './label';
|
||||
import ManageUserOptions, {DIVIDER_MARGIN} from './manage_user_options';
|
||||
import UserProfileOptions, {OptionsType} from './options';
|
||||
import UserProfileTitle from './title';
|
||||
import UserProfileTitle, {HEADER_TEXT_HEIGHT} from './title';
|
||||
import UserInfo from './user_info';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
canChangeMemberRoles: boolean;
|
||||
channelId?: string;
|
||||
closeButtonId: string;
|
||||
currentUserId: string;
|
||||
enablePostIconOverride: boolean;
|
||||
enablePostUsernameOverride: boolean;
|
||||
isChannelAdmin: boolean;
|
||||
canManageAndRemoveMembers?: boolean;
|
||||
isCustomStatusEnabled: boolean;
|
||||
isDirectMessage: boolean;
|
||||
isDefaultChannel: boolean;
|
||||
isMilitaryTime: boolean;
|
||||
isSystemAdmin: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
manageMode?: boolean;
|
||||
location: AvailableScreens;
|
||||
teamId: string;
|
||||
teammateDisplayName: string;
|
||||
@@ -47,13 +51,39 @@ const TITLE_HEIGHT = 118;
|
||||
const OPTIONS_HEIGHT = 82;
|
||||
const SINGLE_OPTION_HEIGHT = 68;
|
||||
const LABEL_HEIGHT = 58;
|
||||
const EXTRA_HEIGHT = 60;
|
||||
const MANAGE_ICON_HEIGHT = 72;
|
||||
|
||||
const messages = defineMessages({
|
||||
manageMember: {
|
||||
id: t('mobile.manage_members.manage_member'),
|
||||
defaultMessage: 'Manage member',
|
||||
},
|
||||
});
|
||||
const channelContextScreens: AvailableScreens[] = [Screens.CHANNEL, Screens.THREAD];
|
||||
|
||||
const UserProfile = ({
|
||||
channelId, closeButtonId, currentUserId, enablePostIconOverride, enablePostUsernameOverride,
|
||||
isChannelAdmin, isCustomStatusEnabled, isDirectMessage, isMilitaryTime, isSystemAdmin, isTeamAdmin,
|
||||
location, teamId, teammateDisplayName,
|
||||
user, userIconOverride, usernameOverride,
|
||||
canChangeMemberRoles,
|
||||
canManageAndRemoveMembers,
|
||||
channelId,
|
||||
closeButtonId,
|
||||
currentUserId,
|
||||
enablePostIconOverride,
|
||||
enablePostUsernameOverride,
|
||||
isChannelAdmin,
|
||||
isCustomStatusEnabled,
|
||||
isDefaultChannel,
|
||||
isDirectMessage,
|
||||
isMilitaryTime,
|
||||
isSystemAdmin,
|
||||
isTeamAdmin,
|
||||
location,
|
||||
manageMode = false,
|
||||
teamId,
|
||||
teammateDisplayName,
|
||||
user,
|
||||
userIconOverride,
|
||||
usernameOverride,
|
||||
}: Props) => {
|
||||
const {formatMessage, locale} = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
@@ -76,37 +106,48 @@ const UserProfile = ({
|
||||
}
|
||||
|
||||
const showCustomStatus = isCustomStatusEnabled && Boolean(customStatus) && !user.isBot && !isCustomStatusExpired(user);
|
||||
const showUserProfileOptions = (!isDirectMessage || !channelContext) && !override;
|
||||
const showNickname = Boolean(user.nickname) && !override && !user.isBot;
|
||||
const showPosition = Boolean(user.position) && !override && !user.isBot;
|
||||
const showLocalTime = Boolean(localTime) && !override && !user.isBot;
|
||||
const showUserProfileOptions = (!isDirectMessage || !channelContext) && !override && !manageMode;
|
||||
const showNickname = Boolean(user.nickname) && !override && !user.isBot && !manageMode;
|
||||
const showPosition = Boolean(user.position) && !override && !user.isBot && !manageMode;
|
||||
const showLocalTime = Boolean(localTime) && !override && !user.isBot && !manageMode;
|
||||
|
||||
const headerText = manageMode ? formatMessage(messages.manageMember) : undefined;
|
||||
|
||||
const snapPoints = useMemo(() => {
|
||||
let title = TITLE_HEIGHT;
|
||||
|
||||
if (headerText) {
|
||||
title += HEADER_TEXT_HEIGHT;
|
||||
}
|
||||
|
||||
if (showUserProfileOptions) {
|
||||
title += showOptions === 'all' ? OPTIONS_HEIGHT : SINGLE_OPTION_HEIGHT;
|
||||
}
|
||||
|
||||
let labels = 0;
|
||||
if (showCustomStatus) {
|
||||
labels += 1;
|
||||
const optionsCount = [
|
||||
showCustomStatus,
|
||||
showNickname,
|
||||
showPosition,
|
||||
showLocalTime,
|
||||
].reduce((acc, v) => {
|
||||
return v ? acc + 1 : acc;
|
||||
}, 0);
|
||||
|
||||
if (manageMode) {
|
||||
title += DIVIDER_MARGIN * 2;
|
||||
if (canChangeMemberRoles) {
|
||||
title += SINGLE_OPTION_HEIGHT; // roles button
|
||||
}
|
||||
if (canManageAndRemoveMembers) {
|
||||
title += SINGLE_OPTION_HEIGHT; // roles button
|
||||
}
|
||||
}
|
||||
|
||||
if (showNickname) {
|
||||
labels += 1;
|
||||
}
|
||||
|
||||
if (showPosition) {
|
||||
labels += 1;
|
||||
}
|
||||
|
||||
if (showLocalTime) {
|
||||
labels += 1;
|
||||
}
|
||||
const extraHeight = manageMode ? 0 : EXTRA_HEIGHT;
|
||||
|
||||
return [
|
||||
1,
|
||||
bottomSheetSnapPoint(labels, LABEL_HEIGHT, bottom) + title,
|
||||
bottomSheetSnapPoint(optionsCount, LABEL_HEIGHT, bottom) + title + extraHeight,
|
||||
];
|
||||
}, [
|
||||
showUserProfileOptions, showCustomStatus, showNickname,
|
||||
@@ -125,6 +166,8 @@ const UserProfile = ({
|
||||
<UserProfileTitle
|
||||
enablePostIconOverride={enablePostIconOverride}
|
||||
enablePostUsernameOverride={enablePostUsernameOverride}
|
||||
headerText={headerText}
|
||||
imageSize={manageMode ? MANAGE_ICON_HEIGHT : undefined}
|
||||
isChannelAdmin={isChannelAdmin}
|
||||
isSystemAdmin={isSystemAdmin}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
@@ -141,27 +184,24 @@ const UserProfile = ({
|
||||
userId={user.id}
|
||||
/>
|
||||
}
|
||||
{showCustomStatus && <UserProfileCustomStatus customStatus={customStatus!}/>}
|
||||
{showNickname &&
|
||||
<UserProfileLabel
|
||||
description={user.nickname}
|
||||
testID='user_profile.nickname'
|
||||
title={formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'})}
|
||||
/>
|
||||
{!manageMode &&
|
||||
<UserInfo
|
||||
localTime={localTime}
|
||||
showCustomStatus={showCustomStatus}
|
||||
showNickname={showNickname}
|
||||
showPosition={showPosition}
|
||||
showLocalTime={showLocalTime}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
{showPosition &&
|
||||
<UserProfileLabel
|
||||
description={user.position}
|
||||
testID='user_profile.position'
|
||||
title={formatMessage({id: 'channel_info.position', defaultMessage: 'Position'})}
|
||||
/>
|
||||
}
|
||||
{showLocalTime &&
|
||||
<UserProfileLabel
|
||||
description={localTime!}
|
||||
testID='user_profile.local_time'
|
||||
title={formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'})}
|
||||
/>
|
||||
{manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) &&
|
||||
<ManageUserOptions
|
||||
canChangeMemberRoles={canChangeMemberRoles}
|
||||
channelId={channelId}
|
||||
isDefaultChannel={isDefaultChannel}
|
||||
isChannelAdmin={isChannelAdmin}
|
||||
userId={user.id}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,3 +30,10 @@ export const showFavoriteChannelSnackbar = (favorited: boolean, onAction: () =>
|
||||
barType: favorited ? SNACK_BAR_TYPE.FAVORITE_CHANNEL : SNACK_BAR_TYPE.UNFAVORITE_CHANNEL,
|
||||
});
|
||||
};
|
||||
|
||||
export const showRemoveChannelUserSnackbar = () => {
|
||||
return showSnackBar({
|
||||
barType: SNACK_BAR_TYPE.REMOVE_CHANNEL_USER,
|
||||
sourceScreen: Screens.MANAGE_CHANNEL_MEMBERS,
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
30
types/api/users.d.ts
vendored
30
types/api/users.d.ts
vendored
@@ -98,3 +98,33 @@ type UserCustomStatus = {
|
||||
};
|
||||
|
||||
type CustomStatusDuration = '' | 'thirty_minutes' | 'one_hour' | 'four_hours' | 'today' | 'this_week' | 'date_and_time';
|
||||
|
||||
type SearchUserOptions = {
|
||||
team_id?: string;
|
||||
not_in_team?: string;
|
||||
in_channel_id?: string;
|
||||
in_group_id?: string;
|
||||
group_constrained?: boolean;
|
||||
allow_inactive?: boolean;
|
||||
without_team?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
type GetUsersOptions = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
in_team?: string;
|
||||
not_in_team?: string;
|
||||
in_channel?: string;
|
||||
not_in_channel?: string;
|
||||
in_group?: string;
|
||||
group_constrained?: boolean;
|
||||
without_team?: boolean;
|
||||
active?: boolean;
|
||||
inactive?: boolean;
|
||||
role?: string;
|
||||
sort?: string;
|
||||
roles?: string;
|
||||
channel_roles?: string;
|
||||
team_roles?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user