Feature schedule posts (#8509)

This commit is contained in:
Rajat Dabade 2025-04-14 22:08:59 +05:30 committed by GitHub
parent 5b72465e40
commit f75c50ad2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
159 changed files with 9289 additions and 770 deletions

View file

@ -31,6 +31,8 @@ import {
removeLastViewedThreadIdAndServer,
storePushDisabledInServerAcknowledged,
removePushDisabledInServerAcknowledged,
storeScheduledPostTutorial,
storeScheduledPostsListTutorial,
} from './global';
const serverUrl = 'server.test.com';
@ -183,4 +185,22 @@ describe('/app/actions/app/global', () => {
// @ts-expect-error testing error message
expect(response.error.message).toBe('App database not found');
});
test('storeScheduledPostTutorial', async () => {
let records = await queryGlobalValue(Tutorial.SCHEDULED_POST)?.fetch();
expect(records?.[0]?.value).toBeUndefined();
await storeScheduledPostTutorial();
records = await queryGlobalValue(Tutorial.SCHEDULED_POST)?.fetch();
expect(records?.[0]?.value).toBe(true);
});
test('storeScheduledPostsListTutorial', async () => {
let records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
expect(records?.[0]?.value).toBeUndefined();
await storeScheduledPostsListTutorial();
records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
expect(records?.[0]?.value).toBe(true);
});
});

View file

@ -44,6 +44,14 @@ export const storeDraftsTutorial = async () => {
return storeGlobal(Tutorial.DRAFTS, 'true', false);
};
export const storeScheduledPostTutorial = async () => {
return storeGlobal(Tutorial.SCHEDULED_POST, 'true', false);
};
export const storeScheduledPostsListTutorial = async () => {
return storeGlobal(Tutorial.SCHEDULED_POSTS_LIST, 'true', false);
};
export const storeDontAskForReview = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.DONT_ASK_FOR_REVIEW, 'true', prepareRecordsOnly);
};

View file

@ -1,11 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {DeviceEventEmitter} from 'react-native';
import {Navigation, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
import DatabaseManager from '@database/manager';
import {goToScreen, popTo} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {
switchToGlobalDrafts,
updateDraftFile,
removeDraftFile,
updateDraftMessage,
@ -48,6 +55,16 @@ afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
jest.mock('@utils/helpers', () => ({
...jest.requireActual('@utils/helpers'),
isTablet: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
popTo: jest.fn(),
goToScreen: jest.fn(),
}));
describe('updateDraftFile', () => {
it('handle not found database', async () => {
const {error} = await updateDraftFile('foo', channelId, '', fileInfo, false);
@ -244,6 +261,114 @@ describe('updateDraftPriority', () => {
});
});
describe('switchToGlobalDrafts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should emit navigation event on tablet', async () => {
jest.mocked(isTablet).mockReturnValue(true);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
await switchToGlobalDrafts(serverUrl);
expect(emitSpy).toHaveBeenCalledWith(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS, {});
});
it('if prepareRecordsOnly is true, should emit navigation event on tablet for Scheduled post tab and also call batchRecord', async () => {
jest.mocked(isTablet).mockReturnValue(true);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
const batchRecordSpy = jest.spyOn(operator, 'batchRecords');
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
await switchToGlobalDrafts(serverUrl, '', DRAFT_SCREEN_TAB_SCHEDULED_POSTS, true);
expect(batchRecordSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalled();
});
it('should fail to emit navigation event on tablet', async () => {
jest.mocked(isTablet).mockReturnValue(true);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
await switchToGlobalDrafts('nonexistent');
expect(emitSpy).not.toHaveBeenCalled();
});
it('should fail to emit navigation event on tablet if currentTeamId is not set', async () => {
jest.mocked(isTablet).mockReturnValue(true);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: ''}], prepareRecordsOnly: false});
await switchToGlobalDrafts(serverUrl);
expect(emitSpy).not.toHaveBeenCalled();
});
it('should call goToScreen on non-tablet', async () => {
jest.mocked(isTablet).mockReturnValue(false);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
const goToScreenMock = jest.mocked(goToScreen);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
await switchToGlobalDrafts(serverUrl);
expect(goToScreenMock).toHaveBeenCalledWith(Screens.GLOBAL_DRAFTS, '', {}, {topBar: {visible: false}});
expect(emitSpy).not.toHaveBeenCalled();
});
it('should not call goToScreen on non-tablet when server url is a non existent URL', async () => {
jest.mocked(isTablet).mockReturnValue(false);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
const goToScreenMock = jest.mocked(goToScreen);
await switchToGlobalDrafts('nonexistent');
expect(goToScreenMock).not.toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalled();
});
it('should pass initialTab param when provided', async () => {
jest.mocked(isTablet).mockReturnValue(true);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
await switchToGlobalDrafts(serverUrl, teamId, DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
expect(emitSpy).toHaveBeenCalledWith(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS, {initialTab: DRAFT_SCREEN_TAB_SCHEDULED_POSTS});
await switchToGlobalDrafts(serverUrl, teamId, DRAFT_SCREEN_TAB_DRAFTS);
expect(emitSpy).toHaveBeenCalledWith(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS, {initialTab: DRAFT_SCREEN_TAB_DRAFTS});
});
it('should pass initialTab param when provided on non-tablet', async () => {
jest.mocked(isTablet).mockReturnValue(false);
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
const goToScreenMock = jest.mocked(goToScreen);
await switchToGlobalDrafts(serverUrl, teamId, DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
expect(goToScreenMock).toHaveBeenCalledWith(Screens.GLOBAL_DRAFTS, '', {initialTab: DRAFT_SCREEN_TAB_SCHEDULED_POSTS}, {topBar: {visible: false}});
await switchToGlobalDrafts(serverUrl, teamId, DRAFT_SCREEN_TAB_DRAFTS);
expect(goToScreenMock).toHaveBeenCalledWith(Screens.GLOBAL_DRAFTS, '', {initialTab: DRAFT_SCREEN_TAB_DRAFTS}, {topBar: {visible: false}});
expect(emitSpy).not.toHaveBeenCalled();
});
it('should call popto from navigation store if Global draft is alreay present', async () => {
NavigationStore.addScreenToStack(Screens.GLOBAL_DRAFTS);
NavigationStore.addScreenToStack(Screens.CHANNEL);
NavigationStore.addScreenToStack(Screens.THREAD);
await switchToGlobalDrafts(serverUrl, teamId, DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
expect(popTo).toHaveBeenCalledWith(Screens.GLOBAL_DRAFTS);
});
});
describe('updateDraftMarkdownImageMetadata', () => {
const postImageData: PostImage = {
height: 1080,

View file

@ -6,17 +6,65 @@ import {DeviceEventEmitter, Image} from 'react-native';
import {Navigation, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getDraft} from '@queries/servers/drafts';
import {goToScreen} from '@screens/navigation';
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory} from '@queries/servers/team';
import {goToScreen, popTo} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {logError} from '@utils/log';
import {isParsableUrl} from '@utils/url';
export const switchToGlobalDrafts = async () => {
const isTablelDevice = isTablet();
if (isTablelDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS);
} else {
goToScreen(Screens.GLOBAL_DRAFTS, '', {}, {topBar: {visible: false}});
import type {DraftScreenTab} from '@constants/draft';
import type {Model} from '@nozbe/watermelondb';
type goToScreenParams = {
initialTab?: DraftScreenTab;
}
export const switchToGlobalDrafts = async (serverUrl: string, teamId?: string, initialTab?: DraftScreenTab, prepareRecordsOnly = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
let teamIdToUse = teamId;
if (!teamId) {
teamIdToUse = await getCurrentTeamId(database);
}
if (!teamIdToUse) {
throw new Error('no team to switch to');
}
const currentChannelId = await getCurrentChannelId(database);
await setCurrentTeamAndChannelId(operator, teamIdToUse, currentChannelId);
const history = await addChannelToTeamHistory(operator, teamIdToUse, Screens.GLOBAL_DRAFTS, true);
models.push(...history);
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'switchToGlobalDrafts');
}
const params: goToScreenParams = {};
const isDraftAlreadyInNavigationStack = NavigationStore.getScreensInStack().includes(Screens.GLOBAL_DRAFTS);
if (isDraftAlreadyInNavigationStack) {
popTo(Screens.GLOBAL_DRAFTS);
return {models};
}
params.initialTab = initialTab;
const isTabletDevice = isTablet();
if (isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS, params);
} else {
goToScreen(Screens.GLOBAL_DRAFTS, '', params, {topBar: {visible: false}});
}
return {models};
} catch (error) {
logError('Failed switchToGlobalDrafts', error);
return {error};
}
};

View file

@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import {scheduledPostsAction, updateScheduledPostErrorCode} from './scheduled_post';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const scheduledPosts: ScheduledPost[] = [
{
channel_id: 'channel_id',
error_code: '',
files: [],
id: 'scheduled_post_id',
message: 'test scheduled post',
metadata: {},
processed_at: 0,
root_id: '',
scheduled_at: 123,
update_at: 456,
create_at: 789,
user_id: '',
},
{
id: 'scheduled_post_id_2',
channel_id: 'channel_id',
root_id: '',
message: 'test scheduled post 2',
files: [],
metadata: {},
scheduled_at: 123,
user_id: 'user_id',
processed_at: 0,
update_at: 456,
create_at: 789,
error_code: '',
},
];
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('handleScheduledPosts', () => {
it('handle not found database', async () => {
const {error} = await scheduledPostsAction('foo', ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, scheduledPosts) as {error: Error};
expect(error.message).toBe('foo database not found');
});
it('should create scheduled post', async () => {
const {models} = await scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, scheduledPosts);
expect(models?.length).toBe(2);
expect(models![0].id).toBe(scheduledPosts[0].id);
expect(models![1].id).toBe(scheduledPosts[1].id);
});
it('should call operator handleScheduledPosts', async () => {
const spyHandledScheduledPosts = jest.spyOn(operator, 'handleScheduledPosts');
await scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, scheduledPosts);
expect(spyHandledScheduledPosts).toHaveBeenCalledTimes(1);
expect(spyHandledScheduledPosts).toHaveBeenCalledWith({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
prepareRecordsOnly: false,
});
});
it('should prepare records only', async () => {
const spybatchRecords = jest.spyOn(operator, 'batchRecords');
const {models} = await scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, scheduledPosts, true);
expect(spybatchRecords).not.toHaveBeenCalled();
expect(models).toHaveLength(2);
expect(models![0].id).toBe(scheduledPosts[0].id);
});
it('should update scheduled post', async () => {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
prepareRecordsOnly: false,
});
const updatedScheduledPost = {
...scheduledPosts[0],
message: 'updated test scheduled post',
update_at: scheduledPosts[0].update_at + 1,
};
const {models} = await scheduledPostsAction(
serverUrl,
ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
[updatedScheduledPost],
false,
);
expect(models![0].message).toEqual(updatedScheduledPost.message);
expect(models![0].id).toEqual(updatedScheduledPost.id);
});
it('should delete scheduled post', async () => {
const spyBatchRecords = jest.spyOn(operator, 'batchRecords');
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
prepareRecordsOnly: false,
});
const {models} = await scheduledPostsAction(
serverUrl,
ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST,
[scheduledPosts[0]],
false,
);
expect(spyBatchRecords).toHaveBeenCalledWith(models, '_deleteScheduledPostByIds');
expect(models![0].id).toEqual(scheduledPosts[0].id);
});
it('should return undefined if no scheduled post', async () => {
const {models} = await scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, []);
expect(models).toBeUndefined();
});
it('should not call batchRecords if perpareRecordsOnly is true', async () => {
const spyBatchRecords = jest.spyOn(operator, 'batchRecords');
await scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST, scheduledPosts, true);
expect(spyBatchRecords).not.toHaveBeenCalled();
});
});
describe('handleUpdateScheduledPostErrorCode', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should update scheduled post error code successfully', async () => {
await scheduledPostsAction(
serverUrl,
ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
false,
);
const scheduledPostId = scheduledPosts[0].id;
const errorCode = 'channel_not_found';
const result = await updateScheduledPostErrorCode(serverUrl, scheduledPostId, errorCode);
expect(result).toBeDefined();
expect(result.models).toBeDefined();
expect(result.models![0].errorCode).toBe(errorCode);
});
it('should handle errors when updating scheduled post error code', async () => {
const scheduledPostId = 'post123';
const errorCode = 'channel_not_found';
const invalidServerUrl = 'foo';
const result = await updateScheduledPostErrorCode(invalidServerUrl, scheduledPostId, errorCode);
expect(result.error).toBeDefined();
expect((result.error as Error).message).toBe('foo database not found');
});
});

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import {logError} from '@utils/log';
import type {ScheduledPostErrorCode} from '@typings/utils/scheduled_post';
export async function scheduledPostsAction(serverUrl: string, actionType: string, scheduledPosts: ScheduledPost[], prepareRecordsOnly = false): Promise<{models?: ScheduledPostModel[]; error?: unknown}> {
if (!scheduledPosts.length) {
return {models: undefined};
}
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handleScheduledPosts({actionType, scheduledPosts, prepareRecordsOnly});
return {models};
} catch (error) {
logError('ScheduledPostsAction cannot handle scheduled post', error);
return {error};
}
}
export async function updateScheduledPostErrorCode(serverUrl: string, scheduledPostId: string, errorCode: ScheduledPostErrorCode, prepareRecordsOnly = false): Promise<{models?: ScheduledPostModel[]; error?: unknown}> {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = [await operator.handleUpdateScheduledPostErrorCode({scheduledPostId, errorCode, prepareRecordsOnly})];
return {models};
} catch (error) {
logError('UpdateScheduledPostErrorCode cannot update scheduled post error code', error);
return {error};
}
}

View file

