Compare commits

...

16 commits

Author SHA1 Message Date
unified-ci-app[bot]
99c12e9938
Bump app build and version number (#9439)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / generate-consolidated-sbom (push) Has been cancelled
github-release / release (push) Has been cancelled
* Bump app build number to 707

* Bump app version number to 2.36.1

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-20 17:30:33 +02:00
Mattermost Build
021ed7e583
Update build-ios-release.yml (#9437) (#9438)
disable intune in the release build

(cherry picked from commit 944a542110)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-20 15:51:21 +02:00
Mattermost Build
f11dc2c5cb
fix(MM-67224): MM Mobile v2.36.0 crash on iOS 16 and below (#9421) (#9431)
* fix(MM-67224): iOS app hanging for at least 2000 ms.

* downgrade intune iOS SDK to 20.9.0

* update to version 20.9.2 (latest)

* reverse changes to .env files

* set INTUNE_IOS_SDK_VERSION to 20.9.2

* revert change

* Intune update

* remove echo

(cherry picked from commit 4324846736)

Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-01-20 07:54:14 +02:00
Mattermost Build
e48a4c0e09
[MM-67056] Show actions on image gallery for android (#9386) (#9423)
* show actions on image gallery for android

* use a constant, update dependencies

* use original window metrics if safe insets fail

* restore deps

(cherry picked from commit f024e00ca6)

Co-authored-by: Guillermo Vayá <guillermo.vaya@mattermost.com>
2026-01-19 11:13:09 +02:00
unified-ci-app[bot]
d066425e6d
Bump app build number to 703 (#9404)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / generate-consolidated-sbom (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-08 13:54:52 +02:00
Mattermost Build
105b5e0568
Fix android logout (#9398) (#9403)
(cherry picked from commit 1a563a7917)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-08 13:31:26 +02:00
unified-ci-app[bot]
87b6c31689
Bump app build number to 702 (#9400)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-07 13:27:06 +02:00
Mattermost Build
bb6cfab2ef
use correct clientId for Intune in the beta builds (#9395) (#9397)
(cherry picked from commit a57d7aa926)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-07 13:01:18 +02:00
Mattermost Build
e844023bcf
Fix login with Intune using the Authenticator app (#9349) (#9396)
(cherry picked from commit 23fec945a4)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-07 12:53:30 +02:00
unified-ci-app[bot]
55d457785a
Bump app build number to 700 (#9392)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-07 08:40:18 +02:00
Mattermost Build
87e9774bb5
[MM-66923] Fix scroll and private channel icon on invite user (#9363) (#9391)
* [MM-66923] Fix scroll and private channel icon on invite user

* Address feedback

* Fix test

(cherry picked from commit 2111c8fb2c)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-01-07 08:26:30 +02:00
unified-ci-app[bot]
34c6df6203
Bump app build number to 696 (#9373)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2025-12-19 09:15:03 +02:00
Mattermost Build
23f3d11524
[MM-66711] Verify push notifications on magic link login (#9322) (#9369)
* [MM-66711] Verify push notifications on magic link login

* Address copilot suggestion

* Add missing await

---------


(cherry picked from commit 4934920e10)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2025-12-18 15:17:31 +02:00
Mattermost Build
d7aa424f37
Bor handle burned ws event (#9346) (#9358)
* handled the websocket event when a user burns a BoR post as receiver

* test: add comprehensive tests for handleBoRPostBurnedEvent function

* Added tests

* reset unintended changes

* Minor fix

(cherry picked from commit 49efb4f470)

Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
2025-12-17 08:36:28 +02:00
unified-ci-app[bot]
970e9fe3c7
Bump app build and version number (#9351)
* Bump app build number to 695

* Bump app version number to 2.36.0

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2025-12-12 13:33:22 +02:00
Mattermost Build
5cd2363b3b
Add guest invites (#9288) (#9347)
* Add guest invites

* Fix tests and address copilot feedback

* Fix test

* Add magic link changes

(cherry picked from commit d76791ebe6)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2025-12-11 18:32:41 +02:00
52 changed files with 3022 additions and 270 deletions

View file

@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 1
INTUNE_ENABLED: 0
jobs:
test:

View file

@ -115,8 +115,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 689
versionName "2.35.0"
versionCode 707
versionName "2.36.1"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -5,6 +5,7 @@ import {defineMessages, type IntlShape} from 'react-intl';
import {Alert, DeviceEventEmitter, type AlertButton} from 'react-native';
import {cancelSessionNotification, findSession} from '@actions/local/session';
import {doPing} from '@actions/remote/general';
import {Database, Events} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
@ -18,8 +19,10 @@ import {getCurrentUser} from '@queries/servers/user';
import {resetToHome} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage, isErrorWithStatusCode, isErrorWithUrl} from '@utils/errors';
import {getIntlShape} from '@utils/general';
import {logWarning, logError, logDebug} from '@utils/log';
import {scheduleExpiredNotification} from '@utils/notification';
import {canReceiveNotifications} from '@utils/push_proxy';
import {type SAMLChallenge} from '@utils/saml_challenge';
import {getCSRFFromCookie} from '@utils/security';
import {getServerUrlAfterRedirect} from '@utils/url';
@ -468,6 +471,19 @@ export const magicLinkLogin = async (serverUrl: string, token: string): Promise<
});
const csrfToken = await getCSRFFromCookie(serverUrlToUse);
client.setCSRFToken(csrfToken);
// Check push notification capability (similar to normal login flow)
const pingResult = await doPing(
serverUrlToUse,
true, // verifyPushProxy
undefined, // timeoutInterval
undefined, // preauthSecret
client, // client
);
if (!pingResult.error && pingResult.canReceiveNotifications) {
const intl = getIntlShape(user.locale);
await canReceiveNotifications(serverUrlToUse, pingResult.canReceiveNotifications as string, intl);
}
} catch (error) {
return {error, failed: true};
}

View file

@ -14,6 +14,7 @@ import {
addUserToTeam,
addUsersToTeam,
sendEmailInvitesToTeam,
sendGuestEmailInvitesToTeam,
fetchMyTeams,
fetchMyTeam,
fetchTeamById,
@ -83,6 +84,7 @@ const mockClient = {
addToTeam: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})),
addUsersToTeamGracefully: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({member: {id: userId + '-' + id, user_id: userId, team_id: id, roles: ''}, error: undefined, user_id: userId})))),
sendEmailInvitesToTeamGracefully: jest.fn((id: string, emails: string[]) => (emails.map((email) => ({email, error: undefined})))),
sendGuestEmailInvitesToTeamGracefully: jest.fn(() => Promise.resolve<TeamInviteWithError[]>([{email: 'guest1@example.com', error: {message: '', status_code: 0}}])),
getRolesByNames: jest.fn((roles: string[]) => roles.map((r) => ({id: r, name: r} as Role))),
getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])),
@ -223,6 +225,88 @@ describe('teamMember', () => {
});
});
describe('sendGuestEmailInvitesToTeam', () => {
const emails = ['guest1@example.com', 'guest2@example.com'];
const channels = ['channel-id-1', 'channel-id-2'];
const message = 'Welcome to the team!';
const throwFunc = () => {
throw Error('error');
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should send guest email invites successfully with all parameters', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
{email: 'guest2@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
it('should send guest email invites successfully without message parameter', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, ['guest1@example.com'], channels);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, ['guest1@example.com'], channels, '', false);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
it('should handle client error', async () => {
const clientError = new Error('Client error');
mockClient.sendGuestEmailInvitesToTeamGracefully.mockRejectedValueOnce(clientError);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.members).toEqual([]);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
});
it('should handle network manager error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.members).toEqual([]);
});
it('should send guest email invites with guest magic link', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message, true);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, true);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
});
describe('teams', () => {
it('fetchMyTeams - handle not found database', async () => {
const result = await fetchMyTeams('foo') as {error: unknown};

View file

@ -109,7 +109,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
}
}
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) {
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false): Promise<{members: TeamMemberWithError[]; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@ -142,20 +142,33 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds:
}
forceLogoutIfNecessary(serverUrl, error);
return {error};
return {members: [], error};
}
}
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) {
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]): Promise<{members: TeamInviteWithError[]; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails);
return {members};
return {members, error: undefined};
} catch (error) {
logDebug('error on sendEmailInvitesToTeam', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
return {members: [], error};
}
}
export async function sendGuestEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false): Promise<{members: TeamInviteWithError[]; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const members = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink);
return {members, error: undefined};
} catch (error) {
logDebug('error on sendGuestEmailInvitesToTeam', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {members: [], error};
}
}
@ -483,7 +496,7 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
}
}
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) {
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean): Promise<{members: TeamMembership[]; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@ -505,7 +518,7 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use
} catch (error) {
logDebug('error on getTeamMembersByIds', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
return {members: [], error};
}
}

View file

@ -1,15 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {removePost} from '@actions/local/post';
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {getPostById} from '@queries/servers/post';
import TestHelper from '@test/test_helper';
import {handleBoRPostRevealedEvent} from './burn_on_read';
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent} from './burn_on_read';
jest.mock('@actions/websocket/posts');
jest.mock('@queries/servers/post');
jest.mock('@actions/local/post');
const serverUrl = 'burnOnRead.test.com';
@ -19,6 +22,7 @@ describe('WebSocket Burn on Read Actions', () => {
const mockedGetPostById = jest.mocked(getPostById);
const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent);
const mockedHandlePostEdited = jest.mocked(handlePostEdited);
const mockedRemovePost = jest.mocked(removePost);
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
@ -99,10 +103,87 @@ describe('WebSocket Burn on Read Actions', () => {
const result = await handleBoRPostRevealedEvent(serverUrl, invalidJsonMsg);
expect(result).toBeNull();
expect(result).toEqual({});
expect(mockedGetPostById).not.toHaveBeenCalled();
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
});
});
describe('handleBoRPostBurnedEvent', () => {
const burnOnReadPost = TestHelper.fakePostModel({
id: 'post1',
type: PostTypes.BURN_ON_READ,
});
const regularPost = TestHelper.fakePostModel({
id: 'post2',
type: '',
});
const msg = {
data: {
post_id: 'post1',
},
} as WebSocketMessage;
it('should remove burn-on-read post when it exists locally', async () => {
mockedGetPostById.mockResolvedValue(burnOnReadPost);
const result = await handleBoRPostBurnedEvent(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedRemovePost).toHaveBeenCalledWith(serverUrl, burnOnReadPost);
expect(result).toEqual({});
});
it('should not remove post when post does not exist locally', async () => {
mockedGetPostById.mockResolvedValue(undefined);
const result = await handleBoRPostBurnedEvent(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedRemovePost).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should not remove post when post is not burn-on-read type', async () => {
mockedGetPostById.mockResolvedValue(regularPost);
const result = await handleBoRPostBurnedEvent(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedRemovePost).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle missing server database gracefully', async () => {
const result = await handleBoRPostBurnedEvent('invalid-server-url', msg);
expect(mockedGetPostById).not.toHaveBeenCalled();
expect(mockedRemovePost).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle missing operator gracefully', async () => {
// Mock a server database without an operator
DatabaseManager.serverDatabases[serverUrl] = {} as any;
const result = await handleBoRPostBurnedEvent(serverUrl, msg);
expect(mockedGetPostById).not.toHaveBeenCalled();
expect(mockedRemovePost).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle errors gracefully and return error object', async () => {
mockedGetPostById.mockRejectedValue(new Error('Database error'));
const result = await handleBoRPostBurnedEvent(serverUrl, msg);
expect(result).toHaveProperty('error');
expect(result!.error).toBeInstanceOf(Error);
expect(mockedRemovePost).not.toHaveBeenCalled();
});
});
});

View file

@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {removePost} from '@actions/local/post';
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {getPostById} from '@queries/servers/post';
import {logError} from '@utils/log';
@ -10,7 +12,7 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock
try {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return null;
return {};
}
const {database} = operator;
@ -18,7 +20,7 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock
try {
post = JSON.parse(msg.data.post);
} catch {
return null;
return {};
}
const existingPost = await getPostById(database, post.id);
@ -34,3 +36,29 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock
return {error};
}
}
export async function handleBoRPostBurnedEvent(serverUrl: string, msg: WebSocketMessage) {
try {
const postId = msg.data.post_id;
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return null;
}
const {database} = operator;
const post = await getPostById(database, postId);
if (!post) {
return null;
}
if (post.type !== PostTypes.BURN_ON_READ) {
return null;
}
await removePost(serverUrl, post);
return {};
} catch (error) {
logError('handleBoRPostBurnedEvent could not handle websocket event for burned burn-on-read post', error);
return {error};
}
}

