Feature schedule posts (#8509)
This commit is contained in:
parent
5b72465e40
commit
f75c50ad2b
159 changed files with 9289 additions and 770 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
174
app/actions/local/scheduled_post.test.ts
Normal file
174
app/actions/local/scheduled_post.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
34
app/actions/local/scheduled_post.ts
Normal file
34
app/actions/local/scheduled_post.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
258
app/actions/remote/scheduled_post.test.ts
Normal file
258
app/actions/remote/scheduled_post.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
app/actions/remote/scheduled_post.ts
Normal file
120
app/actions/remote/scheduled_post.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
110
app/actions/websocket/scheduled_post.test.ts
Normal file
110
app/actions/websocket/scheduled_post.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
app/actions/websocket/scheduled_post.ts
Normal file
30
app/actions/websocket/scheduled_post.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
134
app/client/rest/scheduled_post.test.ts
Normal file
134
app/client/rest/scheduled_post.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
app/client/rest/scheduled_post.ts
Normal file
69
app/client/rest/scheduled_post.ts
Normal 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;
|
||||
|
|
@ -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'}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
155
app/components/draft_scheduled_post/draft_scheduled_post.tsx
Normal file
155
app/components/draft_scheduled_post/draft_scheduled_post.tsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
@ -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},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
@ -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,
|
||||
128
app/components/drafts_buttton/drafts_button.test.tsx
Normal file
128
app/components/drafts_buttton/drafts_button.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
31
app/components/drafts_buttton/index.ts
Normal file
31
app/components/drafts_buttton/index.ts
Normal 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));
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
213
app/components/post_draft/draft_input/draft_input.test.tsx
Normal file
213
app/components/post_draft/draft_input/draft_input.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
19
app/components/post_draft/draft_input/index.ts
Normal file
19
app/components/post_draft/draft_input/index.ts
Normal 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));
|
||||
|
|
@ -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;
|
||||
18
app/components/post_draft/send_button/index.tsx
Normal file
18
app/components/post_draft/send_button/index.tsx
Normal 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));
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
141
app/components/post_draft/send_button/send_button.test.tsx
Normal file
141
app/components/post_draft/send_button/send_button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
117
app/components/post_draft/send_button/send_button.tsx
Normal file
117
app/components/post_draft/send_button/send_button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
312
app/components/post_draft/send_handler/send_handler.test.tsx
Normal file
312
app/components/post_draft/send_handler/send_handler.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
102
app/components/scheduled_post_indicator/index.test.tsx
Normal file
102
app/components/scheduled_post_indicator/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
109
app/components/scheduled_post_indicator/index.tsx
Normal file
109
app/components/scheduled_post_indicator/index.tsx
Normal 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;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
13
app/constants/draft.ts
Normal 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;
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
13
app/constants/tooltip.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
80
app/database/models/server/scheduled_post.ts
Normal file
80
app/database/models/server/scheduled_post.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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!;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
24
app/database/schema/server/table_schemas/scheduled_post.ts
Normal file
24
app/database/schema/server/table_schemas/scheduled_post.ts
Normal 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'},
|
||||
],
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}) => {
|
||||
|
|
|
|||
70
app/queries/servers/scheduled_post.ts
Normal file
70
app/queries/servers/scheduled_post.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
88
app/screens/draft_scheduled_post_options/index.test.tsx
Normal file
88
app/screens/draft_scheduled_post_options/index.test.tsx
Normal 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
Loading…
Reference in a new issue