@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {switchToGlobalDrafts} from '@actions/local/draft';
import {switchToGlobalThreads} from '@actions/local/thread';
import {loadCallForChannel} from '@calls/actions/calls';
import {DeepLink, Events, General, Preferences, Screens} from '@constants';
@ -1123,6 +1124,8 @@ export async function getChannelTimezones(serverUrl: string, channelId: string)
export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false, groupLabel?: RequestGroupLabel) {
if (channelId === Screens.GLOBAL_THREADS) {
return switchToGlobalThreads(serverUrl, teamId);
} else if (channelId === Screens.GLOBAL_DRAFTS) {
return switchToGlobalDrafts(serverUrl, teamId);
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
@ -1347,6 +1350,8 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string
if (currentServer?.url === serverUrl) {
if (newChannelId === Screens.GLOBAL_THREADS) {
await switchToGlobalThreads(serverUrl, teamId, false);
} else if (newChannelId === Screens.GLOBAL_DRAFTS) {
await switchToGlobalDrafts(serverUrl, teamId);
} else {
await switchToChannelById(serverUrl, newChannelId, teamId, true);
}

View file

@ -10,6 +10,7 @@ import {handleKickFromChannel, fetchAllMyChannelsForAllTeams, fetchMissingDirect
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {fetchMyPreferences} from '@actions/remote/preference';
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsThreads, updateCanJoinTeams, handleKickFromTeam, type MyTeamsRequest} from '@actions/remote/team';
import {fetchMe, updateAllUsersSince, autoUpdateTimezone} from '@actions/remote/user';
@ -25,6 +26,7 @@ import NavigationStore from '@store/navigation_store';
import {entry, setExtraSessionProps, verifyPushProxy, entryInitialChannelId, restDeferredAppEntryActions, handleEntryAfterLoadNavigation, deferredAppEntryActions} from './common';
jest.mock('@actions/remote/channel');
jest.mock('@actions/remote/scheduled_post');
jest.mock('@actions/remote/preference');
jest.mock('@actions/remote/systems');
jest.mock('@actions/remote/team');
@ -117,7 +119,7 @@ describe('actions/remote/entry/common', () => {
const mockChannels = {channels: [], memberships: []};
(fetchAllMyChannelsForAllTeams as jest.Mock).mockResolvedValue(mockChannels);
const result = await entry(serverUrl);
const result = await entry(serverUrl, 'team1');
expect(result).toEqual(expect.objectContaining({
initialChannelId: '',
@ -323,6 +325,7 @@ describe('actions/remote/entry/common', () => {
expect(updateCanJoinTeams).toHaveBeenCalledWith(serverUrl);
expect(fetchPostsForUnreadChannels).toHaveBeenCalled();
expect(fetchGroupsForMember).toHaveBeenCalledWith(serverUrl, currentUserId, false, undefined);
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, initialTeamId, true, undefined);
});
it('should handle missing data gracefully', async () => {

View file

@ -9,6 +9,7 @@ import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsThreads, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
@ -221,7 +222,7 @@ export async function entryInitialChannelId(database: Database, requestedChannel
// Check if we are still members of any channel on the history
const teamChannelHistory = await getTeamChannelHistory(database, initialTeamId);
for (const c of teamChannelHistory) {
if (membershipIds.has(c) || c === Screens.GLOBAL_THREADS) {
if (membershipIds.has(c) || c === Screens.GLOBAL_THREADS || c === Screens.GLOBAL_DRAFTS) {
return c;
}
}
@ -305,6 +306,10 @@ export async function restDeferredAppEntryActions(
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId, false, requestLabel);
if (initialTeamId) {
fetchScheduledPosts(serverUrl, initialTeamId, true, groupLabel);
}
}
export const setExtraSessionProps = async (serverUrl: string, groupLabel?: RequestGroupLabel) => {

View file

@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getConfigValue, getCurrentTeamId} from '@queries/servers/system';
import {logError} from '@utils/log';
import {createScheduledPost, deleteScheduledPost, fetchScheduledPosts, updateScheduledPost} from './scheduled_post';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@utils/log', () => ({
logError: jest.fn(),
}));
jest.mock('@actions/remote/session', () => ({
forceLogoutIfNecessary: jest.fn(),
}));
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile;
const scheduledPost: ScheduledPost = {
id: 'scheduledPostId1',
root_id: '',
update_at: 0,
channel_id: 'channelid1',
message: 'Test message',
scheduled_at: Date.now() + 10000,
create_at: Date.now(),
user_id: 'userid1',
error_code: '',
};
const scheduledPostsResponse: FetchScheduledPostsResponse = {
directChannels: [],
bar: [
{
id: 'scheduled_post_id',
channel_id: 'channel_id',
root_id: '',
message: 'test scheduled post',
scheduled_at: 123,
user_id: 'user_id',
processed_at: 0,
update_at: 456,
create_at: 789,
error_code: '',
},
{
id: 'scheduled_post_id_2',
channel_id: 'channel_id',
root_id: '',
message: 'test scheduled post 2',
scheduled_at: 123,
user_id: 'user_id',
processed_at: 0,
update_at: 456,
create_at: 789,
error_code: '',
},
],
};
const throwFunc = () => {
throw Error('error');
};
const mockConnectionId = 'mock-connection-id';
const mockClient = {
createScheduledPost: jest.fn((post, connId) => ({...scheduledPost, connectionId: connId})),
updateScheduledPost: jest.fn(() => Promise.resolve(({...scheduledPost}))),
getScheduledPostsForTeam: jest.fn(() => Promise.resolve({...scheduledPostsResponse})),
deleteScheduledPost: jest.fn((scheduledPostId, connId) => {
const post = scheduledPostsResponse.bar.find((p) => p.id === scheduledPostId);
return Promise.resolve({...post, connectionId: connId});
}),
};
const mockWebSocketClient = {
getConnectionId: jest.fn(() => mockConnectionId),
};
jest.mock('@queries/servers/system', () => ({
getConfigValue: jest.fn(),
getCurrentTeamId: jest.fn(),
getCurrentUserId: jest.fn(),
}));
jest.mock('@managers/websocket_manager', () => ({
getClient: jest.fn(() => mockWebSocketClient),
}));
jest.mock('@utils/scheduled_post', () => {
return {
isScheduledPostModel: jest.fn(() => false),
};
});
const mockedGetConfigValue = jest.mocked(getConfigValue);
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.getServerDatabaseAndOperator(serverUrl)!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('createScheduledPost', () => {
it('handle not found database', async () => {
const result = await createScheduledPost('foo', scheduledPost);
expect((result.error)).toBe('foo database not found');
expect(logError).toHaveBeenCalled();
expect(forceLogoutIfNecessary).toHaveBeenCalled();
});
it('base case', async () => {
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const result = await createScheduledPost(serverUrl, scheduledPost);
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(result!.response!.id).toBe(scheduledPost.id);
expect(mockClient.createScheduledPost).toHaveBeenCalledWith(scheduledPost, mockConnectionId);
});
it('request error', async () => {
const error = new Error('custom error');
mockClient.createScheduledPost.mockImplementationOnce(jest.fn(() => {
throw error;
}));
const result = await createScheduledPost(serverUrl, scheduledPost);
expect(result.error).toBe('custom error');
expect(logError).toHaveBeenCalledWith('error on createScheduledPost', error.message);
expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error);
});
it('operator handling error', async () => {
const error = new Error('operator error');
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
jest.spyOn(operator, 'handleScheduledPosts').mockRejectedValueOnce(error);
const result = await createScheduledPost(serverUrl, scheduledPost);
expect(result.error).toBe('operator error');
expect(logError).toHaveBeenCalledWith('error on createScheduledPost', error.message);
expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error);
});
});
describe('fetchScheduledPosts', () => {
it('handle database not found', async () => {
const {error} = await fetchScheduledPosts('foo', 'bar');
expect((error as Error).message).toEqual('foo database not found');
expect(logError).toHaveBeenCalled();
expect(forceLogoutIfNecessary).toHaveBeenCalled();
});
it('handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await fetchScheduledPosts(serverUrl, 'bar');
expect(result.error).toBeTruthy();
});
it('handle scheduled post disabled', async () => {
const result = await fetchScheduledPosts(serverUrl, 'bar');
expect(result.scheduledPosts).toEqual([]);
expect(mockClient.getScheduledPostsForTeam).not.toHaveBeenCalled();
});
it('handle scheduled post enabled', async () => {
jest.mocked(getCurrentTeamId).mockResolvedValue('bar');
mockedGetConfigValue.mockResolvedValueOnce('true');
const spyHandleScheduledPosts = jest.spyOn(operator, 'handleScheduledPosts');
const result = await fetchScheduledPosts(serverUrl, 'bar');
expect(result.scheduledPosts).toEqual(scheduledPostsResponse.bar);
expect(spyHandleScheduledPosts).toHaveBeenCalledWith({
actionType: ActionType.SCHEDULED_POSTS.RECEIVED_ALL_SCHEDULED_POSTS,
scheduledPosts: scheduledPostsResponse.bar,
prepareRecordsOnly: false,
includeDirectChannelPosts: false,
});
});
});
describe('updateScheduledPost', () => {
it('handle not found database', async () => {
const result = await updateScheduledPost('foo', scheduledPost, 123) as unknown as {error: Error};
expect(result).toEqual({error: 'foo database not found'});
expect(logError).toHaveBeenCalled();
expect(forceLogoutIfNecessary).toHaveBeenCalled();
});
it('base case', async () => {
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const result = await updateScheduledPost(serverUrl, scheduledPost, 123);
expect(result.error).toBeUndefined();
expect(result.scheduledPost).toEqual(scheduledPost);
});
it('request error', async () => {
const error = new Error('custom error');
mockClient.updateScheduledPost = jest.fn().mockRejectedValueOnce(error);
const result = await updateScheduledPost(serverUrl, scheduledPost, 123);
expect(result.error).toBe('custom error');
expect(logError).toHaveBeenCalledWith('error on updateScheduledPost', error.message);
expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error);
});
it('fetch only', async () => {
const spyHandleScheduledPosts = jest.spyOn(operator, 'handleScheduledPosts');
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const mockResponse = {...scheduledPost, update_at: Date.now()};
mockClient.updateScheduledPost.mockImplementationOnce(() => Promise.resolve(mockResponse));
const result = await updateScheduledPost(serverUrl, scheduledPost, 123, true);
console.log(result.error);
expect(result.error).toBeUndefined();
expect(result.scheduledPost).toEqual(mockResponse);
expect(spyHandleScheduledPosts).not.toHaveBeenCalled();
});
});
describe('deleteScheduledPost', () => {
it('handle database not found', async () => {
const {error} = await deleteScheduledPost('foo', 'scheduled_post_id');
expect((error as Error).message).toBe('foo database not found');
expect(logError).toHaveBeenCalled();
expect(forceLogoutIfNecessary).toHaveBeenCalled();
});
it('handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await deleteScheduledPost(serverUrl, 'scheduled_post_id');
expect(result.error).toBeTruthy();
});
it('handle scheduled post enabled', async () => {
const spyHandleScheduledPosts = jest.spyOn(operator, 'handleScheduledPosts');
const result = await deleteScheduledPost(serverUrl, 'scheduled_post_id');
expect(result.scheduledPost).toEqual({...scheduledPostsResponse.bar[0], connectionId: mockConnectionId});
expect(spyHandleScheduledPosts).toHaveBeenCalledWith({
actionType: ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST,
scheduledPosts: [{...scheduledPostsResponse.bar[0], connectionId: mockConnectionId}],
prepareRecordsOnly: false,
});
expect(mockClient.deleteScheduledPost).toHaveBeenCalledWith('scheduled_post_id', mockConnectionId);
});
});

View file

@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import websocketManager from '@managers/websocket_manager';
import {getConfigValue, getCurrentUserId} from '@queries/servers/system';
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {isScheduledPostModel} from '@utils/scheduled_post';
import {forceLogoutIfNecessary} from './session';
import type {CreateResponse} from '@hooks/handle_send_message';
export async function createScheduledPost(serverUrl: string, schedulePost: ScheduledPost): Promise<CreateResponse> {
try {
const operator = DatabaseManager.getServerDatabaseAndOperator(serverUrl).operator;
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
const client = NetworkManager.getClient(serverUrl);
const response = await client.createScheduledPost(schedulePost, connectionId);
if (response) {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts: [response],
prepareRecordsOnly: false,
});
}
return {data: true, response};
} catch (error) {
logError('error on createScheduledPost', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error: getFullErrorMessage(error)};
}
}
export async function updateScheduledPost(serverUrl: string, scheduledPost: ScheduledPost | ScheduledPostModel, updateTime: number, fetchOnly = false) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const currentUserId = await getCurrentUserId(database);
const normalizedScheduledPost = isScheduledPostModel(scheduledPost) ? scheduledPost.toApi(currentUserId) : scheduledPost;
normalizedScheduledPost.scheduled_at = updateTime;
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
const response = await client.updateScheduledPost(normalizedScheduledPost, connectionId);
if (response && !fetchOnly) {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts: [response],
prepareRecordsOnly: false,
});
}
return {scheduledPost: response};
} catch (error) {
logError('error on updateScheduledPost', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error: getFullErrorMessage(error)};
}
}
export async function fetchScheduledPosts(serverUrl: string, teamId: string, includeDirectChannels = false, groupLabel?: RequestGroupLabel) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const scheduledPostEnabled = (await getConfigValue(database, 'ScheduledPosts')) === 'true';
if (!scheduledPostEnabled) {
return {scheduledPosts: []};
}
const scheduledPostsResponse = await client.getScheduledPostsForTeam(teamId, includeDirectChannels, groupLabel);
const {directChannels = [], ...scheduledPostsByTeam} = scheduledPostsResponse;
const scheduledPosts = [...Object.values(scheduledPostsByTeam).flat(), ...directChannels];
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.RECEIVED_ALL_SCHEDULED_POSTS,
scheduledPosts,
prepareRecordsOnly: false,
includeDirectChannelPosts: includeDirectChannels,
});
return {scheduledPosts};
} catch (error) {
logError('error on fetchScheduledPosts', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}
export async function deleteScheduledPost(serverUrl: string, scheduledPostId: string) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
const result = await client.deleteScheduledPost(scheduledPostId, connectionId);
if (result) {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST,
scheduledPosts: [result],
prepareRecordsOnly: false,
});
}
return {scheduledPost: result};
} catch (error) {
logError('error on deleteScheduledPost', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}

View file

@ -8,6 +8,7 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {fetchScheduledPosts} from './scheduled_post';
import {
addCurrentUserToTeam,
addUserToTeam,
@ -28,6 +29,10 @@ import {
fetchTeamsThreads,
} from './team';
jest.mock('./scheduled_post', () => ({
fetchScheduledPosts: jest.fn(),
}));
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
@ -310,6 +315,7 @@ describe('teams', () => {
const result = await handleTeamChange(serverUrl, teamId);
expect(result).toBeDefined();
expect(result?.error).toBeUndefined();
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, teamId, false);
});
it('handleKickFromTeam - base case', async () => {

View file

@ -5,6 +5,7 @@ import {chunk} from 'lodash';
import {DeviceEventEmitter} from 'react-native';
import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team';
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
@ -436,6 +437,7 @@ export async function handleTeamChange(serverUrl: string, teamId: string) {
// Fetch Groups + GroupTeams
fetchGroupsForTeamIfConstrained(serverUrl, teamId);
fetchScheduledPosts(serverUrl, teamId, false);
return {};
}

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import * as bookmark from '@actions/local/channel_bookmark';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
import {WebsocketEvents} from '@constants';
@ -33,6 +34,7 @@ jest.mock('./threads');
jest.mock('@calls/connection/websocket_event_handlers');
jest.mock('./group');
jest.mock('@actions/local/channel_bookmark');
jest.mock('@actions/websocket/scheduled_post');
describe('handleWebSocketEvent', () => {
const serverUrl = 'https://example.com';
@ -521,4 +523,22 @@ describe('handleWebSocketEvent', () => {
await handleWebSocketEvent(serverUrl, msg);
expect(bookmark.handleBookmarkSorted).toHaveBeenCalledWith(serverUrl, msg);
});
it('should handle SCHEDULED_POST_CREATED event', async () => {
msg.event = WebsocketEvents.SCHEDULED_POST_CREATED;
await handleWebSocketEvent(serverUrl, msg);
expect(scheduledPost.handleCreateOrUpdateScheduledPost).toHaveBeenCalledWith(serverUrl, msg);
});
it('should handle SCHEDULED_POST_UPDATED event', async () => {
msg.event = WebsocketEvents.SCHEDULED_POST_UPDATED;
await handleWebSocketEvent(serverUrl, msg);
expect(scheduledPost.handleCreateOrUpdateScheduledPost).toHaveBeenCalledWith(serverUrl, msg);
});
it('should handle SCHEDULED_POST_DELETED event', async () => {
msg.event = WebsocketEvents.SCHEDULED_POST_DELETED;
await handleWebSocketEvent(serverUrl, msg);
expect(scheduledPost.handleDeleteScheduledPost).toHaveBeenCalledWith(serverUrl, msg);
});
});

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import * as bookmark from '@actions/local/channel_bookmark';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
import {WebsocketEvents} from '@constants';
@ -294,5 +295,14 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.CHANNEL_BOOKMARK_SORTED:
bookmark.handleBookmarkSorted(serverUrl, msg);
break;
// scheduled posts
case WebsocketEvents.SCHEDULED_POST_CREATED:
case WebsocketEvents.SCHEDULED_POST_UPDATED:
scheduledPost.handleCreateOrUpdateScheduledPost(serverUrl, msg);
break;
case WebsocketEvents.SCHEDULED_POST_DELETED:
scheduledPost.handleDeleteScheduledPost(serverUrl, msg);
break;
}
}

View file

@ -29,6 +29,7 @@ jest.mock('@actions/local/systems');
jest.mock('@actions/remote/channel');
jest.mock('@actions/remote/entry/common');
jest.mock('@actions/remote/post');
jest.mock('@actions/remote/scheduled_post');
jest.mock('@actions/remote/preference');
jest.mock('@actions/remote/user');
jest.mock('@calls/actions/calls');

View file

@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {ScheduledPostModel} from '@database/models/server';
import {handleCreateOrUpdateScheduledPost, handleDeleteScheduledPost} from './scheduled_post';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const scheduledPost: ScheduledPost = {
channel_id: 'channel_id',
error_code: '',
files: [],
id: 'scheduled_post_id',
message: 'test scheduled post',
metadata: {},
processed_at: 0,
root_id: '',
scheduled_at: 123,
update_at: 456,
user_id: '',
create_at: 789,
};
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('handleCreateOrUpdateSchedulePost', () => {
it('handle database not found', async () => {
const {error} = await handleCreateOrUpdateScheduledPost('foo', {data: {scheduledPost: JSON.stringify(scheduledPost)}} as WebSocketMessage);
expect((error as Error).message).toBe('foo database not found');
});
it('wrong websocket scheduled post message', async () => {
const {models, error} = await handleCreateOrUpdateScheduledPost('foo', {data: {}} as WebSocketMessage);
expect(error).toBeUndefined();
expect(models).toBeUndefined();
});
it('no scheduled post', async () => {
const {models} = await handleCreateOrUpdateScheduledPost(serverUrl, {data: {scheduledPost: ''}} as WebSocketMessage);
expect(models).toBeUndefined();
});
it('success', async () => {
const {models} = await handleCreateOrUpdateScheduledPost(serverUrl, {data: {scheduledPost: JSON.stringify(scheduledPost)}} as WebSocketMessage);
expect(models).toBeDefined();
expect(models![0].id).toEqual(scheduledPost.id);
// Verify post exists in database
const scheduledPosts = await operator.database.get<ScheduledPostModel>(MM_TABLES.SERVER.SCHEDULED_POST).query().fetch();
expect(scheduledPosts.length).toBe(1);
expect(scheduledPosts[0].id).toBe(scheduledPost.id);
expect(scheduledPosts[0].message).toBe(scheduledPost.message);
});
it('should return error for invalid JSON payload', async () => {
const {models, error} = await handleCreateOrUpdateScheduledPost(serverUrl, {data: {scheduledPost: 'invalid_json'}} as WebSocketMessage);
expect(models).toBeUndefined();
expect(error).toBeDefined();
});
});
describe('handleDeleteScheduledPost', () => {
it('handle empty payload', async () => {
const {error, models} = await handleDeleteScheduledPost('foo', {data: {}} as WebSocketMessage);
expect(error).toBeUndefined();
expect(models).toBeUndefined();
});
it('no scheduled post', async () => {
const {models} = await handleDeleteScheduledPost(serverUrl, {data: {scheduledPost: ''}} as WebSocketMessage);
expect(models).toBeUndefined();
});
it('success', async () => {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts: [scheduledPost],
prepareRecordsOnly: false,
});
const deletedRecord = await handleDeleteScheduledPost(serverUrl, {data: {scheduledPost: JSON.stringify(scheduledPost)}} as WebSocketMessage);
expect(deletedRecord.models).toBeDefined();
expect(deletedRecord!.models!.length).toBe(1);
expect(deletedRecord!.models![0].id).toBe(scheduledPost.id);
// Verify post was deleted from database
const scheduledPosts = await operator.database.get<ScheduledPostModel>(MM_TABLES.SERVER.SCHEDULED_POST).query().fetch();
expect(scheduledPosts.length).toBe(0);
});
it('should return error for invalid JSON payload', async () => {
const {models, error} = await handleDeleteScheduledPost(serverUrl, {data: {scheduledPost: 'invalid_json'}} as WebSocketMessage);
expect(models).toBeUndefined();
expect(error).toBeDefined();
});
});

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {scheduledPostsAction} from '@actions/local/scheduled_post';
import {ActionType} from '@constants';
import {logError} from '@utils/log';
export type ScheduledPostWebsocketEventPayload = {
scheduledPost: string;
}
export async function handleCreateOrUpdateScheduledPost(serverUrl: string, msg: WebSocketMessage<ScheduledPostWebsocketEventPayload>, prepareRecordsOnly = false) {
try {
const scheduledPost: ScheduledPost[] = msg.data.scheduledPost ? [JSON.parse(msg.data.scheduledPost)] : [];
return scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, scheduledPost, prepareRecordsOnly);
} catch (error) {
logError('handleCreateOrUpdateScheduledPost cannot handle scheduled post added/update websocket event', error);
return {error};
}
}
export async function handleDeleteScheduledPost(serverUrl: string, msg: WebSocketMessage<ScheduledPostWebsocketEventPayload>, prepareRecordsOnly = false) {
try {
const scheduledPost: ScheduledPost[] = msg.data.scheduledPost ? [JSON.parse(msg.data.scheduledPost)] : [];
return scheduledPostsAction(serverUrl, ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST, scheduledPost, prepareRecordsOnly);
} catch (error) {
logError('handleDeleteScheduledPost cannot handle scheduled post deleted websocket event', error);
return {error};
}
}

View file

@ -171,6 +171,10 @@ export default class ClientBase extends ClientTracking {
return `${this.urlVersion}/redirect_location`;
}
getTeamAndDirectChannelScheduledPostsRoute() {
return `${this.getPostsRoute()}/scheduled`;
}
getThreadsRoute(userId: string, teamId: string): string {
return `${this.getUserRoute(userId)}/teams/${teamId}/threads`;
}
@ -203,6 +207,10 @@ export default class ClientBase extends ClientTracking {
return `${this.urlVersion}/custom_profile_attributes`;
}
getScheduledPostRoute() {
return `${this.getPostsRoute()}/schedule`;
}
getUserCustomProfileAttributesRoute(userId: string) {
return `${this.getUsersRoute()}/${userId}/custom_profile_attributes`;
}

View file

@ -20,6 +20,7 @@ import ClientIntegrations, {type ClientIntegrationsMix} from './integrations';
import ClientNPS, {type ClientNPSMix} from './nps';
import ClientPosts, {type ClientPostsMix} from './posts';
import ClientPreferences, {type ClientPreferencesMix} from './preferences';
import ClientScheduledPost, {type ClientScheduledPostMix} from './scheduled_post';
import ClientTeams, {type ClientTeamsMix} from './teams';
import ClientThreads, {type ClientThreadsMix} from './threads';
import ClientTos, {type ClientTosMix} from './tos';
@ -39,6 +40,7 @@ interface Client extends ClientBase,
ClientIntegrationsMix,
ClientPostsMix,
ClientPreferencesMix,
ClientScheduledPostMix,
ClientTeamsMix,
ClientThreadsMix,
ClientTosMix,
@ -46,7 +48,8 @@ interface Client extends ClientBase,
ClientCallsMix,
ClientPluginsMix,
ClientNPSMix,
ClientCustomAttributesMix
ClientCustomAttributesMix,
ClientScheduledPostMix
{}
class Client extends mix(ClientBase).with(
@ -61,6 +64,7 @@ class Client extends mix(ClientBase).with(
ClientIntegrations,
ClientPosts,
ClientPreferences,
ClientScheduledPost,
ClientTeams,
ClientThreads,
ClientTos,
@ -69,6 +73,7 @@ class Client extends mix(ClientBase).with(
ClientPlugins,
ClientNPS,
ClientCustomAttributes,
ClientScheduledPost,
) {
// eslint-disable-next-line no-useless-constructor
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {

View file

@ -0,0 +1,134 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TestHelper from '@test/test_helper';
import type ClientBase from './base';
import type {ClientScheduledPostMix} from './scheduled_post';
describe('ClientScheduledPost', () => {
let client: ClientScheduledPostMix & ClientBase;
const scheduledPost: ScheduledPost = {
id: 'scheduled_post_id',
channel_id: 'channel_id',
message: 'scheduled post message',
scheduled_at: 1738925211,
user_id: 'current_user_id',
root_id: '',
update_at: 1738925211,
create_at: 1738925211,
};
beforeAll(() => {
client = TestHelper.createClient();
client.doFetch = jest.fn();
});
test('createScheduledPost', async () => {
await client.createScheduledPost(scheduledPost, 'connection_id');
const expectedUrl = client.getScheduledPostRoute();
const expectedOptions = {
method: 'post',
body: scheduledPost,
headers: {'Connection-Id': 'connection_id'},
};
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('createScheduledPost without connection ID', async () => {
await client.createScheduledPost(scheduledPost);
const expectedUrl = client.getScheduledPostRoute();
const expectedOptions = {
method: 'post',
body: scheduledPost,
headers: {'Connection-Id': undefined},
};
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('getScheduledPostsForTeam', async () => {
const teamId = 'team_id';
const includeDirectChannels = false;
const groupLabel = 'WebSocket Reconnect';
const expectedUrl = `${client.getTeamAndDirectChannelScheduledPostsRoute()}/team/${teamId}?includeDirectChannels=${includeDirectChannels}`;
const expectedOptions = {
method: 'get',
groupLabel,
};
await client.getScheduledPostsForTeam(teamId, includeDirectChannels, groupLabel);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('getScheduledPostsForTeam with include direct channels', async () => {
const teamId = 'team_id';
const includeDirectChannels = true;
const groupLabel = 'WebSocket Reconnect';
const expectedUrl = `${client.getTeamAndDirectChannelScheduledPostsRoute()}/team/${teamId}?includeDirectChannels=${includeDirectChannels}`;
const expectedOptions = {
method: 'get',
groupLabel,
};
await client.getScheduledPostsForTeam(teamId, includeDirectChannels, groupLabel);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('updateScheduledPost', async () => {
const updatedPost = {
...scheduledPost,
message: 'updated scheduled post message',
};
const connectionId = 'connection_id';
await client.updateScheduledPost(updatedPost, connectionId);
const expectedUrl = `${client.getScheduledPostRoute()}/${scheduledPost.id}`;
const expectedOptions = {
method: 'put',
body: updatedPost,
headers: {'Connection-Id': connectionId},
};
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('updateScheduledPost without connection ID', async () => {
const updatedPost = {
...scheduledPost,
message: 'updated scheduled post message',
};
await client.updateScheduledPost(updatedPost);
const expectedUrl = `${client.getScheduledPostRoute()}/${scheduledPost.id}`;
const expectedOptions = {
method: 'put',
body: updatedPost,
headers: {},
};
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('deleteScheduledPost', async () => {
const scheduledPostId = 'scheduled_post_id';
const connectionId = 'connection_id';
const expectedUrl = `${client.getScheduledPostRoute()}/${scheduledPostId}`;
const expectedOptions = {
method: 'delete',
headers: {'Connection-Id': connectionId},
};
await client.deleteScheduledPost(scheduledPostId, connectionId);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('deleteScheduledPost without connection ID', async () => {
const scheduledPostId = 'scheduled_post_id';
const expectedUrl = `${client.getScheduledPostRoute()}/${scheduledPostId}`;
const expectedOptions = {
method: 'delete',
headers: {},
};
await client.deleteScheduledPost(scheduledPostId);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import type ClientBase from './base';
export interface ClientScheduledPostMix {
createScheduledPost(schedulePost: ScheduledPost, connectionId?: string): Promise<ScheduledPost>;
updateScheduledPost(scheduledPost: ScheduledPost, connectionId?: string): Promise<ScheduledPost>;
getScheduledPostsForTeam(teamId: string, includeDirectChannels: boolean, groupLabel?: RequestGroupLabel): Promise<FetchScheduledPostsResponse>;
deleteScheduledPost(scheduledPostId: string, connectionId?: string): Promise<ScheduledPost>;
}
const ClientScheduledPost = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
createScheduledPost = (schedulePost: ScheduledPost, connectionId?: string) => {
const headers: Record<string, string> = {};
if (connectionId) {
headers['Connection-Id'] = connectionId;
}
return this.doFetch(
this.getScheduledPostRoute(),
{
method: 'post',
body: schedulePost,
headers,
},
);
};
updateScheduledPost = (scheduledPost: ScheduledPost, connectionId = '') => {
const scheduledPostId = scheduledPost.id;
const headers: Record<string, string> = {};
if (connectionId) {
headers['Connection-Id'] = connectionId;
}
return this.doFetch(
`${this.getScheduledPostRoute()}/${scheduledPostId}`,
{
method: 'put',
body: scheduledPost,
headers,
},
);
};
getScheduledPostsForTeam(teamId: string, includeDirectChannels = false, groupLabel?: RequestGroupLabel) {
return this.doFetch(
`${this.getTeamAndDirectChannelScheduledPostsRoute()}/team/${teamId}${buildQueryString({includeDirectChannels})}`,
{method: 'get', groupLabel},
);
}
deleteScheduledPost(scheduledPostId: string, connectionId = '') {
const headers: Record<string, string> = {};
if (connectionId) {
headers['Connection-Id'] = connectionId;
}
return this.doFetch(
`${this.getScheduledPostRoute()}/${scheduledPostId}`,
{
method: 'delete',
headers,
},
);
}
};
export default ClientScheduledPost;

View file

@ -23,6 +23,7 @@ type Props = {
isMilitaryTime: boolean;
theme: Theme;
handleChange: (currentDate: Moment) => void;
showInitially?: AndroidMode;
}
type AndroidMode = 'date' | 'time';
@ -30,12 +31,10 @@ type AndroidMode = 'date' | 'time';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
paddingTop: 10,
backgroundColor: theme.centerChannelBg,
},
buttonContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-evenly',
@ -44,14 +43,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props) => {
const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme, showInitially}: Props) => {
const styles = getStyleSheet(theme);
const currentTime = getCurrentMomentForTimezone(timezone);
const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined;
const minimumDate = getRoundedTime(currentTime);
const [date, setDate] = useState<Moment>(minimumDate);
const [mode, setMode] = useState<AndroidMode>('date');
const [show, setShow] = useState<boolean>(false);
const [mode, setMode] = useState<AndroidMode>(showInitially || 'date');
const [show, setShow] = useState<boolean>(Boolean(showInitially));
const onChange = (_: DateTimePickerEvent, selectedDate: Date) => {
const currentDate = selectedDate || date;
@ -78,7 +77,10 @@ const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props
};
return (
<View style={styles.container}>
<View
style={styles.container}
testID='custom_date_time_picker'
>
<View style={styles.buttonContainer}>
<Button
testID={'custom_status_clear_after.menu_item.date_and_time.button.date'}

View file

@ -1,124 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, TouchableHighlight, View} from 'react-native';
import {switchToThread} from '@actions/local/thread';
import {switchToChannelById} from '@actions/remote/channel';
import DraftPost from '@components/draft/draft_post';
import DraftPostHeader from '@components/draft_post_header';
import Header from '@components/post_draft/draft_input/header';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_options';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channel: ChannelModel;
location: string;
draftReceiverUser?: UserModel;
draft: DraftModel;
layoutWidth: number;
isPostPriorityEnabled: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
paddingHorizontal: 20,
paddingVertical: 16,
width: '100%',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopWidth: 1,
},
pressInContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
postPriority: {
marginTop: 10,
marginLeft: -12,
},
};
});
const Draft: React.FC<Props> = ({
channel,
location,
draft,
draftReceiverUser,
layoutWidth,
isPostPriorityEnabled,
}) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const showPostPriority = Boolean(isPostPriorityEnabled && draft.metadata?.priority && draft.metadata?.priority?.priority);
const onLongPress = useCallback(() => {
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'draft.options.title', defaultMessage: 'Draft Options'}) : 'Draft Options';
openAsBottomSheet({
closeButtonId: DRAFT_OPTIONS_BUTTON,
screen: Screens.DRAFT_OPTIONS,
theme,
title,
props: {channel, rootId: draft.rootId, draft, draftReceiverUserName: draftReceiverUser?.username},
});
}, [channel, draft, draftReceiverUser?.username, intl, isTablet, theme]);
const onPress = useCallback(() => {
if (draft.rootId) {
switchToThread(serverUrl, draft.rootId, false);
return;
}
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
}, [channel.id, channel.teamId, draft.rootId, serverUrl]);
return (
<TouchableHighlight
onLongPress={onLongPress}
onPress={onPress}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
testID='draft_post'
>
<View
style={style.container}
>
<DraftPostHeader
channel={channel}
draftReceiverUser={draftReceiverUser}
rootId={draft.rootId}
testID='draft_post.channel_info'
updateAt={draft.updateAt}
/>
{showPostPriority && draft.metadata?.priority &&
<View style={style.postPriority}>
<Header
noMentionsError={false}
postPriority={draft.metadata?.priority}
/>
</View>
}
<DraftPost
draft={draft}
location={location}
layoutWidth={layoutWidth}
/>
</View>
</TouchableHighlight>
);
};
export default Draft;

View file

@ -1,159 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ReactNode} from 'react';
import {Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfileAvatar from '@components/draft_post_header/profile_avatar';
import FormattedText from '@components/formatted_text';
import FormattedTime from '@components/formatted_time';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUserTimezone} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channel: ChannelModel;
draftReceiverUser?: UserModel;
updateAt: number;
rootId?: PostModel['rootId'];
testID?: string;
currentUser?: UserModel;
isMilitaryTime: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
infoContainer: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
},
channelInfo: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
},
category: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'SemiBold'),
marginRight: 8,
},
categoryIconContainer: {
width: 24,
height: 24,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
padding: 4,
borderRadius: 555,
},
profileComponentContainer: {
marginRight: 6,
},
displayName: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'SemiBold'),
},
time: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
};
});
const DraftPostHeader: React.FC<Props> = ({
channel,
draftReceiverUser,
updateAt,
rootId,
testID,
currentUser,
isMilitaryTime,
}) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const isChannelTypeDM = channel.type === General.DM_CHANNEL;
const ChannelInfo = ({id, defaultMessage}: {id: string; defaultMessage: string}) => (
<View style={style.channelInfo}>
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={style.category}
/>
<View style={style.profileComponentContainer}>
{draftReceiverUser ? (
<ProfileAvatar author={draftReceiverUser}/>
) : (
<View style={style.categoryIconContainer}>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='globe'
size={16}
/>
</View>
)}
</View>
</View>
);
let headerComponent: ReactNode = null;
if (rootId) {
headerComponent = (
<ChannelInfo
id='channel_info.thread_in'
defaultMessage='Thread in:'
/>
);
} else if (isChannelTypeDM) {
headerComponent = (
<ChannelInfo
id='channel_info.draft_to_user'
defaultMessage='To:'
/>
);
} else {
headerComponent = (
<ChannelInfo
id='channel_info.draft_in_channel'
defaultMessage='In:'
/>
);
}
return (
<View
style={style.container}
testID={testID}
>
<View style={style.infoContainer}>
{headerComponent}
<Text style={style.displayName}>
{channel.displayName}
</Text>
</View>
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
value={updateAt}
style={style.time}
testID='post_header.date_time'
/>
</View>
);
};
export default DraftPostHeader;

View file

@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, TouchableHighlight, View} from 'react-native';
import {switchToThread} from '@actions/local/thread';
import {switchToChannelById} from '@actions/remote/channel';
import DraftAndScheduledPostHeader from '@components/draft_scheduled_post_header';
import Header from '@components/post_draft/draft_input/header';
import {Screens} from '@constants';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_scheduled_post_options';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DraftAndScheduledPostContainer from './draft_scheduled_post_container';
import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channel: ChannelModel;
location: string;
postReceiverUser?: UserModel;
post: DraftModel | ScheduledPostModel;
layoutWidth: number;
isPostPriorityEnabled: boolean;
draftType: DraftType;
firstItem?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
position: 'relative',
},
postContainer: {
paddingHorizontal: 20,
paddingVertical: 16,
width: '100%',
},
postContainerBorderTop: {
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopWidth: 1,
},
pressInContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
postPriority: {
marginTop: 10,
marginLeft: -12,
},
errorLine: {
backgroundColor: theme.errorTextColor,
position: 'absolute',
width: 1,
top: 0,
left: 0,
height: '100%',
},
};
});
const DraftAndScheduledPost: React.FC<Props> = ({
channel,
location,
post,
postReceiverUser,
layoutWidth,
isPostPriorityEnabled,
draftType,
firstItem,
}) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const showPostPriority = Boolean(isPostPriorityEnabled && post.metadata?.priority && post.metadata?.priority?.priority);
const onLongPress = useCallback(() => {
Keyboard.dismiss();
let title;
if (draftType === DRAFT_TYPE_DRAFT) {
title = intl.formatMessage({id: 'draft.options.title', defaultMessage: 'Draft Options'});
} else {
title = intl.formatMessage({id: 'scheduled_post.options.title', defaultMessage: 'Message actions'});
}
openAsBottomSheet({
closeButtonId: DRAFT_OPTIONS_BUTTON,
screen: Screens.DRAFT_SCHEDULED_POST_OPTIONS,
theme,
title,
props: {channel, rootId: post.rootId, draftType, draft: post, draftReceiverUserName: postReceiverUser?.username},
});
}, [intl, draftType, theme, channel, post, postReceiverUser?.username]);
const onPress = useCallback(() => {
if (post.rootId) {
switchToThread(serverUrl, post.rootId, false);
return;
}
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
}, [channel.id, channel.teamId, post.rootId, serverUrl]);
return (
<TouchableHighlight
onLongPress={onLongPress}
onPress={onPress}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
testID='draft_post'
>
<View style={style.container}>
{draftType === DRAFT_TYPE_SCHEDULED && (post as ScheduledPostModel).errorCode !== '' &&
<View style={style.errorLine}/>
}
<View
style={[style.postContainer, firstItem ? null : style.postContainerBorderTop]}
>
<DraftAndScheduledPostHeader
channel={channel}
postReceiverUser={postReceiverUser}
rootId={post.rootId}
testID='draft_post.channel_info'
updateAt={post.updateAt}
draftType={draftType}
postScheduledAt={'scheduledAt' in post ? post.scheduledAt : undefined}
scheduledPostErrorCode={'errorCode' in post ? post.errorCode : undefined}
/>
{showPostPriority && post.metadata?.priority &&
<View style={style.postPriority}>
<Header
noMentionsError={false}
postPriority={post.metadata?.priority}
/>
</View>
}
<DraftAndScheduledPostContainer
post={post}
location={location}
layoutWidth={layoutWidth}
/>
</View>
</View>
</TouchableHighlight>
);
};
export default DraftAndScheduledPost;