View file

@ -4,7 +4,7 @@
import {handleAgentPostUpdate} from '@agents/actions/websocket';
import * as bookmark from '@actions/local/channel_bookmark';
import {handleBoRPostRevealedEvent} from '@actions/websocket/burn_on_read';
import {handleBoRPostBurnedEvent, handleBoRPostRevealedEvent} from '@actions/websocket/burn_on_read';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
import {WebsocketEvents} from '@constants';
@ -314,6 +314,9 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.BOR_POST_REVEALED:
handleBoRPostRevealedEvent(serverUrl, msg);
break;
case WebsocketEvents.BOR_POST_BURNED:
handleBoRPostBurnedEvent(serverUrl, msg);
break;
}
handlePlaybookEvents(serverUrl, msg);
}

View file

@ -194,6 +194,78 @@ describe('ClientTeams', () => {
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
describe('sendGuestEmailInvitesToTeamGracefully', () => {
it('should send guest email invites with all params', async () => {
const teamId = 'team1';
const emails = ['guest1@example.com', 'guest2@example.com'];
const channels = ['channel1', 'channel2'];
const message = 'Welcome to the team!';
const params = {graceful: true};
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`;
const expectedOptions = {method: 'post', body: {message, emails, channels}};
const mockResponse: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
{email: 'guest2@example.com', error: {message: '', status_code: 0}},
];
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
it('should send guest email invites with default message', async () => {
const teamId = 'team1';
const emails = ['guest1@example.com'];
const channels = ['channel1'];
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true})}`;
const expectedOptions = {method: 'post', body: {message: '', emails, channels}};
const mockResponse: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
it('should handle error when sending guest email invites', async () => {
const teamId = 'team1';
const emails = ['guest1@example.com'];
const channels = ['channel1'];
const message = 'Test message';
jest.mocked(client.doFetch).mockRejectedValueOnce(new Error('Network error'));
await expect(client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message)).rejects.toThrow('Network error');
});
it('should send guest email invites with guest magic link', async () => {
const teamId = 'team1';
const emails = ['guest1@example.com'];
const channels = ['channel1'];
const message = 'Test message';
const guestMagicLink = true;
const expectedUrl = `${client.getTeamRoute(teamId)}/invite-guests/email${buildQueryString({graceful: true, guest_magic_link: true})}`;
const expectedOptions = {method: 'post', body: {message, emails, channels}};
const mockResponse: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
jest.mocked(client.doFetch).mockResolvedValueOnce(mockResponse as any);
const result = await client.sendGuestEmailInvitesToTeamGracefully(teamId, emails, channels, message, guestMagicLink);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
});
test('joinTeam', async () => {
const inviteId = 'invite1';
const query = buildQueryString({invite_id: inviteId});

View file

@ -6,6 +6,7 @@ import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
import type ClientBase from './base';
import type {FirstArgument} from '@typings/utils/utils';
export interface ClientTeamsMix {
createTeam: (team: Team) => Promise<Team>;
@ -24,6 +25,7 @@ export interface ClientTeamsMix {
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise<TeamMemberWithError[]>;
sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise<TeamInviteWithError[]>;
sendGuestEmailInvitesToTeamGracefully: (teamId: string, emails: string[], channels: string[], message?: string, magicLink?: boolean) => Promise<TeamInviteWithError[]>;
joinTeam: (inviteId: string) => Promise<TeamMembership>;
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
getTeamStats: (teamId: string) => Promise<any>;
@ -147,6 +149,19 @@ const ClientTeams = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
);
};
sendGuestEmailInvitesToTeamGracefully = (teamId: string, emails: string[], channels: string[], message = '', guestMagicLink = false) => {
const params: FirstArgument<typeof buildQueryString> = {
graceful: true,
};
if (guestMagicLink) {
params.guest_magic_link = true;
}
return this.doFetch(
`${this.getTeamRoute(teamId)}/invite-guests/email${buildQueryString(params)}`,
{method: 'post', body: {message, emails, channels}},
);
};
joinTeam = async (inviteId: string) => {
const query = buildQueryString({invite_id: inviteId});
return this.doFetch(
@ -170,7 +185,7 @@ const ClientTeams = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
};
getTeamIconUrl = (teamId: string, lastTeamIconUpdate: number) => {
const params: any = {};
const params: FirstArgument<typeof buildQueryString> = {};
if (lastTeamIconUpdate) {
params._ = lastTeamIconUpdate;
}

View file

@ -117,6 +117,7 @@ export type OptionItemProps = {
icon?: string;
iconColor?: string;
info?: string | UserChipData;
isInfoDestructive?: boolean;
inline?: boolean;
label: string;
onRemove?: () => void;
@ -138,6 +139,7 @@ const OptionItem = ({
icon,
iconColor,
info,
isInfoDestructive = false,
inline = false,
label,
onRemove,
@ -274,7 +276,7 @@ const OptionItem = ({
} else if (info) {
infoComponent = (
<Text
style={[styles.info, !actionComponent && styles.iconContainer, destructive && {color: theme.dndIndicator}]}
style={[styles.info, !actionComponent && styles.iconContainer, (destructive || isInfoDestructive) && {color: theme.dndIndicator}]}
testID={`${testID}.info`}
numberOfLines={1}
>

View file

@ -375,4 +375,16 @@ describe('OptionItem', () => {
});
expect(props.action).not.toHaveBeenCalled();
});
it('should show destructive info styling when isInfoDestructive is true', () => {
const props = getBaseProps();
props.info = 'Test info';
props.isInfoDestructive = true;
props.destructive = false;
const {getByText} = renderWithIntlAndTheme(<OptionItem {...props}/>);
const info = getByText('Test info');
expect(info).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
});
});

View file

@ -5,6 +5,13 @@ export const GALLERY_FOOTER_HEIGHT = 75;
export const VIDEO_INSET = 100;
export const ANDROID_VIDEO_INSET = 20;
// Fallback for Android navigation bar height when SafeAreaContext returns 0 in overlays
// Typical Android 3-button navigation bar height is 48-63dp
export const ANDROID_NAV_BAR_HEIGHT = 63;
// Additional padding for Android gallery footer to ensure visibility above navigation bar
export const ANDROID_GALLERY_FOOTER_PADDING = 10;
export const DOUBLE_TAP_SCALE = 4;
export const MAX_SCALE = 7;
export const MIN_SCALE = 0.7;

View file

@ -111,6 +111,7 @@ const WebsocketEvents = {
// Burn on Read
BOR_POST_REVEALED: 'post_revealed',
BOR_POST_BURNED: 'post_burned',
};
export default WebsocketEvents;

View file

@ -37,7 +37,7 @@ export const translateYConfig: WithTimingConfig = {
easing: Easing.bezier(0.33, 0.01, 0, 1),
};
export function useGalleryControls() {
export function useGalleryControls(bottomInset = 0) {
const headerAndFooterHidden = useSharedValue(false);
const headerStyles = useAnimatedStyle(() => ({
@ -61,10 +61,10 @@ export function useGalleryControls() {
},
],
position: 'absolute',
bottom: 0,
bottom: bottomInset,
width: '100%',
zIndex: 1,
}));
}), [bottomInset]);
const hideHeaderAndFooter = useCallback((hidden?: boolean) => {
'worklet';
@ -80,7 +80,7 @@ export function useGalleryControls() {
}
headerAndFooterHidden.value = hidden;
}, []);
}, [headerAndFooterHidden]);
return {
headerAndFooterHidden,

View file

@ -92,7 +92,6 @@ export class IntuneManagerSingleton {
*/
async enrollServer(serverUrl: string, identity: MSALIdentity): Promise<void> {
if (!Intune) {
logWarning('IntuneManager: Cannot enroll in MAM - Intune library not available');
return;
}
@ -112,7 +111,6 @@ export class IntuneManagerSingleton {
*/
async unenrollServer(serverUrl: string, doWipe: boolean): Promise<void> {
if (!Intune) {
logWarning('IntuneManager: Cannot unenroll - Intune library not available');
return;
}

View file

@ -112,11 +112,11 @@ export class SessionManagerSingleton {
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName();
await terminateSession(serverUrl, removeServer);
SecurityManager.removeServer(serverUrl);
// We do not unenroll with Wipe as we already removed all the data during terminateSession
await IntuneManager.unenrollServer(serverUrl, false);
await terminateSession(serverUrl, removeServer);
SecurityManager.removeServer(serverUrl);
if (activeServerUrl === serverUrl) {
let displayName = '';

View file

@ -110,7 +110,12 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
}
// Then try ping request - use doPing without client to avoid client pollution
const result = await doPing(headRequest.url, true, undefined, secretForValidation);
const result = await doPing(
headRequest.url, // serverUrl
true, // verifyPushProxy
undefined, // timeoutInterval
secretForValidation, // preauthSecret
);
if (result.error) {
if (result.isPreauthError) {
setPreauthSecretError(formatMessage({

View file

@ -4,7 +4,6 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {Events} from '@constants';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
@ -48,7 +47,6 @@ const styles = StyleSheet.create({
backgroundColor: '#000',
borderTopColor: changeOpacity('#fff', 0.4),
borderTopWidth: 1,
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
height: GALLERY_FOOTER_HEIGHT,
@ -65,9 +63,6 @@ const Footer = ({
const serverUrl = useServerUrl();
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
const [action, setAction] = useState<GalleryAction>('none');
const {bottom} = useSafeAreaInsets();
const bottomStyle = useMemo(() => ({height: bottom, backgroundColor: '#000'}), [bottom]);
let overrideIconUrl;
if (enablePostIconOverride && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) {
@ -157,7 +152,6 @@ const Footer = ({
/>
}
</View>
<View style={bottomStyle}/>
</Animated.View>
);
};

View file

@ -4,8 +4,10 @@
import RNUtils from '@mattermost/rnutils';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, View} from 'react-native';
import {initialWindowMetrics, useSafeAreaInsets} from 'react-native-safe-area-context';
import {Events} from '@constants';
import {ANDROID_GALLERY_FOOTER_PADDING, ANDROID_NAV_BAR_HEIGHT} from '@constants/gallery';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet, useWindowDimensions} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
@ -31,8 +33,13 @@ type Props = {
const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialIndex, items}: Props) => {
const dim = useWindowDimensions();
const isTablet = useIsTablet();
const {bottom: bottomInset} = useSafeAreaInsets();
const [localIndex, setLocalIndex] = useState(initialIndex);
const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls();
// Fallback for Android when SafeAreaContext returns 0 in overlays
const androidBottom = (initialWindowMetrics?.insets.bottom || ANDROID_NAV_BAR_HEIGHT) + ANDROID_GALLERY_FOOTER_PADDING;
const bottom = bottomInset || Platform.select({android: androidBottom, default: 0});
const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls(bottom);
const galleryRef = useRef<GalleryRef>(null);
const containerStyle = dim;

View file

@ -26,7 +26,7 @@ const getIconForChannel = (selectedChannel: Channel): string => {
let icon = 'globe';
if (selectedChannel.type === 'P') {
icon = 'padlock';
icon = 'lock-outline';
}
if (selectedChannel.delete_at) {

View file

@ -1,10 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useRef} from 'react';
import {
Platform, FlatList, RefreshControl, View,
Platform, RefreshControl, View,
} from 'react-native';
import {KeyboardAwareFlatList} from 'react-native-keyboard-aware-scroll-view';
import {useAvoidKeyboard} from '@hooks/device';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
@ -52,9 +54,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
},
}),
},
container: {
flexGrow: 1,
},
separator: {
height: 1,
flex: 1,
@ -109,6 +108,8 @@ function CustomList({
canRefresh = true, testID, refreshing = false, onRefresh,
}: Props) {
const style = getStyleFromTheme(theme);
const keyboardAwareFlatListRef = useRef<KeyboardAwareFlatList>(null);
useAvoidKeyboard(keyboardAwareFlatListRef);
// Renders
const renderEmptyList = useCallback(() => {
@ -159,8 +160,8 @@ function CustomList({
}
return (
<FlatList
contentContainerStyle={style.container}
<KeyboardAwareFlatList
ref={keyboardAwareFlatListRef}
data={data}
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}

View file

@ -0,0 +1,900 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {createIntl} from 'react-intl';
import {addMembersToChannel} from '@actions/remote/channel';
import {addUsersToTeam, getTeamMembersByIds, sendEmailInvitesToTeam, sendGuestEmailInvitesToTeam} from '@actions/remote/team';
import {ServerErrors} from '@constants';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import TestHelper from '@test/test_helper';
import {sendGuestInvites, sendMembersInvites} from './actions';
jest.mock('@actions/remote/channel');
jest.mock('@actions/remote/team');
const mockAddMembersToChannel = jest.mocked(addMembersToChannel);
const mockAddUsersToTeam = jest.mocked(addUsersToTeam);
const mockGetTeamMembersByIds = jest.mocked(getTeamMembersByIds);
const mockSendEmailInvitesToTeam = jest.mocked(sendEmailInvitesToTeam);
const mockSendGuestEmailInvitesToTeam = jest.mocked(sendGuestEmailInvitesToTeam);
const translations = getTranslations(DEFAULT_LOCALE);
const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations});
describe('actions', () => {
const serverUrl = 'https://test.server.com';
const teamId = 'team-1';
const teamDisplayName = 'Test Team';
const formatMessage = intl.formatMessage;
describe('sendMembersInvites', () => {
it('should successfully invite users to team', async () => {
const userId1 = 'user-1';
const userId2 = 'user-2';
const user1 = TestHelper.fakeUser({id: userId1, roles: 'system_user'});
const user2 = TestHelper.fakeUser({id: userId2, roles: 'system_user'});
const selectedIds = {
[userId1]: user1,
[userId2]: user2,
};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: undefined,
});
mockAddUsersToTeam.mockResolvedValue({
members: [
{member: TestHelper.fakeTeamMember(userId1, 'team-1'), user_id: userId1, error: undefined},
{member: TestHelper.fakeTeamMember(userId2, 'team-1'), user_id: userId2, error: undefined},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(2);
expect(result.notSent).toHaveLength(0);
expect(result.sent[0].userId).toBe(userId1);
expect(result.sent[1].userId).toBe(userId2);
expect(mockGetTeamMembersByIds).toHaveBeenCalledWith(serverUrl, teamId, [userId1, userId2]);
expect(mockAddUsersToTeam).toHaveBeenCalledWith(serverUrl, teamId, [userId1, userId2]);
});
it('should successfully invite users via email', async () => {
const email1 = 'user1@example.com';
const email2 = 'user2@example.com';
const selectedIds = {
[email1]: email1,
[email2]: email2,
};
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [
{email: email1, error: undefined},
{email: email2, error: undefined},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(2);
expect(result.notSent).toHaveLength(0);
expect(result.sent[0].userId).toBe(email1);
expect(result.sent[1].userId).toBe(email2);
expect(mockSendEmailInvitesToTeam).toHaveBeenCalledWith(serverUrl, teamId, [email1, email2]);
});
it('should handle mixed users and emails', async () => {
const userId = 'user-1';
const email = 'user@example.com';
const user = TestHelper.fakeUser({id: userId, roles: 'system_user'});
const selectedIds = {
[userId]: user,
[email]: email,
};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: undefined,
});
mockAddUsersToTeam.mockResolvedValue({
members: [{member: {...TestHelper.fakeTeamMember(userId, 'team-1')}, user_id: userId, error: undefined}],
error: undefined,
});
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(2);
expect(result.notSent).toHaveLength(0);
});
it('should not invite guest users', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Contact your admin to make this guest a full member');
expect(mockAddUsersToTeam).not.toHaveBeenCalled();
});
it('should not invite users who are already team members', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_user'} as UserProfile;
const selectedIds = {[userId]: user};
mockGetTeamMembersByIds.mockResolvedValue({
members: [TestHelper.fakeTeamMember(userId, 'team-1')],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(mockAddUsersToTeam).not.toHaveBeenCalled();
});
it('should handle errors from getTeamMembersByIds', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_user'} as UserProfile;
const selectedIds = {[userId]: user};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: {message: 'Database error'} as any,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(true);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(0);
});
it('should handle errors from addUsersToTeam', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_user'} as UserProfile;
const selectedIds = {[userId]: user};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: undefined,
});
mockAddUsersToTeam.mockResolvedValue({
members: [],
error: {message: 'Failed to add users'} as any,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(true);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(0);
});
it('should handle individual user errors from addUsersToTeam', async () => {
const userId1 = 'user-1';
const userId2 = 'user-2';
const user1 = {id: userId1, roles: 'system_user'} as UserProfile;
const user2 = {id: userId2, roles: 'system_user'} as UserProfile;
const selectedIds = {
[userId1]: user1,
[userId2]: user2,
};
mockGetTeamMembersByIds.mockResolvedValue({
members: [],
error: undefined,
});
mockAddUsersToTeam.mockResolvedValue({
members: [
{member: TestHelper.fakeTeamMember(userId1, 'team-1'), user_id: userId1, error: {message: 'User not found'} as any},
{member: TestHelper.fakeTeamMember(userId2, 'team-1'), user_id: userId2, error: undefined},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId1);
expect(result.notSent[0].reason).toBe('User not found');
expect(result.sent[0].userId).toBe(userId2);
});
it('should handle errors from sendEmailInvitesToTeam', async () => {
const email = 'user@example.com';
const selectedIds = {[email]: email};
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [],
error: {message: 'SMTP error'} as any,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(true);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(0);
});
it('should handle individual email errors', async () => {
const email1 = 'user1@example.com';
const email2 = 'user2@example.com';
const selectedIds = {
[email1]: email1,
[email2]: email2,
};
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [
{email: email1, error: {message: 'Invalid email'} as any},
{email: email2, error: undefined},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false,
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(email1);
expect(result.notSent[0].reason).toBe('Invalid email');
expect(result.sent[0].userId).toBe(email2);
});
it('should show SMTP configuration error for admin when email fails with SEND_EMAIL_WITH_DEFAULTS_ERROR', async () => {
const email = 'user@example.com';
const selectedIds = {[email]: email};
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [
{
email,
error: {
message: 'SMTP error',
server_error_id: ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR,
} as any,
},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
true, // isAdmin
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].reason).toContain('SMTP is not configured');
expect(result.notSent[0].reason).toBe('SMTP is not configured in System Console');
});
it('should show regular error message for non-admin when email fails with SEND_EMAIL_WITH_DEFAULTS_ERROR', async () => {
const email = 'user@example.com';
const selectedIds = {[email]: email};
mockSendEmailInvitesToTeam.mockResolvedValue({
members: [
{
email,
error: {
message: 'SMTP error',
server_error_id: ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR,
} as any,
},
],
error: undefined,
});
const result = await sendMembersInvites(
serverUrl,
teamId,
selectedIds,
false, // not admin
teamDisplayName,
formatMessage,
);
expect(result.error).toBe(false);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].reason).toBe('SMTP error');
});
});
describe('sendGuestInvites', () => {
const options = {
inviteAsGuest: true,
includeCustomMessage: false,
customMessage: '',
selectedChannels: ['channel-1', 'channel-2'],
guestMagicLink: false,
};
it('should successfully invite guest users', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
mockAddMembersToChannel.mockResolvedValue({
channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(0);
expect(result.sent[0].userId).toBe(userId);
});
it('should successfully invite guests via email', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(0);
expect(result.sent[0].email).toBe(email);
});
it('should handle mixed users and emails for guest invites', async () => {
const userId = 'user-1';
const email = 'guest@example.com';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {
[userId]: user,
[email]: email,
};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
mockAddMembersToChannel.mockResolvedValue({
channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})],
error: undefined,
});
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(2);
expect(result.notSent).toHaveLength(0);
});
it('should not invite non-guest users as guests', async () => {
const userId = 'user-1';
const user = TestHelper.fakeUser({id: userId, roles: 'system_user'});
const selectedIds = {[userId]: user};
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('This person is already a member of the workspace. Invite them as a member instead of a guest.');
});
it('should handle errors from addUsersToTeam for guest invites', async () => {
const userId = 'user-1';
const user = TestHelper.fakeUser({id: userId, roles: 'system_guest'});
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [],
error: {message: 'Failed to add user'} as any,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Failed to add user');
});
it('should handle errors when adding guest to channels', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
mockAddMembersToChannel.mockResolvedValue({
channelMemberships: [],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Unable to add user to 2 channels');
});
it('should handle errors from addMembersToChannel', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
mockAddMembersToChannel.mockResolvedValue({
channelMemberships: [],
error: {message: 'Channel error'} as any,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent.length).toBe(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Unable to add user to 2 channels');
});
it('should handle partial channel failures (some channels succeed, some fail)', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
// First channel succeeds, second fails with empty memberships
mockAddMembersToChannel.
mockResolvedValueOnce({
channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})],
error: undefined,
}).
mockResolvedValueOnce({
channelMemberships: [],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Unable to add user to 1 channel');
});
it('should successfully add guest to multiple channels', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: undefined}],
error: undefined,
});
mockAddMembersToChannel.
mockResolvedValueOnce({
channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-1'})],
error: undefined,
}).
mockResolvedValueOnce({
channelMemberships: [TestHelper.fakeChannelMember({user_id: userId, channel_id: 'channel-2'})],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(0);
expect(result.sent[0].userId).toBe(userId);
expect(result.sent[0].reason).toBe('This guest has been added to the team and 2 channels.');
});
it('should handle errors from sendGuestEmailInvitesToTeam', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [],
error: {message: 'Email error'} as any,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(email);
expect(result.notSent[0].reason).toBe('Email error');
});
it('should handle individual email errors from sendGuestEmailInvitesToTeam', async () => {
const email1 = 'guest1@example.com';
const email2 = 'guest2@example.com';
const selectedIds = {
[email1]: email1,
[email2]: email2,
};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [
{email: email1, error: {message: 'Invalid email'} as any},
{email: email2, error: undefined},
],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(1);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].email).toBe(email1);
expect(result.notSent[0].reason).toBe('Invalid email');
expect(result.sent[0].email).toBe(email2);
});
it('should include custom message when sending guest email invites', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
const customOptions = {
...options,
includeCustomMessage: true,
customMessage: 'Welcome to the team!',
};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
customOptions,
formatMessage,
);
expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith(
serverUrl,
teamId,
[email],
options.selectedChannels,
'Welcome to the team!',
false,
);
});
it('should not include custom message when includeCustomMessage is false', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
const customOptions = {
...options,
includeCustomMessage: false,
customMessage: 'Welcome to the team!',
};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
customOptions,
formatMessage,
);
expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith(
serverUrl,
teamId,
[email],
options.selectedChannels,
'',
false,
);
});
it('should send guest email invites with guest magic link', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
const customOptions = {
...options,
guestMagicLink: true,
};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [{email, error: undefined}],
error: undefined,
});
await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
customOptions,
formatMessage,
);
expect(mockSendGuestEmailInvitesToTeam).toHaveBeenCalledWith(
serverUrl,
teamId,
[email],
options.selectedChannels,
'',
true,
);
});
it('should handle empty members response from sendGuestEmailInvitesToTeam', async () => {
const email = 'guest@example.com';
const selectedIds = {[email]: email};
mockSendGuestEmailInvitesToTeam.mockResolvedValue({
members: [],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].email).toBe(email);
expect(result.notSent[0].reason).toBe('Failed to send email invitation');
});
it('should handle user error from addUsersToTeam member response', async () => {
const userId = 'user-1';
const user = TestHelper.fakeUser({id: userId, roles: 'system_guest'});
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [{member: TestHelper.fakeTeamMember(userId, 'team-1'), user_id: userId, error: {message: 'User error'} as any}],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('User error');
});
it('should handle empty members array from addUsersToTeam', async () => {
const userId = 'user-1';
const user = {id: userId, roles: 'system_guest'} as UserProfile;
const selectedIds = {[userId]: user};
mockAddUsersToTeam.mockResolvedValue({
members: [],
error: undefined,
});
const result = await sendGuestInvites(
serverUrl,
teamId,
selectedIds,
options,
formatMessage,
);
expect(result.sent).toHaveLength(0);
expect(result.notSent).toHaveLength(1);
expect(result.notSent[0].userId).toBe(userId);
expect(result.notSent[0].reason).toBe('Unknown error');
});
});
});

View file

@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {addMembersToChannel} from '@actions/remote/channel';
import {addUsersToTeam, getTeamMembersByIds, sendEmailInvitesToTeam, sendGuestEmailInvitesToTeam} from '@actions/remote/team';
import {ServerErrors} from '@constants';
import {getErrorMessage} from '@utils/errors';
import {secureGetFromRecord} from '@utils/types';
import {isGuest} from '@utils/user';
import type {InviteResult, Result, SearchResult, SendOptions} from './types';
import type {IntlShape} from 'react-intl';
export async function sendMembersInvites(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, isAdmin: boolean, teamDisplayName: string, formatMessage: IntlShape['formatMessage']) {
const userIds = [];
const emails = [];
const errorResult: Result & {error: boolean} = {sent: [], notSent: [], error: true};
for (const [id, item] of Object.entries(selectedIds)) {
if (typeof item === 'string') {
emails.push(item);
} else {
userIds.push(id);
}
}
const currentMemberIds = new Set();
if (userIds.length) {
const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds);
if (getTeamMembersByIdsError) {
return errorResult;
}
for (const {user_id: currentMemberId} of currentTeamMembers) {
currentMemberIds.add(currentMemberId);
}
}
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
const usersToAdd = [];
for (const userId of userIds) {
if (isGuest((selectedIds[userId] as UserProfile).roles)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_guest', defaultMessage: 'Contact your admin to make this guest a full member'})});
} else if (currentMemberIds.has(userId)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})});
} else {
usersToAdd.push(userId);
}
}
if (usersToAdd.length) {
const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
if (addUsersToTeamError) {
return errorResult;
}
if (members) {
const membersWithError: Record<string, string> = {};
for (const {user_id, error} of members) {
if (error) {
membersWithError[user_id] = error.message;
}
}
for (const userId of usersToAdd) {
if (membersWithError[userId]) {
notSent.push({userId, reason: membersWithError[userId]});
} else {
sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})});
}
}
}
}
if (emails.length) {
const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
if (sendEmailInvitesToTeamError) {
return errorResult;
}
if (members) {
const membersWithError: Record<string, string> = {};
for (const {email, error} of members) {
if (error) {
membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? (
formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'})
) : (
error.message
);
}
}
for (const email of emails) {
const error = secureGetFromRecord(membersWithError, email);
if (error) {
notSent.push({userId: email, reason: error});
} else {
sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
}
}
}
}
return {sent, notSent, error: false};
}
export async function sendGuestInvites(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, options: SendOptions, formatMessage: IntlShape['formatMessage']) {
const userIds = [];
const emails = [];
for (const [id, item] of Object.entries(selectedIds)) {
if (typeof item === 'string') {
emails.push(item);
} else {
userIds.push(id);
}
}
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
if (userIds.length) {
const {sent: sentForUsers, notSent: notSentForUsers} = await sendGuestInviteForUsers(serverUrl, teamId, selectedIds, userIds, options, formatMessage);
sent.push(...sentForUsers);
notSent.push(...notSentForUsers);
}
if (emails.length) {
const {sent: sentForMails, notSent: notSentForMails} = await sendGuestInviteForMails(serverUrl, teamId, emails, options, formatMessage);
sent.push(...sentForMails);
notSent.push(...notSentForMails);
}
return {sent, notSent};
}
async function sendGuestInviteForUsers(serverUrl: string, teamId: string, selectedIds: {[id: string]: SearchResult}, userIds: string[], options: SendOptions, formatMessage: IntlShape['formatMessage']) {
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
for await (const userId of userIds) {
const user = selectedIds[userId] as UserProfile;
if (!isGuest(user.roles)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_not_guest', defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.'})});
continue;
}
const {members, error} = await addUsersToTeam(serverUrl, teamId, [userId]);
if (error) {
notSent.push({userId, reason: getErrorMessage(error)});
continue;
}
if (!members || members.length === 0 || members[0].error) {
notSent.push({userId, reason: getErrorMessage(members?.[0]?.error)});
continue;
}
let channelsAdded = 0;
let channelsFailed = 0;
for await (const channel of options.selectedChannels) {
const {channelMemberships, error: addMembersToChannelError} = await addMembersToChannel(serverUrl, channel, [userId]);
if (addMembersToChannelError) {
channelsFailed++;
continue;
}
if (!channelMemberships || channelMemberships.length === 0) {
channelsFailed++;
continue;
}
channelsAdded++;
}
if (channelsFailed > 0) {
notSent.push({userId, reason: formatMessage({id: 'invite.summary.guest_invite_failed', defaultMessage: 'Unable to add user to {channelsFailed, plural, one {# channel} other {# channels}}'}, {channelsFailed})});
continue;
}
sent.push({userId, reason: formatMessage({id: 'invite.summary.guest_invite', defaultMessage: 'This guest has been added to the team and {count, plural, one {# channel} other {# channels}}.'}, {count: channelsAdded})});
}
return {sent, notSent};
}
async function sendGuestInviteForMails(serverUrl: string, teamId: string, emails: string[], options: SendOptions, formatMessage: IntlShape['formatMessage']) {
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
const message = options.includeCustomMessage ? options.customMessage : '';
const response = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, options.selectedChannels, message, options.guestMagicLink);
if (response.error) {
notSent.push(...emails.map((email) => ({userId: email, reason: getErrorMessage(response.error)})));
return {sent, notSent};
}
if (!response.members || response.members.length === 0) {
notSent.push(...emails.map((email) => ({email, reason: formatMessage({id: 'invite.summary.email_invite_failed', defaultMessage: 'Failed to send email invitation'})})));
return {sent, notSent};
}
for (const member of response.members) {
if (member.error) {
notSent.push({email: member.email, reason: getErrorMessage(member.error)});
continue;
}
sent.push({email: member.email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
}
return {sent, notSent};
}

View file

@ -0,0 +1,283 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {General, Permissions, Preferences} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Invite from './invite';
import InviteContainer from './';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('./invite');
jest.mocked(Invite).mockImplementation(
(props) => React.createElement('Invite', {testID: 'invite-component', ...props}),
);
const serverUrl = 'server-url';
describe('InviteContainer', () => {
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(() => {
DatabaseManager.destroyServerDatabase(serverUrl);
});
function getBaseProps(): ComponentProps<typeof InviteContainer> {
return {
componentId: 'Invite' as any,
};
}
it('should render correctly with no data', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite).toBeTruthy();
expect(invite.props.teamId).toBeUndefined();
expect(invite.props.teamDisplayName).toBeUndefined();
expect(invite.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME);
expect(invite.props.isAdmin).toBe(false);
expect(invite.props.emailInvitationsEnabled).toBe(false);
expect(invite.props.canInviteGuests).toBe(false);
expect(invite.props.allowGuestMagicLink).toBe(false);
});
it('should render correctly with team data', async () => {
const team = TestHelper.fakeTeam({
id: 'team-1',
display_name: 'Test Team',
invite_id: 'invite-id-1',
last_team_icon_update: 1234567890,
});
await operator.handleTeam({
teams: [team],
prepareRecordsOnly: false,
});
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team-1'}],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite.props.teamId).toBe('team-1');
expect(invite.props.teamDisplayName).toBe('Test Team');
expect(invite.props.teamInviteId).toBe('invite-id-1');
expect(invite.props.teamLastIconUpdate).toBe(1234567890);
});
it('should render correctly with system config', async () => {
await operator.handleConfigs({
configs: [
{
id: 'EnableEmailInvitations',
value: 'true',
},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite.props.emailInvitationsEnabled).toBe(true);
expect(invite.props.allowGuestMagicLink).toBe(false);
await act(async () => {
await operator.handleConfigs({
configs: [
{
id: 'EnableGuestMagicLink',
value: 'true',
},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
});
await waitFor(() => {
expect(invite.props.emailInvitationsEnabled).toBe(true);
expect(invite.props.allowGuestMagicLink).toBe(true);
});
});
it('should render correctly with user preferences', async () => {
await operator.handlePreferences({
preferences: [{
category: Preferences.CATEGORIES.DISPLAY_SETTINGS,
name: Preferences.NAME_NAME_FORMAT,
value: General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME,
user_id: 'user-id',
}],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME);
});
it('should render correctly with admin user', async () => {
const user = TestHelper.fakeUser({
id: 'user-1',
roles: 'system_admin',
});
await operator.handleUsers({
users: [user],
prepareRecordsOnly: false,
});
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: 'user-1',
}],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite.props.isAdmin).toBe(true);
user.roles = '';
user.update_at = Date.now();
await act(async () => {
await operator.handleUsers({
users: [user],
prepareRecordsOnly: false,
});
});
await waitFor(() => {
expect(invite.props.isAdmin).toBe(false);
});
});
it('should calculate canInviteGuests correctly', async () => {
const team = TestHelper.fakeTeam({
id: 'team-1',
group_constrained: false,
});
await operator.handleTeam({
teams: [team],
prepareRecordsOnly: false,
});
await operator.handleMyTeam({
myTeams: [{id: 'team-1', roles: 'team_admin'}],
prepareRecordsOnly: false,
});
await operator.handleRole({
roles: [{id: 'team_admin', name: 'team_admin', permissions: [Permissions.INVITE_GUEST]}],
prepareRecordsOnly: false,
});
await operator.handleConfigs({
configs: [{id: 'EnableGuestAccounts', value: 'true'}],
configsToDelete: [],
prepareRecordsOnly: false,
});
await operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user-1'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team-1'},
],
prepareRecordsOnly: false,
});
const user = TestHelper.fakeUser({
id: 'user-1',
});
await operator.handleUsers({
users: [user],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<InviteContainer {...props}/>, {database});
const invite = getByTestId('invite-component');
expect(invite.props.canInviteGuests).toBe(true);
await act(async () => {
team.group_constrained = true;
team.update_at = Date.now();
await operator.handleTeam({
teams: [team],
prepareRecordsOnly: false,
});
});
await waitFor(() => {
expect(invite.props.canInviteGuests).toBe(false);
});
await act(async () => {
team.group_constrained = false;
team.update_at = Date.now();
await operator.handleTeam({
teams: [team],
prepareRecordsOnly: false,
});
await operator.handleMyTeam({
myTeams: [{id: 'team-1', roles: ''}],
prepareRecordsOnly: false,
});
});
await waitFor(() => {
expect(invite.props.canInviteGuests).toBe(false);
});
await act(async () => {
await operator.handleMyTeam({
myTeams: [{id: 'team-1', roles: 'team_admin'}],
prepareRecordsOnly: false,
});
await operator.handleConfigs({
configs: [{id: 'EnableGuestAccounts', value: 'false'}],
configsToDelete: [],
prepareRecordsOnly: false,
});
});
await waitFor(() => {
expect(invite.props.canInviteGuests).toBe(false);
});
});
});

