From f75c50ad2b40860b25cf872a52486c36f923577d Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Mon, 14 Apr 2025 22:08:59 +0530 Subject: [PATCH] Feature schedule posts (#8509) --- app/actions/app/global.test.ts | 20 + app/actions/app/global.ts | 8 + app/actions/local/draft.test.ts | 127 +++++- app/actions/local/draft.ts | 62 ++- app/actions/local/scheduled_post.test.ts | 174 +++++++++ app/actions/local/scheduled_post.ts | 34 ++ app/actions/remote/channel.ts | 5 + app/actions/remote/entry/common.test.ts | 5 +- app/actions/remote/entry/common.ts | 7 +- app/actions/remote/scheduled_post.test.ts | 258 ++++++++++++ app/actions/remote/scheduled_post.ts | 120 ++++++ app/actions/remote/team.test.ts | 6 + app/actions/remote/team.ts | 2 + app/actions/websocket/event.test.ts | 20 + app/actions/websocket/event.ts | 10 + app/actions/websocket/index.test.ts | 1 + app/actions/websocket/scheduled_post.test.ts | 110 ++++++ app/actions/websocket/scheduled_post.ts | 30 ++ app/client/rest/base.ts | 8 + app/client/rest/index.ts | 7 +- app/client/rest/scheduled_post.test.ts | 134 +++++++ app/client/rest/scheduled_post.ts | 69 ++++ .../copy_text_option/index.tsx} | 0 .../data_time_selector/index.tsx} | 14 +- app/components/draft/draft.tsx | 124 ------ .../draft_post_header/draft_post_header.tsx | 159 -------- .../draft_scheduled_post.tsx | 155 ++++++++ .../delete_draft.test.tsx | 106 +++++ .../delete_draft.tsx | 52 ++- .../edit_draft.tsx | 0 .../rescheduled_draft.test.tsx | 113 ++++++ .../rescheduled_draft.tsx | 85 ++++ .../send_draft/index.ts | 69 ++++ .../send_draft/send_draft.test.tsx | 278 +++++++++++++ .../send_draft}/send_draft.tsx | 71 +++- .../draft_and_scheduled_post_files}/index.ts | 0 .../draft_and_scheduled_post_message.tsx} | 17 +- .../draft_scheduled_post_container}/index.tsx | 21 +- .../{draft => draft_scheduled_post}/index.tsx | 17 +- .../draft_scheduled_post_header.tsx | 242 ++++++++++++ .../index.tsx | 4 +- .../profile_avatar.tsx | 8 +- .../drafts_buttton/drafts_button.test.tsx | 128 ++++++ .../drafts_buttton/drafts_button.tsx | 92 ++++- app/components/drafts_buttton/index.ts | 31 ++ app/components/formatted_time/index.tsx | 20 +- .../draft_input/draft_input.test.tsx | 213 ++++++++++ .../{index.tsx => draft_input.tsx} | 54 ++- .../post_draft/draft_input/index.ts | 19 + .../post_draft/send_action/index.tsx | 75 ---- .../post_draft/send_button/index.tsx | 18 + .../scheduled_post_tooltip.test.tsx | 42 ++ .../send_button/scheduled_post_tooltip.tsx} | 30 +- .../send_button/send_button.test.tsx | 141 +++++++ .../post_draft/send_button/send_button.tsx | 117 ++++++ .../post_draft/send_handler/index.ts | 48 ++- .../send_handler/send_handler.test.tsx | 312 +++++++++++++++ .../post_draft/send_handler/send_handler.tsx | 12 +- app/components/post_draft/uploads/index.tsx | 5 +- .../post_list/scroll_to_end_view.tsx | 5 +- .../scheduled_post_indicator/index.test.tsx | 102 +++++ .../scheduled_post_indicator/index.tsx | 109 ++++++ app/components/threads_button/index.ts | 6 + .../threads_button/threads_button.tsx | 16 +- app/components/toast/index.tsx | 8 +- app/constants/action_type.ts | 7 + app/constants/database.ts | 1 + app/constants/draft.ts | 13 + app/constants/screens.ts | 14 +- app/constants/snack_bar.ts | 7 +- app/constants/tooltip.ts | 13 + app/constants/tutorial.ts | 4 + app/constants/websocket.ts | 4 + app/database/manager/__mocks__/index.ts | 3 +- app/database/manager/index.ts | 4 +- app/database/migration/server/index.ts | 22 +- app/database/models/server/index.ts | 1 + app/database/models/server/scheduled_post.ts | 80 ++++ .../comparators/scheduled_post.test.ts | 26 ++ .../comparators/scheduled_post.ts | 8 + .../handlers/post.test.ts | 171 +++++++- .../server_data_operator/handlers/post.ts | 129 +++++- .../transformers/post.test.ts | 162 ++++++++ .../server_data_operator/transformers/post.ts | 42 ++ app/database/schema/server/index.ts | 4 +- .../schema/server/table_schemas/index.ts | 1 + .../server/table_schemas/scheduled_post.ts | 24 ++ app/database/schema/server/test.ts | 31 +- app/hooks/handle_send_message.test.tsx | 369 +++++++++++++++++- app/hooks/handle_send_message.ts | 97 ++++- app/queries/servers/scheduled_post.ts | 70 ++++ app/queries/servers/team.test.ts | 63 +++ app/queries/servers/team.ts | 9 +- app/screens/bottom_sheet/index.tsx | 4 +- app/screens/channel/channel.tsx | 8 + app/screens/channel/index.tsx | 11 +- app/screens/channel_bookmark/index.tsx | 2 +- .../components/clear_after_menu_item.tsx | 3 +- app/screens/draft_options/index.tsx | 113 ------ .../index.test.tsx | 88 +++++ .../draft_scheduled_post_options/index.tsx | 144 +++++++ ..._and_scheduled_post_swipe_actions.test.tsx | 154 ++++++++ ...raft_and_scheduled_post_swipe_actions.tsx} | 64 ++- .../components/draft_empty_component.tsx | 1 + .../global_drafts_list/global_drafts_list.tsx | 36 +- .../global_scheduled_post_list.tsx | 189 +++++++++ .../global_scheduled_post_list/index.tsx | 29 ++ .../scheduled_post_empty_component.test.tsx | 22 ++ .../scheduled_post_empty_component.tsx | 66 ++++ .../draft_tabs_header.test.tsx | 69 ++++ .../tabbed_contents/draft_tabs_header.tsx | 174 +++++++++ .../components/tabbed_contents/index.ts | 25 ++ .../tabbed_contents/tabbed_contents.test.tsx | 161 ++++++++ .../tabbed_contents/tabbed_contents.tsx | 115 ++++++ .../draft_scheduled_post_tooltip.test.tsx | 60 +++ .../draft_scheduled_post_tooltip.tsx | 99 +++++ .../global_drafts/global_drafts.test.tsx | 106 +++++ .../{index.tsx => global_drafts.tsx} | 33 +- app/screens/global_drafts/index.ts | 27 ++ .../additional_tablet_view.tsx | 30 +- .../additional_tablet_view/index.ts | 42 +- .../categories_list/categories_list.tsx | 22 +- .../categories_list/index.test.tsx | 42 ++ .../channel_list/categories_list/index.tsx | 20 +- app/screens/index.tsx | 10 +- app/screens/navigation.ts | 9 + app/screens/post_options/post_options.tsx | 2 +- app/screens/reschedule_draft/index.tsx | 21 + .../reschedule_draft.test.tsx | 262 +++++++++++++ .../reschedule_draft/reschedule_draft.tsx | 149 +++++++ .../core_options/core_options.tsx | 155 ++++++++ .../core_options/index.ts | 20 + .../scheduled_post_options/footer/index.tsx | 139 +++++++ .../footer/scheduled_post_footer.test.tsx | 139 +++++++ app/screens/scheduled_post_options/index.ts | 20 + .../scheduled_post_picker.test.tsx | 174 +++++++++ .../scheduled_post_picker.tsx | 164 ++++++++ app/screens/snack_bar/index.tsx | 41 +- app/screens/snack_bar/snack_bar.test.tsx | 87 +++++ app/screens/thread/index.tsx | 4 + app/screens/thread/thread.tsx | 11 + app/utils/datetime.test.ts | 52 ++- app/utils/datetime.ts | 18 + app/utils/post/index.test.ts | 49 +++ app/utils/post/index.ts | 21 + app/utils/scheduled_post/index.ts | 235 +++++++++++ .../scheduled_post/scheduled_post.test.ts | 345 ++++++++++++++++ app/utils/snack_bar/index.ts | 12 +- app/utils/time.test.ts | 43 ++ app/utils/time.ts | 21 + assets/base/i18n/en.json | 52 +++ assets/base/images/Scheduled_Message.png | Bin 0 -> 3427 bytes test/setup.ts | 24 +- types/api/config.d.ts | 1 + types/api/scheduled_post.d.ts | 21 + types/database/database.ts | 11 + .../database/models/servers/scheduled_post.ts | 49 +++ types/database/raw_values.d.ts | 1 + types/utils/scheduled_post.d.ts | 4 + 159 files changed, 9289 insertions(+), 770 deletions(-) create mode 100644 app/actions/local/scheduled_post.test.ts create mode 100644 app/actions/local/scheduled_post.ts create mode 100644 app/actions/remote/scheduled_post.test.ts create mode 100644 app/actions/remote/scheduled_post.ts create mode 100644 app/actions/websocket/scheduled_post.test.ts create mode 100644 app/actions/websocket/scheduled_post.ts create mode 100644 app/client/rest/scheduled_post.test.ts create mode 100644 app/client/rest/scheduled_post.ts rename app/{screens/post_options/options/copy_text_option.tsx => components/copy_text_option/index.tsx} (100%) rename app/{screens/custom_status_clear_after/components/date_time_selector.tsx => components/data_time_selector/index.tsx} (92%) delete mode 100644 app/components/draft/draft.tsx delete mode 100644 app/components/draft_post_header/draft_post_header.tsx create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post.tsx create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/delete_draft.test.tsx rename app/{screens/draft_options => components/draft_scheduled_post/draft_scheduled_post_actions}/delete_draft.tsx (58%) rename app/{screens/draft_options => components/draft_scheduled_post/draft_scheduled_post_actions}/edit_draft.tsx (100%) create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/rescheduled_draft.test.tsx create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/rescheduled_draft.tsx create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/send_draft/index.ts create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/send_draft/send_draft.test.tsx rename app/{screens/draft_options => components/draft_scheduled_post/draft_scheduled_post_actions/send_draft}/send_draft.tsx (67%) rename app/components/{draft/draft_post/draft_files => draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_files}/index.ts (100%) rename app/components/{draft/draft_post/draft_message.tsx => draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_message.tsx} (88%) rename app/components/{draft/draft_post => draft_scheduled_post/draft_scheduled_post_container}/index.tsx (73%) rename app/components/{draft => draft_scheduled_post}/index.tsx (82%) create mode 100644 app/components/draft_scheduled_post_header/draft_scheduled_post_header.tsx rename app/components/{draft_post_header => draft_scheduled_post_header}/index.tsx (85%) rename app/components/{draft_post_header => draft_scheduled_post_header}/profile_avatar.tsx (95%) create mode 100644 app/components/drafts_buttton/drafts_button.test.tsx create mode 100644 app/components/drafts_buttton/index.ts create mode 100644 app/components/post_draft/draft_input/draft_input.test.tsx rename app/components/post_draft/draft_input/{index.tsx => draft_input.tsx} (76%) create mode 100644 app/components/post_draft/draft_input/index.ts delete mode 100644 app/components/post_draft/send_action/index.tsx create mode 100644 app/components/post_draft/send_button/index.tsx create mode 100644 app/components/post_draft/send_button/scheduled_post_tooltip.test.tsx rename app/{screens/global_drafts/components/global_drafts_list/draft_tooltip.tsx => components/post_draft/send_button/scheduled_post_tooltip.tsx} (73%) create mode 100644 app/components/post_draft/send_button/send_button.test.tsx create mode 100644 app/components/post_draft/send_button/send_button.tsx create mode 100644 app/components/post_draft/send_handler/send_handler.test.tsx create mode 100644 app/components/scheduled_post_indicator/index.test.tsx create mode 100644 app/components/scheduled_post_indicator/index.tsx create mode 100644 app/constants/draft.ts create mode 100644 app/constants/tooltip.ts create mode 100644 app/database/models/server/scheduled_post.ts create mode 100644 app/database/operator/server_data_operator/comparators/scheduled_post.test.ts create mode 100644 app/database/operator/server_data_operator/comparators/scheduled_post.ts create mode 100644 app/database/schema/server/table_schemas/scheduled_post.ts create mode 100644 app/queries/servers/scheduled_post.ts delete mode 100644 app/screens/draft_options/index.tsx create mode 100644 app/screens/draft_scheduled_post_options/index.test.tsx create mode 100644 app/screens/draft_scheduled_post_options/index.tsx create mode 100644 app/screens/global_drafts/components/draft_and_scheduled_post_swipe_actions.test.tsx rename app/screens/global_drafts/components/{global_drafts_list/draft_swipe_actions.tsx => draft_and_scheduled_post_swipe_actions.tsx} (68%) create mode 100644 app/screens/global_drafts/components/global_scheduled_post_list/global_scheduled_post_list.tsx create mode 100644 app/screens/global_drafts/components/global_scheduled_post_list/index.tsx create mode 100644 app/screens/global_drafts/components/scheduled_post_empty_component.test.tsx create mode 100644 app/screens/global_drafts/components/scheduled_post_empty_component.tsx create mode 100644 app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.test.tsx create mode 100644 app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.tsx create mode 100644 app/screens/global_drafts/components/tabbed_contents/index.ts create mode 100644 app/screens/global_drafts/components/tabbed_contents/tabbed_contents.test.tsx create mode 100644 app/screens/global_drafts/components/tabbed_contents/tabbed_contents.tsx create mode 100644 app/screens/global_drafts/draft_scheduled_post_tooltip.test.tsx create mode 100644 app/screens/global_drafts/draft_scheduled_post_tooltip.tsx create mode 100644 app/screens/global_drafts/global_drafts.test.tsx rename app/screens/global_drafts/{index.tsx => global_drafts.tsx} (71%) create mode 100644 app/screens/global_drafts/index.ts create mode 100644 app/screens/reschedule_draft/index.tsx create mode 100644 app/screens/reschedule_draft/reschedule_draft.test.tsx create mode 100644 app/screens/reschedule_draft/reschedule_draft.tsx create mode 100644 app/screens/scheduled_post_options/core_options/core_options.tsx create mode 100644 app/screens/scheduled_post_options/core_options/index.ts create mode 100644 app/screens/scheduled_post_options/footer/index.tsx create mode 100644 app/screens/scheduled_post_options/footer/scheduled_post_footer.test.tsx create mode 100644 app/screens/scheduled_post_options/index.ts create mode 100644 app/screens/scheduled_post_options/scheduled_post_picker.test.tsx create mode 100644 app/screens/scheduled_post_options/scheduled_post_picker.tsx create mode 100644 app/screens/snack_bar/snack_bar.test.tsx create mode 100644 app/utils/scheduled_post/index.ts create mode 100644 app/utils/scheduled_post/scheduled_post.test.ts create mode 100644 app/utils/time.test.ts create mode 100644 app/utils/time.ts create mode 100644 assets/base/images/Scheduled_Message.png create mode 100644 types/api/scheduled_post.d.ts create mode 100644 types/database/models/servers/scheduled_post.ts create mode 100644 types/utils/scheduled_post.d.ts diff --git a/app/actions/app/global.test.ts b/app/actions/app/global.test.ts index 8ac3d2c93..911c2e37a 100644 --- a/app/actions/app/global.test.ts +++ b/app/actions/app/global.test.ts @@ -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); + }); }); diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index 9c1159bc9..8f47d6466 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -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); }; diff --git a/app/actions/local/draft.test.ts b/app/actions/local/draft.test.ts index d9248b354..d0369e5c3 100644 --- a/app/actions/local/draft.test.ts +++ b/app/actions/local/draft.test.ts @@ -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, diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index 42aa96af3..647d7f518 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -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}; } }; diff --git a/app/actions/local/scheduled_post.test.ts b/app/actions/local/scheduled_post.test.ts new file mode 100644 index 000000000..ecbb5a493 --- /dev/null +++ b/app/actions/local/scheduled_post.test.ts @@ -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'); + }); +}); diff --git a/app/actions/local/scheduled_post.ts b/app/actions/local/scheduled_post.ts new file mode 100644 index 000000000..f894a6e09 --- /dev/null +++ b/app/actions/local/scheduled_post.ts @@ -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}; + } +} diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 4786d7e7e..08737319f 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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); } diff --git a/app/actions/remote/entry/common.test.ts b/app/actions/remote/entry/common.test.ts index 37effb595..71b5128d1 100644 --- a/app/actions/remote/entry/common.test.ts +++ b/app/actions/remote/entry/common.test.ts @@ -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 () => { diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index cdc3ac02a..1fe0ccb79 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -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) => { diff --git a/app/actions/remote/scheduled_post.test.ts b/app/actions/remote/scheduled_post.test.ts new file mode 100644 index 000000000..ba95780ee --- /dev/null +++ b/app/actions/remote/scheduled_post.test.ts @@ -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); + }); +}); diff --git a/app/actions/remote/scheduled_post.ts b/app/actions/remote/scheduled_post.ts new file mode 100644 index 000000000..ea2b16ee2 --- /dev/null +++ b/app/actions/remote/scheduled_post.ts @@ -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 { + 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}; + } +} diff --git a/app/actions/remote/team.test.ts b/app/actions/remote/team.test.ts index 6427d82b4..43b878db1 100644 --- a/app/actions/remote/team.test.ts +++ b/app/actions/remote/team.test.ts @@ -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 () => { diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index d645b1d61..a8f881e56 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -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 {}; } diff --git a/app/actions/websocket/event.test.ts b/app/actions/websocket/event.test.ts index 160132d14..4485594bb 100644 --- a/app/actions/websocket/event.test.ts +++ b/app/actions/websocket/event.test.ts @@ -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); + }); }); diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index fa4aedd86..de3668e6a 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -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; } } diff --git a/app/actions/websocket/index.test.ts b/app/actions/websocket/index.test.ts index 20d088eee..25e57d165 100644 --- a/app/actions/websocket/index.test.ts +++ b/app/actions/websocket/index.test.ts @@ -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'); diff --git a/app/actions/websocket/scheduled_post.test.ts b/app/actions/websocket/scheduled_post.test.ts new file mode 100644 index 000000000..789bd86e4 --- /dev/null +++ b/app/actions/websocket/scheduled_post.test.ts @@ -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(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(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(); + }); +}); diff --git a/app/actions/websocket/scheduled_post.ts b/app/actions/websocket/scheduled_post.ts new file mode 100644 index 000000000..012113c27 --- /dev/null +++ b/app/actions/websocket/scheduled_post.ts @@ -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, 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, 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}; + } +} diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 2704b0a97..b3a7ad030 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -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`; } diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index a0f86d1b5..8492157c8 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -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) { diff --git a/app/client/rest/scheduled_post.test.ts b/app/client/rest/scheduled_post.test.ts new file mode 100644 index 000000000..4597801d2 --- /dev/null +++ b/app/client/rest/scheduled_post.test.ts @@ -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); + }); +}); diff --git a/app/client/rest/scheduled_post.ts b/app/client/rest/scheduled_post.ts new file mode 100644 index 000000000..71f03f5e3 --- /dev/null +++ b/app/client/rest/scheduled_post.ts @@ -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; + updateScheduledPost(scheduledPost: ScheduledPost, connectionId?: string): Promise; + getScheduledPostsForTeam(teamId: string, includeDirectChannels: boolean, groupLabel?: RequestGroupLabel): Promise; + deleteScheduledPost(scheduledPostId: string, connectionId?: string): Promise; +} + +const ClientScheduledPost = >(superclass: TBase) => class extends superclass { + createScheduledPost = (schedulePost: ScheduledPost, connectionId?: string) => { + const headers: Record = {}; + 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 = {}; + 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 = {}; + if (connectionId) { + headers['Connection-Id'] = connectionId; + } + return this.doFetch( + `${this.getScheduledPostRoute()}/${scheduledPostId}`, + { + method: 'delete', + headers, + }, + ); + } +}; + +export default ClientScheduledPost; diff --git a/app/screens/post_options/options/copy_text_option.tsx b/app/components/copy_text_option/index.tsx similarity index 100% rename from app/screens/post_options/options/copy_text_option.tsx rename to app/components/copy_text_option/index.tsx diff --git a/app/screens/custom_status_clear_after/components/date_time_selector.tsx b/app/components/data_time_selector/index.tsx similarity index 92% rename from app/screens/custom_status_clear_after/components/date_time_selector.tsx rename to app/components/data_time_selector/index.tsx index d37304ce4..aa8a27d7d 100644 --- a/app/screens/custom_status_clear_after/components/date_time_selector.tsx +++ b/app/components/data_time_selector/index.tsx @@ -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(minimumDate); - const [mode, setMode] = useState('date'); - const [show, setShow] = useState(false); + const [mode, setMode] = useState(showInitially || 'date'); + const [show, setShow] = useState(Boolean(showInitially)); const onChange = (_: DateTimePickerEvent, selectedDate: Date) => { const currentDate = selectedDate || date; @@ -78,7 +77,10 @@ const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props }; return ( - +