View file

@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Screens} from '@constants';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft';
import {dismissBottomSheet} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import {deleteDraftConfirmation} from '@utils/draft';
import {deleteScheduledPostConfirmation} from '@utils/scheduled_post';
import DeleteDraft from './delete_draft';
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => 'http://baseUrl.com'),
}));
jest.mock('@utils/draft', () => ({
deleteDraftConfirmation: jest.fn(),
}));
jest.mock('@utils/scheduled_post', () => ({
deleteScheduledPostConfirmation: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
}));
describe('screens/draft_scheduled_post_options/DeleteDraft', () => {
const baseProps = {
bottomSheetId: Screens.DRAFT_SCHEDULED_POST_OPTIONS,
channelId: 'channel-id',
rootId: 'root-id',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders draft delete option correctly', () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_DRAFT,
};
renderWithIntl(<DeleteDraft {...props}/>);
expect(screen.getByText('Delete draft')).toBeTruthy();
expect(screen.getByTestId('delete_draft')).toBeTruthy();
});
it('renders scheduled post delete option correctly', () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
postId: 'post-id',
};
renderWithIntl(<DeleteDraft {...props}/>);
expect(screen.getByText('Delete')).toBeTruthy();
expect(screen.getByTestId('delete_draft')).toBeTruthy();
});
it('handles draft deletion correctly', async () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_DRAFT,
};
renderWithIntl(<DeleteDraft {...props}/>);
const deleteButton = screen.getByTestId('delete_draft');
fireEvent.press(deleteButton);
expect(dismissBottomSheet).toHaveBeenCalledWith(Screens.DRAFT_SCHEDULED_POST_OPTIONS);
await waitFor(() => expect(deleteDraftConfirmation).toHaveBeenCalledWith(expect.objectContaining({
channelId: 'channel-id',
rootId: 'root-id',
serverUrl: 'http://baseUrl.com',
})));
expect(deleteScheduledPostConfirmation).not.toHaveBeenCalled();
});
it('handles scheduled post deletion correctly', async () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
postId: 'post-id',
};
renderWithIntl(<DeleteDraft {...props}/>);
const deleteButton = screen.getByTestId('delete_draft');
fireEvent.press(deleteButton);
expect(dismissBottomSheet).toHaveBeenCalledWith(Screens.DRAFT_SCHEDULED_POST_OPTIONS);
await waitFor(() => expect(deleteScheduledPostConfirmation).toHaveBeenCalledWith(expect.objectContaining({
scheduledPostId: 'post-id',
})));
expect(deleteDraftConfirmation).not.toHaveBeenCalled();
});
});

View file

@ -7,12 +7,14 @@ import {useIntl} from 'react-intl';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
import {ICON_SIZE} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {dismissBottomSheet} from '@screens/navigation';
import {deleteDraftConfirmation} from '@utils/draft';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {deleteScheduledPostConfirmation} from '@utils/scheduled_post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -21,11 +23,13 @@ type Props = {
bottomSheetId: AvailableScreens;
channelId: string;
rootId: string;
draftType?: DraftType;
postId?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
color: theme.dndIndicator,
...typography('Body', 200),
},
draftOptions: {
@ -41,6 +45,8 @@ const DeleteDraft: React.FC<Props> = ({
bottomSheetId,
channelId,
rootId,
draftType,
postId,
}) => {
const theme = useTheme();
const style = getStyleSheet(theme);
@ -49,12 +55,22 @@ const DeleteDraft: React.FC<Props> = ({
const draftDeleteHandler = async () => {
await dismissBottomSheet(bottomSheetId);
deleteDraftConfirmation({
intl,
serverUrl,
channelId,
rootId,
});
if (draftType === DRAFT_TYPE_DRAFT) {
deleteDraftConfirmation({
intl,
serverUrl,
channelId,
rootId,
});
return;
}
if (draftType === DRAFT_TYPE_SCHEDULED && postId) {
deleteScheduledPostConfirmation({
intl,
serverUrl,
scheduledPostId: postId,
});
}
};
return (
@ -67,13 +83,21 @@ const DeleteDraft: React.FC<Props> = ({
<CompassIcon
name='trash-can-outline'
size={ICON_SIZE}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
<FormattedText
id='draft.options.delete.title'
defaultMessage={'Delete draft'}
style={style.title}
color={theme.dndIndicator}
/>
{draftType === DRAFT_TYPE_DRAFT ? (
<FormattedText
id='draft.options.delete.title'
defaultMessage={'Delete draft'}
style={style.title}
/>
) : (
<FormattedText
id='scheduled_post.options.delete.title'
defaultMessage={'Delete'}
style={style.title}
/>
)}
</TouchableWithFeedback>
);
};

View file

@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import React from 'react';
import {dismissBottomSheet, showModal} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import RescheduledDraft from './rescheduled_draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type {AvailableScreens} from '@typings/screens/navigation';
jest.mock('@screens/navigation', () => {
return {
dismissBottomSheet: jest.fn(() => Promise.resolve()),
showModal: jest.fn(),
};
});
// Mock CompassIcon as a function component
jest.mock('@components/compass_icon', () => {
const MockCompassIcon = () => null;
MockCompassIcon.getImageSourceSync = jest.fn(() => 'mockedImageSource');
return MockCompassIcon;
});
describe('RescheduledDraft', () => {
const baseProps = {
bottomSheetId: 'bottomSheet1' as AvailableScreens,
draft: {
id: 'draft1',
channelId: 'channel1',
message: 'Test message',
createAt: 1234567890,
scheduledAt: 1234567890,
processedAt: 1234567890,
errorCode: '',
toApi: true,
updateAt: 1234567890,
rootId: '',
metadata: {},
} as unknown as ScheduledPostModel,
};
it('renders correctly', () => {
const {getByTestId, getByText} = renderWithIntlAndTheme(
<RescheduledDraft {...baseProps}/>,
);
expect(getByTestId('rescheduled_draft')).toBeTruthy();
expect(getByText('Reschedule')).toBeTruthy();
});
it('calls dismissBottomSheet when pressed', async () => {
// Reset all mocks before the test
jest.clearAllMocks();
// Mock the functions directly
jest.mocked(dismissBottomSheet).mockImplementation(() => Promise.resolve());
const {getByTestId} = renderWithIntlAndTheme(
<RescheduledDraft {...baseProps}/>,
);
// Trigger the button press
fireEvent.press(getByTestId('rescheduled_draft'));
await TestHelper.wait(0);
// Wait for dismissBottomSheet to be called
await waitFor(() => {
expect(dismissBottomSheet).toHaveBeenCalledWith(baseProps.bottomSheetId);
});
});
it('calls showModal when pressed', async () => {
// Reset all mocks before the test
jest.clearAllMocks();
const {getByTestId} = renderWithIntlAndTheme(
<RescheduledDraft {...baseProps}/>,
);
// Trigger the button press
fireEvent.press(getByTestId('rescheduled_draft'));
await TestHelper.wait(0);
// Wait for showModal to be called
await waitFor(() => {
expect(showModal).toHaveBeenCalledWith(
'RescheduleDraft',
'Change Schedule',
{
closeButtonId: 'close-rescheduled-draft',
draft: baseProps.draft,
},
{
topBar: {
leftButtons: [{
id: 'close-rescheduled-draft',
testID: 'close.reschedule_draft.button',
icon: 'mockedImageSource',
}],
},
},
);
});
});
});

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, showModal} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
bottomSheetId: AvailableScreens;
draft: ScheduledPostModel;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
rescheduledContainer: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 16,
paddingVertical: 12,
},
}));
const RescheduledDraft: React.FC<Props> = ({
bottomSheetId,
draft,
}) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const intl = useIntl();
const rescheduledDraft = useCallback(async () => {
await dismissBottomSheet(bottomSheetId);
const title = intl.formatMessage({id: 'mobile.reschedule_draft.title', defaultMessage: 'Change Schedule'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-rescheduled-draft';
const passProps = {closeButtonId, draft};
const options = {
topBar: {
leftButtons: [{
id: closeButtonId,
testID: 'close.reschedule_draft.button',
icon: closeButton,
}],
},
};
showModal(Screens.RESCHEDULE_DRAFT, title, passProps, options);
}, [bottomSheetId, draft, intl, theme.sidebarHeaderTextColor]);
return (
<TouchableWithFeedback
type={'opacity'}
style={style.rescheduledContainer}
onPress={rescheduledDraft}
testID='rescheduled_draft'
>
<CompassIcon
name='clock-send-outline'
size={ICON_SIZE}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
<FormattedText
id='draft.options.reschedule.title'
defaultMessage={'Reschedule'}
style={style.title}
/>
</TouchableWithFeedback>
);
};
export default RescheduledDraft;

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user';
import SendDraft from './send_draft';
import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
channelId: string;
rootId?: string;
}
const enhanced = withObservables(['channelId', 'rootId'], (ownProps: WithDatabaseArgs & OwnProps) => {
const {database} = ownProps;
let channelId = of$(ownProps.channelId);
if (!ownProps.channelId) {
channelId = observeCurrentChannelId(database);
}
const currentUser = observeCurrentUser(database);
const channel = channelId.pipe(
switchMap((id) => observeChannel(database, id!)),
);
const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => (c && u ? observePermissionForChannel(database, c, u, Permissions.CREATE_POST, true) : of$(true))));
const channelIsArchived = channel.pipe(switchMap((c) => (of$(c?.deleteAt !== 0))));
const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly');
const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe(
switchMap(([u, c, readOnly]) => of$(c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u?.roles || '') && readOnly)),
);
const deactivatedChannel = combineLatest([currentUser, channel]).pipe(
switchMap(([u, c]) => {
if (!u || !c || c.type !== General.DM_CHANNEL) {
return of$(null);
}
const teammateId = getUserIdFromChannelName(u.id, c.name);
if (!teammateId) {
return of$(null);
}
return observeUser(database, teammateId);
}),
switchMap((u2) => of$(Boolean(u2?.deleteAt))),
);
return {
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
};
});
export default withDatabase(enhanced(SendDraft));

View file

@ -0,0 +1,278 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {removeDraft} from '@actions/local/draft';
import {updateScheduledPostErrorCode} from '@actions/local/scheduled_post';
import {createPost} from '@actions/remote/post';
import {deleteScheduledPost} from '@actions/remote/scheduled_post';
import {General, Screens} from '@constants';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft';
import {dismissBottomSheet} from '@screens/navigation';
import {act, fireEvent, renderWithEverything, renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {sendMessageWithAlert} from '@utils/post';
import {canPostDraftInChannelOrThread} from '@utils/scheduled_post';
import SendDraft from './send_draft';
import type {Database} from '@nozbe/watermelondb';
const SERVER_URL = 'https://www.baseUrl.com';
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
}));
jest.mock('@actions/local/draft', () => ({
removeDraft: jest.fn(),
}));
jest.mock('@actions/remote/scheduled_post', () => ({
deleteScheduledPost: jest.fn(),
}));
jest.mock('@actions/remote/scheduled_post', () => ({
deleteScheduledPost: jest.fn(),
}));
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => 'https://server.com'),
}));
jest.mock('@utils/post', () => ({
sendMessageWithAlert: jest.fn(),
persistentNotificationsConfirmation: jest.fn(),
}));
jest.mock('@utils/scheduled_post');
jest.mock('@actions/remote/post', () => ({
deleteScheduledPost: jest.fn(),
createPost: jest.fn(),
}));
jest.mock('@actions/local/scheduled_post', () => ({
handleUpdateScheduledPostErrorCode: jest.fn(),
updateScheduledPostErrorCode: jest.fn(),
}));
jest.mock('@database/manager');
jest.mock('@queries/servers/post');
describe('Send Draft', () => {
let database: Database;
const baseProps: Parameters<typeof SendDraft>[0] = {
channelId: 'channel_id',
channelName: 'channel_name',
rootId: '',
channelType: General.OPEN_CHANNEL,
bottomSheetId: Screens.DRAFT_SCHEDULED_POST_OPTIONS,
currentUserId: 'current_user_id',
maxMessageLength: 4000,
useChannelMentions: true,
userIsOutOfOffice: false,
customEmojis: [],
value: 'value',
files: [],
postPriority: {} as PostPriority,
persistentNotificationInterval: 0,
persistentNotificationMaxRecipients: 0,
draftReceiverUserName: undefined,
draftType: DRAFT_TYPE_DRAFT,
};
beforeEach(async () => {
jest.clearAllMocks();
const server = await TestHelper.setupServerDatabase(SERVER_URL);
database = server.database;
});
it('should render the component', () => {
const wrapper = renderWithIntlAndTheme(
<SendDraft
{...baseProps}
/>,
);
const {getByText} = wrapper;
expect(getByText('Send draft')).toBeTruthy();
});
it('should call removeDraft when send draft is pressed for draft', async () => {
jest.mocked(createPost).mockResolvedValueOnce({
data: true,
});
jest.mocked(canPostDraftInChannelOrThread).mockResolvedValueOnce(true);
const wrapper = renderWithIntlAndTheme(
<SendDraft
{...baseProps}
/>,
);
const {getByTestId} = wrapper;
await act(async () => {
fireEvent.press(getByTestId('send_draft_button'));
});
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
jest.mocked(sendMessageWithAlert).mock.calls[0][0].sendMessageHandler();
});
expect(removeDraft).toHaveBeenCalled();
});
it('should call deleteScheduledPost when send button is pressed for scheduled post', async () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
postId: 'post_id',
};
jest.mocked(createPost).mockResolvedValueOnce({
data: true,
});
jest.mocked(canPostDraftInChannelOrThread).mockResolvedValueOnce(true);
const wrapper = renderWithIntlAndTheme(
<SendDraft
{...props}
/>,
);
const {getByTestId} = wrapper;
await act(async () => {
fireEvent.press(getByTestId('send_draft_button'));
});
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
jest.mocked(sendMessageWithAlert).mock.calls[0][0].sendMessageHandler();
});
expect(deleteScheduledPost).toHaveBeenCalled();
});
it('should call dismissBottomSheet after sending the draft and should call createPost', async () => {
jest.mocked(sendMessageWithAlert).mockImplementation(() => {
return Promise.resolve();
});
const wrapper = renderWithIntlAndTheme(
<SendDraft
{...baseProps}
/>,
);
const {getByTestId} = wrapper;
await act(async () => {
fireEvent.press(getByTestId('send_draft_button'));
});
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
jest.mocked(sendMessageWithAlert).mock.calls[0][0].sendMessageHandler();
});
expect(createPost).toHaveBeenCalledWith(
'https://server.com', {
user_id: 'current_user_id',
channel_id: 'channel_id',
root_id: '',
message: 'value',
}, [],
);
});
it('should render the component for scheduled post', () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
};
const wrapper = renderWithIntlAndTheme(
<SendDraft
{...props}
/>,
);
const {getByText} = wrapper;
expect(getByText('Send')).toBeTruthy();
});
test('should handle deleteScheduledPost failure and call handleUpdateScheduledPostErrorCode', async () => {
const mockError = new Error('Failed to delete scheduled post');
jest.mocked(deleteScheduledPost).mockResolvedValue({error: mockError});
jest.mocked(sendMessageWithAlert).mockImplementation(() => {
return Promise.resolve();
});
jest.mocked(createPost).mockResolvedValueOnce({
data: true,
});
jest.mocked(canPostDraftInChannelOrThread).mockResolvedValueOnce(true);
const props: Parameters<typeof SendDraft>[0] = {
...baseProps,
postPriority: {
persistent_notifications: false,
priority: '',
},
persistentNotificationInterval: 0,
persistentNotificationMaxRecipients: 0,
draftType: DRAFT_TYPE_SCHEDULED,
postId: 'post-id',
};
const {getByTestId} = renderWithEverything(<SendDraft {...props}/>, {database});
// Trigger the send action
await act(async () => {
fireEvent.press(getByTestId('send_draft_button'));
});
// Verify dismissBottomSheet was called
expect(dismissBottomSheet).toHaveBeenCalled();
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
jest.mocked(sendMessageWithAlert).mock.calls[0][0].sendMessageHandler();
});
// Verify deleteScheduledPost was called with correct params
expect(deleteScheduledPost).toHaveBeenCalledWith(
'https://server.com',
'post-id',
);
// Verify handleUpdateScheduledPostErrorCode was called with correct params
expect(updateScheduledPostErrorCode).toHaveBeenCalledWith(
'https://server.com',
'post-id',
'post_send_success_delete_failed',
);
// Verify Alert was called with error message
expect(Alert.alert).toHaveBeenCalledWith(
'Delete fails',
'Post has been create successfully but failed to delete. Please delete the scheduled post manually from the scheduled post list.',
[{
style: 'cancel',
text: 'Cancel',
}],
{cancelable: false},
);
});
});

View file