View file

@ -2,10 +2,12 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged, map} from 'rxjs/operators';
import {observeConfigValue} from '@queries/servers/system';
import {Permissions} from '@constants';
import {observePermissionForTeam} from '@queries/servers/role';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentTeam} from '@queries/servers/team';
import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user';
import {isSystemAdmin} from '@utils/user';
@ -16,6 +18,19 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const team = observeCurrentTeam(database);
const currentUser = observeCurrentUser(database);
const guestAccountsEnabled = observeConfigBooleanValue(database, 'EnableGuestAccounts');
const emailInvitationsEnabled = observeConfigBooleanValue(database, 'EnableEmailInvitations');
const isGroupConstrained = team.pipe(
switchMap((t) => of$(Boolean(t?.isGroupConstrained))),
);
const hasPermissionToInviteGuests = combineLatest([team, currentUser]).pipe(
switchMap(([t, u]) => observePermissionForTeam(database, t, u, Permissions.INVITE_GUEST, false)),
);
const canInviteGuests = combineLatest([isGroupConstrained, guestAccountsEnabled, hasPermissionToInviteGuests]).pipe(
switchMap(([group, enabled, permission]) => of$(!group && enabled && permission)),
);
return {
teamId: team.pipe(
@ -35,7 +50,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
map((user) => isSystemAdmin(user?.roles || '')),
distinctUntilChanged(),
),
allowPasswordlessInvites: observeConfigValue(database, 'EnableGuestMagicLink'),
emailInvitationsEnabled,
canInviteGuests,
allowGuestMagicLink: observeConfigBooleanValue(database, 'EnableGuestMagicLink'),
};
});

