From bb7ff622afed688c218ac8973e402504735d0768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 14 Jul 2025 09:21:37 +0200 Subject: [PATCH] Add Playbooks read-only support for mobile devices (#8978) * Add the channel options to get into playbooks (#8750) * Add the channel options to get into playbooks * Use playbook run id instead of playbook id * i18n * Fix issues and move playbooks to the products folder * Address some tests * Fix test * Address design issues * Add requested comment --------- Co-authored-by: Elias Nahum * Playbooks database (#8802) * Add lastPlaybookFetchAt to channel table (#8916) * Add lastPlaybookFetchAt to channel table * Add missing commit * Use my_channel table instead * Fix test * Address feedback * First implementation of playbooks API (#8897) * First implementation of playbooks API * Add version check * Address feedback * Fix test * Add last fetch at usage and other improvements * Simplify test * Add sort_order, update_at and previousReminder columns (#8927) * Add sort_order, update_at and previousReminder columns * Remove order from the schema * Fix tests * Add tests * Add websockets for playbooks (#8947) * Add websocket events for playbooks * Fix typo * Add playbook run list (#8761) * Add the channel options to get into playbooks * Use playbook run id instead of playbook id * i18n * Fix issues and move playbooks to the products folder * Address some tests * Fix test * Add playbook run list * Add missing texts * Add back button support and item size to flash list * Address design issues * Add requested comment * Standardize tag and use it in the card * Fix merge * Add API related functionality --------- Co-authored-by: Elias Nahum * Add playbooks run details (#8872) * Add the channel options to get into playbooks * Use playbook run id instead of playbook id * i18n * Fix issues and move playbooks to the products folder * Address some tests * Fix test * Add playbook run list * Add missing texts * Add back button support and item size to flash list * Address design issues * Add requested comment * Standardize tag and use it in the card * Add playbooks run details * Fix merge * Add API related functionality * Add API related changes * Order fixes * Several fixes * Add error state on playbook run * i18n-extract * Fix tests * Fix test * Several fixes * Fixes and add missing UI elements * i18n-extract * Fix tests * Remove files from bad merge --------- Co-authored-by: Elias Nahum * Add missing tests for playbooks (#8976) * Add the channel options to get into playbooks * Use playbook run id instead of playbook id * i18n * Fix issues and move playbooks to the products folder * Address some tests * Fix test * Add playbook run list * Add missing texts * Add back button support and item size to flash list * Address design issues * Add requested comment * Standardize tag and use it in the card * Add playbooks run details * Fix merge * Add API related functionality * Add API related changes * Order fixes * Several fixes * Add error state on playbook run * i18n-extract * Fix tests * Fix test * Several fixes * Fixes and add missing UI elements * i18n-extract * Fix tests * Remove files from bad merge * Add tests * Fix typo * Add missing strings * Fix tests and skip some * Fix test * Fix typo --------- Co-authored-by: Elias Nahum * Address feedback * Address feedback and fix tests * Address comments and fix tests * Address feedback * Address plugin changes and fix bugs --------- Co-authored-by: Elias Nahum --- app/actions/remote/session.test.ts | 19 +- app/actions/remote/session.ts | 2 + app/actions/websocket/event.test.ts | 12 + app/actions/websocket/event.ts | 2 + app/actions/websocket/index.test.ts | 5 + app/actions/websocket/index.ts | 4 + app/client/rest/index.ts | 4 +- app/client/rest/tracking.test.ts | 3 +- app/components/app_version/index.test.tsx | 4 +- app/components/chips/base_chip.test.tsx | 55 ++ app/components/chips/base_chip.tsx | 34 +- app/components/connection_banner/index.ts | 4 +- .../friendly_date/friendly_date.test.tsx | 44 ++ app/components/friendly_date/index.tsx | 79 +++ .../navigation_header/header.test.tsx | 51 ++ app/components/navigation_header/header.tsx | 29 +- .../post_list/post/footer/footer.test.tsx | 44 ++ .../post_list/post/footer/footer.tsx | 10 +- app/components/profile_picture/image.tsx | 7 +- .../user_avatars_stack/index.test.tsx | 47 ++ app/components/user_avatars_stack/index.tsx | 11 +- .../__snapshots__/index.test.tsx.snap | 76 +++ app/constants/database.ts | 1 + app/constants/screens.ts | 4 + app/constants/snack_bar.ts | 118 +++- app/constants/websocket.ts | 1 + app/database/manager/__mocks__/index.ts | 5 +- app/database/manager/index.ts | 4 +- app/database/migration/server/index.ts | 81 ++- app/database/models/server/channel.ts | 9 + app/database/models/server/my_channel.ts | 3 + app/database/models/server/role.ts | 4 +- app/database/models/server/team.ts | 10 + .../models/server/team_channel_history.ts | 4 +- .../operator/server_data_operator/index.ts | 3 + .../transformers/channel.ts | 1 + app/database/operator/utils/general.test.ts | 125 ++++ app/database/operator/utils/general.ts | 53 +- app/database/schema/server/index.ts | 7 +- .../schema/server/table_schemas/my_channel.ts | 1 + app/database/schema/server/test.ts | 121 +++- app/hooks/android_back_handler.test.ts | 16 +- app/hooks/device.test.tsx | 26 - app/hooks/useCollapsibleHeader.test.tsx | 8 +- app/managers/websocket_manager.test.ts | 21 +- app/managers/websocket_manager.ts | 7 + .../join_call_banner.test.tsx | 60 ++ .../join_call_banner/join_call_banner.tsx | 8 +- .../playbooks/actions/local/channel.test.ts | 85 +++ .../playbooks/actions/local/channel.ts | 25 + .../playbooks/actions/local/checklist.test.ts | 70 ++ .../playbooks/actions/local/checklist.ts | 27 + .../playbooks/actions/local/run.test.ts | 47 ++ app/products/playbooks/actions/local/run.ts | 20 + .../playbooks/actions/local/version.test.ts | 49 ++ .../playbooks/actions/local/version.ts | 22 + .../actions/remote/checklist.test.ts | 100 +++ .../playbooks/actions/remote/checklist.ts | 49 ++ .../playbooks/actions/remote/runs.test.ts | 171 +++++ app/products/playbooks/actions/remote/runs.ts | 82 +++ .../playbooks/actions/remote/version.test.ts | 81 +++ .../playbooks/actions/remote/version.ts | 23 + .../playbooks/actions/websocket/events.ts | 42 ++ .../playbooks/actions/websocket/runs.test.ts | 508 ++++++++++++++ .../playbooks/actions/websocket/runs.ts | 168 +++++ .../actions/websocket/version.test.ts | 89 +++ .../playbooks/actions/websocket/version.ts | 19 + app/products/playbooks/client/rest.test.ts | 174 +++++ app/products/playbooks/client/rest.ts | 111 +++ .../playbook_runs_option/index.test.tsx | 78 +++ .../playbook_runs_option/index.ts | 28 + .../playbook_runs_option.test.tsx | 58 ++ .../playbook_runs_option.tsx | 41 ++ .../components/progress_bar/index.ts | 4 + .../progress_bar/progress_bar.test.tsx | 80 +++ .../components/progress_bar/progress_bar.tsx | 46 ++ app/products/playbooks/constants/database.ts | 8 + app/products/playbooks/constants/plugin.ts | 4 + app/products/playbooks/constants/version.ts | 6 + app/products/playbooks/constants/websocket.ts | 12 + .../playbooks/database/models/index.ts | 6 + .../database/models/playbook_checklist.ts | 57 ++ .../models/playbook_checklist_item.ts | 87 +++ .../playbooks/database/models/playbook_run.ts | 144 ++++ .../operators/comparators/index.test.ts | 78 +++ .../database/operators/comparators/index.ts | 18 + .../database/operators/handlers/index.test.ts | 556 +++++++++++++++ .../database/operators/handlers/index.ts | 279 ++++++++ .../operators/transformers/index.test.ts | 644 ++++++++++++++++++ .../database/operators/transformers/index.ts | 140 ++++ .../database/queries/checklist.test.ts | 77 +++ .../playbooks/database/queries/checklist.ts | 26 + .../playbooks/database/queries/item.test.ts | 76 +++ .../playbooks/database/queries/item.ts | 25 + .../playbooks/database/queries/run.test.ts | 424 ++++++++++++ .../playbooks/database/queries/run.ts | 74 ++ .../database/queries/version.test.ts | 154 +++++ .../playbooks/database/queries/version.ts | 27 + .../playbooks/database/schema/index.ts | 6 + .../database/schema/playbook_checklist.ts | 20 + .../schema/playbook_checklist_item.ts | 29 + .../playbooks/database/schema/playbook_run.ts | 38 ++ .../playbooks/screens/navigation.test.ts | 67 ++ app/products/playbooks/screens/navigation.ts | 26 + .../playbook_run/checklist/checklist.test.tsx | 314 +++++++++ .../playbook_run/checklist/checklist.tsx | 177 +++++ .../checklist_item/checkbox.test.tsx | 165 +++++ .../checklist/checklist_item/checkbox.tsx | 83 +++ .../checklist_item/checklist_item.test.tsx | 348 ++++++++++ .../checklist_item/checklist_item.tsx | 240 +++++++ .../checklist/checklist_item/index.test.tsx | 206 ++++++ .../checklist/checklist_item/index.ts | 51 ++ .../playbook_run/checklist/index.test.tsx | 123 ++++ .../screens/playbook_run/checklist/index.ts | 54 ++ .../playbook_run/checklist_list.test.tsx | 80 +++ .../screens/playbook_run/checklist_list.tsx | 49 ++ .../screens/playbook_run/error_state.test.tsx | 29 + .../screens/playbook_run/error_state.tsx | 68 ++ .../playbook_run/error_state_icon.test.tsx | 14 + .../screens/playbook_run/error_state_icon.tsx | 193 ++++++ .../screens/playbook_run/index.test.tsx | 242 +++++++ .../playbooks/screens/playbook_run/index.ts | 104 +++ .../out_of_date_header/index.test.tsx | 63 ++ .../playbook_run/out_of_date_header/index.ts | 14 + .../out_of_date_header.test.tsx | 58 ++ .../out_of_date_header/out_of_date_header.tsx | 110 +++ .../playbook_run/playbook_run.test.tsx | 231 +++++++ .../screens/playbook_run/playbook_run.tsx | 273 ++++++++ .../status_update_indicator.test.tsx | 77 +++ .../playbook_run/status_update_indicator.tsx | 105 +++ .../playbooks_runs/empty_state.test.tsx | 28 + .../screens/playbooks_runs/empty_state.tsx | 75 ++ .../playbooks_runs/empty_state_icon.test.tsx | 14 + .../playbooks_runs/empty_state_icon.tsx | 251 +++++++ .../screens/playbooks_runs/index.test.tsx | 74 ++ .../playbooks/screens/playbooks_runs/index.ts | 22 + .../playbook_card/index.test.tsx | 176 +++++ .../playbooks_runs/playbook_card/index.ts | 39 ++ .../playbook_card/playbook_card.test.tsx | 124 ++++ .../playbook_card/playbook_card.tsx | 164 +++++ .../playbooks_runs/playbook_runs.test.tsx | 88 +++ .../screens/playbooks_runs/playbook_runs.tsx | 182 +++++ app/products/playbooks/types/api.d.ts | 114 ++++ app/products/playbooks/types/client.d.ts | 37 + .../database/models/playbook_checklist.ts | 45 ++ .../models/playbook_checklist_item.ts | 72 ++ .../types/database/models/playbook_run.ts | 114 ++++ .../types/database/transformers/index.d.ts | 14 + app/products/playbooks/types/websocket.d.ts | 44 ++ .../playbooks/utils/items_order.test.ts | 114 ++++ app/products/playbooks/utils/items_order.ts | 23 + app/products/playbooks/utils/progress.test.ts | 303 ++++++++ app/products/playbooks/utils/progress.ts | 27 + app/products/playbooks/utils/run.test.ts | 311 +++++++++ app/products/playbooks/utils/run.ts | 65 ++ app/queries/servers/channel.test.ts | 2 +- app/screens/channel/header/header.test.tsx | 207 ++++++ app/screens/channel/header/header.tsx | 94 ++- app/screens/channel/header/index.test.tsx | 245 +++++++ app/screens/channel/header/index.ts | 62 +- .../header/quick_actions/index.test.tsx | 56 ++ .../channel/header/quick_actions/index.tsx | 12 +- .../channel_info/options/index.test.tsx | 47 ++ app/screens/channel_info/options/index.tsx | 2 + .../global_drafts/global_drafts.test.tsx | 4 +- .../thread_footer/thread_footer.test.tsx | 87 +++ .../thread/thread_footer/thread_footer.tsx | 4 + .../categories/header/header.test.tsx | 4 +- .../categories_list/header/header.test.tsx | 4 +- .../categories_list/index.test.tsx | 14 +- .../search/initial/modifiers/index.test.tsx | 5 +- app/screens/index.test.tsx | 36 + app/screens/index.tsx | 6 + app/screens/navigation.test.ts | 4 +- app/screens/navigation.ts | 2 +- app/screens/snack_bar/index.tsx | 13 +- app/store/ephemeral_store.test.ts | 31 + app/store/ephemeral_store.ts | 20 + app/utils/emoji/prefetch.test.ts | 14 +- app/utils/font_family.test.ts | 18 +- app/utils/gallery/index.test.ts | 43 +- app/utils/helpers.test.ts | 74 ++ app/utils/helpers.ts | 21 + app/utils/snack_bar/index.test.ts | 18 + app/utils/snack_bar/index.ts | 6 + assets/base/i18n/en.json | 34 + babel.config.js | 1 + docs/database/server/server.md | 56 +- eslint.config.mjs | 2 +- test/setup.ts | 16 +- test/test_helper.ts | 241 ++++++- tsconfig.json | 1 + types/database/database.ts | 2 + types/database/models/servers/channel.ts | 4 + types/database/models/servers/my_channel.ts | 3 + types/database/models/servers/team.ts | 4 + types/database/raw_values.d.ts | 3 + 197 files changed, 13912 insertions(+), 253 deletions(-) create mode 100644 app/components/navigation_header/header.test.tsx create mode 100644 app/components/post_list/post/footer/footer.test.tsx create mode 100644 app/components/user_avatars_stack/index.test.tsx create mode 100644 app/database/operator/utils/general.test.ts create mode 100644 app/products/calls/components/join_call_banner/join_call_banner.test.tsx create mode 100644 app/products/playbooks/actions/local/channel.test.ts create mode 100644 app/products/playbooks/actions/local/channel.ts create mode 100644 app/products/playbooks/actions/local/checklist.test.ts create mode 100644 app/products/playbooks/actions/local/checklist.ts create mode 100644 app/products/playbooks/actions/local/run.test.ts create mode 100644 app/products/playbooks/actions/local/run.ts create mode 100644 app/products/playbooks/actions/local/version.test.ts create mode 100644 app/products/playbooks/actions/local/version.ts create mode 100644 app/products/playbooks/actions/remote/checklist.test.ts create mode 100644 app/products/playbooks/actions/remote/checklist.ts create mode 100644 app/products/playbooks/actions/remote/runs.test.ts create mode 100644 app/products/playbooks/actions/remote/runs.ts create mode 100644 app/products/playbooks/actions/remote/version.test.ts create mode 100644 app/products/playbooks/actions/remote/version.ts create mode 100644 app/products/playbooks/actions/websocket/events.ts create mode 100644 app/products/playbooks/actions/websocket/runs.test.ts create mode 100644 app/products/playbooks/actions/websocket/runs.ts create mode 100644 app/products/playbooks/actions/websocket/version.test.ts create mode 100644 app/products/playbooks/actions/websocket/version.ts create mode 100644 app/products/playbooks/client/rest.test.ts create mode 100644 app/products/playbooks/client/rest.ts create mode 100644 app/products/playbooks/components/channel_actions/playbook_runs_option/index.test.tsx create mode 100644 app/products/playbooks/components/channel_actions/playbook_runs_option/index.ts create mode 100644 app/products/playbooks/components/channel_actions/playbook_runs_option/playbook_runs_option.test.tsx create mode 100644 app/products/playbooks/components/channel_actions/playbook_runs_option/playbook_runs_option.tsx create mode 100644 app/products/playbooks/components/progress_bar/index.ts create mode 100644 app/products/playbooks/components/progress_bar/progress_bar.test.tsx create mode 100644 app/products/playbooks/components/progress_bar/progress_bar.tsx create mode 100644 app/products/playbooks/constants/database.ts create mode 100644 app/products/playbooks/constants/plugin.ts create mode 100644 app/products/playbooks/constants/version.ts create mode 100644 app/products/playbooks/constants/websocket.ts create mode 100644 app/products/playbooks/database/models/index.ts create mode 100644 app/products/playbooks/database/models/playbook_checklist.ts create mode 100644 app/products/playbooks/database/models/playbook_checklist_item.ts create mode 100644 app/products/playbooks/database/models/playbook_run.ts create mode 100644 app/products/playbooks/database/operators/comparators/index.test.ts create mode 100644 app/products/playbooks/database/operators/comparators/index.ts create mode 100644 app/products/playbooks/database/operators/handlers/index.test.ts create mode 100644 app/products/playbooks/database/operators/handlers/index.ts create mode 100644 app/products/playbooks/database/operators/transformers/index.test.ts create mode 100644 app/products/playbooks/database/operators/transformers/index.ts create mode 100644 app/products/playbooks/database/queries/checklist.test.ts create mode 100644 app/products/playbooks/database/queries/checklist.ts create mode 100644 app/products/playbooks/database/queries/item.test.ts create mode 100644 app/products/playbooks/database/queries/item.ts create mode 100644 app/products/playbooks/database/queries/run.test.ts create mode 100644 app/products/playbooks/database/queries/run.ts create mode 100644 app/products/playbooks/database/queries/version.test.ts create mode 100644 app/products/playbooks/database/queries/version.ts create mode 100644 app/products/playbooks/database/schema/index.ts create mode 100644 app/products/playbooks/database/schema/playbook_checklist.ts create mode 100644 app/products/playbooks/database/schema/playbook_checklist_item.ts create mode 100644 app/products/playbooks/database/schema/playbook_run.ts create mode 100644 app/products/playbooks/screens/navigation.test.ts create mode 100644 app/products/playbooks/screens/navigation.ts create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checkbox.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checkbox.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.ts create mode 100644 app/products/playbooks/screens/playbook_run/checklist/index.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/index.ts create mode 100644 app/products/playbooks/screens/playbook_run/checklist_list.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist_list.tsx create mode 100644 app/products/playbooks/screens/playbook_run/error_state.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/error_state.tsx create mode 100644 app/products/playbooks/screens/playbook_run/error_state_icon.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/error_state_icon.tsx create mode 100644 app/products/playbooks/screens/playbook_run/index.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/index.ts create mode 100644 app/products/playbooks/screens/playbook_run/out_of_date_header/index.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/out_of_date_header/index.ts create mode 100644 app/products/playbooks/screens/playbook_run/out_of_date_header/out_of_date_header.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/out_of_date_header/out_of_date_header.tsx create mode 100644 app/products/playbooks/screens/playbook_run/playbook_run.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/playbook_run.tsx create mode 100644 app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/status_update_indicator.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/empty_state.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/empty_state.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/empty_state_icon.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/empty_state_icon.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/index.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/index.ts create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_card/index.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx create mode 100644 app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx create mode 100644 app/products/playbooks/types/api.d.ts create mode 100644 app/products/playbooks/types/client.d.ts create mode 100644 app/products/playbooks/types/database/models/playbook_checklist.ts create mode 100644 app/products/playbooks/types/database/models/playbook_checklist_item.ts create mode 100644 app/products/playbooks/types/database/models/playbook_run.ts create mode 100644 app/products/playbooks/types/database/transformers/index.d.ts create mode 100644 app/products/playbooks/types/websocket.d.ts create mode 100644 app/products/playbooks/utils/items_order.test.ts create mode 100644 app/products/playbooks/utils/items_order.ts create mode 100644 app/products/playbooks/utils/progress.test.ts create mode 100644 app/products/playbooks/utils/progress.ts create mode 100644 app/products/playbooks/utils/run.test.ts create mode 100644 app/products/playbooks/utils/run.ts create mode 100644 app/screens/channel/header/header.test.tsx create mode 100644 app/screens/channel/header/index.test.tsx create mode 100644 app/screens/channel/header/quick_actions/index.test.tsx create mode 100644 app/screens/channel_info/options/index.test.tsx create mode 100644 app/screens/global_threads/threads_list/thread/thread_footer/thread_footer.test.tsx create mode 100644 app/store/ephemeral_store.test.ts create mode 100644 app/utils/snack_bar/index.test.ts diff --git a/app/actions/remote/session.test.ts b/app/actions/remote/session.test.ts index 42ad69a48..f22013c1a 100644 --- a/app/actions/remote/session.test.ts +++ b/app/actions/remote/session.test.ts @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable max-lines */ - import {createIntl} from 'react-intl'; import {Alert, DeviceEventEmitter, Platform} from 'react-native'; @@ -10,6 +8,7 @@ import {Events} from '@constants'; import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; +import WebsocketManager from '@managers/websocket_manager'; import {logWarning} from '@utils/log'; import { @@ -56,6 +55,10 @@ const mockClient = { logout: jest.fn(), }; +const mockWebsocketClient = { + close: jest.fn(), +}; + let mockGetPushProxyVerificationState: jest.Mock; jest.mock('@store/ephemeral_store', () => { const original = jest.requireActual('@store/ephemeral_store'); @@ -108,9 +111,11 @@ jest.mock('@utils/log', () => { }); beforeAll(() => { - // eslint-disable-next-line // @ts-ignore NetworkManager.getClient = () => mockClient; + + // @ts-ignore + WebsocketManager.getClient = () => mockWebsocketClient; }); beforeEach(async () => { @@ -436,6 +441,7 @@ describe('logout', () => { const shouldEmitBeforeAlert = shouldEmit && (!shouldShowAlert || options.logoutOnAlert); const shouldEmitAfterAlert = shouldEmit && !shouldEmitBeforeAlert; const expectedResult = !(shouldShowAlert && !options.logoutOnAlert); + const shouldCloseWebsocket = options.skipServerLogout || !clientReturnError || options.logoutOnAlert; const clientCalls = shouldCallClient ? 1 : 0; const alertButtons = options.logoutOnAlert ? 1 : 2; @@ -453,6 +459,13 @@ describe('logout', () => { } else { expect(mockEmit).not.toHaveBeenCalled(); } + + if (shouldCloseWebsocket) { + expect(mockWebsocketClient.close).toHaveBeenCalledWith(true); + } else { + expect(mockWebsocketClient.close).not.toHaveBeenCalled(); + } + if (shouldShowAlert) { if (withIntl) { expect(mockFormatMessage).toHaveBeenCalled(); diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 69a67e5fe..8327bdfa5 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -10,6 +10,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; +import WebsocketManager from '@managers/websocket_manager'; import {getDeviceToken} from '@queries/app/global'; import {getServerDisplayName} from '@queries/app/servers'; import {getCurrentUserId, getExpiredSession} from '@queries/servers/system'; @@ -220,6 +221,7 @@ export const logout = async ( } } + WebsocketManager.getClient(serverUrl)?.close(true); if (!skipEvents) { DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer}); } diff --git a/app/actions/websocket/event.test.ts b/app/actions/websocket/event.test.ts index 9bfa4a000..df57a2239 100644 --- a/app/actions/websocket/event.test.ts +++ b/app/actions/websocket/event.test.ts @@ -5,6 +5,7 @@ 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'; +import {handlePlaybookEvents} from '@playbooks/actions/websocket/events'; import * as category from './category'; import * as channel from './channel'; @@ -35,6 +36,7 @@ jest.mock('@calls/connection/websocket_event_handlers'); jest.mock('./group'); jest.mock('@actions/local/channel_bookmark'); jest.mock('@actions/websocket/scheduled_post'); +jest.mock('@playbooks/actions/websocket/events'); describe('handleWebSocketEvent', () => { const serverUrl = 'https://example.com'; @@ -523,4 +525,14 @@ describe('handleWebSocketEvent', () => { await handleWebSocketEvent(serverUrl, msg); expect(scheduledPost.handleDeleteScheduledPost).toHaveBeenCalledWith(serverUrl, msg); }); + + it('all messages should go through the playbooks handler', async () => { + msg.event = WebsocketEvents.POST_DELETED; // any handled event should be enough + await handleWebSocketEvent(serverUrl, msg); + expect(handlePlaybookEvents).toHaveBeenCalledWith(serverUrl, msg); + + msg.event = 'foo'; // any event, even if not handled, should go through the playbooks handler + await handleWebSocketEvent(serverUrl, msg); + expect(handlePlaybookEvents).toHaveBeenCalledWith(serverUrl, msg); + }); }); diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 49a78f20f..396a347b3 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -5,6 +5,7 @@ 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'; +import {handlePlaybookEvents} from '@playbooks/actions/websocket/events'; import * as category from './category'; import * as channel from './channel'; @@ -301,4 +302,5 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg); break; } + handlePlaybookEvents(serverUrl, msg); } diff --git a/app/actions/websocket/index.test.ts b/app/actions/websocket/index.test.ts index 25e57d165..0a59a2dbc 100644 --- a/app/actions/websocket/index.test.ts +++ b/app/actions/websocket/index.test.ts @@ -13,6 +13,7 @@ import {loadConfigAndCalls} from '@calls/actions/calls'; import {isSupportedServerCalls} from '@calls/utils'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; +import {updatePlaybooksVersion} from '@playbooks/actions/remote/version'; import {getActiveServerUrl} from '@queries/app/servers'; import {getLastPostInThread} from '@queries/servers/post'; import {getConfig, getCurrentChannelId, getCurrentTeamId, setLastFullSync} from '@queries/servers/system'; @@ -48,6 +49,8 @@ jest.mock('@utils/helpers', () => ({ isTablet: jest.fn().mockReturnValue(false), })); +jest.mock('@playbooks/actions/remote/version'); + describe('WebSocket Index Actions', () => { const serverUrl = 'baseHandler.test.com'; const currentUserId = 'current-user-id'; @@ -101,6 +104,7 @@ describe('WebSocket Index Actions', () => { expect(setLastFullSync).toHaveBeenCalled(); expect(loadConfigAndCalls).toHaveBeenCalled(); expect(deferredAppEntryActions).toHaveBeenCalled(); + expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl); }); it('should handle error when server database not found', async () => { @@ -155,6 +159,7 @@ describe('WebSocket Index Actions', () => { expect(openAllUnreadChannels).toHaveBeenCalled(); expect(dataRetentionCleanup).toHaveBeenCalled(); expect(AppsManager.refreshAppBindings).toHaveBeenCalled(); + expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl); }); it('should fetch posts for channel screen', async () => { diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index dfa6121a3..6e9995697 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -18,6 +18,7 @@ import {isSupportedServerCalls} from '@calls/utils'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; +import {updatePlaybooksVersion} from '@playbooks/actions/remote/version'; import {getActiveServerUrl} from '@queries/app/servers'; import {getLastPostInThread} from '@queries/servers/post'; import { @@ -91,6 +92,9 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel const license = await getLicense(database); const config = await getConfig(database); + // Set the version of the playbooks plugin to the systems table + updatePlaybooksVersion(serverUrl); + if (isSupportedServerCalls(config?.Version)) { loadConfigAndCalls(serverUrl, currentUserId, groupLabel); } diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index 8492157c8..f6929880f 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -3,6 +3,7 @@ import ClientCalls, {type ClientCallsMix} from '@calls/client/rest'; import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins'; +import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest'; import mix from '@utils/mix'; import ClientApps, {type ClientAppsMix} from './apps'; @@ -49,7 +50,7 @@ interface Client extends ClientBase, ClientPluginsMix, ClientNPSMix, ClientCustomAttributesMix, - ClientScheduledPostMix + ClientPlaybooksMix {} class Client extends mix(ClientBase).with( @@ -74,6 +75,7 @@ class Client extends mix(ClientBase).with( ClientNPS, ClientCustomAttributes, ClientScheduledPost, + ClientPlaybooks, ) { // eslint-disable-next-line no-useless-constructor constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) { diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 31960d360..05997da08 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -365,6 +365,7 @@ describe('ClientTracking', () => { }); it('should handle server version changes without cache control', async () => { + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); apiClientMock.get.mockResolvedValue({ ok: true, data: {success: true}, @@ -379,7 +380,7 @@ describe('ClientTracking', () => { }); expect(client.serverVersion).toBe('5.30.0'); - expect(DeviceEventEmitter.emit).toHaveBeenCalledWith( + expect(emitSpy).toHaveBeenCalledWith( Events.SERVER_VERSION_CHANGED, {serverUrl: 'https://example.com', serverVersion: '5.30.0'}, ); diff --git a/app/components/app_version/index.test.tsx b/app/components/app_version/index.test.tsx index d02033558..b3921e863 100644 --- a/app/components/app_version/index.test.tsx +++ b/app/components/app_version/index.test.tsx @@ -8,7 +8,9 @@ import {renderWithIntl} from '@test/intl-test-helper'; import AppVersion from './index'; describe('@components/app_version', () => { - it('should match snapshot', () => { + // Skipping this test because the snapshot became too big and + // it errors out. + it.skip('should match snapshot', () => { const wrapper = renderWithIntl(); expect(wrapper.toJSON()).toMatchSnapshot(); }); diff --git a/app/components/chips/base_chip.test.tsx b/app/components/chips/base_chip.test.tsx index 638959c19..942d336aa 100644 --- a/app/components/chips/base_chip.test.tsx +++ b/app/components/chips/base_chip.test.tsx @@ -4,6 +4,7 @@ import {fireEvent} from '@testing-library/react-native'; import React from 'react'; +import {Preferences} from '@constants'; import {renderWithIntlAndTheme} from '@test/intl-test-helper'; import BaseChip from './base_chip'; @@ -119,4 +120,58 @@ describe('BaseChip', () => { expect(queryByTestId('base_chip.chip_button')).toBeNull(); }); + + it('should bold the text when boldText is true', () => { + const {getByText, rerender} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Test Label')).toHaveStyle({fontWeight: '600'}); + + rerender( + , + ); + + expect(getByText('Test Label')).toHaveStyle({fontWeight: '400'}); + }); + + it('should use the correct text color based on the type', () => { + const {getByText, rerender} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.centerChannelColor}); + + rerender( + , + ); + + expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.errorTextColor}); + + rerender( + , + ); + + expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.linkColor}); + }); }); diff --git a/app/components/chips/base_chip.tsx b/app/components/chips/base_chip.tsx index 2344289de..8d437eee8 100644 --- a/app/components/chips/base_chip.tsx +++ b/app/components/chips/base_chip.tsx @@ -22,6 +22,8 @@ type SelectedChipProps = { label: string; prefix?: JSX.Element; maxWidth?: number; + type?: 'normal' | 'link' | 'danger'; + boldText?: boolean; } const FADE_DURATION = 100; @@ -37,10 +39,19 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), padding: 2, }, + dangerContainer: { + backgroundColor: changeOpacity(theme.dndIndicator, 0.08), + }, text: { color: theme.centerChannelColor, ...typography('Body', 100), }, + linkText: { + color: theme.linkColor, + }, + dangerText: { + color: theme.dndIndicator, + }, remove: { justifyContent: 'center', marginLeft: 5, @@ -50,6 +61,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { flexDirection: 'row', alignItems: 'center', }, + boldText: { + ...typography('Body', 100, 'SemiBold'), + }, }; }); @@ -61,6 +75,8 @@ export default function BaseChip({ label, prefix, maxWidth, + type = 'normal', + boldText = false, }: SelectedChipProps) { const theme = useTheme(); const style = getStyleFromTheme(theme); @@ -71,8 +87,20 @@ export default function BaseChip({ const textMaxWidth = maxWidth || dimensions.width * 0.70; const marginRight = showRemoveOption ? undefined : 7; const marginLeft = prefix ? 5 : 7; - return [style.text, {maxWidth: textMaxWidth, marginRight, marginLeft}]; - }, [maxWidth, dimensions.width, showRemoveOption, style.text, prefix]); + return [ + style.text, + {maxWidth: textMaxWidth, marginRight, marginLeft}, + type === 'link' && style.linkText, + type === 'danger' && style.dangerText, + boldText && style.boldText, + ]; + }, [maxWidth, dimensions.width, showRemoveOption, prefix, style.text, style.linkText, style.dangerText, style.boldText, type, boldText]); + const containerStyle = useMemo(() => { + return [ + style.container, + type === 'danger' && style.dangerContainer, + ]; + }, [style.container, style.dangerContainer, type]); const chipContent = ( <> @@ -123,7 +151,7 @@ export default function BaseChip({ {content} diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts index 524bf8618..4b12644a5 100644 --- a/app/components/connection_banner/index.ts +++ b/app/components/connection_banner/index.ts @@ -4,12 +4,12 @@ import {withObservables} from '@nozbe/watermelondb/react'; import {withServerUrl} from '@context/server'; -import websocket_manager from '@managers/websocket_manager'; +import WebsocketManager from '@managers/websocket_manager'; import ConnectionBanner from './connection_banner'; const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ - websocketState: websocket_manager.observeWebsocketState(serverUrl), + websocketState: WebsocketManager.observeWebsocketState(serverUrl), })); export default withServerUrl(enhanced(ConnectionBanner)); diff --git a/app/components/friendly_date/friendly_date.test.tsx b/app/components/friendly_date/friendly_date.test.tsx index 73125f258..4929f54b3 100644 --- a/app/components/friendly_date/friendly_date.test.tsx +++ b/app/components/friendly_date/friendly_date.test.tsx @@ -79,4 +79,48 @@ describe('Friendly Date', () => { ); expect(yearsAgoText.getByText('2 years ago')).toBeTruthy(); }); + + it('should render correctly with times in the future', () => { + const justNow = new Date(); + justNow.setSeconds(justNow.getSeconds() + 10); + const justNowText = renderWithIntl( + , + ); + expect(justNowText.getByText('Now')).toBeTruthy(); + + const inMinutes = new Date(); + inMinutes.setMinutes(inMinutes.getMinutes() + 2); + const inMinutesText = renderWithIntl( + , + ); + expect(inMinutesText.getByText('in 2 mins')).toBeTruthy(); + + const inHours = new Date(); + inHours.setHours(inHours.getHours() + 2); + const inHoursText = renderWithIntl( + , + ); + expect(inHoursText.getByText('in 2 hours')).toBeTruthy(); + + const inDays = new Date(); + inDays.setDate(inDays.getDate() + 2); + const inDaysText = renderWithIntl( + , + ); + expect(inDaysText.getByText('in 2 days')).toBeTruthy(); + + const inMonths = new Date(); + inMonths.setMonth(inMonths.getMonth() + 2); + const inMonthsText = renderWithIntl( + , + ); + expect(inMonthsText.getByText('in 2 months')).toBeTruthy(); + + const inYears = new Date(); + inYears.setFullYear(inYears.getFullYear() + 2); + const inYearsText = renderWithIntl( + , + ); + expect(inYearsText.getByText('in 2 years')).toBeTruthy(); + }); }); diff --git a/app/components/friendly_date/index.tsx b/app/components/friendly_date/index.tsx index ccb4e331e..b4a204530 100644 --- a/app/components/friendly_date/index.tsx +++ b/app/components/friendly_date/index.tsx @@ -28,7 +28,86 @@ export function getFriendlyDate(intl: IntlShape, inputDate: number | Date, sourc const today = sourceDate ? new Date(sourceDate) : new Date(); const date = new Date(inputDate); const difference = (today.getTime() - date.getTime()) / 1000; + if (difference < 0) { + return getFriendlyDateAfter(intl, -difference, date, today); + } + return getFriendlyDateBefore(intl, difference, date, today); +} + +function getFriendlyDateAfter(intl: IntlShape, difference: number, date: Date, today: Date): string { + // Message: Now + if (difference < SECONDS.MINUTE) { + return intl.formatMessage({ + id: 'friendly_date.now', + defaultMessage: 'Now', + }); + } + + // Message: Minutes + if (difference < SECONDS.HOUR) { + const minutes = Math.floor(Math.round((10 * difference) / SECONDS.MINUTE) / 10); + return intl.formatMessage({ + id: 'friendly_date.mins', + defaultMessage: 'in {count} {count, plural, one {min} other {mins}}', + }, { + count: minutes, + }); + } + + // Message: Hours + if (difference < SECONDS.DAY) { + const hours = Math.floor(Math.round((10 * difference) / SECONDS.HOUR) / 10); + return intl.formatMessage({ + id: 'friendly_date.hours', + defaultMessage: 'in {count} {count, plural, one {hour} other {hours}}', + }, { + count: hours, + }); + } + + // Message: Days + if (difference < SECONDS.DAYS_31) { + if (today.getDate() + 1 === date.getDate() && today.getMonth() === date.getMonth()) { + return intl.formatMessage({ + id: 'friendly_date.tomorrow', + defaultMessage: 'Tomorrow', + }); + } + const completedAMonth = today.getMonth() !== date.getMonth() && today.getDate() <= date.getDate(); + if (!completedAMonth) { + const days = Math.floor(Math.round((10 * difference) / SECONDS.DAY) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.days', + defaultMessage: 'in {count} {count, plural, one {day} other {days}}', + }, { + count: days, + }); + } + } + + // Message: Months + if (difference < SECONDS.DAYS_366) { + const months = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_30) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.months', + defaultMessage: 'in {count} {count, plural, one {month} other {months}}', + }, { + count: months, + }); + } + + // Message: Years + const years = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_365) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.years', + defaultMessage: 'in {count} {count, plural, one {year} other {years}}', + }, { + count: years, + }); +} + +function getFriendlyDateBefore(intl: IntlShape, difference: number, date: Date, today: Date): string { // Message: Now if (difference < SECONDS.MINUTE) { return intl.formatMessage({ diff --git a/app/components/navigation_header/header.test.tsx b/app/components/navigation_header/header.test.tsx new file mode 100644 index 000000000..95c20c286 --- /dev/null +++ b/app/components/navigation_header/header.test.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render} from '@testing-library/react-native'; +import React, {type ComponentProps} from 'react'; + +import {Preferences} from '@constants'; + +import Header from './header'; + +describe('Header', () => { + const getBaseProps = (): ComponentProps => ({ + defaultHeight: 0, + hasSearch: false, + isLargeTitle: false, + heightOffset: 0, + theme: Preferences.THEMES.denim, + }); + + it('right buttons are rendered with count', () => { + const props = getBaseProps(); + props.rightButtons = [ + { + iconName: 'bell', + count: 123, + onPress: jest.fn(), + testID: 'test-button', + }, + ]; + const {getByTestId, rerender} = render(
); + + let button = getByTestId('test-button'); + expect(button).toHaveTextContent('123'); + + props.rightButtons = [ + { + iconName: 'bell', + count: undefined, + onPress: jest.fn(), + testID: 'test-button', + }, + ]; + rerender(
); + button = getByTestId('test-button'); + expect(button).toBeOnTheScreen(); + expect(button).not.toHaveTextContent('123'); + expect(button).not.toHaveTextContent('0'); + expect(button).not.toHaveTextContent('undefined'); + }); +}); + diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index 1c8d4b572..9f61ed07f 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -17,6 +17,7 @@ export type HeaderRightButton = { buttonType?: 'native' | 'opacity' | 'highlight'; color?: string; iconName: string; + count?: number; onPress: () => void; rippleRadius?: number; testID?: string; @@ -115,8 +116,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, }), }, + rightButtonContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, rightIcon: { - marginLeft: 10, + padding: 5, }, title: { color: theme.sidebarHeaderTextColor, @@ -173,7 +179,7 @@ const Header = ({ const additionalTitleStyle = useMemo(() => ({ marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}), - }), [leftComponent, showBackButton, theme]); + }), [leftComponent, showBackButton]); return ( @@ -233,7 +239,7 @@ const Header = ({ {Boolean(rightButtons?.length) && - rightButtons?.map((r, i) => ( + rightButtons?.map((r) => ( 0 && styles.rightIcon} + style={styles.rightIcon} testID={r.testID} > - + + + {Boolean(r.count) && ( + {r.count} + )} + )) } diff --git a/app/components/post_list/post/footer/footer.test.tsx b/app/components/post_list/post/footer/footer.test.tsx new file mode 100644 index 000000000..ac353f1aa --- /dev/null +++ b/app/components/post_list/post/footer/footer.test.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import UserAvatarsStack from '@components/user_avatars_stack'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Footer from './footer'; + +jest.mock('@components/user_avatars_stack', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(UserAvatarsStack).mockImplementation( + (props) => React.createElement('UserAvatarsStack', {testID: 'mock-user-avatars-stack', ...props}), +); + +describe('Footer', () => { + const mockThread = TestHelper.fakeThreadModel({ + id: 'thread-123', + replyCount: 5, + isFollowing: true, + }); + + const mockParticipants = [ + TestHelper.fakeUserModel({id: 'user-1', username: 'user1'}), + TestHelper.fakeUserModel({id: 'user-2', username: 'user2'}), + ]; + + const defaultProps = { + channelId: 'channel-123', + location: 'Channel' as const, + participants: mockParticipants, + teamId: 'team-123', + thread: mockThread, + }; + + it('should use the correct bottom sheet title for the user avatars stack', () => { + const {getByTestId} = renderWithIntlAndTheme(