@ -3,18 +3,23 @@
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {removeDraft} from '@actions/local/draft';
import {updateScheduledPostErrorCode} from '@actions/local/scheduled_post';
import {deleteScheduledPost} from '@actions/remote/scheduled_post';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {General} from '@constants';
import {DRAFT_TYPE_DRAFT, type DraftType} from '@constants/draft';
import {ICON_SIZE} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useHandleSendMessage} from '@hooks/handle_send_message';
import {usePersistentNotificationProps} from '@hooks/persistent_notification_props';
import {dismissBottomSheet} from '@screens/navigation';
import {logError} from '@utils/log';
import {persistentNotificationsConfirmation, sendMessageWithAlert} from '@utils/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -25,6 +30,7 @@ import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
channelId: string;
rootId: string;
draftType?: DraftType;
channelType: ChannelType | undefined;
currentUserId: string;
channelName: string | undefined;
@ -42,6 +48,11 @@ type Props = {
persistentNotificationInterval: number;
persistentNotificationMaxRecipients: number;
draftReceiverUserName?: string;
postId?: string;
canPost?: boolean;
channelIsArchived?: boolean;
channelIsReadOnly?: boolean;
deactivatedChannel?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -66,6 +77,8 @@ const SendDraft: React.FC<Props> = ({
channelName,
channelDisplayName,
rootId,
draftType,
postId,
channelType,
bottomSheetId,
currentUserId,
@ -81,13 +94,43 @@ const SendDraft: React.FC<Props> = ({
persistentNotificationInterval,
persistentNotificationMaxRecipients,
draftReceiverUserName,
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
}) => {
const theme = useTheme();
const intl = useIntl();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const clearDraft = () => {
removeDraft(serverUrl, channelId, rootId);
const clearDraft = async () => {
if (draftType === DRAFT_TYPE_DRAFT) {
removeDraft(serverUrl, channelId, rootId);
return;
}
if (postId) {
const res = await deleteScheduledPost(serverUrl, postId);
if (res?.error) {
try {
await updateScheduledPostErrorCode(serverUrl, postId, 'post_send_success_delete_failed');
} catch {
logError('Fail to update scheduled post error code');
}
Alert.alert(
intl.formatMessage({id: 'scheduled_post.delete_fails', defaultMessage: 'Delete fails'}),
intl.formatMessage({
id: 'scheduled_post.delete_fails.message',
defaultMessage: 'Post has been create successfully but failed to delete. Please delete the scheduled post manually from the scheduled post list.',
}),
[{
text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}),
style: 'cancel',
},
], {cancelable: false},
);
}
}
};
const {persistentNotificationsEnabled, mentionsList} = usePersistentNotificationProps({
@ -110,6 +153,11 @@ const SendDraft: React.FC<Props> = ({
currentUserId,
channelType,
postPriority,
isFromDraftView: true,
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
clearDraft,
});
@ -154,11 +202,20 @@ const SendDraft: React.FC<Props> = ({
size={ICON_SIZE}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
<FormattedText
id='draft.options.send.title'
defaultMessage='Send draft'
style={style.title}
/>
{draftType === DRAFT_TYPE_DRAFT ? (
<FormattedText
id='draft.options.send.title'
defaultMessage='Send draft'
style={style.title}
/>
) : (
<FormattedText
id='scheduled_post.options.send.title'
defaultMessage='Send'
style={style.title}
/>
)
}
</TouchableWithFeedback>
);
};

View file

@ -14,10 +14,11 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type DraftModel from '@typings/database/models/servers/draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type {UserMentionKey} from '@typings/global/markdown';
type Props = {
draft: DraftModel;
post: DraftModel | ScheduledPostModel;
layoutWidth: number;
location: string;
}
@ -43,8 +44,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const DraftMessage: React.FC<Props> = ({
draft,
const DraftAndScheduledPostMessage: React.FC<Props> = ({
post,
layoutWidth,
location,
}) => {
@ -80,15 +81,15 @@ const DraftMessage: React.FC<Props> = ({
<Markdown
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={draft.channelId}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
postId={draft.id}
postId={post.id}
textStyles={textStyles}
value={draft.message}
value={post.message}
mentionKeys={EMPTY_MENTION_KEYS}
theme={theme}
imagesMetadata={draft.metadata?.images}
imagesMetadata={post.metadata?.images}
/>
</View>
</ScrollView>
@ -105,4 +106,4 @@ const DraftMessage: React.FC<Props> = ({
);
};
export default DraftMessage;
export default DraftAndScheduledPostMessage;

View file

@ -4,16 +4,17 @@
import React from 'react';
import {View} from 'react-native';
import DraftMessage from '@components/draft/draft_post/draft_message';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DraftFiles from './draft_files';
import DraftFiles from './draft_and_scheduled_post_files';
import DraftAndScheduledPostMessage from './draft_and_scheduled_post_message';
import type DraftModel from '@typings/database/models/servers/draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
type Props = {
draft: DraftModel;
post: DraftModel | ScheduledPostModel;
location: string;
layoutWidth: number;
}
@ -37,13 +38,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const DraftPost: React.FC<Props> = ({
draft,
const DraftAndScheduledPostContainer: React.FC<Props> = ({
post,
location,
layoutWidth,
}) => {
const theme = useTheme();
const hasFiles = draft.files.length > 0;
const hasFiles = post.files.length > 0;
const style = getStyleSheet(theme);
return (
@ -51,15 +52,15 @@ const DraftPost: React.FC<Props> = ({
style={style.container}
testID='draft_post_with_message_and_file'
>
<DraftMessage
<DraftAndScheduledPostMessage
layoutWidth={layoutWidth}
location={location}
draft={draft}
post={post}
/>
{
hasFiles &&
<DraftFiles
filesInfo={draft.files}
filesInfo={post.files}
isReplyPost={false}
location={location}
layoutWidth={layoutWidth}
@ -69,4 +70,4 @@ const DraftPost: React.FC<Props> = ({
);
};
export default DraftPost;
export default DraftAndScheduledPostContainer;

View file

@ -9,12 +9,13 @@ import {observeChannel, observeChannelMembers} from '@queries/servers/channel';
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
import Drafts from './draft';
import DraftAndScheduledPost from './draft_scheduled_post';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
import type DraftModel from '@typings/database/models/servers/draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
@ -22,7 +23,7 @@ type Props = {
currentUser?: UserModel;
members?: ChannelMembershipModel[];
channel?: ChannelModel;
draft: DraftModel;
post: DraftModel | ScheduledPostModel;
} & WithDatabaseArgs;
const withCurrentUser = withObservables([], ({database}: WithDatabaseArgs) => ({
@ -50,7 +51,7 @@ const withChannelMembers = withObservables(['channelId'], ({channelId, database}
};
});
const observeDraftReceiverUser = ({
const observePostReceiverUser = ({
members,
database,
channelData,
@ -74,12 +75,12 @@ const observeDraftReceiverUser = ({
return of(undefined);
};
const enhance = withObservables(['channel', 'members', 'draft'], ({channel, database, currentUser, members, draft}: Props) => {
const draftReceiverUser = observeDraftReceiverUser({members, database, channelData: channel, currentUser});
const enhance = withObservables(['channel', 'members', 'post'], ({channel, database, currentUser, members, post}: Props) => {
const postReceiverUser = observePostReceiverUser({members, database, channelData: channel, currentUser});
return {
draft: draft.observe(),
post: post.observe(),
channel,
draftReceiverUser,
postReceiverUser,
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
};
});
@ -89,7 +90,7 @@ export default React.memo(
withChannel(
withCurrentUser(
withChannelMembers(
enhance(Drafts),
enhance(DraftAndScheduledPost),
),
),
),

View file

@ -0,0 +1,242 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfileAvatar from '@components/draft_scheduled_post_header/profile_avatar';
import FormattedText from '@components/formatted_text';
import FormattedTime from '@components/formatted_time';
import {General} from '@constants';
import {DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
import {useTheme} from '@context/theme';
import {DEFAULT_LOCALE} from '@i18n';
import {getReadableTimestamp} from '@utils/datetime';
import {getErrorStringFromCode} from '@utils/scheduled_post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUserTimezone} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {ScheduledPostErrorCode} from '@typings/utils/scheduled_post';
type Props = {
channel: ChannelModel;
postReceiverUser?: UserModel;
updateAt: number;
rootId?: PostModel['rootId'];
testID?: string;
currentUser?: UserModel;
isMilitaryTime: boolean;
draftType: DraftType;
postScheduledAt?: number;
scheduledPostErrorCode?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
infoContainer: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
maxWidth: '80%',
},
channelInfo: {
flexDirection: 'row',
alignItems: 'center',
},
category: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'SemiBold'),
marginRight: 8,
},
categoryIconContainer: {
width: 24,
height: 24,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
padding: 4,
borderRadius: 555,
},
profileComponentContainer: {
marginRight: 6,
},
displayName: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'SemiBold'),
flexShrink: 1,
flexGrow: 1,
},
time: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
scheduledContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: 10,
},
scheduledAtText: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
errorState: {
backgroundColor: theme.errorTextColor,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginLeft: 8,
paddingHorizontal: 4,
borderRadius: 4,
},
errorText: {
color: theme.buttonColor,
...typography('Heading', 25),
},
};
});
const DraftAndScheduledPostHeader: React.FC<Props> = ({
channel,
postReceiverUser,
updateAt,
rootId,
testID,
currentUser,
isMilitaryTime,
draftType,
postScheduledAt,
scheduledPostErrorCode,
}) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const isDM = channel.type === General.DM_CHANNEL;
const renderScheduledInfo = () => {
if (draftType !== DRAFT_TYPE_SCHEDULED) {
return null;
}
const isSent = scheduledPostErrorCode === 'post_send_success_delete_failed';
const scheduledTime = getReadableTimestamp(
postScheduledAt!,
getUserTimezone(currentUser),
isMilitaryTime,
currentUser?.locale || DEFAULT_LOCALE,
);
return (
<View style={style.scheduledContainer}>
<Text style={style.scheduledAtText}>
{isSent? intl.formatMessage({id: 'scheduled_post.header.sent', defaultMessage: 'Sent'}): intl.formatMessage(
{id: 'channel_info.scheduled', defaultMessage: 'Send on {time}'},
{time: scheduledTime},
)}
</Text>
{scheduledPostErrorCode && (
<View style={style.errorState}>
<CompassIcon
name='alert-outline'
size={12}
color={theme.buttonColor}
/>
<Text style={style.errorText}>
{getErrorStringFromCode(intl, scheduledPostErrorCode as ScheduledPostErrorCode)}
</Text>
</View>
)}
</View>
);
};
const ChannelInfo = ({id, defaultMessage}: {id: string; defaultMessage: string}) => (
<View style={style.channelInfo}>
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={style.category}
/>
<View style={style.profileComponentContainer}>
{postReceiverUser ? (
<ProfileAvatar author={postReceiverUser}/>
) : (
<View style={style.categoryIconContainer}>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='globe'
size={16}
/>
</View>
)}
</View>
</View>
);
const getHeaderLabel = () => {
if (rootId) {
return (
<ChannelInfo
id='channel_info.thread_in'
defaultMessage='Thread in:'
/>
);
}
if (isDM) {
return (
<ChannelInfo
id='channel_info.draft_to_user'
defaultMessage='To:'
/>
);
}
return (
<ChannelInfo
id='channel_info.draft_in_channel'
defaultMessage='In:'
/>
);
};
return (
<View>
{renderScheduledInfo()}
<View
style={style.container}
testID={testID}
>
<View style={style.infoContainer}>
{getHeaderLabel()}
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.displayName}
>
{channel.displayName}
</Text>
</View>
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
value={updateAt}
style={style.time}
testID='post_header.date_time'
/>
</View>
</View>
);
};
export default DraftAndScheduledPostHeader;

View file

@ -8,7 +8,7 @@ import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {observeCurrentUser} from '@queries/servers/user';
import DraftPostHeader from './draft_post_header';
import DraftAndScheduledPostHeader from './draft_scheduled_post_header';
const enhance = withObservables([], ({database}) => {
const currentUser = observeCurrentUser(database);
@ -22,4 +22,4 @@ const enhance = withObservables([], ({database}) => {
};
});
export default withDatabase(enhance(DraftPostHeader));
export default withDatabase(enhance(DraftAndScheduledPostHeader));

View file

@ -20,12 +20,12 @@ type Props = {
const styles = StyleSheet.create({
avatarContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
width: 24,
height: 24,
width: 20,
height: 20,
},
avatar: {
height: 24,
width: 24,
height: 20,
width: 20,
},
avatarRadius: {
borderRadius: 18,

View file

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {DeviceEventEmitter} from 'react-native';
import {switchToGlobalDrafts} from '@actions/local/draft';
import DraftsButton from '@components/drafts_buttton/drafts_button';
import {Events} from '@constants';
import {DRAFT} from '@constants/screens';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
jest.mock('@actions/local/draft', () => ({
switchToGlobalDrafts: jest.fn(),
}));
describe('components/drafts_button/DraftsButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const baseProps = {
draftsCount: 0,
scheduledPostCount: 0,
scheduledPostHasError: false,
isActiveTab: true,
};
test('should show draft count when greater than 0', () => {
const props: Parameters<typeof DraftsButton>[0] = {
...baseProps,
draftsCount: 5,
isActiveTab: true,
};
const {getByTestId, queryByTestId} = renderWithIntlAndTheme(
<DraftsButton {...props}/>,
);
expect(getByTestId('channel_list.drafts.count')).toHaveTextContent('5');
expect(queryByTestId('channel_list.scheduled_post.count')).toBeNull();
});
test('should show scheduled post count when greater than 0', () => {
const props = {
...baseProps,
scheduledPostCount: 3,
};
const {getByTestId, queryByTestId} = renderWithIntlAndTheme(
<DraftsButton {...props}/>,
);
expect(getByTestId('channel_list.scheduled_post.count')).toHaveTextContent('3');
expect(queryByTestId('channel_list.drafts.count')).toBeNull();
});
test('should show both drafts and scheduled post count when greater than 0', () => {
const props = {
...baseProps,
scheduledPostCount: 3,
draftsCount: 5,
};
const {getByTestId} = renderWithIntlAndTheme(
<DraftsButton {...props}/>,
);
expect(getByTestId('channel_list.drafts.count')).toHaveTextContent('5');
expect(getByTestId('channel_list.scheduled_post.count')).toHaveTextContent('3');
});
test('should show both drafts and scheduled post count when greater than 0 and scheduled post has error', () => {
const props = {
...baseProps,
scheduledPostCount: 3,
draftsCount: 5,
scheduledPostHasError: true,
};
const {getByTestId} = renderWithIntlAndTheme(
<DraftsButton {...props}/>,
);
expect(getByTestId('channel_list.drafts.count')).toHaveTextContent('5');
expect(getByTestId('channel_list.scheduled_post.count')).toHaveTextContent('3');
const countElement = getByTestId('channel_list.scheduled_post.count.container');
expect(countElement).toBeVisible();
expect(countElement).toHaveStyle({backgroundColor: expect.any(String)});
});
test('should apply error styles when scheduled post has error', () => {
const props = {
...baseProps,
scheduledPostCount: 1,
scheduledPostHasError: true,
};
const {getByTestId} = renderWithIntlAndTheme(
<DraftsButton {...props}/>,
);
const countElement = getByTestId('channel_list.scheduled_post.count.container');
expect(countElement).toBeVisible();
expect(countElement).toHaveStyle({backgroundColor: expect.any(String)});
});
test('should handle press and emit events', async () => {
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
const {getByTestId} = renderWithIntlAndTheme(
<DraftsButton {...baseProps}/>,
);
const button = getByTestId('channel_list.drafts.button');
jest.mocked(switchToGlobalDrafts).mockResolvedValue({error: null});
await act(async () => {
fireEvent.press(button);
await TestHelper.wait(0);
});
expect(switchToGlobalDrafts).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(Events.ACTIVE_SCREEN, DRAFT);
});
});

View file

@ -14,15 +14,18 @@ import FormattedText from '@components/formatted_text';
import {Events} from '@constants';
import {DRAFT} from '@constants/screens';
import {HOME_PADDING} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {preventDoubleTap} from '@utils/tap';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type DraftListProps = {
shouldHighlightActive?: boolean;
isActiveTab: boolean;
draftsCount: number;
scheduledPostCount: number;
scheduledPostHasError: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -46,31 +49,50 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
alignItems: 'center',
gap: 4,
},
errorCountContainer: {
backgroundColor: theme.dndIndicator,
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 2,
},
count: {
color: theme.sidebarText,
...typography('Body', 75, 'SemiBold'),
opacity: 0.64,
},
badgeOpacityWithError: {
opacity: 1,
},
opacity: {
opacity: 0.56,
},
countBadgeContainer: {
display: 'flex',
flexDirection: 'row',
gap: 8,
},
}));
const DraftsButton: React.FC<DraftListProps> = ({
shouldHighlightActive = false,
isActiveTab,
draftsCount,
scheduledPostCount,
scheduledPostHasError,
}) => {
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getChannelItemStyleSheet(theme);
const customStyles = getStyleSheet(theme);
const isTablet = useIsTablet();
const handlePress = useCallback(preventDoubleTap(() => {
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, DRAFT);
switchToGlobalDrafts();
}), []);
const handlePress = usePreventDoubleTap(useCallback(async () => {
const {error} = await switchToGlobalDrafts(serverUrl);
if (!error) {
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, DRAFT);
}
}, [serverUrl]));
const isActive = isTablet && shouldHighlightActive;
const isActive = isTablet && isActiveTab;
const [containerStyle, iconStyle, textStyle] = useMemo(() => {
const container = [
@ -112,19 +134,47 @@ const DraftsButton: React.FC<DraftListProps> = ({
defaultMessage='Drafts'
style={textStyle}
/>
<View style={customStyles.countContainer}>
<CompassIcon
name='pencil-outline'
size={14}
color={theme.sidebarText}
style={customStyles.opacity}
/>
<Text
testID='channel_list.drafts.count'
style={customStyles.count}
>
{draftsCount}
</Text>
<View style={customStyles.countBadgeContainer}>
{
draftsCount > 0 &&
<View
testID='channel_list.drafts.count.container'
style={customStyles.countContainer}
>
<CompassIcon
name='pencil-outline'
size={14}
color={theme.sidebarText}
style={customStyles.opacity}
/>
<Text
testID='channel_list.drafts.count'
style={customStyles.count}
>
{draftsCount}
</Text>
</View>
}
{
scheduledPostCount > 0 &&
<View
testID='channel_list.scheduled_post.count.container'
style={[customStyles.countContainer, scheduledPostHasError && customStyles.errorCountContainer]}
>
<CompassIcon
name='clock-send-outline'
size={14}
color={theme.sidebarText}
style={scheduledPostHasError ? customStyles.badgeOpacityWithError : customStyles.opacity}
/>
<Text
testID='channel_list.scheduled_post.count'
style={[customStyles.count, scheduledPostHasError && customStyles.badgeOpacityWithError]}
>
{scheduledPostCount}
</Text>
</View>
}
</View>
</View>
</TouchableOpacity>

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Screens} from '@constants';
import {observeCurrentTeamId} from '@queries/servers/system';
import {observeTeamLastChannelId} from '@queries/servers/team';
import DraftsButton from './drafts_button';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables(['shouldHighlightActive'], ({database, shouldHighlightActive}: {shouldHighlightActive: boolean} & WithDatabaseArgs) => {
const currentTeamId = observeCurrentTeamId(database);
const isActiveTab = currentTeamId.pipe(
switchMap(
(teamId) => observeTeamLastChannelId(database, teamId),
),
switchMap((lastChannelId) => of$(lastChannelId === Screens.GLOBAL_DRAFTS || (shouldHighlightActive && !lastChannelId))),
distinctUntilChanged(),
);
return {
isActiveTab,
};
});
export default withDatabase(enhanced(DraftsButton));

View file

@ -2,12 +2,12 @@
// See LICENSE.txt for license information.
import moment from 'moment';
import mtz from 'moment-timezone';
import React from 'react';
import {useIntl} from 'react-intl';
import {Text, type TextProps} from 'react-native';
import {getLocaleFromLanguage} from '@i18n';
import {getFormattedTime} from '@utils/time';
type FormattedTimeProps = TextProps & {
isMilitaryTime: boolean;
@ -18,24 +18,8 @@ type FormattedTimeProps = TextProps & {
const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => {
const {locale} = useIntl();
moment.locale(getLocaleFromLanguage(locale).toLowerCase());
const getFormattedTime = () => {
let format = 'H:mm';
if (!isMilitaryTime) {
const localeFormat = mtz.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
let zone: string;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
} else {
zone = timezone;
}
return timezone ? mtz.tz(value, zone).format(format) : mtz(value).format(format);
};
const formattedTime = getFormattedTime();
const formattedTime = getFormattedTime(isMilitaryTime, timezone, value);
return (
<Text {...props}>

View file

@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Screens} from '@constants';
import {PostPriorityType} from '@constants/post';
import NetworkManager from '@managers/network_manager';
import {openAsBottomSheet} from '@screens/navigation';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {persistentNotificationsConfirmation} from '@utils/post';
import DraftInput from './draft_input';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
const SERVER_URL = 'https://appv1.mattermost.com';
// this is needed to when using the useServerUrl hook
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => SERVER_URL),
}));
jest.mock('@screens/navigation', () => ({
openAsBottomSheet: jest.fn(),
}));
jest.mock('@utils/post', () => ({
persistentNotificationsConfirmation: jest.fn(),
}));
describe('DraftInput', () => {
const baseProps = {
testID: 'draft_input',
channelId: 'channelId',
channelType: 'O' as ChannelType,
currentUserId: 'currentUserId',
postPriority: {priority: ''} as PostPriority,
updatePostPriority: jest.fn(),
persistentNotificationInterval: 0,
persistentNotificationMaxRecipients: 0,
updateCursorPosition: jest.fn(),
cursorPosition: 0,
sendMessage: jest.fn(),
canSend: true,
maxMessageLength: 4000,
files: [],
value: '',
uploadFileError: null,
updateValue: jest.fn(),
addFiles: jest.fn(),
updatePostInputTop: jest.fn(),
setIsFocused: jest.fn(),
scheduledPostsEnabled: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
const server = await TestHelper.setupServerDatabase(SERVER_URL);
database = server.database;
operator = server.operator;
});
afterEach(async () => {
await TestHelper.tearDown();
NetworkManager.invalidateClient(SERVER_URL);
});
describe('Rendering', () => {
it('renders all required components', async () => {
const {getByTestId, queryByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
// Main container
const container = getByTestId('draft_input');
expect(container).toBeVisible();
// Input field
const input = getByTestId('draft_input.post.input');
expect(input).toBeVisible();
// Quick actions
const quickActions = getByTestId('draft_input.quick_actions');
expect(quickActions).toBeVisible();
// Send button
const sendButton = getByTestId('draft_input.send_action.send.button');
expect(sendButton).toBeVisible();
expect(sendButton).not.toBeDisabled();
// Should not show disabled send button
const disabledSend = queryByTestId('draft_input.send_action.send.button.disabled');
expect(disabledSend).toBeNull();
});
it('shows upload error with correct message', () => {
const errorMsg = 'Test error message';
const props = {...baseProps, uploadFileError: errorMsg};
const {getByText, getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
const error = getByText(errorMsg);
expect(error).toBeVisible();
// Error should be within uploads section
const uploadsSection = getByTestId('uploads');
expect(uploadsSection).toContainElement(error);
});
});
describe('Message Actions', () => {
it('sends message on press', () => {
const {getByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
fireEvent.press(getByTestId('draft_input.send_action.send.button'));
expect(baseProps.sendMessage).toHaveBeenCalledWith(undefined);
});
it('opens scheduled post options on long press and verify action', async () => {
jest.mocked(openAsBottomSheet).mockImplementation(({props}) => props!.onSchedule({scheduled_at: 100} as SchedulingInfo));
// make this a re-usable function
await operator.handleConfigs({
configs: [
{id: 'ScheduledPosts', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
const {getByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
fireEvent(getByTestId('draft_input.send_action.send.button'), 'longPress');
expect(openAsBottomSheet).toHaveBeenCalledWith(expect.objectContaining({
screen: Screens.SCHEDULED_POST_OPTIONS,
closeButtonId: 'close-scheduled-post-picker',
}));
expect(baseProps.sendMessage).toHaveBeenCalledWith({scheduled_at: 100});
});
it('should not open scheduled post options if scheduled post are disabled', async () => {
jest.mocked(openAsBottomSheet).mockImplementation(({props}) => props!.onSchedule({scheduled_at: 100} as SchedulingInfo));
const props = {
...baseProps,
scheduledPostsEnabled: false,
};
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
fireEvent(getByTestId('draft_input.send_action.send.button'), 'longPress');
expect(openAsBottomSheet).not.toHaveBeenCalled();
expect(baseProps.sendMessage).not.toHaveBeenCalled();
});
it('handles persistent notifications', async () => {
jest.mocked(persistentNotificationsConfirmation).mockResolvedValueOnce();
const props = {
...baseProps,
postPriority: {
persistent_notifications: true,
priority: PostPriorityType.URGENT,
} as PostPriority,
value: '@user1 @user2 message',
};
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
fireEvent.press(getByTestId('draft_input.send_action.send.button'));
expect(persistentNotificationsConfirmation).toHaveBeenCalled();
});
});
describe('Input Handling', () => {
it('updates text value', () => {
const {getByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
fireEvent.changeText(getByTestId('draft_input.post.input'), 'new message');
expect(baseProps.updateValue).toHaveBeenCalledWith('new message');
});
it('handles cursor position', () => {
const {getByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
fireEvent(getByTestId('draft_input.post.input'), 'selectionChange', {
nativeEvent: {selection: {start: 5, end: 5}},
});
expect(baseProps.updateCursorPosition).toHaveBeenCalledWith(5);
});
it('updates focus state', () => {
const {getByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
fireEvent(getByTestId('draft_input.post.input'), 'focus');
expect(baseProps.setIsFocused).toHaveBeenCalledWith(true);
});
});
describe('Validation', () => {
it('disables send when canSend is false', () => {
const props = {...baseProps, canSend: false};
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
const sendButton = getByTestId('draft_input.send_action.send.button.disabled');
expect(sendButton).toBeVisible();
expect(sendButton).toBeDisabled();
fireEvent(sendButton, 'longPress');
expect(baseProps.sendMessage).not.toHaveBeenCalled();
fireEvent.press(sendButton);
expect(baseProps.sendMessage).not.toHaveBeenCalled();
});
});
});

View file

@ -3,18 +3,21 @@
import React, {useCallback, useRef} from 'react';
import {useIntl} from 'react-intl';
import {type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {usePersistentNotificationProps} from '@hooks/persistent_notification_props';
import {openAsBottomSheet} from '@screens/navigation';
import {persistentNotificationsConfirmation} from '@utils/post';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input';
import QuickActions from '../quick_actions';
import SendAction from '../send_action';
import SendAction from '../send_button';
import Typing from '../typing';
import Uploads from '../uploads';
@ -22,7 +25,7 @@ import Header from './header';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
type Props = {
export type Props = {
testID?: string;
channelId: string;
channelType?: ChannelType;
@ -42,7 +45,7 @@ type Props = {
cursorPosition: number;
// Send Handler
sendMessage: () => void;
sendMessage: (schedulingInfo?: SchedulingInfo) => Promise<void | {data?: boolean; error?: unknown}>;
canSend: boolean;
maxMessageLength: number;
@ -54,10 +57,13 @@ type Props = {
addFiles: (files: FileInfo[]) => void;
updatePostInputTop: (top: number) => void;
setIsFocused: (isFocused: boolean) => void;
scheduledPostsEnabled: boolean;
}
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const SCHEDULED_POST_PICKER_BUTTON = 'close-scheduled-post-picker';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
actionsContainer: {
@ -103,7 +109,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
};
});
export default function DraftInput({
function DraftInput({
testID,
channelId,
channelType,
@ -127,10 +133,12 @@ export default function DraftInput({
persistentNotificationInterval,
persistentNotificationMaxRecipients,
setIsFocused,
scheduledPostsEnabled,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const isTablet = useIsTablet();
const handleLayout = useCallback((e: LayoutChangeEvent) => {
updatePostInputTop(e.nativeEvent.layout.height);
@ -153,13 +161,36 @@ export default function DraftInput({
postPriority,
});
const handleSendMessage = useCallback(async () => {
const handleSendMessage = useCallback(async (schedulingInfoParam?: SchedulingInfo) => {
const schedulingInfo = (schedulingInfoParam && 'scheduled_at' in schedulingInfoParam) ? schedulingInfoParam : undefined;
if (persistentNotificationsEnabled) {
persistentNotificationsConfirmation(serverUrl, value, mentionsList, intl, sendMessage, persistentNotificationMaxRecipients, persistentNotificationInterval, currentUserId, channelName, channelType);
} else {
sendMessage();
const sendMessageWithScheduledPost = () => sendMessage(schedulingInfo);
await persistentNotificationsConfirmation(serverUrl, value, mentionsList, intl, sendMessageWithScheduledPost, persistentNotificationMaxRecipients, persistentNotificationInterval, currentUserId, channelName, channelType);
return Promise.resolve();
}
}, [serverUrl, mentionsList, persistentNotificationsEnabled, persistentNotificationMaxRecipients, sendMessage, value, channelType]);
return sendMessage(schedulingInfo);
}, [persistentNotificationsEnabled, serverUrl, value, mentionsList, intl, sendMessage, persistentNotificationMaxRecipients, persistentNotificationInterval, currentUserId, channelName, channelType]);
const handleShowScheduledPostOptions = useCallback(() => {
if (!scheduledPostsEnabled) {
return;
}
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'scheduled_post.picker.title', defaultMessage: 'Schedule draft'}) : '';
openAsBottomSheet({
closeButtonId: SCHEDULED_POST_PICKER_BUTTON,
screen: Screens.SCHEDULED_POST_OPTIONS,
theme,
title,
props: {
closeButtonId: SCHEDULED_POST_PICKER_BUTTON,
onSchedule: handleSendMessage,
},
});
}, [handleSendMessage, intl, isTablet, scheduledPostsEnabled, theme]);
const sendActionDisabled = !canSend || noMentionsError;
@ -228,6 +259,7 @@ export default function DraftInput({
testID={sendActionTestID}
disabled={sendActionDisabled}
sendMessage={handleSendMessage}
showScheduledPostOptions={handleShowScheduledPostOptions}
/>
</View>
</ScrollView>
@ -235,3 +267,5 @@ export default function DraftInput({
</>
);
}
export default DraftInput;

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeConfigBooleanValue} from '@queries/servers/system';
import DraftInput from './draft_input';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const scheduledPostsEnabled = observeConfigBooleanValue(database, 'ScheduledPosts');
return {
scheduledPostsEnabled,
};
});
export default withDatabase(enhanced(DraftInput));

View file

@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID: string;
disabled: boolean;
sendMessage: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
sendButtonContainer: {
justifyContent: 'flex-end',
paddingRight: 8,
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 4,
height: 32,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
};
});
function SendButton({
testID,
disabled,
sendMessage,
}: Props) {
const theme = useTheme();
const sendButtonTestID = disabled ? `${testID}.send.button.disabled` : `${testID}.send.button`;
const style = getStyleSheet(theme);
const viewStyle = useMemo(() => {
if (disabled) {
return [style.sendButton, style.disableButton];
}
return style.sendButton;
}, [disabled, style]);
const buttonColor = disabled ? changeOpacity(theme.buttonColor, 0.5) : theme.buttonColor;
return (
<TouchableWithFeedback
testID={sendButtonTestID}
onPress={sendMessage}
style={style.sendButtonContainer}
type={'opacity'}
disabled={disabled}
>
<View style={viewStyle}>
<CompassIcon
name='send'
size={24}
color={buttonColor}
/>
</View>
</TouchableWithFeedback>
);
}
export default SendButton;

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {SendButton} from '@components/post_draft/send_button/send_button';
import {Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
const enhanced = withObservables([], () => {
const scheduledPostFeatureTooltipWatched = observeTutorialWatched(Tutorial.SCHEDULED_POST);
return {
scheduledPostFeatureTooltipWatched,
};
});
export default withDatabase(enhanced(SendButton));

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react-native';
import React from 'react';
import ScheduledPostTooltip from '@components/post_draft/send_button/scheduled_post_tooltip';
import {renderWithIntl} from '@test/intl-test-helper';
describe('components/post_draft/send_button/ScheduledPostTooltip', () => {
const baseProps = {
onClose: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
renderWithIntl(<ScheduledPostTooltip {...baseProps}/>);
// Verify main container
expect(screen.getByTestId('scheduled_post_tutorial_tooltip')).toBeVisible();
// Verify close button
expect(screen.getByTestId('scheduled_post.tooltip.close.button')).toBeVisible();
// Verify description text
const description = screen.getByTestId('scheduled_post.tooltip.description');
expect(description).toBeVisible();
expect(description.props.children).toBe('Type a message and long press the send button to schedule it for a later time.');
});
it('calls onClose when close button is pressed', () => {
renderWithIntl(<ScheduledPostTooltip {...baseProps}/>);
const closeButton = screen.getByTestId('scheduled_post.tooltip.close.button');
fireEvent.press(closeButton);
expect(baseProps.onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -20,15 +20,17 @@ const longPressGestureHandLogo = require('@assets/images/emojis/swipe.png');
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 4,
paddingVertical: 8,
flexDirection: 'column',
gap: 18,
},
close: {
position: 'absolute',
top: 0,
right: 0,
},
descriptionContainer: {
marginBottom: 24,
marginTop: 12,
},
description: {
color: Preferences.THEMES.denim.centerChannelColor,
...typography('Heading', 200),
@ -49,9 +51,12 @@ const styles = StyleSheet.create({
},
});
const DraftTooltip = ({onClose}: Props) => {
const ScheduledPostTooltip = ({onClose}: Props) => {
return (
<View>
<View
style={styles.container}
testID='scheduled_post_tutorial_tooltip'
>
<View style={styles.titleContainer}>
<Image
source={longPressGestureHandLogo}
@ -61,25 +66,26 @@ const DraftTooltip = ({onClose}: Props) => {
style={styles.close}
hitSlop={hitSlop}
onPress={onClose}
testID='draft.tooltip.close.button'
testID='scheduled_post.tooltip.close.button'
>
<CompassIcon
color={changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.56)}
name='close'
size={18}
testID='scheduled_post_tutorial_tooltip.close'
/>
</TouchableOpacity>
</View>
<View style={styles.descriptionContainer}>
<View>
<FormattedText
id='draft.tooltip.description'
defaultMessage='Long-press on an item to see draft actions'
id='scheduled_post.feature_tooltip'
defaultMessage='Type a message and long press the send button to schedule it for a later time.'
style={styles.description}
testID='draft.tooltip.description'
testID='scheduled_post.tooltip.description'
/>
</View>
</View>
);
};
export default DraftTooltip;
export default ScheduledPostTooltip;

View file

@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react-hooks';
import React from 'react';
import {InteractionManager} from 'react-native';
import {SendButton} from '@components/post_draft/send_button/send_button';
import {fireEvent, renderWithIntl} from '@test/intl-test-helper';
describe('components/post_draft/send_button', () => {
const baseProps = {
disabled: false,
sendMessage: jest.fn(),
testID: 'test_id',
showScheduledPostOptions: jest.fn(),
scheduledPostFeatureTooltipWatched: true,
};
it('should render send button when enabled', () => {
const {getByTestId} = renderWithIntl(
<SendButton {...baseProps}/>,
);
const button = getByTestId('test_id.send.button');
expect(button).toBeTruthy();
});
it('should render disabled send button when disabled', () => {
const {getByTestId} = renderWithIntl(
<SendButton
{...baseProps}
disabled={true}
/>,
);
const button = getByTestId('test_id.send.button.disabled');
expect(button).toBeTruthy();
expect(button).toBeDisabled();
});
it('should handle single tap', () => {
const onPress = jest.fn();
const {getByTestId} = renderWithIntl(
<SendButton
{...baseProps}
sendMessage={onPress}
/>,
);
const button = getByTestId('test_id.send.button');
fireEvent.press(button);
expect(onPress).toHaveBeenCalledTimes(1);
});
it('should prevent double tap within debounce period', async () => {
const onPress = jest.fn();
const {getByTestId} = renderWithIntl(
<SendButton
{...baseProps}
sendMessage={onPress}
/>,
);
const button = getByTestId('test_id.send.button');
// First tap
fireEvent.press(button);
// Second tap immediately after
fireEvent.press(button);
// Should only call once despite two taps
expect(onPress).toHaveBeenCalledTimes(1);
});
it('should allow tap after debounce period', async () => {
jest.useFakeTimers();
const onPress = jest.fn();
const {getByTestId} = renderWithIntl(
<SendButton
{...baseProps}
sendMessage={onPress}
/>,
);
const button = getByTestId('test_id.send.button');
// First tap
act(() => {
fireEvent.press(button);
});
expect(onPress).toHaveBeenCalledTimes(1);
act(() => {
jest.advanceTimersByTime(750);
fireEvent.press(button);
});
expect(onPress).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
it('should not call onPress when disabled', () => {
const onPress = jest.fn();
const {getByTestId} = renderWithIntl(
<SendButton
{...baseProps}
disabled={true}
sendMessage={onPress}
/>,
);
const button = getByTestId('test_id.send.button.disabled');
fireEvent.press(button);
expect(onPress).not.toHaveBeenCalled();
});
it('should show the tooltip after when the tutorial has not been watched', async () => {
const handle = InteractionManager.createInteractionHandle();
const props = {...baseProps, scheduledPostFeatureTooltipWatched: false};
const {queryByText} = renderWithIntl(
<SendButton
{...props}
/>,
);
const text = 'Type a message and long press the send button to schedule it for a later time.';
expect(queryByText(text)).toBeNull();
InteractionManager.clearInteractionHandle(handle);
await new Promise((resolve) => setImmediate(resolve));
act(() => {
expect(queryByText(text)).toBeTruthy();
});
});
});

View file

@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import Tooltip from 'react-native-walkthrough-tooltip';
import {storeScheduledPostTutorial} from '@actions/app/global';
import CompassIcon from '@components/compass_icon';
import ScheduledPostTooltip from '@components/post_draft/send_button/scheduled_post_tooltip';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID: string;
disabled: boolean;
sendMessage: () => void;
showScheduledPostOptions: () => void;
scheduledPostFeatureTooltipWatched: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
sendButtonContainer: {
justifyContent: 'flex-end',
paddingRight: 8,
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 4,
height: 32,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
scheduledPostTooltipStyle: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowRadius: 2,
shadowOpacity: 0.16,
elevation: 24,
width: 250,
height: 140,
},
};
});
export function SendButton({
testID,
disabled,
sendMessage,
showScheduledPostOptions,
scheduledPostFeatureTooltipWatched,
}: Props) {
const theme = useTheme();
const sendButtonTestID = `${testID}.send.button` + (disabled ? '.disabled' : '');
const style = getStyleSheet(theme);
const [scheduledPostTooltipVisible, setScheduledPostTooltipVisible] = useState(false);
useEffect(() => {
if (scheduledPostFeatureTooltipWatched) {
return;
}
InteractionManager.runAfterInteractions(() => {
setScheduledPostTooltipVisible(true);
});
// This effect is intended to run only on the first mount, so dependencies are omitted intentionally.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onCloseScheduledPostTooltip = useCallback(() => {
setScheduledPostTooltipVisible(false);
storeScheduledPostTutorial();
}, []);
const viewStyle = useMemo(() => [style.sendButton, disabled ? style.disableButton : {}], [disabled, style]);
const buttonColor = disabled ? changeOpacity(theme.buttonColor, 0.5) : theme.buttonColor;
const sendMessageWithDoubleTapPrevention = usePreventDoubleTap(sendMessage);
return (
<TouchableWithFeedback
testID={sendButtonTestID}
onPress={sendMessageWithDoubleTapPrevention}
style={style.sendButtonContainer}
type={'opacity'}
disabled={disabled}
onLongPress={showScheduledPostOptions}
>
<Tooltip
isVisible={scheduledPostTooltipVisible}
useInteractionManager={true}
placement='top'
content={<ScheduledPostTooltip onClose={onCloseScheduledPostTooltip}/>}
onClose={onCloseScheduledPostTooltip}
tooltipStyle={style.scheduledPostTooltipStyle}
>
<View style={viewStyle}>
<CompassIcon
name='send'
size={24}
color={buttonColor}
/>
</View>
</Tooltip>
</TouchableWithFeedback>
);
}

View file

@ -6,11 +6,13 @@ import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {observeChannel, observeChannelInfo, observeCurrentChannel} from '@queries/servers/channel';
import {observeChannel, observeChannelInfo} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeFirstDraft, queryDraft} from '@queries/servers/drafts';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeFirstScheduledPost, queryScheduledPost} from '@queries/servers/scheduled_post';
import {observeConfigBooleanValue, observeConfigIntValue, observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
@ -20,19 +22,15 @@ import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
rootId: string;
draftType?: DraftType;
channelId: string;
channelIsArchived?: boolean;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const enhanced = withObservables(['channelId', 'rootId', 'draftType'], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const {rootId, channelId} = ownProps;
let channel;
if (rootId) {
channel = observeChannel(database, channelId);
} else {
channel = observeCurrentChannel(database);
}
const {rootId, channelId, draftType} = ownProps;
const channel = observeChannel(database, channelId);
const currentUserId = observeCurrentUserId(database);
const currentUser = currentUserId.pipe(
@ -42,16 +40,30 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)),
);
const postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
switchMap(observeFirstDraft),
switchMap((d) => {
if (!d?.metadata?.priority) {
return of$(INITIAL_PRIORITY);
}
let postPriority;
if (draftType === DRAFT_TYPE_SCHEDULED) {
postPriority = queryScheduledPost(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
switchMap(observeFirstScheduledPost),
switchMap((d) => {
if (!d?.metadata?.priority) {
return of$(INITIAL_PRIORITY);
}
return of$(d.metadata.priority);
}),
);
return of$(d.metadata.priority);
}),
);
} else {
postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
switchMap(observeFirstDraft),
switchMap((d) => {
if (!d?.metadata?.priority) {
return of$(INITIAL_PRIORITY);
}
return of$(d.metadata.priority);
}),
);
}
const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel');
const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK);

View file

@ -0,0 +1,312 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent, waitFor} from '@testing-library/react-native';
import React from 'react';
import {removeDraft} from '@actions/local/draft';
import {createPost} from '@actions/remote/post';
import {General} from '@constants';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {sendMessageWithAlert} from '@utils/post';
import {canPostDraftInChannelOrThread} from '@utils/scheduled_post';
import {showSnackBar} from '@utils/snack_bar';
import SendHandler from './send_handler';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@actions/remote/post');
jest.mock('@actions/remote/channel', () => ({
getChannelTimezones: jest.fn().mockResolvedValue({channelTimezones: []}),
}));
jest.mock('@utils/post', () => ({
sendMessageWithAlert: jest.fn(),
persistentNotificationsConfirmation: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
}));
jest.mock('@actions/local/draft', () => ({
removeDraft: jest.fn(),
}));
jest.mock('@actions/remote/post', () => ({
createPost: jest.fn(),
}));
jest.mock('@utils/snack_bar', () => ({
showSnackBar: jest.fn(),
}));
jest.mock('@utils/scheduled_post', () => ({
canPostDraftInChannelOrThread: jest.fn(),
}));
describe('components/post_draft/send_handler/SendHandler', () => {
let database: Database;
const baseProps = {
testID: 'test_send_handler',
channelId: 'channel-id',
channelType: General.OPEN_CHANNEL,
channelName: 'test-channel',
rootId: '',
currentUserId: 'current-user-id',
cursorPosition: 0,
enableConfirmNotificationsToChannel: true,
maxMessageLength: 4000,
membersCount: 3,
useChannelMentions: true,
userIsOutOfOffice: false,
customEmojis: [],
value: '',
files: [],
clearDraft: jest.fn(),
updateValue: jest.fn(),
updateCursorPosition: jest.fn(),
updatePostInputTop: jest.fn(),
addFiles: jest.fn(),
uploadFileError: null,
setIsFocused: jest.fn(),
persistentNotificationInterval: 0,
persistentNotificationMaxRecipients: 5,
postPriority: {
priority: 'urgent',
requested_ack: false,
persistent_notifications: false,
} as PostPriority,
};
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should render DraftInput when not from draft view', () => {
const wrapper = renderWithEverything(
<SendHandler {...baseProps}/>, {database},
);
expect(wrapper.getByTestId('test_send_handler')).toBeTruthy();
});
it('should render SendDraft when from draft view', () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT,
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
expect(wrapper.getByTestId('send_draft_button')).toBeTruthy();
expect(wrapper.getByText('Send draft')).toBeTruthy();
});
it('should render Send text when draft type is scheduled', () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_SCHEDULED,
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
expect(wrapper.getByTestId('send_draft_button')).toBeTruthy();
expect(wrapper.getByText('Send')).toBeTruthy();
});
it('should show correct post priority', async () => {
const wrapper = renderWithEverything(
<SendHandler
{...baseProps}
canShowPostPriority={true}
/>, {database},
);
expect(wrapper.getByTestId('test_send_handler')).toBeTruthy();
expect(wrapper.getByText('URGENT')).toBeTruthy();
});
it('should pass correct props to SendDraft component when in draft view', async () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT,
channelDisplayName: 'Test Channel',
draftReceiverUserName: 'test-user',
postId: 'test-post-id',
value: 'test message',
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
// Verify the SendDraft button is rendered with correct text
const sendDraftButton = wrapper.getByTestId('send_draft_button');
expect(sendDraftButton).toBeTruthy();
expect(wrapper.getByText('Send draft')).toBeTruthy();
// Manually trigger the button press
fireEvent.press(sendDraftButton);
await waitFor(() => expect(sendMessageWithAlert).toHaveBeenCalled());
// Reset the mock for the next test
jest.clearAllMocks();
// Test with empty message to verify disabled state
const emptyProps = {
...props,
value: '',
files: [],
};
wrapper.rerender(
<SendHandler {...emptyProps}/>,
);
// Button should still exist but pressing it should not trigger send when empty
const emptyButton = wrapper.getByTestId('send_draft_button');
expect(emptyButton).toBeTruthy();
fireEvent.press(emptyButton);
await waitFor(() => expect(sendMessageWithAlert).not.toHaveBeenCalled());
});
it('should call sendMessageWithAlert with correct params when Send button clicked', async () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT,
channelName: 'test-channel',
value: 'test message',
postPriority: {
persistent_notifications: false,
} as PostPriority,
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
const sendButton = wrapper.getByTestId('send_draft_button');
expect(sendButton).toBeTruthy();
await act(async () => {
fireEvent.press(sendButton);
});
await waitFor(() => {
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
channelName: 'test-channel',
title: 'Send message now',
intl: expect.any(Object),
sendMessageHandler: expect.any(Function),
}));
});
});
it('should execute sendMessageHandler when send_draft_button is clicked', async () => {
// Mock implementation to capture the sendMessageHandler
let capturedHandler: Function;
jest.mocked(sendMessageWithAlert).mockImplementation((params) => {
capturedHandler = params.sendMessageHandler;
return Promise.resolve();
});
jest.mocked(createPost).mockResolvedValueOnce({
data: true,
});
jest.mocked(canPostDraftInChannelOrThread).mockResolvedValueOnce(true);
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT,
value: 'test message',
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
// Find and press the send button
const sendButton = wrapper.getByTestId('send_draft_button');
await act(async () => {
fireEvent.press(sendButton);
});
// Verify sendMessageWithAlert was called
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
await capturedHandler();
});
// Varify removeDraft function is been called.
expect(removeDraft).toHaveBeenCalled();
});
it('should show snackbar if creating post failing when executing sendMessageHandler when send_draft_button is checked', async () => {
// Mock implementation to capture the sendMessageHandler
let capturedHandler: Function;
jest.mocked(sendMessageWithAlert).mockImplementation((params) => {
capturedHandler = params.sendMessageHandler;
return Promise.resolve();
});
jest.mocked(createPost).mockResolvedValueOnce({
error: new Error('Failed to create post'),
});
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT,
value: 'test message',
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
// Find and press the send button
const sendButton = wrapper.getByTestId('send_draft_button');
await act(async () => {
fireEvent.press(sendButton);
});
// Verify sendMessageWithAlert was called
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));
// Now execute the captured handler to simulate user confirming the send
await act(async () => {
await capturedHandler();
});
expect(showSnackBar).toHaveBeenCalledWith({
barType: SNACK_BAR_TYPE.CREATE_POST_ERROR,
customMessage: 'Failed to create post',
type: 'error',
});
// Varify removeDraft function is been called.
expect(removeDraft).not.toHaveBeenCalled();
});
});

View file

@ -4,13 +4,13 @@
import React, {useCallback} from 'react';
import {updateDraftPriority} from '@actions/local/draft';
import SendDraft from '@components/draft_scheduled_post/draft_scheduled_post_actions/send_draft';
import DraftInput from '@components/post_draft/draft_input/';
import {PostPriorityType} from '@constants/post';
import {useServerUrl} from '@context/server';
import {useHandleSendMessage} from '@hooks/handle_send_message';
import SendDraft from '@screens/draft_options/send_draft';
import DraftInput from '../draft_input';
import type {DraftType} from '@constants/draft';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -46,6 +46,8 @@ type Props = {
persistentNotificationMaxRecipients: number;
postPriority: PostPriority;
draftType?: DraftType;
postId?: string;
bottomSheetId?: AvailableScreens;
channelDisplayName?: string;
isFromDraftView?: boolean;
@ -89,6 +91,8 @@ export default function SendHandler({
bottomSheetId,
draftReceiverUserName,
isFromDraftView,
draftType,
postId,
}: Props) {
const serverUrl = useServerUrl();
@ -135,6 +139,8 @@ export default function SendHandler({
persistentNotificationInterval={persistentNotificationInterval}
persistentNotificationMaxRecipients={persistentNotificationMaxRecipients}
draftReceiverUserName={draftReceiverUserName}
draftType={draftType}
postId={postId}
/>
);
}

View file

@ -144,7 +144,10 @@ function Uploads({
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<View style={style.previewContainer}>
<View
style={style.previewContainer}
testID='uploads'
>
<Animated.View
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
>

View file

@ -25,7 +25,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
buttonStyle: {
position: 'absolute',
alignSelf: 'center',
bottom: -70,
bottom: -100,
flexDirection: 'row',
},
shadow: {
@ -97,10 +97,11 @@ const ScrollToEndView = ({
() => ({
transform: [
{
translateY: withTiming(showScrollToEndBtn ? -80 - keyboardOverlap - bottomAdjustment : 0, {duration: 300}),
translateY: withTiming(showScrollToEndBtn ? -100 - keyboardOverlap - bottomAdjustment : -15, {duration: 300}),
},
],
maxWidth: withTiming(isNewMessage ? 169 : 40, {duration: 300}),
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
}),
[showScrollToEndBtn, isNewMessage, keyboardOverlap, bottomAdjustment],
);

View file

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react-native';
import React, {act} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {switchToGlobalDrafts} from '@actions/local/draft';
import {Events} from '@constants';
import {DRAFT} from '@constants/screens';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ScheduledPostIndicator from '.';
jest.mock('@utils/theme', () => ({
changeOpacity: jest.fn().mockReturnValue('rgba(0,0,0,0.5)'),
makeStyleSheetFromTheme: jest.fn().mockReturnValue(() => ({
wrapper: {},
container: {},
text: {},
link: {},
})),
}));
jest.mock('@actions/local/draft', () => ({
switchToGlobalDrafts: jest.fn().mockResolvedValue({error: null}),
}));
jest.mock('@components/formatted_time', () => {
const MockFormattedTime = (props: any) => {
// Store props for test assertions
MockFormattedTime.mockProps = props;
return null;
};
MockFormattedTime.mockProps = {};
return MockFormattedTime;
});
describe('ScheduledPostIndicator', () => {
const baseProps = {
isMilitaryTime: false,
scheduledPostCount: 1,
};
test('should render multiple posts message when scheduledPostCount is greater than 1', () => {
const props = {
...baseProps,
scheduledPostCount: 2,
channelId: 'channel_id',
};
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
expect(screen.getByText(/2 scheduled messages in channel/i)).toBeTruthy();
});
test('should render thread message when isThread is true', () => {
const props = {
...baseProps,
scheduledPostCount: 2,
isThread: true,
channelId: 'channel_id',
};
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
expect(screen.getByText(/2 scheduled messages in thread/i)).toBeTruthy();
});
test('should render correct message when there is only one scheduled post', () => {
const props = {
...baseProps,
scheduledPostCount: 1,
isThread: true,
channelId: 'channel_id',
};
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
expect(screen.getByText(/1 scheduled message in thread/i)).toBeTruthy();
});
test('should handle see all scheduled posts click', async () => {
const props = {
...baseProps,
scheduledPostCount: 2,
channelId: 'channel_id',
};
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
jest.mocked(switchToGlobalDrafts).mockResolvedValue({error: null});
const button = screen.getByText('See all.');
await act(async () => {
fireEvent.press(button);
await TestHelper.wait(0);
});
expect(emitSpy).toHaveBeenCalledWith(Events.ACTIVE_SCREEN, DRAFT);
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {DeviceEventEmitter, Text, View} from 'react-native';
import {switchToGlobalDrafts} from '@actions/local/draft';
import CompassIcon from '@components/compass_icon';
import {Events} from '@constants';
import {DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
import {DRAFT} from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
paddingVertical: 12,
paddingHorizontal: 16,
color: changeOpacity(theme.centerChannelColor, 0.72),
flexDirection: 'row',
alignItems: 'center',
gap: 12,
width: '100%',
},
text: {
color: changeOpacity(theme.centerChannelColor, 0.75),
...typography('Body', 100, 'SemiBold'),
},
link: {
color: theme.linkColor,
...typography('Body', 100, 'SemiBold'),
},
};
});
type Props = {
isThread?: boolean;
scheduledPostCount?: number;
}
function ScheduledPostIndicator({
isThread,
scheduledPostCount = 0,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const handleSeeAllScheduledPosts = useCallback(async () => {
const {error} = await switchToGlobalDrafts(serverUrl, undefined, DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
if (!error) {
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, DRAFT);
}
}, [serverUrl]);
const scheduledPostText = isThread ? (
<FormattedMessage
id='scheduled_post.channel_indicator.thread'
defaultMessage={`{count, plural,
one {# scheduled message in thread.}
other {# scheduled messages in thread.}
}`}
values={{count: scheduledPostCount}}
/>
) : (
<FormattedMessage
id='scheduled_post.channel_indicator'
defaultMessage={`{count, plural,
one {# scheduled message in channel.}
other {# scheduled messages in channel.}
}`}
values={{count: scheduledPostCount}}
/>
);
return (
<View
className='ScheduledPostIndicator'
>
<View style={styles.container}>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.6)}
name='clock-send-outline'
size={18}
/>
<Text style={styles.text}>
{scheduledPostText}
{' '}
<Text
style={styles.link}
onPress={handleSeeAllScheduledPosts}
>
<FormattedMessage
id='scheduled_post.channel_indicator.link_to_scheduled_posts.text'
defaultMessage='See all.'
/>
</Text>
</Text>
</View>
</View>
);
}
export default ScheduledPostIndicator;

View file

@ -5,6 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {switchMap} from 'rxjs/operators';
import {observeCurrentChannelId, observeCurrentTeamId} from '@queries/servers/system';
import {observeTeamLastChannelId} from '@queries/servers/team';
import {observeUnreadsAndMentionsInTeam} from '@queries/servers/thread';
import ThreadsButton from './threads_button';
@ -16,6 +17,11 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentChannelId: observeCurrentChannelId(database),
lastChannelId: currentTeamId.pipe(
switchMap(
(teamId) => observeTeamLastChannelId(database, teamId),
),
),
unreadsAndMentions: currentTeamId.pipe(
switchMap(
(teamId) => observeUnreadsAndMentionsInTeam(database, teamId),

View file

@ -13,13 +13,13 @@ import {
} from '@components/channel_item/channel_item';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {Events} from '@constants';
import {Events, Screens} from '@constants';
import {THREAD} from '@constants/screens';
import {HOME_PADDING} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {preventDoubleTap} from '@utils/tap';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -41,9 +41,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
type Props = {
currentChannelId: string;
lastChannelId?: string;
onCenterBg?: boolean;
onPress?: () => void;
shouldHighlighActive?: boolean;
shouldHighlightActive?: boolean;
unreadsAndMentions: {
unreads: boolean;
mentions: number;
@ -53,10 +54,11 @@ type Props = {
const ThreadsButton = ({
currentChannelId,
lastChannelId,
onCenterBg,
onPress,
unreadsAndMentions,
shouldHighlighActive = false,
shouldHighlightActive = false,
isOnHome = false,
}: Props) => {
const isTablet = useIsTablet();
@ -66,17 +68,17 @@ const ThreadsButton = ({
const styles = getChannelItemStyleSheet(theme);
const customStyles = getStyleSheet(theme);
const handlePress = useCallback(preventDoubleTap(() => {
const handlePress = usePreventDoubleTap(useCallback(() => {
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, THREAD);
if (onPress) {
onPress();
} else {
switchToGlobalThreads(serverUrl);
}
}), [onPress, serverUrl]);
}, [onPress, serverUrl]));
const {unreads, mentions} = unreadsAndMentions;
const isActive = isTablet && shouldHighlighActive && !currentChannelId;
const isActive = isTablet && ((shouldHighlightActive && !currentChannelId) || (!currentChannelId && lastChannelId === Screens.GLOBAL_THREADS));
const [containerStyle, iconStyle, textStyle, badgeStyle] = useMemo(() => {
const container = [

View file

@ -18,6 +18,7 @@ type ToastProps = {
message?: string;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
}
export const TOAST_HEIGHT = 56;
@ -52,7 +53,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const Toast = ({animatedStyle, children, style, iconName, message, textStyle}: ToastProps) => {
const Toast = ({animatedStyle, children, style, iconName, message, textStyle, testID}: ToastProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dim = useWindowDimensions();
@ -64,7 +65,10 @@ const Toast = ({animatedStyle, children, style, iconName, message, textStyle}: T
}, [dim, styles.container, style]);
return (
<Animated.View style={[styles.center, animatedStyle]}>
<Animated.View
style={[styles.center, animatedStyle]}
testID={testID}
>
<Animated.View style={containerStyle}>
{Boolean(iconName) &&
<CompassIcon

View file

@ -14,6 +14,13 @@ export const POSTS = keyMirror({
RECEIVED_POST_THREAD: null,
});
export const SCHEDULED_POSTS = keyMirror({
RECEIVED_ALL_SCHEDULED_POSTS: null,
CREATE_OR_UPDATED_SCHEDULED_POST: null,
DELETE_SCHEDULED_POST: null,
});
export default {
POSTS,
SCHEDULED_POSTS,
};

View file

@ -35,6 +35,7 @@ export const MM_TABLES = {
PREFERENCE: 'Preference',
REACTION: 'Reaction',
ROLE: 'Role',
SCHEDULED_POST: 'ScheduledPost',
SYSTEM: 'System',
TEAM: 'Team',
TEAM_CHANNEL_HISTORY: 'TeamChannelHistory',

13
app/constants/draft.ts Normal file
View file

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const DRAFT_TYPE_DRAFT = 'draft' as const;
export const DRAFT_TYPE_SCHEDULED = 'scheduled' as const;
export type DraftType = typeof DRAFT_TYPE_DRAFT | typeof DRAFT_TYPE_SCHEDULED;
export const DRAFT_SCHEDULED_POST_LAYOUT_PADDING = 40;
export const DRAFT_SCREEN_TAB_DRAFTS = 0;
export const DRAFT_SCREEN_TAB_SCHEDULED_POSTS = 1;
export type DraftScreenTab = typeof DRAFT_SCREEN_TAB_DRAFTS | typeof DRAFT_SCREEN_TAB_SCHEDULED_POSTS;

View file

@ -23,7 +23,7 @@ export const COMPONENT_LIBRARY = 'ComponentLibrary';
export const CUSTOM_STATUS = 'CustomStatus';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const DRAFT = 'Draft';
export const DRAFT_OPTIONS = 'DraftOptions';
export const DRAFT_SCHEDULED_POST_OPTIONS = 'DraftScheduledPostOptions';
export const EDIT_POST = 'EditPost';
export const EDIT_PROFILE = 'EditProfile';
export const EDIT_SERVER = 'EditServer';
@ -50,8 +50,10 @@ export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions';
export const RESCHEDULE_DRAFT = 'RescheduleDraft';
export const REVIEW_APP = 'ReviewApp';
export const SAVED_MESSAGES = 'SavedMessages';
export const SCHEDULED_POST_OPTIONS = 'ScheduledPostOptions';
export const SEARCH = 'Search';
export const SELECT_TEAM = 'SelectTeam';
export const SERVER = 'Server';
@ -81,6 +83,7 @@ export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit';
export const GENERIC_OVERLAY = 'GenericOverlay';
export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts';
export const CHANNEL_BANNER = 'ChannelBanner';
export default {
@ -106,7 +109,7 @@ export default {
CREATE_TEAM,
CUSTOM_STATUS,
CUSTOM_STATUS_CLEAR_AFTER,
DRAFT_OPTIONS,
DRAFT_SCHEDULED_POST_OPTIONS,
EDIT_POST,
EDIT_PROFILE,
EDIT_SERVER,
@ -133,6 +136,7 @@ export default {
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,
RESCHEDULE_DRAFT,
REVIEW_APP,
SAVED_MESSAGES,
SEARCH,
@ -163,6 +167,8 @@ export default {
THREAD_OPTIONS,
USER_PROFILE,
GENERIC_OVERLAY,
SCHEDULED_POST_OPTIONS,
GLOBAL_DRAFTS_AND_SCHEDULED_POSTS,
CHANNEL_BANNER,
} as const;
@ -181,6 +187,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
MANAGE_CHANNEL_MEMBERS,
INVITE,
PERMALINK,
RESCHEDULE_DRAFT,
]);
export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
@ -192,7 +199,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
BOTTOM_SHEET,
DRAFT_OPTIONS,
DRAFT_SCHEDULED_POST_OPTIONS,
EMOJI_PICKER,
POST_OPTIONS,
POST_PRIORITY_PICKER,
@ -201,6 +208,7 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
USER_PROFILE,
CALL_PARTICIPANTS,
CALL_HOST_CONTROLS,
SCHEDULED_POST_OPTIONS,
]);
export const SCREENS_WITH_EXTRA_KEYBOARD = new Set<string>([CHANNEL, THREAD]);

View file

@ -19,6 +19,11 @@ export const SNACK_BAR_TYPE = keyMirror({
UNFAVORITE_CHANNEL: null,
UNMUTE_CHANNEL: null,
UNFOLLOW_THREAD: null,
CREATE_POST_ERROR: null,
CONNECTION_ERROR: null,
SCHEDULED_POST_CREATION_ERROR: null,
RESCHEDULED_POST: null,
DELETE_SCHEDULED_POST_ERROR: null,
});
export const MESSAGE_TYPE = {
@ -27,7 +32,7 @@ export const MESSAGE_TYPE = {
DEFAULT: 'default',
};
type SnackBarConfig = {
export type SnackBarConfig = {
id: string;
defaultMessage: string;
iconName: string;

13
app/constants/tooltip.ts Normal file
View file

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {StyleSheet} from 'react-native';
export const staticStyles = StyleSheet.create({
tooltipContent: {
borderRadius: 8,
width: 247,
padding: 16,
height: 160,
},
});

View file

@ -5,10 +5,14 @@ export const MULTI_SERVER = 'multiServerTutorial';
export const PROFILE_LONG_PRESS = 'profileLongPressTutorial';
export const EMOJI_SKIN_SELECTOR = 'emojiSkinSelectorTutorial';
export const DRAFTS = 'draftsTutorial';
export const SCHEDULED_POST = 'scheduledPostTutorial';
export const SCHEDULED_POSTS_LIST = 'scheduledPostsListTutorial';
export default {
MULTI_SERVER,
PROFILE_LONG_PRESS,
EMOJI_SKIN_SELECTOR,
DRAFTS,
SCHEDULED_POST,
SCHEDULED_POSTS_LIST,
};

View file

@ -106,5 +106,9 @@ const WebsocketEvents = {
CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated',
CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted',
CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted',
SCHEDULED_POST_CREATED: 'scheduled_post_created',
SCHEDULED_POST_UPDATED: 'scheduled_post_updated',
SCHEDULED_POST_DELETED: 'scheduled_post_deleted',
};
export default WebsocketEvents;

View file

@ -16,6 +16,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
ScheduledPostModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
@ -50,7 +51,7 @@ class DatabaseManagerSingleton {
CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
];
this.databaseDirectory = '';

View file

@ -15,7 +15,7 @@ import {InfoModel, GlobalModel, ServersModel} from '@database/models/app';
import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, ConfigModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
@ -47,7 +47,7 @@ class DatabaseManagerSingleton {
CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
];

View file

@ -8,9 +8,29 @@ import {addColumns, createTable, schemaMigrations, unsafeExecuteSql} from '@nozb
import {MM_TABLES} from '@constants/database';
const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST, CHANNEL, CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD} = MM_TABLES.SERVER;
const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST, CHANNEL, CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD, SCHEDULED_POST} = MM_TABLES.SERVER;
export default schemaMigrations({migrations: [
{
toVersion: 9,
steps: [
createTable({
name: SCHEDULED_POST,
columns: [
{name: 'channel_id', type: 'string', isIndexed: true},
{name: 'message', type: 'string'},
{name: 'files', type: 'string'},
{name: 'root_id', type: 'string', isIndexed: true},
{name: 'metadata', type: 'string', isOptional: true},
{name: 'create_at', type: 'number'},
{name: 'update_at', type: 'number'},
{name: 'scheduled_at', type: 'number'},
{name: 'processed_at', type: 'number'},
{name: 'error_code', type: 'string'},
],
}),
],
},
{
toVersion: 8,
steps: [

View file

@ -26,6 +26,7 @@ export {default as PostsInThreadModel} from './posts_in_thread';
export {default as PreferenceModel} from './preference';
export {default as ReactionModel} from './reaction';
export {default as RoleModel} from './role';
export {default as ScheduledPostModel} from './scheduled_post';
export {default as SystemModel} from './system';
export {default as TeamChannelHistoryModel} from './team_channel_history';
export {default as TeamMembershipModel} from './team_membership';

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {field, json} from '@nozbe/watermelondb/decorators';
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
import type ScheduledPostModelInterface from '@typings/database/models/servers/scheduled_post';
const {CHANNEL, POST, SCHEDULED_POST} = MM_TABLES.SERVER;
/**
* The Scheduled post model represents a scheduled post anywhere in Mattermost - channels, DMs, or GMs.
*/
export default class ScheduledPostModel extends Model implements ScheduledPostModelInterface {
/** table (name) : SchedulePost */
static table = SCHEDULED_POST;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A SCHEDULED_POST can belong to only one CHANNEL */
[CHANNEL]: {type: 'belongs_to', key: 'channel_id'},
/** A SCHEDULED_POST is associated to only one POST */
[POST]: {type: 'belongs_to', key: 'root_id'},
};
/** channel_id : The foreign key pointing to the channel in which the schedule post was made */
@field('channel_id') channelId!: string;
/** message : The scheduled post message */
@field('message') message!: string;
/** files : The files field will hold an array of file objects that have not yet been uploaded and persisted within the FILE table */
@json('files', safeParseJSON) files!: FileInfo[];
/** root_id : The root_id will be empty unless the scheduled post is created inside the thread */
@field('root_id') rootId!: string;
@json('metadata', safeParseJSON) metadata!: PostMetadata | null;
/** create_at : The timestamp to when this scheduled post was created on the server */
@field('create_at') createAt!: number;
/** update_at : The timestamp to when this scheduled post was last updated on the server */
@field('update_at') updateAt!: number;
/** scheduled_at : The timestamp when the schedule post is scheduled at */
@field('scheduled_at') scheduledAt!: number;
/** processed_at : The timestamp when the schedule post is processed at */
@field('processed_at') processedAt!: number;
/** error_code : The reason message if the schedule post failed */
@field('error_code') errorCode!: string;
toApi = (user_id: string) => {
const scheduledPost: ScheduledPost = {
id: this.id,
channel_id: this.channelId,
root_id: this.rootId,
message: this.message,
files: this.files,
metadata: this.metadata ? this.metadata : {},
update_at: this.updateAt,
scheduled_at: this.scheduledAt,
processed_at: this.processedAt,
error_code: this.errorCode,
create_at: this.createAt,
user_id,
};
if (this.metadata?.priority) {
scheduledPost.priority = this.metadata.priority;
}
return scheduledPost;
};
}

View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shouldUpdateScheduledPostRecord} from './scheduled_post';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
describe('shouldUpdateScheduledPostRecord', () => {
it('returns true when new update_at is greater than existing updateAt', () => {
const existingPost = {updateAt: 1000} as ScheduledPostModel;
const newPost = {update_at: 2000} as ScheduledPost;
expect(shouldUpdateScheduledPostRecord(existingPost, newPost)).toBe(true);
});
it('returns false when new update_at is equal to existing updateAt', () => {
const existingPost = {updateAt: 2000} as ScheduledPostModel;
const newPost = {update_at: 2000} as ScheduledPost;
expect(shouldUpdateScheduledPostRecord(existingPost, newPost)).toBe(false);
});
it('returns false when new update_at is less than existing updateAt', () => {
const existingPost = {updateAt: 3000} as ScheduledPostModel;
const newPost = {update_at: 2000} as ScheduledPost;
expect(shouldUpdateScheduledPostRecord(existingPost, newPost)).toBe(false);
});
});

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
export function shouldUpdateScheduledPostRecord(e: ScheduledPostModel, n: ScheduledPost) {
return Boolean(n.update_at > e.updateAt);
}

View file

@ -1,5 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {Database, Q} from '@nozbe/watermelondb';
@ -9,19 +10,30 @@ import DatabaseManager from '@database/manager';
import {buildDraftKey} from '@database/operator/server_data_operator/comparators';
import {transformDraftRecord, transformPostsInChannelRecord} from '@database/operator/server_data_operator/transformers/post';
import {createPostsChain} from '@database/operator/utils/post';
import * as ScheduledPostQueries from '@queries/servers/scheduled_post';
import {logWarning} from '@utils/log';
import {shouldUpdateScheduledPostRecord} from '../comparators/scheduled_post';
import {exportedForTest} from './post';
import type ServerDataOperator from '..';
import type ServerDataOperator from '@database/operator/server_data_operator/index';
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
Q.sortBy = jest.fn().mockImplementation((field) => {
return Q.where(field, Q.gte(0));
});
jest.mock('@utils/log', () => ({
logWarning: jest.fn(),
}));
describe('*** Operator: Post Handlers tests ***', () => {
let operator: ServerDataOperator;
let database: Database;
let posts: Post[] = [];
let scheduledPosts: ScheduledPost[] = [];
beforeEach(async () => {
posts = [
{
@ -171,8 +183,36 @@ describe('*** Operator: Post Handlers tests ***', () => {
},
];
scheduledPosts = [
{
id: 'scheduled_post_id',
channel_id: 'channel_id',
root_id: '',
message: 'test scheduled post',
scheduled_at: 123,
user_id: 'user_id',
processed_at: 0,
create_at: 789,
update_at: 456,
error_code: '',
},
{
id: 'scheduled_post_id_2',
channel_id: 'channel_id',
root_id: '',
message: 'test scheduled post 2',
scheduled_at: 123,
user_id: 'user_id',
processed_at: 0,
create_at: 789,
update_at: 456,
error_code: '',
},
];
await DatabaseManager.init(['baseHandler.test.com']);
operator = DatabaseManager.serverDatabases['baseHandler.test.com']!.operator;
database = DatabaseManager.serverDatabases['baseHandler.test.com']!.database;
});
afterEach(async () => {
@ -542,6 +582,135 @@ describe('*** Operator: Post Handlers tests ***', () => {
expect(files[0].id).toBe('another-file-id');
});
it('should return empty array when scheduledPosts is empty and actionType is not RECEIVED_ALL_SCHEDULED_POSTS', async () => {
const result = await operator.handleScheduledPosts(
{
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts: [],
prepareRecordsOnly: false,
});
expect(result).toEqual([]);
});
it('HandleScheduledPosts: should write to the ScheduledPost table', async () => {
const spyOnBatchRecords = jest.spyOn(operator, 'processRecords');
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
prepareRecordsOnly: false,
});
expect(spyOnBatchRecords).toHaveBeenCalledWith({
createOrUpdateRawValues: scheduledPosts,
deleteRawValues: [],
tableName: 'ScheduledPost',
fieldName: 'id',
shouldUpdate: shouldUpdateScheduledPostRecord,
});
});
it('HandleScheduledPosts: should delete from the ScheduledPost table', async () => {
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts,
prepareRecordsOnly: false,
});
const scheduledPost = scheduledPosts[0];
const deletedRecord = await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST,
scheduledPosts: [scheduledPost],
prepareRecordsOnly: false,
});
expect(deletedRecord).toBeTruthy();
expect(deletedRecord[0]._raw.id).toBe(scheduledPost.id);
});
it('HandleScheduledPosts: should handle empty input array', async () => {
const deletedRecord = await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST,
scheduledPosts: [],
prepareRecordsOnly: false,
});
expect(deletedRecord).toBeTruthy();
expect(deletedRecord.length).toBe(0);
});
it('HandleScheduledPosts: should delete all the schedule post from the database when action is RECEIVED_ALL_SCHEDULED_POSTS', async () => {
const spyOnBatchRecords = jest.spyOn(operator, 'batchRecords');
jest.spyOn(ScheduledPostQueries, 'queryScheduledPostsForTeam').mockReturnValue({
fetch: jest.fn().mockResolvedValue(scheduledPosts),
} as any);
jest.spyOn(database, 'get').mockReturnValue({
query: jest.fn().mockReturnValue({
fetch: jest.fn().mockResolvedValue(scheduledPosts.map((post) => ({...post, toApi: () => post}))),
}),
} as any);
jest.spyOn(operator, 'prepareRecords').mockResolvedValue(scheduledPosts as unknown as ScheduledPostModel[]);
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.RECEIVED_ALL_SCHEDULED_POSTS,
scheduledPosts: [],
prepareRecordsOnly: false,
});
expect(spyOnBatchRecords).toHaveBeenCalledWith(scheduledPosts, 'handleScheduledPosts');
});
it('HandleUpdateScheduledPostErrorCode: should update error code for a scheduled post', async () => {
// First create a scheduled post
await operator.handleScheduledPosts({
actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST,
scheduledPosts: [scheduledPosts[0]],
prepareRecordsOnly: false,
});
const errorCode = 'ERROR_CODE_TEST';
const spyOnBatchRecords = jest.spyOn(operator, 'batchRecords');
// Update the error code
const updatedPost = await operator.handleUpdateScheduledPostErrorCode({
scheduledPostId: scheduledPosts[0].id,
errorCode,
prepareRecordsOnly: false,
});
expect(updatedPost).toBeTruthy();
expect(updatedPost?.id).toBe(scheduledPosts[0].id);
expect(updatedPost?.errorCode).toBe(errorCode);
expect(spyOnBatchRecords).toHaveBeenCalledWith([updatedPost], 'handleScheduledPostErrorCode');
// Verify the post was updated in the database
const scheduledPost = await operator.database.get<ScheduledPostModel>('ScheduledPost').query(
Q.where('id', scheduledPosts[0].id),
).fetch();
expect(scheduledPost.length).toBe(1);
expect(scheduledPost[0].errorCode).toBe(errorCode);
});
it('HandleUpdateScheduledPostErrorCode: should return null when post is not found', async () => {
const errorCode = 'ERROR_CODE_TEST';
jest.spyOn(database, 'get').mockReturnValue({
find: jest.fn().mockReturnValue(null),
} as any);
const result = await operator.handleUpdateScheduledPostErrorCode({
scheduledPostId: 'non_existent_id',
errorCode,
prepareRecordsOnly: false,
});
expect(result).toBeNull();
expect(logWarning).toHaveBeenCalled();
});
it('=> HandlePosts: should not remove files if file ids are present but metadata is missing', async () => {
const postWithMetadata = posts[0];
const uploadedFiles = postWithMetadata.metadata.files!;

View file

@ -11,17 +11,23 @@ import {
transformPostInThreadRecord,
transformPostRecord,
transformPostsInChannelRecord,
transformSchedulePostsRecord,
} from '@database/operator/server_data_operator/transformers/post';
import {getRawRecordPairs, getUniqueRawsBy, getValidRecordsForUpdate} from '@database/operator/utils/general';
import {createPostsChain, getPostListEdges} from '@database/operator/utils/post';
import {queryScheduledPostsForTeam} from '@queries/servers/scheduled_post';
import {getCurrentTeamId} from '@queries/servers/system';
import FileModel from '@typings/database/models/servers/file';
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import {safeParseJSON} from '@utils/helpers';
import {logWarning} from '@utils/log';
import {shouldUpdateScheduledPostRecord} from '../comparators/scheduled_post';
import type ServerDataOperatorBase from '.';
import type Database from '@nozbe/watermelondb/Database';
import type Model from '@nozbe/watermelondb/Model';
import type {HandleDraftArgs, HandleFilesArgs, HandlePostsArgs, RecordPair} from '@typings/database/database';
import type {HandleDraftArgs, HandleFilesArgs, HandlePostsArgs, HandleScheduledPostErrorCodeArgs, HandleScheduledPostsArgs, RecordPair} from '@typings/database/database';
import type DraftModel from '@typings/database/models/servers/draft';
import type PostModel from '@typings/database/models/servers/post';
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
@ -33,11 +39,14 @@ const {
POST,
POSTS_IN_CHANNEL,
POSTS_IN_THREAD,
SCHEDULED_POST,
} = MM_TABLES.SERVER;
type PostActionType = keyof typeof ActionType.POSTS;
export interface PostHandlerMix {
handleScheduledPosts: ({actionType, scheduledPosts, includeDirectChannelPosts, prepareRecordsOnly}: HandleScheduledPostsArgs) => Promise<ScheduledPostModel[]>;
handleUpdateScheduledPostErrorCode: ({scheduledPostId, errorCode, prepareRecordsOnly}: HandleScheduledPostErrorCodeArgs) => Promise<ScheduledPostModel>;
handleDraft: ({drafts, prepareRecordsOnly}: HandleDraftArgs) => Promise<DraftModel[]>;
handleFiles: ({files, prepareRecordsOnly}: HandleFilesArgs) => Promise<FileModel[]>;
handlePosts: ({actionType, order, posts, previousPostId, prepareRecordsOnly}: HandlePostsArgs) => Promise<Model[]>;
@ -104,6 +113,124 @@ export const exportedForTest = {
};
const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superclass: TBase) => class extends superclass {
/**
* handleScheduledPosts: Handler responsible for the Create/Update operations occurring the SchedulePost table from the 'Server' schema
* @param {HandleScheduledPostsArgs} ScheduledPostsArgs
* @returns {Promise<ScheduledPostModel[]>}
*/
handleScheduledPosts = async ({actionType, scheduledPosts, includeDirectChannelPosts, prepareRecordsOnly}: HandleScheduledPostsArgs): Promise<ScheduledPostModel[]> => {
const database: Database = this.database;
const scheduledPostsToDelete: ScheduledPostModel[] = [];
const scheduledPostsToCreateAndUpdate: ScheduledPostModel[] = [];
const currentTeamId = await getCurrentTeamId(database);
switch (actionType) {
case ActionType.SCHEDULED_POSTS.DELETE_SCHEDULED_POST: {
const toDeleteIds = scheduledPosts?.map((post) => post.id) || [];
if (toDeleteIds.length > 0) {
scheduledPostsToDelete.push(...await this._deleteScheduledPostByIds(toDeleteIds, prepareRecordsOnly));
}
break;
}
case ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST: {
const createOrUpdateRawValues = getUniqueRawsBy({raws: scheduledPosts ?? [], key: 'id'}) as ScheduledPost[];
scheduledPostsToCreateAndUpdate.push(...await this._createOrUpdateScheduledPost(createOrUpdateRawValues, prepareRecordsOnly));
break;
}
case ActionType.SCHEDULED_POSTS.RECEIVED_ALL_SCHEDULED_POSTS: {
const idsFromServer = new Set(scheduledPosts?.map((post) => post.id) || []);
const existingScheduledPosts = await queryScheduledPostsForTeam(database, currentTeamId, includeDirectChannelPosts).fetch();
const deletedScheduledPostIds = existingScheduledPosts.
filter((post) => !idsFromServer.has(post.id)).
map((post) => post.id);
if (deletedScheduledPostIds.length > 0) {
scheduledPostsToDelete.push(...await this._deleteScheduledPostByIds(deletedScheduledPostIds, prepareRecordsOnly));
}
if (scheduledPosts?.length) {
const createOrUpdateRawValues = getUniqueRawsBy({raws: scheduledPosts ?? [], key: 'id'}) as ScheduledPost[];
scheduledPostsToCreateAndUpdate.push(...await this._createOrUpdateScheduledPost(createOrUpdateRawValues, prepareRecordsOnly));
}
break;
}
}
const batch: ScheduledPostModel[] = [...scheduledPostsToDelete, ...scheduledPostsToCreateAndUpdate];
if (!prepareRecordsOnly && batch.length) {
await this.batchRecords(batch, 'handleScheduledPosts');
}
return batch;
};
handleUpdateScheduledPostErrorCode = async ({scheduledPostId, errorCode, prepareRecordsOnly}: HandleScheduledPostErrorCodeArgs) => {
const database: Database = this.database;
const scheduledPost = await database.get<ScheduledPostModel>(SCHEDULED_POST).find(scheduledPostId);
if (!scheduledPost) {
logWarning(
`Scheduled Post with id ${scheduledPostId} not found in the database`,
);
return null;
}
const updatedScheduledPost = scheduledPost.prepareUpdate((record) => {
record.errorCode = errorCode;
record.updateAt = scheduledPost.updateAt; // We don't want to update the updateAt field as prepareUpdate will do it if it is not set
});
if (!prepareRecordsOnly) {
await this.batchRecords([updatedScheduledPost], 'handleScheduledPostErrorCode');
}
return updatedScheduledPost;
};
_createOrUpdateScheduledPost = async (scheduledPosts: ScheduledPost[], prepareRecordsOnly = false): Promise<ScheduledPostModel[]> => {
const processedScheduledPosts = await this.processRecords({
createOrUpdateRawValues: scheduledPosts,
deleteRawValues: [],
tableName: SCHEDULED_POST,
fieldName: 'id',
shouldUpdate: shouldUpdateScheduledPostRecord,
});
const preparedScheduledPosts = await this.prepareRecords({
createRaws: processedScheduledPosts.createRaws,
updateRaws: processedScheduledPosts.updateRaws,
deleteRaws: processedScheduledPosts.deleteRaws,
transformer: transformSchedulePostsRecord,
tableName: SCHEDULED_POST,
}) as ScheduledPostModel[];
if (preparedScheduledPosts.length && !prepareRecordsOnly) {
await this.batchRecords(preparedScheduledPosts, 'handleScheduledPosts');
}
return preparedScheduledPosts;
};
_deleteScheduledPostByIds = async (scheduledPostIds: string[], prepareRecordsOnly = false): Promise<ScheduledPostModel[]> => {
const database: Database = this.database;
const scheduledPostsToDelete = await database.get<ScheduledPostModel>(SCHEDULED_POST).query(Q.where('id', Q.oneOf(scheduledPostIds))).fetch();
const preparedScheduledPosts = await this.prepareRecords({
deleteRaws: scheduledPostsToDelete,
transformer: transformSchedulePostsRecord,
tableName: SCHEDULED_POST,
}) as ScheduledPostModel[];
if (preparedScheduledPosts.length && !prepareRecordsOnly) {
await this.batchRecords(preparedScheduledPosts, '_deleteScheduledPostByIds');
}
return preparedScheduledPosts;
};
/**
* handleDraft: Handler responsible for the Create/Update operations occurring the Draft table from the 'Server' schema
* @param {HandleDraftArgs} draftsArgs

View file

@ -7,9 +7,12 @@ import {
transformPostInThreadRecord,
transformPostRecord,
transformPostsInChannelRecord,
transformSchedulePostsRecord,
} from '@database/operator/server_data_operator/transformers/post';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
describe('*** POST Prepare Records Test ***', () => {
it('=> transformPostRecord: should return an array of type Post', async () => {
expect.assertions(3);
@ -123,3 +126,162 @@ describe('*** POST Prepare Records Test ***', () => {
expect(preparedRecords!.collection.table).toBe('PostsInChannel');
});
});
describe('transformSchedulePostsRecord', () => {
it('=> should create a ScheduledPost record from raw data', async () => {
expect.assertions(3);
const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true});
expect(database).toBeTruthy();
const preparedRecords = await transformSchedulePostsRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'schedule_post_id',
channel_id: 'channel_id',
root_id: '',
message: 'schedule post message',
user_id: 'user_id',
processed_at: 0,
scheduled_at: 1223456789,
update_at: 0,
error_code: '',
} as ScheduledPost,
},
});
expect(preparedRecords).toBeTruthy();
expect(preparedRecords!.collection.table).toBe('ScheduledPost');
});
it('should properly map all fields from raw data', async () => {
const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true});
expect(database).toBeTruthy();
const rawPost = {
id: 'schedule_post_id',
channel_id: 'channel_id',
root_id: 'root_id',
message: 'test message',
user_id: 'user_id',
processed_at: 1000,
scheduled_at: 2000,
update_at: 3000,
create_at: 4000,
error_code: 'some_error',
priority: undefined,
file_ids: ['file_1', 'file_2'],
} as ScheduledPost;
const preparedRecord = await transformSchedulePostsRecord({
action: OperationType.CREATE,
database: database!,
value: {record: undefined, raw: rawPost},
});
expect(preparedRecord.id).toBe(rawPost.id);
expect(preparedRecord.channelId).toBe(rawPost.channel_id);
expect(preparedRecord.rootId).toBe(rawPost.root_id);
expect(preparedRecord.message).toBe(rawPost.message);
expect(preparedRecord.processedAt).toBe(rawPost.processed_at);
expect(preparedRecord.scheduledAt).toBe(rawPost.scheduled_at);
expect(preparedRecord.updateAt).toBe(rawPost.update_at);
expect(preparedRecord.createAt).toBe(rawPost.create_at);
expect(preparedRecord.errorCode).toBe(rawPost.error_code);
expect(preparedRecord.metadata?.priority).toBe(rawPost.priority);
});
it('should set default values for missing fields', async () => {
const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true});
expect(database).toBeTruthy();
const rawPost = {
id: 'schedule_post_id',
channel_id: 'channel_id',
user_id: 'user_id',
create_at: 4000,
scheduled_at: 2000,
message: 'scheduled post message',
} as ScheduledPost;
const preparedRecord = await transformSchedulePostsRecord({
action: OperationType.CREATE,
database: database!,
value: {record: undefined, raw: rawPost},
});
expect(preparedRecord.rootId).toBe('');
expect(preparedRecord.files).toEqual([]);
expect(preparedRecord.metadata).toBeUndefined();
expect(preparedRecord.updateAt).toBeGreaterThan(0);
expect(preparedRecord.processedAt).toBe(0);
expect(preparedRecord.errorCode).toBe('');
});
it('should not overwrite local errorCode if remote does not provide one', async () => {
const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true});
expect(database).toBeTruthy();
const localErrorCode = 'local_error';
const existingRecord = {
errorCode: localErrorCode,
prepareUpdate: jest.fn().mockReturnValue({
errorCode: localErrorCode,
}),
} as unknown as ScheduledPostModel;
const rawPost = {
id: 'schedule_post_id',
channel_id: 'channel_id',
user_id: 'user_id',
create_at: 4000,
scheduled_at: 2000,
message: 'schedule post message',
} as ScheduledPost;
const preparedRecord = await transformSchedulePostsRecord({
action: OperationType.UPDATE,
database: database!,
value: {record: existingRecord, raw: rawPost},
});
expect(preparedRecord.errorCode).toBe(localErrorCode);
});
it('=> should return error if message and files are empty', async () => {
expect.assertions(3);
const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true});
expect(database).toBeTruthy();
let emptyMessageAndFileError;
let preparedRecords;
try {
preparedRecords = await transformSchedulePostsRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'schedule_post_id',
channel_id: 'channel_id',
root_id: '',
message: '',
user_id: 'user_id',
processed_at: 0,
scheduled_at: 1223456789,
update_at: 0,
error_code: '',
} as ScheduledPost,
},
});
} catch (error) {
emptyMessageAndFileError = error;
}
expect(preparedRecords).not.toBeTruthy();
expect(emptyMessageAndFileError).toBeTruthy();
});
});

View file

@ -9,12 +9,14 @@ import type DraftModel from '@typings/database/models/servers/draft';
import type PostModel from '@typings/database/models/servers/post';
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
const {
DRAFT,
POST,
POSTS_IN_CHANNEL,
POSTS_IN_THREAD,
SCHEDULED_POST,
} = MM_TABLES.SERVER;
/**
@ -160,3 +162,43 @@ export const transformPostsInChannelRecord = ({action, database, value}: Transfo
fieldsMapper,
});
};
/**
* transformPostRecords: Prepares records of the SERVER database 'ScheduledPosts' table for update or create actions.
*/
export const transformSchedulePostsRecord = ({action, database, value}: TransformerArgs<ScheduledPostModel, ScheduledPost>): Promise<ScheduledPostModel> => {
const emptyFileInfo: FileInfo[] = [];
const raw = value.raw;
if (!raw.message && !raw.metadata?.files?.length) {
throw new Error('Scheduled post message and files are empty');
}
const fieldsMapper = (scheduledPost: ScheduledPostModel) => {
scheduledPost._raw.id = raw.id;
scheduledPost.rootId = raw?.root_id ?? '';
scheduledPost.message = raw?.message ?? '';
scheduledPost.channelId = raw?.channel_id ?? '';
scheduledPost.files = raw?.metadata?.files ?? emptyFileInfo;
scheduledPost.metadata = raw?.metadata ?? null;
if (raw.priority) {
scheduledPost.metadata = {
...scheduledPost.metadata,
priority: raw.priority,
};
}
scheduledPost.updateAt = raw.update_at ?? Date.now();
scheduledPost.createAt = raw.create_at;
scheduledPost.scheduledAt = raw.scheduled_at;
scheduledPost.processedAt = raw.processed_at ?? 0;
scheduledPost.errorCode = raw.error_code || scheduledPost.errorCode;
};
return prepareBaseRecord({
action,
database,
tableName: SCHEDULED_POST,
value,
fieldsMapper,
}) as Promise<ScheduledPostModel>;
};

View file

@ -29,6 +29,7 @@ import {
PreferenceSchema,
ReactionSchema,
RoleSchema,
ScheduledPostSchema,
SystemSchema,
TeamChannelHistorySchema,
TeamMembershipSchema,
@ -42,7 +43,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 8,
version: 9,
tables: [
CategorySchema,
CategoryChannelSchema,
@ -69,6 +70,7 @@ export const serverSchema: AppSchema = appSchema({
PreferenceSchema,
ReactionSchema,
RoleSchema,
ScheduledPostSchema,
SystemSchema,
TeamChannelHistorySchema,
TeamMembershipSchema,

View file

@ -25,6 +25,7 @@ export {default as PostsInChannelSchema} from './posts_in_channel';
export {default as PreferenceSchema} from './preference';
export {default as ReactionSchema} from './reaction';
export {default as RoleSchema} from './role';
export {default as ScheduledPostSchema} from './scheduled_post';
export {default as SystemSchema} from './system';
export {default as TeamChannelHistorySchema} from './team_channel_history';
export {default as TeamMembershipSchema} from './team_membership';

View file

@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
const {SCHEDULED_POST} = MM_TABLES.SERVER;
export default tableSchema({
name: SCHEDULED_POST,
columns: [
{name: 'channel_id', type: 'string', isIndexed: true},
{name: 'files', type: 'string'},
{name: 'message', type: 'string'},
{name: 'root_id', type: 'string', isIndexed: true},
{name: 'metadata', type: 'string', isOptional: true},
{name: 'create_at', type: 'number'},
{name: 'update_at', type: 'number'},
{name: 'scheduled_at', type: 'number'},
{name: 'processed_at', type: 'number'},
{name: 'error_code', type: 'string'},
],
});

View file

@ -33,6 +33,7 @@ const {
PREFERENCE,
REACTION,
ROLE,
SCHEDULED_POST,
SYSTEM,
TEAM,
TEAM_CHANNEL_HISTORY,
@ -48,7 +49,7 @@ const {
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 8,
version: 9,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
@ -534,6 +535,34 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'permissions', type: 'string'},
],
},
[SCHEDULED_POST]: {
name: SCHEDULED_POST,
unsafeSql: undefined,
columns: {
channel_id: {name: 'channel_id', type: 'string', isIndexed: true},
message: {name: 'message', type: 'string'},
files: {name: 'files', type: 'string'},
root_id: {name: 'root_id', type: 'string', isIndexed: true},
metadata: {name: 'metadata', type: 'string', isOptional: true},
create_at: {name: 'create_at', type: 'number'},
update_at: {name: 'update_at', type: 'number'},
scheduled_at: {name: 'scheduled_at', type: 'number'},
processed_at: {name: 'processed_at', type: 'number'},
error_code: {name: 'error_code', type: 'string'},
},
columnArray: [
{name: 'channel_id', type: 'string', isIndexed: true},
{name: 'files', type: 'string'},
{name: 'message', type: 'string'},
{name: 'root_id', type: 'string', isIndexed: true},
{name: 'metadata', type: 'string', isOptional: true},
{name: 'create_at', type: 'number'},
{name: 'update_at', type: 'number'},
{name: 'scheduled_at', type: 'number'},
{name: 'processed_at', type: 'number'},
{name: 'error_code', type: 'string'},
],
},
[SYSTEM]: {
name: SYSTEM,
unsafeSql: undefined,

View file

@ -1,25 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {renderHook, act} from '@testing-library/react-hooks';
import React from 'react';
import {IntlProvider} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {Alert, DeviceEventEmitter} from 'react-native';
import {getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
import {createScheduledPost} from '@actions/remote/scheduled_post';
import {handleCallsSlashCommand} from '@calls/actions';
import {Events, Screens} from '@constants';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import DraftUploadManager from '@managers/draft_upload_manager';
import {getPostById} from '@queries/servers/post';
import * as DraftUtils from '@utils/draft';
import {showSnackBar} from '@utils/snack_bar';
import {useHandleSendMessage} from './handle_send_message';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter');
jest.mock('@actions/remote/post');
jest.mock('@actions/remote/scheduled_post');
jest.mock('@actions/remote/channel', () => ({
getChannelTimezones: jest.fn().mockResolvedValue({channelTimezones: []}),
}));
@ -30,6 +39,16 @@ jest.mock('@calls/actions');
jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue('https://server.com'),
}));
jest.mock('@utils/snack_bar', () => ({
showSnackBar: jest.fn(),
}));
jest.mock('@database/manager');
jest.mock('@queries/servers/post');
let operator: ServerDataOperator;
const database = {
write: jest.fn(async (callback) => callback()),
};
describe('useHandleSendMessage', () => {
const defaultProps = {
@ -63,6 +82,9 @@ describe('useHandleSendMessage', () => {
jest.mocked(getChannelTimezones).mockResolvedValue({channelTimezones: []});
jest.mocked(useServerUrl).mockReturnValue('https://server.com');
jest.spyOn(DeviceEventEmitter, 'emit');
jest.mocked(createPost).mockResolvedValue({
data: true,
});
});
it('should handle basic message send', async () => {
@ -141,22 +163,6 @@ describe('useHandleSendMessage', () => {
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_LIST_SCROLL_TO_BOTTOM, Screens.THREAD);
});
it('should not allow sending while already sending', async () => {
const {result} = renderHook(() => useHandleSendMessage(defaultProps), {wrapper});
// Trigger first send
await act(async () => {
result.current.handleSendMessage();
});
// Try to send again immediately
await act(async () => {
result.current.handleSendMessage();
});
expect(createPost).toHaveBeenCalledTimes(1);
});
it('should not allow sending with uploading files', () => {
const props = {
...defaultProps,
@ -359,6 +365,90 @@ describe('useHandleSendMessage', () => {
);
});
it('should handle scheduled post creation', async () => {
const mockCreateScheduledPost = jest.mocked(createScheduledPost);
mockCreateScheduledPost.mockResolvedValueOnce({data: true});
const schedulingInfo = {
scheduled_at: 1234567890,
timezone: 'UTC',
};
const props = {
...defaultProps,
value: 'scheduled message',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage(schedulingInfo);
});
expect(mockCreateScheduledPost).toHaveBeenCalledWith(
'https://server.com',
expect.objectContaining({
message: 'scheduled message',
scheduled_at: 1234567890,
}),
);
expect(defaultProps.clearDraft).toHaveBeenCalled();
});
it('should handle failed post creation', async () => {
jest.mocked(createPost).mockResolvedValueOnce({
error: new Error('Failed to create post'),
});
const props = {
...defaultProps,
value: 'test message',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
const response = await result.current.handleSendMessage();
expect(response?.error).toBeDefined();
});
});
it('should fetch and handle channel timezones', async () => {
jest.mocked(getChannelTimezones).mockResolvedValueOnce({
channelTimezones: ['UTC', 'America/New_York'],
});
const props = {
...defaultProps,
value: '@channel message',
enableConfirmNotificationsToChannel: true,
useChannelMentions: true,
membersCount: 25,
};
renderHook(() => useHandleSendMessage(props), {wrapper});
expect(getChannelTimezones).toHaveBeenCalledWith(
'https://server.com',
'channel-id',
);
});
it('should not allow sending during message submission', () => {
const props = {
...defaultProps,
value: 'test message',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
act(() => {
result.current.handleSendMessage();
});
expect(result.current.canSend).toBe(false);
});
it('should handle channel-wide mentions confirmation', async () => {
jest.spyOn(DraftUtils, 'textContainsAtAllAtChannel').mockReturnValue(true);
jest.spyOn(DraftUtils, 'alertChannelWideMention');
@ -380,4 +470,249 @@ describe('useHandleSendMessage', () => {
expect(DraftUtils.alertChannelWideMention).toHaveBeenCalled();
expect(createPost).not.toHaveBeenCalled();
});
it('should include post priority metadata', async () => {
const props = {
...defaultProps,
value: 'priority message',
postPriority: {
priority: 'urgent',
requested_ack: true,
persistent_notifications: true,
},
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage();
});
expect(createPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
metadata: {
priority: {
priority: 'urgent',
requested_ack: true,
persistent_notifications: true,
},
},
}),
[],
);
});
it('should not include priority metadata for replies', async () => {
const props = {
...defaultProps,
value: 'reply message',
rootId: 'some-root-id',
postPriority: {
priority: 'urgent',
requested_ack: true,
},
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage();
});
expect(createPost).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({
metadata: expect.anything(),
}),
[],
);
});
describe('message length validation', () => {
it('should not allow empty messages', () => {
const props = {
...defaultProps,
value: '',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
expect(result.current.canSend).toBe(false);
});
it('should not allow whitespace-only messages', () => {
const props = {
...defaultProps,
value: ' ',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
expect(result.current.canSend).toBe(false);
});
it('should allow messages within length limit', () => {
const props = {
...defaultProps,
value: 'valid message',
maxMessageLength: 100,
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
expect(result.current.canSend).toBe(true);
});
});
describe('file upload handling', () => {
it('should allow sending when all files are uploaded', () => {
jest.spyOn(DraftUploadManager, 'isUploading').mockReturnValue(false);
const props = {
...defaultProps,
value: 'message with files',
files: [{clientId: 'file1', loading: false}],
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
expect(result.current.canSend).toBe(true);
});
it('should block sending during file upload', () => {
jest.spyOn(DraftUploadManager, 'isUploading').mockReturnValue(true);
const props = {
...defaultProps,
value: 'message with uploading file',
files: [{clientId: 'file1', loading: true}],
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
expect(result.current.canSend).toBe(false);
});
});
describe('channel mentions handling', () => {
it('should bypass mention confirmation when disabled', async () => {
const props = {
...defaultProps,
value: '@channel message',
enableConfirmNotificationsToChannel: false,
membersCount: 25,
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage();
});
expect(createPost).toHaveBeenCalled();
expect(DraftUtils.alertChannelWideMention).not.toHaveBeenCalled();
});
it('should bypass mention confirmation for small channels', async () => {
const props = {
...defaultProps,
value: '@channel message',
enableConfirmNotificationsToChannel: true,
membersCount: 5,
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage();
});
expect(createPost).toHaveBeenCalled();
expect(DraftUtils.alertChannelWideMention).not.toHaveBeenCalled();
});
});
describe('command handling', () => {
beforeEach(() => {
jest.mocked(createScheduledPost).mockResolvedValueOnce({
data: true,
});
});
it('should bypass command handling for scheduled messages', async () => {
const props = {
...defaultProps,
value: '/away',
};
const schedulingInfo = {
scheduled_at: 1234567890,
timezone: 'UTC',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage(schedulingInfo);
});
expect(executeCommand).not.toHaveBeenCalled();
expect(createScheduledPost).toHaveBeenCalled();
});
});
describe('handle error while failing creating post from scheduled post and draft', () => {
const serverUrl = 'baseHandler.test.com';
beforeEach(async () => {
jest.clearAllMocks();
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
DatabaseManager.getServerDatabaseAndOperator = jest.fn();
(DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockReturnValue({
database,
operator,
});
});
it('should handle failed post creation', async () => {
jest.mocked(createPost).mockResolvedValueOnce({
error: new Error('Failed to create post'),
});
const props = {
...defaultProps,
isFromDraftView: true,
value: 'test message',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
const response = await result.current.handleSendMessage();
expect(response?.error).toBeDefined();
});
expect(showSnackBar).toHaveBeenCalledWith({
barType: SNACK_BAR_TYPE.CREATE_POST_ERROR,
customMessage: 'Failed to create post',
type: 'error',
});
expect(defaultProps.clearDraft).not.toHaveBeenCalled();
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
});
it('should show alert when root post is not found', async () => {
jest.mocked(getPostById).mockResolvedValueOnce(undefined);
const props = {
...defaultProps,
isFromDraftView: true,
rootId: 'root-post-id',
value: 'test message',
};
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
await act(async () => {
await result.current.handleSendMessage();
});
expect(Alert.alert).toHaveBeenCalledWith(
'Sending post failed',
'Someone delete the message on which you tried to post a comment.',
[{text: 'Cancel', style: 'cancel'}],
{cancelable: false},
);
expect(defaultProps.clearDraft).not.toHaveBeenCalled();
});
});
});

View file

@ -9,20 +9,30 @@ import {getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
import {handleReactionToLatestPost} from '@actions/remote/reactions';
import {createScheduledPost} from '@actions/remote/scheduled_post';
import {setStatus} from '@actions/remote/user';
import {handleCallsSlashCommand} from '@calls/actions';
import {Events, Screens} from '@constants';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {MESSAGE_TYPE, SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@managers/draft_upload_manager';
import * as DraftUtils from '@utils/draft';
import {isReactionMatch} from '@utils/emoji/helpers';
import {getFullErrorMessage} from '@utils/errors';
import {preventDoubleTap} from '@utils/tap';
import {getErrorMessage, getFullErrorMessage} from '@utils/errors';
import {scheduledPostFromPost} from '@utils/post';
import {canPostDraftInChannelOrThread} from '@utils/scheduled_post';
import {showSnackBar} from '@utils/snack_bar';
import {confirmOutOfOfficeDisabled} from '@utils/user';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
export type CreateResponse = {
data?: boolean;
error?: unknown;
response?: Post | ScheduledPost;
}
type Props = {
value: string;
channelId: string;
@ -37,7 +47,12 @@ type Props = {
currentUserId: string;
channelType: ChannelType | undefined;
postPriority: PostPriority;
isFromDraftView?: boolean;
clearDraft: () => void;
canPost?: boolean;
channelIsArchived?: boolean;
channelIsReadOnly?: boolean;
deactivatedChannel?: boolean;
}
export const useHandleSendMessage = ({
@ -54,6 +69,11 @@ export const useHandleSendMessage = ({
currentUserId,
channelType,
postPriority,
isFromDraftView,
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
clearDraft,
}: Props) => {
const intl = useIntl();
@ -86,7 +106,7 @@ export const useHandleSendMessage = ({
setSendingMessage(false);
}, [serverUrl, rootId, clearDraft]);
const doSubmitMessage = useCallback(() => {
const doSubmitMessage = useCallback(async (schedulingInfo?: SchedulingInfo): Promise<CreateResponse> => {
const postFiles = files.filter((f) => !f.failed);
const post = {
user_id: currentUserId,
@ -105,20 +125,56 @@ export const useHandleSendMessage = ({
};
}
createPost(serverUrl, post, postFiles);
let response: CreateResponse;
if (schedulingInfo) {
response = await createScheduledPost(serverUrl, scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles));
} else {
response = await createPost(serverUrl, post, postFiles);
}
clearDraft();
if (response.error && isFromDraftView) {
showSnackBar({
barType: SNACK_BAR_TYPE.CREATE_POST_ERROR,
customMessage: getErrorMessage(response.error),
type: MESSAGE_TYPE.ERROR,
});
return response;
}
if (isFromDraftView) {
const shouldClearDraft = await canPostDraftInChannelOrThread({
serverUrl,
rootId,
intl,
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
});
if (shouldClearDraft) {
clearDraft();
}
} else {
clearDraft();
}
setSendingMessage(false);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
}, [files, currentUserId, channelId, rootId, value, postPriority, serverUrl, clearDraft]);
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
return response;
}, [files, currentUserId, channelId, rootId, value, postPriority, isFromDraftView, serverUrl, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel, clearDraft]);
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean, schedulingInfo?: SchedulingInfo) => {
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere);
const cancel = () => {
setSendingMessage(false);
};
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
// Creating a wrapper function to pass the schedulingInfo to the doSubmitMessage function as the accepted
// function signature causes conflict.
// TODO for later - change alert message if this is a scheduled post
const doSubmitMessageScheduledPostWrapper = () => doSubmitMessage(schedulingInfo);
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessageScheduledPostWrapper, cancel);
}, [intl, channelTimezoneCount, doSubmitMessage]);
const sendCommand = useCallback(async () => {
@ -167,23 +223,26 @@ export const useHandleSendMessage = ({
}
}, [value, userIsOutOfOffice, serverUrl, intl, channelId, rootId, clearDraft, channelType, currentUserId]);
const sendMessage = useCallback(() => {
const sendMessage = useCallback(async (schedulingInfo?: SchedulingInfo) => {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const toHere = DraftUtils.textContainsAtHere(value);
if (value.indexOf('/') === 0) {
if (value.indexOf('/') === 0 && !schedulingInfo) {
// Don't execute slash command when scheduling message
sendCommand();
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel);
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel, schedulingInfo);
} else {
doSubmitMessage();
return doSubmitMessage(schedulingInfo);
}
return Promise.resolve();
}, [enableConfirmNotificationsToChannel, useChannelMentions, value, membersCount, sendCommand, showSendToAllOrChannelOrHereAlert, doSubmitMessage]);
const handleSendMessage = useCallback(preventDoubleTap(() => {
const handleSendMessage = useCallback(async (schedulingInfo?: SchedulingInfo) => {
if (!canSend) {
return;
return Promise.resolve();
}
setSendingMessage(true);
@ -191,7 +250,7 @@ export const useHandleSendMessage = ({
const match = isReactionMatch(value, customEmojis);
if (match && !files.length) {
handleReaction(match.emoji, match.add);
return;
return Promise.resolve();
}
const hasFailedAttachments = files.some((f) => f.failed);
@ -201,14 +260,16 @@ export const useHandleSendMessage = ({
};
const accept = () => {
// Files are filtered on doSubmitMessage
sendMessage();
sendMessage(schedulingInfo);
};
DraftUtils.alertAttachmentFail(intl, accept, cancel);
} else {
sendMessage();
return sendMessage(schedulingInfo);
}
}), [canSend, value, handleReaction, files, sendMessage, customEmojis]);
return Promise.resolve();
}, [canSend, value, customEmojis, files, handleReaction, intl, sendMessage]);
useEffect(() => {
getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => {

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs';
import {MM_TABLES} from '@constants/database';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
const {SERVER: {CHANNEL, SCHEDULED_POST}} = MM_TABLES;
export const queryScheduledPostsForTeam = (database: Database, teamId: string, includeDirectChannelPosts?: boolean) => {
return database.collections.get<ScheduledPostModel>(SCHEDULED_POST).query(
Q.on(CHANNEL,
Q.or(
Q.where('team_id', teamId),
...(includeDirectChannelPosts ? [
Q.where('team_id', ''), // Direct messages
] : []),
),
),
Q.sortBy('scheduled_at', Q.asc),
);
};
export const queryScheduledPost = (database: Database, channelId: string, rootId = '') => {
return database.collections.get<ScheduledPostModel>(SCHEDULED_POST).query(
Q.and(
Q.where('channel_id', channelId),
Q.where('root_id', rootId),
),
);
};
export function observeFirstScheduledPost(v: ScheduledPostModel[]) {
return v[0]?.observe() || of$(undefined);
}
export const observeScheduledPostsForTeam = (database: Database, teamId: string, includeDirectChannelPosts?: boolean) => {
return queryScheduledPostsForTeam(database, teamId, includeDirectChannelPosts).observeWithColumns(['update_at', 'error_code']);
};
export const observeScheduledPostCount = (database: Database, teamId: string, includeDirectChannelPosts?: boolean) => {
return queryScheduledPostsForTeam(database, teamId, includeDirectChannelPosts).observeCount();
};
export const observeScheduledPostCountForChannel = (
database: Database,
channelId: string,
isCRTEnabled: boolean,
) => {
let query = database.get<ScheduledPostModel>(SCHEDULED_POST).query(
Q.and(
Q.where('channel_id', channelId),
Q.where('error_code', ''),
),
);
if (isCRTEnabled) {
query = query.extend(Q.where('root_id', ''));
}
return query.observeCount();
};
export const observeScheduledPostCountForThread = (database: Database, rootId: string) => {
return database.collections.get<ScheduledPostModel>(SCHEDULED_POST).query(
Q.where('root_id', rootId),
).observeCount();
};

View file

@ -42,6 +42,7 @@ import {
getDefaultTeamId,
observeIsTeamUnread,
observeSortedJoinedTeams,
observeTeamLastChannelId,
} from './team';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -286,6 +287,16 @@ describe('Team Queries', () => {
const history = await getTeamChannelHistory(database, teamId);
expect(history).toEqual([Screens.GLOBAL_THREADS]);
});
it('should add GLOBAL_DRAFTS to team history but skip channel check', async () => {
const channelId = Screens.GLOBAL_DRAFTS;
const result = await addChannelToTeamHistory(operator, teamId, channelId);
expect(result.length).toBe(1);
const history = await getTeamChannelHistory(database, teamId);
expect(history).toEqual([Screens.GLOBAL_DRAFTS]);
});
});
describe('getTeamChannelHistory', () => {
@ -1313,4 +1324,56 @@ describe('Team Queries', () => {
});
});
});
describe('observeTeamLastChannelId', () => {
it('should return undefined if no team channel history exists', (done) => {
observeTeamLastChannelId(database, teamId).subscribe((channelId) => {
expect(channelId).toBeUndefined();
done();
});
});
it('should return the last channel id from team channel history', async () => {
const channelId = 'channel1';
await operator.handleTeamChannelHistory({
teamChannelHistories: [{
id: teamId,
channel_ids: [channelId],
}],
prepareRecordsOnly: false,
});
observeTeamLastChannelId(database, teamId).subscribe((lastChannelId) => {
expect(lastChannelId).toBe(channelId);
});
});
it('should update when team channel history changes', async () => {
const initialChannelId = 'channel1';
const newChannelId = 'channel2';
await operator.handleTeamChannelHistory({
teamChannelHistories: [{
id: teamId,
channel_ids: [initialChannelId],
}],
prepareRecordsOnly: false,
});
const subscription = observeTeamLastChannelId(database, teamId).subscribe((lastChannelId) => {
if (lastChannelId === initialChannelId) {
operator.handleTeamChannelHistory({
teamChannelHistories: [{
id: teamId,
channel_ids: [newChannelId],
}],
prepareRecordsOnly: false,
});
} else if (lastChannelId === newChannelId) {
expect(lastChannelId).toBe(newChannelId);
subscription.unsubscribe();
}
});
});
});
});

View file

@ -49,7 +49,7 @@ export const addChannelToTeamHistory = async (operator: ServerDataOperator, team
const {database} = operator;
// Exlude GLOBAL_THREADS from channel check
if (channelId !== Screens.GLOBAL_THREADS) {
if (channelId !== Screens.GLOBAL_THREADS && channelId !== Screens.GLOBAL_DRAFTS) {
const myChannel = (await database.get<MyChannelModel>(MY_CHANNEL).find(channelId));
if (!myChannel) {
return [];
@ -86,6 +86,13 @@ export const getTeamChannelHistory = async (database: Database, teamId: string)
}
};
export const observeTeamLastChannelId = (database: Database, teamId: string) => {
return database.get<TeamChannelHistoryModel>(TEAM_CHANNEL_HISTORY).query(Q.where('id', teamId), Q.take(1)).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(undefined))),
map$((result) => result?.channelIds[0]),
);
};
export const getNthLastChannelFromTeam = async (database: Database, teamId: string, n = 0, ignoreIdForDefault?: string) => {
let channelId = '';

View file

@ -34,6 +34,7 @@ type Props = {
footerComponent?: React.FC<unknown>;
renderContent: () => ReactNode;
snapPoints?: Array<string | number>;
enableDynamicSizing?: boolean;
testID?: string;
}
@ -97,6 +98,7 @@ const BottomSheet = ({
renderContent,
snapPoints = [1, '50%', '80%'],
testID,
enableDynamicSizing = false,
}: Props) => {
const sheetRef = useRef<BottomSheetM>(null);
const isTablet = useIsTablet();
@ -228,7 +230,7 @@ const BottomSheet = ({
keyboardBlurBehavior='restore'
onClose={close}
bottomInset={insets.bottom}
enableDynamicSizing={false}
enableDynamicSizing={enableDynamicSizing}
>
<BottomSheetView style={styles.view}>
{renderContainerContent()}

View file

@ -9,6 +9,7 @@ import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} f
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useChannelSwitch} from '@hooks/channel_switch';
@ -41,6 +42,7 @@ type ChannelProps = {
hasGMasDMFeature: boolean;
includeBookmarkBar?: boolean;
includeChannelBanner: boolean;
scheduledPostCount: number;
};
const edges: Edge[] = ['left', 'right'];
@ -66,6 +68,7 @@ const Channel = ({
hasGMasDMFeature,
includeBookmarkBar,
includeChannelBanner,
scheduledPostCount,
}: ChannelProps) => {
useGMasDMNotice(currentUserId, channelType, dismissedGMasDMNotice, hasGMasDMFeature);
const isTablet = useIsTablet();
@ -138,6 +141,11 @@ const Channel = ({
nativeID={channelId}
/>
</View>
<>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
}
</>
<PostDraft
channelId={channelId}
testID='channel.post_draft'

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {combineLatestWith, distinctUntilChanged, of as of$, switchMap} from 'rxjs';
import {combineLatest, combineLatestWith, distinctUntilChanged, of as of$, switchMap} from 'rxjs';
import {observeCallStateInChannel, observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeCallsConfig} from '@calls/state';
@ -12,12 +12,14 @@ import {observeChannel, observeCurrentChannel} from '@queries/servers/channel';
import {queryBookmarks} from '@queries/servers/channel_bookmark';
import {observeHasGMasDMFeature} from '@queries/servers/features';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeScheduledPostCountForChannel} from '@queries/servers/scheduled_post';
import {
observeConfigBooleanValue,
observeCurrentChannelId,
observeCurrentUserId,
observeLicense,
} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {shouldShowChannelBanner} from '@screens/channel/channel_feature_checks';
import Channel from './channel';
@ -68,6 +70,12 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
),
);
const isCRTEnabled = observeIsCRTEnabled(database);
const scheduledPostCount = combineLatest([channelId, isCRTEnabled]).pipe(
switchMap(([cid, isCRT]) => observeScheduledPostCountForChannel(database, cid, isCRT)),
);
return {
channelId,
...observeCallStateInChannel(serverUrl, database, channelId),
@ -79,6 +87,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
hasGMasDMFeature,
includeBookmarkBar,
includeChannelBanner,
scheduledPostCount,
};
});

View file

@ -94,7 +94,7 @@ const ChannelBookmarkAddOrEdit = ({
enabled,
}],
});
}, [formatMessage, theme]);
}, [componentId, formatMessage, theme.sidebarHeaderTextColor]);
const setBookmarkToSave = useCallback((b?: ChannelBookmark) => {
enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id)));

View file

@ -9,14 +9,13 @@ import {View, TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import CustomStatusText from '@components/custom_status/custom_status_text';
import DateTimePicker from '@components/data_time_selector';
import {CST, CustomStatusDurationEnum} from '@constants/custom_status';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getTimezone} from '@utils/user';
import DateTimePicker from './date_time_selector';
import type UserModel from '@typings/database/models/servers/user';
type Props = {

View file

@ -1,113 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Platform, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import SendHandler from '@components/post_draft/send_handler/';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import BottomSheet from '@screens/bottom_sheet';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import DeleteDraft from './delete_draft';
import EditDraft from './edit_draft';
import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
type Props = {
channel: ChannelModel;
rootId: string;
draft: DraftModel;
draftReceiverUserName: string | undefined;
}
export const DRAFT_OPTIONS_BUTTON = 'close-post-options';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
header: {
...typography('Heading', 600, 'SemiBold'),
display: 'flex',
paddingBottom: 4,
color: theme.centerChannelColor,
},
};
});
const TITLE_HEIGHT = 54;
const ITEM_HEIGHT = 48;
const DraftOptions: React.FC<Props> = ({
channel,
rootId,
draft,
draftReceiverUserName,
}) => {
const isTablet = useIsTablet();
const theme = useTheme();
const styles = getStyleSheet(theme);
const snapPoints = useMemo(() => {
const bottomSheetAdjust = Platform.select({ios: 5, default: 20});
const COMPONENT_HIEGHT = TITLE_HEIGHT + (3 * ITEM_HEIGHT) + bottomSheetAdjust;
return [1, COMPONENT_HIEGHT];
}, []);
const renderContent = () => {
return (
<View>
{!isTablet &&
<FormattedText
id='draft.option.header'
defaultMessage='Draft actions'
style={styles.header}
/>}
<EditDraft
bottomSheetId={Screens.DRAFT_OPTIONS}
channel={channel}
rootId={rootId}
/>
<SendHandler
bottomSheetId={Screens.DRAFT_OPTIONS}
channelId={channel.id}
rootId={rootId}
files={draft.files}
value={draft.message}
draftReceiverUserName={draftReceiverUserName}
isFromDraftView={true}
uploadFileError={null}
cursorPosition={0}
/* eslint-disable no-empty-function */
clearDraft={() => {}}
updateCursorPosition={() => {}}
updatePostInputTop={() => {}}
addFiles={() => {}}
setIsFocused={() => {}}
updateValue={() => {}}
/* eslint-enable no-empty-function */
/>
<DeleteDraft
bottomSheetId={Screens.DRAFT_OPTIONS}
channelId={channel.id}
rootId={rootId}
/>
</View>
);
};
return (
<BottomSheet
componentId={Screens.DRAFT_OPTIONS}
renderContent={renderContent}
closeButtonId={DRAFT_OPTIONS_BUTTON}
snapPoints={snapPoints}
testID='draft_options'
/>
);
};
export default DraftOptions;

View file

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import React from 'react';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import DraftScheduledPostOptions from './index';
import type {Database} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn().mockReturnValue(false),
}));
describe('DraftScheduledPostOptions', () => {
let database: Database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
const baseProps: Parameters<typeof DraftScheduledPostOptions>[0] = {
draftType: DRAFT_TYPE_DRAFT,
channel: {
id: 'channel-id',
name: 'channel-name',
} as ChannelModel,
rootId: '',
draft: {
id: 'draft-id',
message: 'draft message',
files: [],
} as unknown as DraftModel,
draftReceiverUserName: 'username',
};
it('renders draft options correctly', () => {
renderWithEverything(
<DraftScheduledPostOptions {...baseProps}/>, {database},
);
expect(screen.getByText('Draft actions')).toBeTruthy();
expect(screen.getByText('Copy Text')).toBeTruthy();
expect(screen.getByTestId('edit_draft')).toBeTruthy();
expect(screen.getByTestId('send_draft_button')).toBeTruthy();
expect(screen.getByTestId('delete_draft')).toBeTruthy();
});
it('renders scheduled post options correctly', () => {
const scheduledProps = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
draft: {
...baseProps.draft,
scheduledAt: Date.now() + 3600000, // 1 hour in the future
} as ScheduledPostModel,
};
renderWithEverything(
<DraftScheduledPostOptions {...scheduledProps}/>, {database},
);
expect(screen.getByText('Message actions')).toBeTruthy();
expect(screen.getByText('Copy Text')).toBeTruthy();
expect(screen.getByTestId('send_draft_button')).toBeTruthy();
expect(screen.getByTestId('rescheduled_draft')).toBeTruthy();
expect(screen.getByTestId('delete_draft')).toBeTruthy();
expect(screen.queryByTestId('edit_draft')).toBeFalsy(); // Edit option should not be present for scheduled posts
});
it('renders tablet view without header', () => {
jest.requireMock('@hooks/device').useIsTablet.mockReturnValueOnce(true);
renderWithEverything(
<DraftScheduledPostOptions {...baseProps}/>, {database},
);
expect(screen.queryByText('Draft actions')).toBeFalsy(); // Header should not be present in tablet view
expect(screen.getByTestId('edit_draft')).toBeTruthy(); // Verify some content is rendered
});
});

Some files were not shown because too many files have changed in this diff Show more