View file

@ -6,13 +6,12 @@ import {type IntlShape, useIntl} from 'react-intl';
import {Keyboard, View, type LayoutChangeEvent} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {getTeamMembersByIds, addUsersToTeam, sendEmailInvitesToTeam} from '@actions/remote/team';
import {searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import {ServerErrors} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
@ -21,12 +20,12 @@ import {isEmail} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {isGuest} from '@utils/user';
import {sendGuestInvites, sendMembersInvites} from './actions';
import Selection from './selection';
import Summary from './summary';
import type UserModel from '@typings/database/models/servers/user';
import type {EmailInvite, Result, SearchResult, SendOptions} from './types';
import type {AvailableScreens, NavButtons} from '@typings/screens/navigation';
import type {OptionsTopBarButton} from 'react-native-navigation';
@ -70,20 +69,6 @@ const getStyleSheet = makeStyleSheetFromTheme(() => {
};
});
export type EmailInvite = string;
export type SearchResult = UserProfile|UserModel|EmailInvite;
export type InviteResult = {
userId: string;
reason: string;
};
export type Result = {
sent: InviteResult[];
notSent: InviteResult[];
}
enum Stage {
SELECTION = 'selection',
RESULT = 'result',
@ -98,6 +83,9 @@ type InviteProps = {
teamInviteId: string;
teammateNameDisplay: string;
isAdmin: boolean;
emailInvitationsEnabled: boolean;
canInviteGuests: boolean;
allowGuestMagicLink: boolean;
}
export default function Invite({
@ -108,9 +96,12 @@ export default function Invite({
teamInviteId,
teammateNameDisplay,
isAdmin,
emailInvitationsEnabled,
canInviteGuests,
allowGuestMagicLink,
}: InviteProps) {
const intl = useIntl();
const {formatMessage, locale} = intl;
const {formatMessage} = intl;
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
@ -130,12 +121,29 @@ export default function Invite({
const [stage, setStage] = useState(Stage.SELECTION);
const [sendError, setSendError] = useState('');
const [sendOptions, setSendOptions] = useState<SendOptions>({
inviteAsGuest: false,
includeCustomMessage: false,
customMessage: '',
selectedChannels: [],
guestMagicLink: false,
});
const isResult = stage === Stage.RESULT;
const isSelecting = stage === Stage.SELECTION;
const selectedCount = Object.keys(selectedIds).length;
const hasSelection = selectedCount > 0;
const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => {
setWrapperHeight(e.nativeEvent.layout.height);
}, []);
const handleClearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const searchUsers = useCallback(async (searchTerm: string) => {
if (searchTerm === '') {
handleClearSearch();
@ -145,25 +153,19 @@ export default function Invite({
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {});
const results: SearchResult[] = data ?? [];
if (!results.length && isEmail(searchTerm.trim())) {
if (!results.length && isEmail(searchTerm.trim()) && emailInvitationsEnabled) {
results.push(searchTerm.trim() as EmailInvite);
}
setSearchResults(results);
}, [serverUrl, teamId]);
}, [emailInvitationsEnabled, handleClearSearch, serverUrl]);
const handleReset = () => {
setStage(Stage.LOADING);
const handleReset = useCallback(() => {
setSendError('');
setTerm('');
setSearchResults([]);
setResult(DEFAULT_RESULT);
setStage(Stage.SELECTION);
};
const handleClearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const handleSearchChange = useCallback((text: string) => {
@ -194,128 +196,43 @@ export default function Invite({
handleClearSearch();
}, [selectedIds, handleClearSearch]);
const handleRetry = () => {
const handleSendError = useCallback(() => {
setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'}));
setResult(DEFAULT_RESULT);
setStage(Stage.RESULT);
}, [formatMessage]);
const handleSend = useCallback(async () => {
if (!hasSelection) {
return;
}
setStage(Stage.LOADING);
if (sendOptions.inviteAsGuest) {
const {sent, notSent} = await sendGuestInvites(serverUrl, teamId, selectedIds, sendOptions, formatMessage);
setResult({sent, notSent});
setStage(Stage.RESULT);
return;
}
const {sent, notSent, error} = await sendMembersInvites(serverUrl, teamId, selectedIds, isAdmin, teamDisplayName, formatMessage);
if (error) {
handleSendError();
} else {
setResult({sent, notSent});
setStage(Stage.RESULT);
}
}, [formatMessage, handleSendError, isAdmin, hasSelection, selectedIds, sendOptions, serverUrl, teamDisplayName, teamId]);
const handleRetry = useCallback(() => {
setSendError('');
setStage(Stage.LOADING);
retryTimeoutId.current = setTimeout(() => {
handleSend();
}, TIMEOUT_MILLISECONDS);
};
const handleSendError = () => {
setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'}));
setResult(DEFAULT_RESULT);
setStage(Stage.RESULT);
};
const handleSend = async () => {
if (!selectedCount) {
return;
}
setStage(Stage.LOADING);
const userIds = [];
const emails = [];
for (const [id, item] of Object.entries(selectedIds)) {
if (typeof item === 'string') {
emails.push(item);
} else {
userIds.push(id);
}
}
const currentMemberIds = new Set();
if (userIds.length) {
const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds);
if (getTeamMembersByIdsError) {
handleSendError();
return;
}
for (const {user_id: currentMemberId} of currentTeamMembers) {
currentMemberIds.add(currentMemberId);
}
}
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
const usersToAdd = [];
for (const userId of userIds) {
if (isGuest((selectedIds[userId] as UserProfile).roles)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_guest', defaultMessage: 'Contact your admin to make this guest a full member'})});
} else if (currentMemberIds.has(userId)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})});
} else {
usersToAdd.push(userId);
}
}
if (usersToAdd.length) {
const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
if (addUsersToTeamError) {
handleSendError();
return;
}
if (members) {
const membersWithError: Record<string, string> = {};
for (const {user_id, error} of members) {
if (error) {
membersWithError[user_id] = error.message;
}
}
for (const userId of usersToAdd) {
if (membersWithError[userId]) {
notSent.push({userId, reason: membersWithError[userId]});
} else {
sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})});
}
}
}
}
if (emails.length) {
const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
if (sendEmailInvitesToTeamError) {
handleSendError();
return;
}
if (members) {
const membersWithError: Record<string, string> = {};
for (const {email, error} of members) {
if (error) {
membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? (
formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'})
) : (
error.message
);
}
}
for (const email of emails) {
const error = secureGetFromRecord(membersWithError, email);
if (error) {
notSent.push({userId: email, reason: error});
} else {
sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
}
}
}
}
setResult({sent, notSent});
setStage(Stage.RESULT);
};
}, [handleSend]);
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]);
useNavButtonPressed(SEND_BUTTON_ID, componentId, handleSend, [handleSend]);
@ -323,18 +240,18 @@ export default function Invite({
useEffect(() => {
const buttons: NavButtons = {
leftButtons: [makeLeftButton(theme)],
rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [],
rightButtons: isSelecting ? [makeRightButton(theme, formatMessage, hasSelection)] : [],
};
setButtons(componentId, buttons);
}, [theme, locale, componentId, selectedCount > 0, stage === Stage.SELECTION, sendError]);
}, [theme, componentId, hasSelection, isSelecting, formatMessage]);
useEffect(() => {
mergeNavigationOptions(componentId, {
topBar: {
title: {
color: theme.sidebarHeaderTextColor,
text: stage === Stage.RESULT ? (
text: isResult ? (
formatMessage({id: 'invite.title.summary', defaultMessage: 'Invite summary'})
) : (
formatMessage({id: 'invite.title', defaultMessage: 'Invite'})
@ -342,7 +259,7 @@ export default function Invite({
},
},
});
}, [componentId, locale, theme, stage === Stage.RESULT]);
}, [componentId, formatMessage, isResult, theme]);
useEffect(() => {
return () => {
@ -364,6 +281,8 @@ export default function Invite({
setSelectedIds(newSelectedIds);
}, [selectedIds]);
useAndroidHardwareBackHandler(componentId, closeModal);
const renderContent = () => {
switch (stage) {
case Stage.LOADING:
@ -406,6 +325,10 @@ export default function Invite({
onRemoveItem={handleRemoveItem}
onClose={closeModal}
testID='invite.screen.selection'
sendOptions={sendOptions}
onSendOptionsChange={setSendOptions}
canInviteGuests={canInviteGuests}
allowGuestMagicLink={allowGuestMagicLink}
/>
);
}

View file

@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import SelectedChip from '@components/chips/selected_chip';
import SelectedUserChip from '@components/chips/selected_user_chip';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import OptionItem from '@components/option_item';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {goToScreen} from '@screens/navigation';
import {fireEvent, renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Selection from './selection';
import SelectionSearchBar from './selection_search_bar';
import SelectionTeamBar from './selection_team_bar';
import TextItem from './text_item';
import {TextItemType} from './types';
jest.mock('./selection_search_bar');
jest.mocked(SelectionSearchBar).mockImplementation(
(props) => React.createElement('SelectionSearchBar', {testID: 'selection-search-bar', ...props}),
);
jest.mock('./selection_team_bar');
jest.mocked(SelectionTeamBar).mockImplementation(
(props) => React.createElement('SelectionTeamBar', {testID: 'selection-team-bar', ...props}),
);
jest.mock('./text_item');
jest.mocked(TextItem).mockImplementation(
(props) => React.createElement('TextItem', {...props}),
);
jest.mock('@components/chips/selected_chip');
jest.mocked(SelectedChip).mockImplementation(
(props) => React.createElement('SelectedChip', {testID: 'selected-chip', ...props}),
);
jest.mock('@components/chips/selected_user_chip');
jest.mocked(SelectedUserChip).mockImplementation(
(props) => React.createElement('SelectedUserChip', {testID: 'selected-user-chip', ...props}),
);
jest.mock('@components/user_item');
jest.mocked(UserItem).mockImplementation(
(props) => React.createElement('UserItem', {testID: 'user-item', ...props}),
);
jest.mock('@components/option_item');
jest.mocked(OptionItem).mockImplementation(
(props) => React.createElement('OptionItem', {testID: 'option-item', ...props}),
);
jest.mock('@components/floating_input/floating_text_input_label', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(FloatingTextInput).mockImplementation(
(props) => React.createElement('FloatingTextInput', {testID: 'floating-text-input', ...props}),
);
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
}));
describe('Selection', () => {
const mockOnSearchChange = jest.fn();
const mockOnSelectItem = jest.fn();
const mockOnRemoveItem = jest.fn();
const mockOnClose = jest.fn().mockResolvedValue(undefined);
const mockOnSendOptionsChange = jest.fn();
function getBaseProps(): ComponentProps<typeof Selection> {
return {
teamId: 'team-1',
teamDisplayName: 'Test Team',
teamLastIconUpdate: 1234567890,
teamInviteId: 'invite-id-1',
teammateNameDisplay: 'username',
serverUrl: 'https://test.server.com',
term: '',
searchResults: [],
selectedIds: {},
keyboardOverlap: 0,
wrapperHeight: 800,
loading: false,
testID: 'invite.selection',
sendOptions: {
inviteAsGuest: false,
includeCustomMessage: false,
customMessage: '',
selectedChannels: [],
guestMagicLink: false,
},
onSendOptionsChange: mockOnSendOptionsChange,
onSearchChange: mockOnSearchChange,
onSelectItem: mockOnSelectItem,
onRemoveItem: mockOnRemoveItem,
onClose: mockOnClose,
canInviteGuests: true,
allowGuestMagicLink: true,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
expect(getByTestId('selection-search-bar')).toBeTruthy();
expect(getByTestId('selection-team-bar')).toBeTruthy();
});
it('renders selected items correctly', () => {
const props = getBaseProps();
const user = TestHelper.fakeUser({id: 'user-1', username: 'user1'});
props.selectedIds = {
'user-1': user,
'email-1': 'test@example.com',
};
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
expect(getByTestId('invite.selected_items')).toBeTruthy();
const userChip = getByTestId('invite.selected_item');
expect(userChip).toHaveProp('user', user);
expect(userChip).toHaveProp('teammateNameDisplay', 'username');
userChip.props.onPress('user-1');
expect(mockOnRemoveItem).toHaveBeenCalledTimes(1);
expect(mockOnRemoveItem).toHaveBeenCalledWith('user-1');
const chip = getByTestId('invite.selected_item.test@example.com');
expect(chip).toHaveProp('id', 'email-1');
expect(chip).toHaveProp('text', 'test@example.com');
chip.props.onRemove('email-1');
expect(mockOnRemoveItem).toHaveBeenCalledTimes(2);
expect(mockOnRemoveItem).toHaveBeenCalledWith('email-1');
});
it('does not render selected items when empty', () => {
const props = getBaseProps();
const {queryByTestId} = renderWithIntl(<Selection {...props}/>);
expect(queryByTestId('invite.selected_items')).toBeNull();
});
it('renders search results correctly', () => {
const props = getBaseProps();
const user = TestHelper.fakeUser({id: 'user-1', username: 'user1'});
props.searchResults = [user, 'test@example.com'];
props.term = 'test';
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
expect(getByTestId('invite.search_list')).toBeVisible();
const userItem = getByTestId('invite.search_list_user_item');
expect(userItem).toHaveProp('user', user);
expect(userItem).toHaveProp('onUserPress', mockOnSelectItem);
userItem.props.onUserPress(user);
expect(mockOnSelectItem).toHaveBeenCalledTimes(1);
expect(mockOnSelectItem).toHaveBeenCalledWith(user);
const textItem = getByTestId('invite.search_list_text_item');
expect(textItem).toHaveProp('text', 'test@example.com');
expect(textItem).toHaveProp('type', TextItemType.SEARCH_INVITE);
fireEvent.press(textItem);
expect(mockOnSelectItem).toHaveBeenCalledTimes(2);
expect(mockOnSelectItem).toHaveBeenCalledWith('test@example.com');
});
it('renders no results message when term exists and no results', () => {
const props = getBaseProps();
props.term = 'nonexistent';
props.searchResults = [];
props.loading = false;
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
const textItem = getByTestId('invite.search_list_no_results');
expect(textItem).toHaveProp('text', 'nonexistent');
expect(textItem).toHaveProp('type', TextItemType.SEARCH_NO_RESULTS);
});
it('renders invite as guest option when canInviteGuests is true', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
const optionItem = getByTestId('invite.invite_as_guest');
expect(optionItem).toHaveProp('label', 'Invite as guest');
expect(optionItem).toHaveProp('type', 'toggle');
expect(optionItem).toHaveProp('selected', false);
expect(optionItem).toHaveProp('action', expect.any(Function));
expect(optionItem).toHaveProp('testID', 'invite.invite_as_guest');
optionItem.props.action();
expect(mockOnSendOptionsChange).toHaveBeenCalledTimes(1);
const setStateFunction = mockOnSendOptionsChange.mock.calls[0][0];
const result = setStateFunction(props.sendOptions);
expect(result.inviteAsGuest).toBe(true);
});
it('does not render invite as guest option when canInviteGuests is false', () => {
const props = getBaseProps();
props.canInviteGuests = false;
const {queryByTestId} = renderWithIntl(<Selection {...props}/>);
// The invite as guest option should not be rendered
const optionItem = queryByTestId('invite.invite_as_guest');
expect(optionItem).toBeNull();
});
it('renders custom message input when includeCustomMessage is true', () => {
const props = getBaseProps();
props.sendOptions = {
inviteAsGuest: true,
includeCustomMessage: true,
customMessage: 'Test message',
selectedChannels: [],
guestMagicLink: false,
};
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
expect(getByTestId('invite.custom_message')).toBeTruthy();
});
it('handles channel selection', () => {
const props = getBaseProps();
props.sendOptions = {
inviteAsGuest: true,
includeCustomMessage: false,
customMessage: '',
selectedChannels: [],
guestMagicLink: false,
};
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
const channelOption = getByTestId('invite.selected_channels');
channelOption.props.action();
expect(goToScreen).toHaveBeenCalledWith(
Screens.INTEGRATION_SELECTOR,
expect.any(String),
expect.objectContaining({
dataSource: 'channels',
isMultiselect: true,
}),
);
});
it('renders guest magic link option when guestMagicLink is true', () => {
const props = getBaseProps();
props.allowGuestMagicLink = true;
props.sendOptions = {
inviteAsGuest: true,
includeCustomMessage: false,
customMessage: '',
selectedChannels: [],
guestMagicLink: false,
};
const {getByTestId} = renderWithIntl(<Selection {...props}/>);
const optionItem = getByTestId('invite.guest_magic_link');
expect(optionItem).toHaveProp('label', 'Use magic link');
expect(optionItem).toHaveProp('description', 'Newly created guests will join and log in without a password, using a magic link sent to their email address');
expect(optionItem).toHaveProp('type', 'toggle');
expect(optionItem).toHaveProp('selected', false);
optionItem.props.action();
expect(mockOnSendOptionsChange).toHaveBeenCalledTimes(1);
const setStateFunction = mockOnSendOptionsChange.mock.calls[0][0];
const result = setStateFunction(props.sendOptions);
expect(result.guestMagicLink).toBe(true);
});
it('does not render guest magic link option when allowGuestMagicLink is false', () => {
const props = getBaseProps();
props.allowGuestMagicLink = false;
props.sendOptions = {
inviteAsGuest: true,
includeCustomMessage: false,
customMessage: '',
selectedChannels: [],
guestMagicLink: false,
};
const {queryByTestId} = renderWithIntl(<Selection {...props}/>);
const optionItem = queryByTestId('invite.guest_magic_link');
expect(optionItem).toBeNull();
});
it('passes correct props to SelectionSearchBar', () => {
const props = getBaseProps();
props.term = 'test search';
renderWithIntl(<Selection {...props}/>);
const searchBar = jest.mocked(SelectionSearchBar).mock.calls[0][0];
expect(searchBar.term).toBe('test search');
expect(searchBar.onSearchChange).toBe(mockOnSearchChange);
});
it('passes correct props to SelectionTeamBar', () => {
const props = getBaseProps();
renderWithIntl(<Selection {...props}/>);
const teamBar = jest.mocked(SelectionTeamBar).mock.calls[0][0];
expect(teamBar.teamId).toBe('team-1');
expect(teamBar.teamDisplayName).toBe('Test Team');
expect(teamBar.onClose).toBe(mockOnClose);
});
});

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState, useMemo} from 'react';
import React, {useCallback, useState, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {
Keyboard,
Platform,
@ -12,23 +13,27 @@ import {
type ListRenderItemInfo,
ScrollView,
} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import SelectedChip from '@components/chips/selected_chip';
import SelectedUserChip from '@components/chips/selected_user_chip';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import OptionItem from '@components/option_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete';
import {useTheme} from '@context/theme';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useIsTablet} from '@hooks/device';
import {useAvoidKeyboard, useIsTablet} from '@hooks/device';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import SelectionSearchBar from './selection_search_bar';
import SelectionTeamBar from './selection_team_bar';
import TextItem, {TextItemType} from './text_item';
import type {SearchResult} from './invite';
import TextItem from './text_item';
import {TextItemType, type SearchResult, type SendOptions} from './types';
const AUTOCOMPLETE_ADJUST = 5;
@ -88,19 +93,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
selectedItems: {
display: 'flex',
flexGrowth: 1,
},
selectedItemsContainer: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: 20,
marginVertical: 16,
gap: 8,
},
contentContainer: {
paddingHorizontal: 20,
},
optionsContainer: {
marginTop: 16,
gap: 8,
},
};
});
function extractChannelId(channelId: Channel) {
return channelId.id;
}
type SelectionProps = {
teamId: string;
teamDisplayName: string;
@ -115,10 +129,14 @@ type SelectionProps = {
wrapperHeight: number;
loading: boolean;
testID: string;
sendOptions: SendOptions;
onSendOptionsChange: React.Dispatch<React.SetStateAction<SendOptions>>;
onSearchChange: (text: string) => void;
onSelectItem: (item: SearchResult) => void;
onRemoveItem: (id: string) => void;
onClose: () => Promise<void>;
canInviteGuests: boolean;
allowGuestMagicLink: boolean;
}
export default function Selection({
@ -139,15 +157,31 @@ export default function Selection({
onSelectItem,
onRemoveItem,
onClose,
sendOptions: {
inviteAsGuest,
includeCustomMessage,
customMessage,
selectedChannels,
guestMagicLink,
},
onSendOptionsChange,
canInviteGuests,
allowGuestMagicLink,
}: SelectionProps) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const isTablet = useIsTablet();
const intl = useIntl();
const [teamBarHeight, setTeamBarHeight] = useState(0);
const [searchBarHeight, setSearchBarHeight] = useState(0);
const hasChannelsSelected = selectedChannels.length > 0;
const keyboardAwareRef = useRef<KeyboardAwareScrollView>(null);
useAvoidKeyboard(keyboardAwareRef);
const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => {
setTeamBarHeight(e.nativeEvent.layout.height);
}, []);
@ -156,9 +190,9 @@ export default function Selection({
setSearchBarHeight(e.nativeEvent.layout.height);
}, []);
const handleOnRemoveItem = (id: string) => {
const handleOnRemoveItem = useCallback((id: string) => {
onRemoveItem(id);
};
}, [onRemoveItem]);
const otherElementsSize = teamBarHeight + searchBarHeight;
const workingSpace = wrapperHeight - keyboardOverlap;
@ -198,7 +232,7 @@ export default function Selection({
}
return style;
}, [searchResults, styles, searchListContainerAnimatedStyle]);
}, [styles, searchListContainerAnimatedStyle]);
const searchListFlatListStyle = useMemo(() => {
const style = [];
@ -210,7 +244,7 @@ export default function Selection({
}
return style;
}, [searchResults, styles, Boolean(term && !loading)]);
}, [loading, searchResults.length, styles, term]);
const renderNoResults = useCallback(() => {
if (!term || loading) {
@ -251,9 +285,56 @@ export default function Selection({
onUserPress={onSelectItem}
/>
);
}, [searchResults, onSelectItem]);
}, [theme.buttonBg, onSelectItem]);
const renderSelectedItems = useMemo(() => {
const goToSelectorScreen = useCallback((() => {
const screen = Screens.INTEGRATION_SELECTOR;
const title = intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'});
const handleSelectChannels = (channels: Channel[]) => {
onSendOptionsChange((options) => ({
...options,
selectedChannels: channels.map(extractChannelId),
}));
};
goToScreen(screen, title, {
dataSource: 'channels',
handleSelect: handleSelectChannels,
selected: selectedChannels,
isMultiselect: true,
});
}), [intl, selectedChannels, onSendOptionsChange]);
const handleInviteAsGuestChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
inviteAsGuest: !options.inviteAsGuest,
}));
}, [onSendOptionsChange]);
const handleIncludeCustomMessageChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
includeCustomMessage: !options.includeCustomMessage,
}));
}, [onSendOptionsChange]);
const handleCustomMessageChange = useCallback((text: string) => {
onSendOptionsChange((options) => ({
...options,
customMessage: text,
}));
}, [onSendOptionsChange]);
const handlePasswordlessInvitesChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
guestMagicLink: !options.guestMagicLink,
}));
}, [onSendOptionsChange]);
const renderSelectedItems = () => {
const selectedItems = [];
for (const id of Object.keys(selectedIds)) {
@ -279,7 +360,7 @@ export default function Selection({
}
return selectedItems;
}, [selectedIds]);
};
return (
<View
@ -295,20 +376,77 @@ export default function Selection({
onLayoutContainer={onLayoutSelectionTeamBar}
onClose={onClose}
/>
<SelectionSearchBar
term={term}
onSearchChange={onSearchChange}
onLayoutContainer={onLayoutSearchBar}
/>
{Object.keys(selectedIds).length > 0 && (
<ScrollView
style={styles.selectedItems}
contentContainerStyle={styles.selectedItemsContainer}
testID='invite.selected_items'
>
{renderSelectedItems}
</ScrollView>
)}
<KeyboardAwareScrollView
ref={keyboardAwareRef}
style={styles.contentContainer}
>
<SelectionSearchBar
term={term}
onSearchChange={onSearchChange}
onLayoutContainer={onLayoutSearchBar}
/>
{Object.keys(selectedIds).length > 0 && (
<ScrollView
style={styles.selectedItems}
contentContainerStyle={styles.selectedItemsContainer}
testID='invite.selected_items'
>
{renderSelectedItems()}
</ScrollView>
)}
<View style={styles.optionsContainer}>
{canInviteGuests && (
<OptionItem
label={intl.formatMessage({id: 'invite.invite_as_guest', defaultMessage: 'Invite as guest'})}
description={intl.formatMessage({id: 'invite.invite_as_guest_description', defaultMessage: 'Guests are limited to selected channels'})}
type='toggle'
selected={inviteAsGuest}
action={handleInviteAsGuestChange}
testID='invite.invite_as_guest'
/>
)}
{inviteAsGuest && (
<>
<OptionItem
label={intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'})}
type='arrow'
action={goToSelectorScreen}
info={hasChannelsSelected ? intl.formatMessage({id: 'invite.selected_channels_count', defaultMessage: '{count} {count, plural, one{channel} other{channels}}'}, {count: selectedChannels.length}) : intl.formatMessage({id: 'invite.no_channels_selected', defaultMessage: 'Required for guests'})}
isInfoDestructive={!hasChannelsSelected}
testID='invite.selected_channels'
icon={'globe'}
/>
<OptionItem
label={intl.formatMessage({id: 'invite.set_custom_message', defaultMessage: 'Set a custom message'})}
type='toggle'
selected={includeCustomMessage}
action={handleIncludeCustomMessageChange}
testID='invite.include_custom_message'
/>
{includeCustomMessage && (
<FloatingTextInput
label={intl.formatMessage({id: 'invite.custom_message', defaultMessage: 'Enter a custom message'})}
value={customMessage}
onChangeText={handleCustomMessageChange}
testID='invite.custom_message'
theme={theme}
multiline={true}
/>
)}
{allowGuestMagicLink && (
<OptionItem
label={intl.formatMessage({id: 'invite.guest_magic_link', defaultMessage: 'Use magic link'})}
description={intl.formatMessage({id: 'invite.guest_magic_link_description', defaultMessage: 'Newly created guests will join and log in without a password, using a magic link sent to their email address'})}
type='toggle'
selected={guestMagicLink}
action={handlePasswordlessInvitesChange}
testID='invite.guest_magic_link'
/>
)}
</>
)}
</View>
</KeyboardAwareScrollView>
<Animated.View style={searchListContainerStyle}>
<FlatList
data={searchResults}

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {act, fireEvent, renderWithIntl} from '@test/intl-test-helper';
import SelectionSearchBar from './selection_search_bar';
describe('SelectionSearchBar', () => {
const mockOnSearchChange = jest.fn();
const mockOnLayoutContainer = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
function getBaseProps(): ComponentProps<typeof SelectionSearchBar> {
return {
term: '',
onSearchChange: mockOnSearchChange,
onLayoutContainer: mockOnLayoutContainer,
};
}
it('renders correctly', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntl(
<SelectionSearchBar {...props}/>,
);
expect(getByTestId('invite.search_bar')).toBeTruthy();
expect(getByText('Send invitations to…')).toBeTruthy();
expect(getByTestId('invite.search_bar_input')).toBeTruthy();
});
it('calls onSearchChange when text changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(
<SelectionSearchBar {...props}/>,
);
const input = getByTestId('invite.search_bar_input');
act(() => {
fireEvent.changeText(input, 'test');
});
expect(mockOnSearchChange).toHaveBeenCalledWith('test');
});
it('displays the term value', () => {
const props = getBaseProps();
props.term = 'test@example.com';
const {getByTestId} = renderWithIntl(
<SelectionSearchBar {...props}/>,
);
const input = getByTestId('invite.search_bar_input');
expect(input.props.value).toBe('test@example.com');
});
});

View file

@ -19,13 +19,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
display: 'flex',
},
searchBarTitleText: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_TITLE_MARGIN_TOP,
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
},
searchBar: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_MARGIN_TOP,
},
searchInput: {
@ -65,17 +63,17 @@ export default function SelectionSearchBar({
onLayoutContainer(e);
}, [onLayoutContainer]);
const onTextInputFocus = () => {
const onTextInputFocus = useCallback(() => {
setIsFocused(true);
};
}, []);
const onTextInputBlur = () => {
const onTextInputBlur = useCallback(() => {
setIsFocused(false);
};
}, []);
const handleSearchChange = (text: string) => {
const handleSearchChange = useCallback((text: string) => {
onSearchChange(text);
};
}, [onSearchChange]);
const searchInputStyle = useMemo(() => {
const style = [];
@ -90,7 +88,7 @@ export default function SelectionSearchBar({
}
return style;
}, [isFocused, styles]);
}, [isFocused, styles.searchInput, theme.buttonBg]);
return (
<View

View file

@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import Share from 'react-native-share';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import {useServerDisplayName} from '@context/server';
import {act, fireEvent, renderWithIntl, waitFor} from '@test/intl-test-helper';
import SelectionTeamBar from './selection_team_bar';
jest.mock('react-native-share', () => ({
__esModule: true,
default: {
open: jest.fn(),
},
}));
jest.mock('@context/server', () => ({
useServerDisplayName: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
dismissModal: jest.fn(),
}));
jest.mock('@components/team_sidebar/team_list/team_item/team_icon', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(TeamIcon).mockImplementation(
(props) => React.createElement('TeamIcon', {testID: 'team-icon.mock', ...props}),
);
describe('SelectionTeamBar', () => {
const mockOnLayoutContainer = jest.fn();
const mockOnClose = jest.fn().mockResolvedValue(undefined);
const mockServerDisplayName = 'Test Server';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useServerDisplayName).mockReturnValue(mockServerDisplayName);
jest.mocked(Share.open).mockResolvedValue({success: true} as Awaited<ReturnType<typeof Share.open>>);
});
function getBaseProps(): ComponentProps<typeof SelectionTeamBar> {
return {
teamId: 'team-1',
teamDisplayName: 'Test Team',
teamLastIconUpdate: 1234567890,
teamInviteId: 'invite-id-1',
serverUrl: 'https://test.server.com',
onLayoutContainer: mockOnLayoutContainer,
onClose: mockOnClose,
};
}
it('renders correctly', () => {
const props = getBaseProps();
const {getByText, getByTestId} = renderWithIntl(
<SelectionTeamBar {...props}/>,
);
expect(getByText('Test Team')).toBeTruthy();
expect(getByText(mockServerDisplayName)).toBeTruthy();
expect(getByText('Share link')).toBeTruthy();
expect(getByTestId('invite.team_icon')).toBeTruthy();
expect(getByTestId('invite.share_link.button')).toBeTruthy();
});
it('handles share link press', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(
<SelectionTeamBar {...props}/>,
);
const shareButton = getByTestId('invite.share_link.button');
act(() => {
fireEvent.press(shareButton);
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
expect(Share.open).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import AlertSvgComponent from '@components/illustrations/alert';
import ErrorSvgComponent from '@components/illustrations/error';
import SuccessSvgComponent from '@components/illustrations/success';
import {act, fireEvent, renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Summary from './summary';
import SummaryReport from './summary_report';
import type {SearchResult} from './types';
jest.mock('./summary_report');
jest.mocked(SummaryReport).mockImplementation(
(props) => React.createElement('SummaryReport', {...props}),
);
jest.mock('@components/illustrations/success', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(SuccessSvgComponent).mockImplementation(
() => React.createElement('SuccessSvgComponent', {testID: 'success-svg'}),
);
jest.mock('@components/illustrations/error', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(ErrorSvgComponent).mockImplementation(
() => React.createElement('ErrorSvgComponent', {testID: 'error-svg'}),
);
jest.mock('@components/illustrations/alert', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(AlertSvgComponent).mockImplementation(
() => React.createElement('AlertSvgComponent', {testID: 'alert-svg'}),
);
describe('Summary', () => {
const mockOnClose = jest.fn();
const mockOnRetry = jest.fn();
const mockOnBack = jest.fn();
const mockSelectedIds: {[id: string]: SearchResult} = {
'user-1': TestHelper.fakeUser({id: 'user-1', username: 'user1'}),
'email-1': 'test@example.com',
};
beforeEach(() => {
jest.clearAllMocks();
});
function getBaseProps(): ComponentProps<typeof Summary> {
return {
result: {
sent: [],
notSent: [],
},
selectedIds: mockSelectedIds,
onClose: mockOnClose,
onRetry: mockOnRetry,
onBack: mockOnBack,
testID: 'invite.summary',
};
}
it('renders success state correctly', () => {
const result = {
sent: [
{userId: 'user-1', reason: 'Invited as a member'},
],
notSent: [],
};
const props = getBaseProps();
props.result = result;
const {getByText, getByTestId} = renderWithIntl(
<Summary {...props}/>,
);
expect(getByText(/invitation has been sent/)).toBeTruthy();
expect(getByText('Done')).toBeTruthy();
expect(getByTestId('success-svg')).toBeTruthy();
});
it('renders error state correctly', () => {
const result = {
sent: [],
notSent: [],
};
const error = 'Something went wrong';
const props = getBaseProps();
props.result = result;
props.error = error;
const {getByText} = renderWithIntl(
<Summary {...props}/>,
);
expect(getByText(/could not be sent successfully/)).toBeTruthy();
expect(getByText(error)).toBeTruthy();
expect(getByText('Go back')).toBeTruthy();
expect(getByText('Try again')).toBeTruthy();
});
it('renders not sent state correctly', () => {
const result = {
sent: [],
notSent: [
{userId: 'user-1', reason: 'Already a member'},
],
};
const props = getBaseProps();
props.result = result;
const {getByText, getByTestId} = renderWithIntl(
<Summary {...props}/>,
);
expect(getByText(/Invitation wasnt sent/)).toBeTruthy();
expect(getByTestId('error-svg')).toBeTruthy();
});
it('renders partial success state correctly', () => {
const result = {
sent: [
{userId: 'user-1', reason: 'Invited as a member'},
],
notSent: [
{email: 'test@example.com', reason: 'Failed to send'},
],
};
const props = getBaseProps();
props.result = result;
const {getByText, getByTestId} = renderWithIntl(
<Summary {...props}/>,
);
expect(getByText(/An invitation was not sent/)).toBeTruthy();
expect(getByTestId('alert-svg')).toBeTruthy();
});
it('handles done button press', () => {
const result = {
sent: [
{userId: 'user-1', reason: 'Invited as a member'},
],
notSent: [],
};
const props = getBaseProps();
props.result = result;
const {getByText} = renderWithIntl(
<Summary {...props}/>,
);
const doneButton = getByText('Done');
act(() => {
fireEvent.press(doneButton);
});
expect(mockOnClose).toHaveBeenCalled();
});
it('handles retry button press', () => {
const result = {
sent: [],
notSent: [],
};
const error = 'Something went wrong';
const props = getBaseProps();
props.result = result;
props.error = error;
const {getByText} = renderWithIntl(
<Summary {...props}/>,
);
const retryButton = getByText('Try again');
act(() => {
fireEvent.press(retryButton);
});
expect(mockOnRetry).toHaveBeenCalled();
});
it('handles back button press', () => {
const result = {
sent: [],
notSent: [],
};
const error = 'Something went wrong';
const props = getBaseProps();
props.result = result;
props.error = error;
const {getByText} = renderWithIntl(
<Summary {...props}/>,
);
const backButton = getByText('Go back');
act(() => {
fireEvent.press(backButton);
});
expect(mockOnBack).toHaveBeenCalled();
});
it('renders summary reports for sent and not sent', () => {
const result = {
sent: [
{userId: 'user-1', reason: 'Invited as a member'},
],
notSent: [
{email: 'test@example.com', reason: 'Failed to send'},
],
};
const props = getBaseProps();
props.result = result;
const {getAllByTestId} = renderWithIntl(
<Summary {...props}/>,
);
const reports = getAllByTestId('invite.summary_report');
expect(reports).toHaveLength(2);
});
});

View file

@ -15,7 +15,7 @@ import {typography} from '@utils/typography';
import SummaryReport, {SummaryReportType} from './summary_report';
import type {SearchResult, Result} from './invite';
import type {SearchResult, Result} from './types';
const MAX_WIDTH_CONTENT = 480;

View file

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import UserItem from '@components/user_item';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import SummaryReport, {SummaryReportType} from './summary_report';
import TextItem from './text_item';
import type {SearchResult} from './types';
jest.mock('./text_item');
jest.mocked(TextItem).mockImplementation(
(props) => React.createElement('TextItem', {...props}),
);
jest.mock('@components/user_item', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(UserItem).mockImplementation(
(props) => React.createElement('UserItem', {...props}),
);
describe('SummaryReport', () => {
const mockSelectedIds: {[id: string]: SearchResult} = {
'user-1': TestHelper.fakeUser({id: 'user-1', username: 'user1'}),
'email-1': 'test@example.com',
};
function getBaseProps(): ComponentProps<typeof SummaryReport> {
return {
type: SummaryReportType.SENT,
invites: [],
selectedIds: mockSelectedIds,
testID: 'invite.summary_report',
};
}
it('renders sent type correctly', () => {
const invites = [
{userId: 'user-1', reason: 'Invited as a member'},
];
const props = getBaseProps();
props.type = SummaryReportType.SENT;
props.invites = invites;
const {getByText, getByTestId} = renderWithIntl(
<SummaryReport {...props}/>,
);
expect(getByText(/1 successful invitation/)).toBeTruthy();
expect(getByTestId('invite.summary_report.sent')).toBeTruthy();
});
it('renders not sent type correctly', () => {
const invites = [
{userId: 'user-1', reason: 'Already a member'},
];
const props = getBaseProps();
props.type = SummaryReportType.NOT_SENT;
props.invites = invites;
const {getByText, getByTestId} = renderWithIntl(
<SummaryReport {...props}/>,
);
expect(getByText(/1 invitation not sent/)).toBeTruthy();
expect(getByTestId('invite.summary_report.not_sent')).toBeTruthy();
});
it('renders email invites correctly', () => {
const invites = [
{email: 'test@example.com', reason: 'An invitation email has been sent'},
];
const props = getBaseProps();
props.type = SummaryReportType.SENT;
props.invites = invites;
const {getByText} = renderWithIntl(
<SummaryReport {...props}/>,
);
expect(getByText('An invitation email has been sent')).toBeTruthy();
});
it('renders multiple invites correctly', () => {
const invites = [
{userId: 'user-1', reason: 'Invited as a member'},
{email: 'test@example.com', reason: 'An invitation email has been sent'},
];
const props = getBaseProps();
props.type = SummaryReportType.SENT;
props.invites = invites;
const {getByText} = renderWithIntl(
<SummaryReport {...props}/>,
);
expect(getByText('Invited as a member')).toBeTruthy();
expect(getByText('An invitation email has been sent')).toBeTruthy();
});
});

View file

@ -12,9 +12,8 @@ import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
import TextItem, {TextItemType} from './text_item';
import type {SearchResult, InviteResult} from './invite';
import TextItem from './text_item';
import {type SearchResult, type InviteResult, TextItemType} from './types';
const COLOR_SUCCESS = '#3db887';
const COLOR_ERROR = '#d24b4e';
@ -29,12 +28,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
borderRadius: 4,
marginBottom: 16,
paddingVertical: 8,
paddingHorizontal: 20,
},
title: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
},
titleText: {
@ -48,8 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
paddingVertical: 12,
},
reason: {
paddingLeft: 56,
paddingRight: 20,
paddingLeft: 36,
...typography('Body', 75, 'Regular'),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
@ -114,12 +112,13 @@ export default function SummaryReport({
{message}
</Text>
</View>
{invites.map(({userId, reason}) => {
const item = secureGetFromRecord(selectedIds, userId);
{invites.map(({userId, email, reason}) => {
const key = userId ?? email ?? '';
const item = secureGetFromRecord(selectedIds, key);
return (
<View
key={userId}
key={key}
style={styles.item}
>
{typeof item === 'string' ? (

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
import TextItem from './text_item';
import {TextItemType} from './types';
jest.mock('@components/compass_icon', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('TextItem', () => {
it('renders search invite type correctly', () => {
const {getByText} = renderWithIntl(
<TextItem
text='test@example.com'
type={TextItemType.SEARCH_INVITE}
testID='invite.text_item'
/>,
);
expect(getByText('invite')).toBeTruthy();
expect(getByText('test@example.com')).toBeTruthy();
});
it('renders search no results type correctly', () => {
const {getByText} = renderWithIntl(
<TextItem
text='test@example.com'
type={TextItemType.SEARCH_NO_RESULTS}
testID='invite.text_item'
/>,
);
expect(getByText('No one found matching')).toBeTruthy();
expect(getByText('test@example.com')).toBeTruthy();
});
it('renders summary type correctly', () => {
const {getByText} = renderWithIntl(
<TextItem
text='test@example.com'
type={TextItemType.SUMMARY}
testID='invite.text_item'
/>,
);
expect(getByText('test@example.com')).toBeTruthy();
});
});

View file

@ -10,6 +10,8 @@ import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {TextItemType} from './types';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
item: {
@ -44,15 +46,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
itemIcon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
flex: {
flex: 1,
},
};
});
export enum TextItemType {
SEARCH_INVITE = 'search_invite',
SEARCH_NO_RESULTS = 'search_no_results',
SUMMARY = 'summary',
}
type TextItemProps = {
text?: string;
type: TextItemType;
@ -73,7 +72,7 @@ export default function TextItem({
return (
<View
style={[styles.item, search ? styles.search : {}]}
style={[styles.item, search ? styles.search : undefined]}
testID={`${testID}.${text}`}
>
{email && (
@ -98,7 +97,7 @@ export default function TextItem({
</Text>
)}
<Text
style={[search ? styles.itemTerm : styles.itemText, {flex: 1}]}
style={[search ? styles.itemTerm : styles.itemText, styles.flex]}
numberOfLines={1}
testID={`${testID}.text.${text}`}
>

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type UserModel from '@typings/database/models/servers/user';
export type SendOptions = {
inviteAsGuest: boolean;
includeCustomMessage: boolean;
customMessage: string;
selectedChannels: string[];
guestMagicLink: boolean;
}
export type EmailInvite = string;
export type SearchResult = UserProfile|UserModel|EmailInvite;
export type InviteResult = {
userId?: string;
email?: string;
reason: string;
};
export type Result = {
sent: InviteResult[];
notSent: InviteResult[];
}
export enum TextItemType {
SEARCH_INVITE = 'search_invite',
SEARCH_NO_RESULTS = 'search_no_results',
SUMMARY = 'summary',
}

View file

@ -146,8 +146,8 @@ const Server = ({
handleConnect(managedConfig?.serverUrl || LocalConfig.DefaultServerUrl);
}
// functions do not need memoization
// eslint-disable-next-line react-hooks/exhaustive-deps
// We only want to handle connect when a smaller set of variables change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [managedConfig?.allowOtherServers, managedConfig?.serverUrl, managedConfig?.serverName, defaultServerUrl]);
useEffect(() => {
@ -189,8 +189,8 @@ const Server = ({
return () => backHandler.remove();
// only needed on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
// We register the back handler and the push notifications only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useNavButtonPressed(closeButtonId || '', componentId, dismiss, []);
@ -329,7 +329,12 @@ const Server = ({
}
return;
}
const result = await doPing(headRequest.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, preauthSecret.trim() || undefined);
const result = await doPing(
headRequest.url,
true, // verifyPushProxy
managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, // timeoutInterval
preauthSecret.trim() || undefined, // preauthSecret
);
if (canceled) {
return;

View file

@ -440,19 +440,32 @@
"intro.welcome.public": "Add some more team members to the channel or start a conversation below.",
"invite_people_to_team.message": "Heres a link to collaborate and communicate with us on Mattermost.",
"invite_people_to_team.title": "Join the {team} team",
"invite.custom_message": "Enter a custom message",
"invite.guest_magic_link": "Use magic link",
"invite.guest_magic_link_description": "Newly created guests will join and log in without a password, using a magic link sent to their email address",
"invite.invite_as_guest": "Invite as guest",
"invite.invite_as_guest_description": "Guests are limited to selected channels",
"invite.members.already_member": "This person is already a team member",
"invite.members.user_is_guest": "Contact your admin to make this guest a full member",
"invite.members.user_is_not_guest": "This person is already a member of the workspace. Invite them as a member instead of a guest.",
"invite.no_channels_selected": "Required for guests",
"invite.search.email_invite": "invite",
"invite.search.no_results": "No one found matching",
"invite.searchPlaceholder": "Type a name or email address…",
"invite.selected_channels": "Selected channels",
"invite.selected_channels_count": "{count} {count, plural, one{channel} other{channels}}",
"invite.send_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.",
"invite.send_invite": "Send",
"invite.sendInvitationsTo": "Send invitations to…",
"invite.set_custom_message": "Set a custom message",
"invite.shareLink": "Share link",
"invite.summary.back": "Go back",
"invite.summary.done": "Done",
"invite.summary.email_invite": "An invitation email has been sent",
"invite.summary.email_invite_failed": "Failed to send email invitation",
"invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully",
"invite.summary.guest_invite": "This guest has been added to the team and {count, plural, one {# channel} other {# channels}}.",
"invite.summary.guest_invite_failed": "Unable to add user to {channelsFailed, plural, one {# channel} other {# channels}}",
"invite.summary.member_invite": "Invited as a member of {teamDisplayName}",
"invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasnt} other {Invitations werent}} sent",
"invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent",

View file

@ -29,4 +29,4 @@ SHOW_ONBOARDING=true
SYNC_PROVISIONING_PROFILES=true
INTUNE_ENABLED=true
INTUNE_CLIENT_ID="b8543726-2747-4acc-950a-f1ec2766bc2a"
INTUNE_CLIENT_ID="64e9952b-20eb-46dc-92ad-99089ed24903"

View file

@ -569,7 +569,8 @@ platform :ios do
"ADALLogOverrideDisabled" => true,
"HooksAlwaysEnabled" => true,
"ADALClientId" => intune_client_id,
"ADALRedirectUri" => intune_redirect_uri
"ADALRedirectUri" => intune_redirect_uri,
"MaxFileProtectionLevel" => "NSFileProtectionCompleteUntilFirstUserAuthentication"
}
plist["LSApplicationQueriesSchemes"] = [

View file

@ -2085,7 +2085,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 689;
CURRENT_PROJECT_VERSION = 707;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
@ -2127,7 +2127,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 689;
CURRENT_PROJECT_VERSION = 707;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
@ -2273,7 +2273,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 689;
CURRENT_PROJECT_VERSION = 707;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2324,7 +2324,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 689;
CURRENT_PROJECT_VERSION = 707;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;

View file

@ -168,10 +168,18 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test";
// Required for deeplinking
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options{
// Handle MSAL URLs first before passing to RCTLinkingManager
if ([self handleMSALURL:url]) {
return YES;
}
return [RCTLinkingManager application:application openURL:url options:options];
}
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {
// Handle MSAL URLs first before passing to RCTLinkingManager
if ([self handleMSALURL:url]) {
return YES;
}
return [RCTLinkingManager application:application openURL:url sourceApplication:sourceApplication annotation:annotation];
}

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.35.0</string>
<string>2.36.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>689</string>
<string>707</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.35.0</string>
<string>2.36.1</string>
<key>CFBundleVersion</key>
<string>689</string>
<string>707</string>
<key>UIAppFonts</key>
<array>
<string>OpenSans-Bold.ttf</string>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.35.0</string>
<string>2.36.1</string>
<key>CFBundleVersion</key>
<string>689</string>
<string>707</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

@ -1 +1 @@
Subproject commit 412afe596e58c973673366c101ba309a21d115f0
Subproject commit 9a888c921b339c5f268ad202b6e498ca1678c82e

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "mattermost-mobile",
"version": "2.35.0",
"version": "2.36.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.35.0",
"version": "2.36.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "2.35.0",
"version": "2.36.1",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",

View file

@ -16,12 +16,12 @@ type TeamMembership = {
type TeamMemberWithError = {
member: TeamMembership;
user_id: string;
error: ApiError;
error?: ApiError;
}
type TeamInviteWithError = {
email: string;
error: ApiError;
error?: ApiError;
}
type TeamType = 'O' | 'I';