diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts index 21ddbd83e..bbae45191 100644 --- a/app/actions/local/user.ts +++ b/app/actions/local/user.ts @@ -97,27 +97,37 @@ export const updateRecentCustomStatuses = async (serverUrl: string, customStatus export const updateLocalUser = async ( serverUrl: string, userDetails: Partial & { 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; }); }); } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 9d3a92003..8bc9ba0b3 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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 { + 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) { diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index a1d6dc9c7..cc6153fc2 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -74,7 +74,7 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise { +export async function fetchProfilesInChannel(serverUrl: string, channelId: string, excludeUserId?: string, options?: GetUsersOptions, fetchOnly = false): Promise { 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}; } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 15499a607..b5adb465a 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -40,6 +40,8 @@ export interface ClientChannelsMix { searchChannels: (teamId: string, term: string) => Promise; searchArchivedChannels: (teamId: string, term: string) => Promise; searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise; + updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise; + getMemberInChannel: (channelId: string, userId: string) => Promise; } 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; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index eada89f63..aac8d355e 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -24,7 +24,7 @@ export interface ClientUsersMix { getProfilesInTeam: (teamId: string, page?: number, perPage?: number, sort?: string, options?: Record) => Promise; getProfilesNotInTeam: (teamId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; getProfilesWithoutTeam: (page?: number, perPage?: number, options?: Record) => Promise; - getProfilesInChannel: (channelId: string, page?: number, perPage?: number, sort?: string) => Promise; + getProfilesInChannel: (channelId: string, options?: GetUsersOptions) => Promise; getProfilesInGroupChannels: (channelsIds: string[]) => Promise<{[x: string]: UserProfile[]}>; getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; getMe: () => Promise; @@ -37,7 +37,7 @@ export interface ClientUsersMix { getSessions: (userId: string) => Promise; checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>; attachDevice: (deviceId: string) => Promise; - searchUsers: (term: string, options: any) => Promise; + searchUsers: (term: string, options: SearchUserOptions) => Promise; getStatusesByIds: (userIds: string[]) => Promise; getStatus: (userId: string) => Promise; updateStatus: (status: UserStatus) => Promise; @@ -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'}, diff --git a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx index e4544209f..009c18c39 100644 --- a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx +++ b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx @@ -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; diff --git a/app/components/channel_actions/manage_members_label/index.tsx b/app/components/channel_actions/manage_members_label/index.tsx new file mode 100644 index 000000000..207c7c370 --- /dev/null +++ b/app/components/channel_actions/manage_members_label/index.tsx @@ -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)); diff --git a/app/components/channel_actions/manage_members_label/manage_members_label.tsx b/app/components/channel_actions/manage_members_label/manage_members_label.tsx new file mode 100644 index 000000000..e7f33b9fc --- /dev/null +++ b/app/components/channel_actions/manage_members_label/manage_members_label.tsx @@ -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 ( + + ); +}; + +export default ManageMembersLabel; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 9863abade..2989962c8 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -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); diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index 22a2b45a7..45fc81c90 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -928,3 +928,506 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` `; + +exports[`components/channel_list_row should show results no tutorial 2 users 1`] = ` + + + + + + + J + + + + + + + + + + + + + + + + + johndoe + + + + + + + + + + + + + + + + R + + + + + + + + + + + + + + + + + rocky + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx index 122c491fd..fd0ae5d99 100644 --- a/app/components/user_list/index.test.tsx +++ b/app/components/user_list/index.test.tsx @@ -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( + { + // noop + }} + fetchMore={() => { + // noop + }} + loading={true} + selectedIds={{}} + showNoResults={true} + tutorialWatched={true} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + it('should show results and tutorial', () => { const wrapper = renderWithEverything( & {section?: SectionListData} + 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 & {section?: SectionListData}) => { + 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 ( ); - }, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]); + }, [selectedIds, handleSelectProfile, showManageMode, manageMode, teammateNameDisplay, tutorialWatched]); const renderLoading = useCallback(() => { if (!loading) { diff --git a/app/components/user_list_row/index.tsx b/app/components/user_list_row/index.tsx index b908e17a0..85bcc2b91 100644 --- a/app/components/user_list_row/index.tsx +++ b/app/components/user_list_row/index.tsx @@ -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({startX: 0, startY: 0, endX: 0, endY: 0}); const viewRef = useRef(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 ( + + + + + ); + }, [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({ } - {icon} + {manageMode ? manageModeIcon : icon} {showTutorial && @@ -267,7 +311,7 @@ function UserListRow({ onLayout={onLayout} > diff --git a/app/constants/events.ts b/app/constants/events.ts index 5d34955ce..27ed32684 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -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, diff --git a/app/constants/index.ts b/app/constants/index.ts index 8483e6d24..d7a407f40 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -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, diff --git a/app/constants/members.ts b/app/constants/members.ts new file mode 100644 index 000000000..87fc416b5 --- /dev/null +++ b/app/constants/members.ts @@ -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, +}; + diff --git a/app/constants/screens.ts b/app/constants/screens.ts index b4d60e4b9..6880459e5 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -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([ EDIT_SERVER, FIND_CHANNELS, GALLERY, + MANAGE_CHANNEL_MEMBERS, INVITE, PERMALINK, ]); diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 79a0a5c30..aedc6d790 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -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 = { 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', diff --git a/app/queries/servers/role.ts b/app/queries/servers/role.ts index 358cd9de6..58417da69 100644 --- a/app/queries/servers/role.ts +++ b/app/queries/servers/role.ts @@ -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); diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx index 895b4cc56..f9069c2c1 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx @@ -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); } }, []); diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx index ef47a4d7e..d2c856ec6 100644 --- a/app/screens/channel_info/options/index.tsx +++ b/app/screens/channel_info/options/index.tsx @@ -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) => { } {/**/} - {/* Add back in after MM-47653 is resolved. https://mattermost.atlassian.net/browse/MM-47653 {type !== General.DM_CHANNEL && } - */} {callsEnabled && !isDMorGM && // if calls is not enabled, copy link will show in the channel actions { 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, }; }); diff --git a/app/screens/channel_info/options/members/members.tsx b/app/screens/channel_info/options/members/members.tsx index 778abbb1b..5af7286ed 100644 --- a/app/screens/channel_info/options/members/members.tsx +++ b/app/screens/channel_info/options/members/members.tsx @@ -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 ( diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 051d446f1..957e32067 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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; diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index 8f8640fea..dda58a93a 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -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())) { diff --git a/app/screens/manage_channel_members/index.tsx b/app/screens/manage_channel_members/index.tsx new file mode 100644 index 000000000..ae1d73906 --- /dev/null +++ b/app/screens/manage_channel_members/index.tsx @@ -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)); diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx new file mode 100644 index 000000000..81e1199e2 --- /dev/null +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -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(null); + const mounted = useRef(false); + + const [isManageMode, setIsManageMode] = useState(false); + const [profiles, setProfiles] = useState(EMPTY); + const [channelMembers, setChannelMembers] = useState(EMPTY_MEMBERS); + const [searchResults, setSearchResults] = useState(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 ( + + + + + + {/* TODO: https://mattermost.atlassian.net/browse/MM-48830 */} + {/* fix flashing No Results page when results are present */} + + + ); +} diff --git a/app/screens/snack_bar/index.tsx b/app/screens/snack_bar/index.tsx index ec218850b..a1e4404e0 100644 --- a/app/screens/snack_bar/index.tsx +++ b/app/screens/snack_bar/index.tsx @@ -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 { diff --git a/app/screens/user_profile/index.ts b/app/screens/user_profile/index.ts index 7ef852886..47dda6e59 100644 --- a/app/screens/user_profile/index.ts +++ b/app/screens/user_profile/index.ts @@ -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, }; }); diff --git a/app/screens/user_profile/manage_user_options.tsx b/app/screens/user_profile/manage_user_options.tsx new file mode 100644 index 000000000..0371f4ccb --- /dev/null +++ b/app/screens/user_profile/manage_user_options.tsx @@ -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 ( + <> + + {canChangeMemberRoles && + + } + + + ); +}; + +export default ManageUserOptions; diff --git a/app/screens/user_profile/title/avatar.tsx b/app/screens/user_profile/title/avatar.tsx index 26c364853..41e7be256 100644 --- a/app/screens/user_profile/title/avatar.tsx +++ b/app/screens/user_profile/title/avatar.tsx @@ -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; + 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 ( @@ -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`} /> diff --git a/app/screens/user_profile/title/index.tsx b/app/screens/user_profile/title/index.tsx index 4253f7bc6..137813e3a 100644 --- a/app/screens/user_profile/title/index.tsx +++ b/app/screens/user_profile/title/index.tsx @@ -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 ( - - - - - - - - - - + <> + {headerText && - {`${prefix}${displayName}`} + {headerText} - {!hideUsername && - - {`@${user.username}`} - - } + } + + + + + + + + + + + + {`${prefix}${displayName}`} + + {!hideUsername && + + {`@${user.username}`} + + } + - + ); }; diff --git a/app/screens/user_profile/user_info.tsx b/app/screens/user_profile/user_info.tsx new file mode 100644 index 000000000..7905d7f7d --- /dev/null +++ b/app/screens/user_profile/user_info.tsx @@ -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 && } + {showNickname && + + } + {showPosition && + + } + {showLocalTime && + + } + + ); +}; + +export default UserInfo; diff --git a/app/screens/user_profile/user_profile.tsx b/app/screens/user_profile/user_profile.tsx index dcaf576fa..20187bbbe 100644 --- a/app/screens/user_profile/user_profile.tsx +++ b/app/screens/user_profile/user_profile.tsx @@ -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 = ({ } - {showCustomStatus && } - {showNickname && - + {!manageMode && + } - {showPosition && - - } - {showLocalTime && - + {manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) && + } ); diff --git a/app/utils/snack_bar/index.ts b/app/utils/snack_bar/index.ts index 62f8afdbe..1f1c08b29 100644 --- a/app/utils/snack_bar/index.ts +++ b/app/utils/snack_bar/index.ts @@ -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, + }); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 6e0a16d08..efe846037 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -5,7 +5,7 @@ "about.enterpriseEditionSt": "Modern communication from behind your firewall.", "about.hash": "Build Hash:", "about.hashee": "EE Build Hash:", - "about.teamEditionLearn": "Join the Mattermost community at ", + "about.teamEditionLearn": "Join the Mattermost community at", "about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.", "about.teamEditiont0": "Team Edition", "about.teamEditiont1": "Enterprise Edition", @@ -529,6 +529,20 @@ "mobile.login_options.select_option": "Select a login option below.", "mobile.login_options.separator_text": "or log in with", "mobile.login_options.sso_continue": "Continue with", + "mobile.manage_members.admin": "Admin", + "mobile.manage_members.cancel": "Cancel", + "mobile.manage_members.change_role.error": "An error occurred while trying to update the role. Please check your connection and try again.", + "mobile.manage_members.done": "Done", + "mobile.manage_members.make_channel_admin": "Make Channel Admin", + "mobile.manage_members.make_channel_member": "Make Channel Member", + "mobile.manage_members.manage": "Manage", + "mobile.manage_members.manage_member": "Manage member", + "mobile.manage_members.member": "Member", + "mobile.manage_members.message": "Are you sure you want to remove the selected member from the channel?", + "mobile.manage_members.remove": "Remove", + "mobile.manage_members.remove_member": "Remove from Channel", + "mobile.manage_members.section_title_admins": "CHANNEL ADMINS", + "mobile.manage_members.section_title_members": "MEMBERS", "mobile.managed.blocked_by": "Blocked by {vendor}", "mobile.managed.exit": "Exit", "mobile.managed.jailbreak": "Jailbroken devices are not trusted by {vendor}.\n\nReason {reason}\n\n\n\nDebug info: {debug}\n\nPlease exit the app.", @@ -891,6 +905,7 @@ "snack.bar.link.copied": "Link copied to clipboard", "snack.bar.message.copied": "Text copied to clipboard", "snack.bar.mute.channel": "This channel was muted", + "snack.bar.remove.user": "1 member was removed from the channel", "snack.bar.undo": "Undo", "snack.bar.unfavorite.channel": "This channel was unfavorited", "snack.bar.unmute.channel": "This channel was unmuted", diff --git a/types/api/users.d.ts b/types/api/users.d.ts index cfa759c67..3485b5050 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -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; +};