From f6341a17baa0b7133a5622a0a208dd22c978fa65 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 16 Apr 2026 17:34:31 -0400 Subject: [PATCH 1/3] MM-68247 Move user agent utilities into shared package and clean it up (#36033) * Start moving user agent utils into shared package * Remove inobounce and stop exporting isIosSafari Based on some quick testing on my phone, inobounce is no longer needed. Both local and Community: 1. Let you overscroll on the landing and login pages 2. Don't overscroll in a channel, a thread, or the LHS while fully zoomed out 3. Do let you overscroll when zoomed in That also lets me reduce the size of the interface for utils/user_agent. * Remove unneeded exports and unused functions * Remove outdated workarounds from FileUpload component These were only needed to support a 10 year old version of iOS Chrome and the classic app. * Remove useOrientationHandler This was added in https://github.com/mattermost/mattermost-webapp/pull/2504, but I don't think the extra complexity is worth keeping it around when we mostly support mobile view for desktop accessibility reasons. * Replace isIosWeb/isAndroidWeb with isIos/isAndroid These were previously needed to differentiate between the mobile web app and the classic app. * Replace isMobileApp with isMobile Similar to the last commit, we used to need to differentiate between the mobile web and the classic app. For most places, I just replaced isMobileApp with isMobile, but I removed the check in ProductMenuList because we want to show that link on mobile web. * Move isInternetExplorer and isEdge out of the shared package Those should be removed, so I don't want to include them in the shared package at all. I also renamed isChromiumEdge to just isEdge since that should be its name once the old ones are removed. * Change how functions are re-exported to fix tests * Update web app code to use shared user agent utils directly * Removed useless mock * Fix how tests mock utils/user_agent now that it's fully moved * Actually export user_agent utils from shared package --- NOTICE.txt | 24 --- webapp/channels/package.json | 1 - webapp/channels/src/actions/command.test.js | 15 +- webapp/channels/src/actions/command.ts | 2 +- .../src/actions/notification_actions.tsx | 4 +- .../channels/src/actions/post_actions.test.ts | 3 +- .../about_build_modal/about_build_modal.tsx | 2 +- .../advanced_text_editor.tsx | 2 - .../advanced_text_editor/edit_post_footer.tsx | 4 +- .../advanced_text_editor/use_key_handler.tsx | 2 +- .../use_orientation_handler.tsx | 63 ------ .../use_textbox_focus.tsx | 3 +- .../notification_permission_bar/index.tsx | 3 +- .../channel_popout/channel_popout.tsx | 2 +- .../use_desktop_notification_permission.ts | 3 +- .../delete_post_modal/delete_post_modal.tsx | 2 +- .../src/components/desktop_auth_token.tsx | 3 +- .../edit_channel_header_modal.tsx | 2 +- .../edit_scheduled_post/edit_post_footer.tsx | 4 +- .../__snapshots__/file_upload.test.tsx.snap | 1 - .../components/file_upload/file_upload.tsx | 21 +- .../left_controls/left_controls.tsx | 2 +- .../product_menu_list/product_menu_list.tsx | 3 +- .../initial_loading_screen_class.ts | 3 +- .../keyboard_shortcuts_modal.tsx | 4 +- .../keyboard_shortcuts_sequence.tsx | 3 +- .../linking_landing_page.tsx | 9 +- .../src/components/logged_in/logged_in.tsx | 2 +- .../src/components/login/login.test.tsx | 5 +- .../channels/src/components/login/login.tsx | 2 +- .../member_list_channel.tsx | 2 +- .../member_list_team/member_list_team.tsx | 2 +- .../src/components/new_search/new_search.tsx | 3 +- .../src/components/popout_button.test.tsx | 2 +- .../post_view/post_time/post_time.test.tsx | 6 +- .../post_view/post_time/post_time.tsx | 3 +- .../product_notices.test.tsx | 18 +- .../product_notices_modal.tsx | 3 +- .../quick_switch_modal/quick_switch_modal.tsx | 2 +- webapp/channels/src/components/root/root.tsx | 7 +- .../channels/src/components/search/search.tsx | 3 +- .../search_shortcut/search_shortcut.test.tsx | 26 ++- .../search_shortcut/search_shortcut.tsx | 4 +- .../components/searchable_channel_list.tsx | 2 +- .../components/select_team/select_team.tsx | 4 +- .../channel_navigator/channel_navigator.tsx | 3 +- .../sidebar_channel_link.test.tsx | 8 +- .../sidebar_right/sidebar_right.tsx | 2 +- .../channels/src/components/signup/signup.tsx | 2 +- .../app_command_parser_dependencies.ts | 2 +- .../command_provider.test.tsx | 2 +- .../command_provider/command_provider.tsx | 2 +- .../suggestion_box/suggestion_box.jsx | 3 +- .../suggestion_box/suggestion_box.test.jsx | 4 +- .../team_controller/team_controller.tsx | 12 -- .../thread_popout/thread_popout.tsx | 2 +- .../unreads_status_handler.test.tsx | 6 +- .../unreads_status_handler.tsx | 2 +- .../advanced/user_settings_advanced.test.tsx | 14 +- .../advanced/user_settings_advanced.tsx | 2 +- .../index.test.tsx | 11 +- .../index.tsx | 3 +- .../user_access_token_section.tsx | 2 +- .../components/with_tooltip/index.test.tsx | 2 +- .../with_tooltip/tooltip_shortcut.test.tsx | 5 +- .../with_tooltip/tooltip_shortcut.tsx | 3 +- .../src/plugins/shared_dependencies.ts | 5 +- .../src/types/external/inobounce.d.ts | 4 - webapp/channels/src/utils/a11y_controller.ts | 3 +- webapp/channels/src/utils/browser_history.tsx | 3 +- webapp/channels/src/utils/desktop_api.ts | 3 +- webapp/channels/src/utils/file_utils.test.ts | 18 +- webapp/channels/src/utils/file_utils.tsx | 6 +- webapp/channels/src/utils/keyboard.ts | 3 +- .../src/utils/notification_sounds.tsx | 4 +- webapp/channels/src/utils/notifications.ts | 4 +- .../platform_detection.ts | 4 +- .../channels/src/utils/popouts/focus.test.ts | 5 +- .../src/utils/popouts/popout_windows.test.ts | 5 +- .../src/utils/popouts/popout_windows.ts | 2 +- .../src/utils/popouts/use_browser_popout.ts | 2 +- .../src/utils/popouts/use_popout_title.ts | 3 +- webapp/channels/src/utils/post_utils.ts | 2 +- webapp/channels/src/utils/user_agent.tsx | 180 +----------------- webapp/channels/src/utils/utils.tsx | 11 +- webapp/package-lock.json | 5 - .../shared/src/utils/user_agent/index.ts | 120 ++++++++++++ 87 files changed, 324 insertions(+), 446 deletions(-) delete mode 100644 webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx delete mode 100644 webapp/channels/src/types/external/inobounce.d.ts create mode 100644 webapp/platform/shared/src/utils/user_agent/index.ts diff --git a/NOTICE.txt b/NOTICE.txt index c6c27b42ac3..b8d101a2d22 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -6875,30 +6875,6 @@ Fake data generator for Go (Golang) ---- - -## inobounce - -This product contains 'inobounce' by Larry Davis. - -Stop your iOS webapp from bouncing around when scrolling - -* HOMEPAGE: - * https://github.com/lazd/iNoBounce - -* LICENSE: BSD-2-Clause - -Copyright (c) 2013, Lawrence Davis -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --- ## ipaddr.js diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 5356c6f71f3..40863158526 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -43,7 +43,6 @@ "history": "4.10.1", "hoist-non-react-statics": "3.3.2", "html-to-react": "1.6.0", - "inobounce": "0.2.1", "ipaddr.js": "2.1.0", "katex": "0.16.21", "localforage": "1.10.0", diff --git a/webapp/channels/src/actions/command.test.js b/webapp/channels/src/actions/command.test.js index 17111484722..0348d6b4309 100644 --- a/webapp/channels/src/actions/command.test.js +++ b/webapp/channels/src/actions/command.test.js @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {Client4} from 'mattermost-redux/client'; import {Permissions} from 'mattermost-redux/constants'; import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; @@ -13,7 +15,6 @@ import UserSettingsModal from 'components/user_settings/modal'; import mockStore from 'tests/test_store'; import {ActionTypes, Constants, ModalIdentifiers} from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import {executeCommand} from './command'; @@ -132,7 +133,11 @@ const initialState = { }, }; -jest.mock('utils/user_agent'); +const isMobileMock = jest.mocked(UserAgent.isMobile); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isDesktopApp: jest.fn(), + isMobile: jest.fn(), +})); jest.mock('actions/global_actions'); describe('executeCommand', () => { @@ -159,10 +164,8 @@ describe('executeCommand', () => { }); describe('shortcuts', () => { - UserAgent.isMobile = jest.fn(); - test('should return error in case of mobile', async () => { - UserAgent.isMobile.mockReturnValueOnce(true); + isMobileMock.mockReturnValueOnce(true); const result = await store.dispatch(executeCommand('/shortcuts', [])); @@ -174,7 +177,7 @@ describe('executeCommand', () => { }); test('should open shortcut modal in case of no mobile', async () => { - UserAgent.isMobile.mockReturnValueOnce(false); + isMobileMock.mockReturnValueOnce(false); const result = await store.dispatch(executeCommand('/shortcuts', [])); diff --git a/webapp/channels/src/actions/command.ts b/webapp/channels/src/actions/command.ts index 54a7c4b4d5d..41a1b5f746e 100644 --- a/webapp/channels/src/actions/command.ts +++ b/webapp/channels/src/actions/command.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {AppCallResponse} from '@mattermost/types/apps'; import type {CommandArgs, CommandResponse} from '@mattermost/types/integrations'; @@ -31,7 +32,6 @@ import {getHistory} from 'utils/browser_history'; import {Constants, ModalIdentifiers} from 'utils/constants'; import {getIntl} from 'utils/i18n'; import {isUrlSafe, getSiteURL} from 'utils/url'; -import * as UserAgent from 'utils/user_agent'; import {getUserIdFromChannelName} from 'utils/utils'; import type {ActionFuncAsync} from 'types/store'; diff --git a/webapp/channels/src/actions/notification_actions.tsx b/webapp/channels/src/actions/notification_actions.tsx index e1ec2252b52..f7bbb430596 100644 --- a/webapp/channels/src/actions/notification_actions.tsx +++ b/webapp/channels/src/actions/notification_actions.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isDesktopApp, isMobile} from '@mattermost/shared/utils/user_agent'; import type {Channel, ChannelMembership} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; import {isMessageAttachmentArray} from '@mattermost/types/message_attachments'; @@ -33,7 +34,6 @@ import {DesktopNotificationSounds, ding} from 'utils/notification_sounds'; import {showNotification} from 'utils/notifications'; import {getFocusedPopoutInfo} from 'utils/popouts/focus'; import {cjkrPattern} from 'utils/text_formatting'; -import {isDesktopApp, isMobileApp} from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {ActionFuncAsync, GlobalState} from 'types/store'; @@ -166,7 +166,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp const result = dispatch(notifyMe(argsAfterHooks.title, argsAfterHooks.body, channel.id, teamId, argsAfterHooks.silent, argsAfterHooks.soundName, argsAfterHooks.url)); //Don't add extra sounds on native desktop clients - if (desktopSoundEnabled && !isDesktopApp() && !isMobileApp()) { + if (desktopSoundEnabled && !isDesktopApp() && !isMobile()) { ding(soundName); } diff --git a/webapp/channels/src/actions/post_actions.test.ts b/webapp/channels/src/actions/post_actions.test.ts index 01c1a0306a4..2318941389b 100644 --- a/webapp/channels/src/actions/post_actions.test.ts +++ b/webapp/channels/src/actions/post_actions.test.ts @@ -48,8 +48,7 @@ jest.mock('actions/storage', () => { }; }); -jest.mock('utils/user_agent', () => ({ - isIosClassic: jest.fn().mockReturnValueOnce(true).mockReturnValue(false), +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn().mockReturnValue(false), })); diff --git a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx index bcdf4440379..88cf5d9eabb 100644 --- a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx +++ b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx @@ -5,6 +5,7 @@ import React, {useState, useEffect} from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage, useIntl} from 'react-intl'; +import {getDesktopVersion, isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {ClientConfig, ClientLicense} from '@mattermost/types/config'; import {Client4} from 'mattermost-redux/client'; @@ -16,7 +17,6 @@ import MattermostLogo from 'components/widgets/icons/mattermost_logo'; import {AboutLinks} from 'utils/constants'; import {getSkuDisplayName} from 'utils/subscription'; -import {getDesktopVersion, isDesktopApp} from 'utils/user_agent'; import AboutBuildModalCloud from './about_build_modal_cloud/about_build_modal_cloud'; diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index eb4294ca544..ddb52be5bc4 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -82,7 +82,6 @@ import UnifiedLabelsWrapper from './unified_labels_wrapper'; import useBurnOnRead from './use_burn_on_read'; import useEditorEmojiPicker from './use_editor_emoji_picker'; import useKeyHandler from './use_key_handler'; -import useOrientationHandler from './use_orientation_handler'; import usePluginItems from './use_plugin_items'; import usePriority from './use_priority'; import useRewrite from './use_rewrite'; @@ -312,7 +311,6 @@ const AdvancedTextEditor = ({ }])); }, [dispatch, currentUserId, getFormattingBarPreferenceName, isFormattingBarHidden]); - useOrientationHandler(textboxRef, rootId); const pluginItems = usePluginItems(draft, textboxRef, handleDraftChange, channelId); const focusTextbox = useTextboxFocus(textboxRef, channelId, isRHS, canPost); const { diff --git a/webapp/channels/src/components/advanced_text_editor/edit_post_footer.tsx b/webapp/channels/src/components/advanced_text_editor/edit_post_footer.tsx index d05c06b2649..2fa163b1f21 100644 --- a/webapp/channels/src/components/advanced_text_editor/edit_post_footer.tsx +++ b/webapp/channels/src/components/advanced_text_editor/edit_post_footer.tsx @@ -5,11 +5,11 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; +import {isMac} from '@mattermost/shared/utils/user_agent'; + import {unsetEditingPost} from 'actions/post_actions'; import {isSendOnCtrlEnter} from 'selectors/preferences'; -import {isMac} from 'utils/user_agent'; - type Props = { onSave: () => void; onCancel?: () => void; diff --git a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx index 8122e38d867..ad6f39d6168 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx @@ -5,6 +5,7 @@ import type React from 'react'; import {useCallback, useEffect, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {SchedulingInfo} from '@mattermost/types/schedule_post'; import {getBool} from 'mattermost-redux/selectors/entities/preferences'; @@ -22,7 +23,6 @@ import * as Keyboard from 'utils/keyboard'; import {type ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; import {pasteHandler} from 'utils/paste'; import {isWithinCodeBlock, postMessageOnKeyPress} from 'utils/post_utils'; -import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx deleted file mode 100644 index 6e03127b11c..00000000000 --- a/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useCallback, useEffect, useRef} from 'react'; - -import type TextboxClass from 'components/textbox/textbox'; - -import * as UserAgent from 'utils/user_agent'; - -const useOrientationHandler = ( - textboxRef: React.RefObject, - postId: string, -) => { - const lastOrientation = useRef(''); - - const onOrientationChange = useCallback(() => { - if (!UserAgent.isIosWeb()) { - return; - } - - const LANDSCAPE_ANGLE = 90; - let orientation = 'portrait'; - if (window.orientation) { - orientation = Math.abs(window.orientation as number) === LANDSCAPE_ANGLE ? 'landscape' : 'portrait'; - } - - if (window.screen.orientation) { - orientation = window.screen.orientation.type.split('-')[0]; - } - - if ( - lastOrientation.current && - orientation !== lastOrientation.current && - (document.activeElement || {}).id === 'post_textbox' - ) { - textboxRef.current?.blur(); - } - - lastOrientation.current = orientation; - }, [textboxRef]); - - useEffect(() => { - if (!postId && UserAgent.isIosWeb()) { - onOrientationChange(); - if (window.screen.orientation && 'onchange' in window.screen.orientation) { - window.screen.orientation.addEventListener('change', onOrientationChange); - } else if ('onorientationchange' in window) { - window.addEventListener('orientationchange', onOrientationChange); - } - } - return () => { - if (!postId) { - if (window.screen.orientation && 'onchange' in window.screen.orientation) { - window.screen.orientation.removeEventListener('change', onOrientationChange); - } else if ('onorientationchange' in window) { - window.removeEventListener('orientationchange', onOrientationChange); - } - } - }; - }, []); -}; - -export default useOrientationHandler; diff --git a/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx b/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx index c790830d5ac..d88150101c2 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx @@ -5,6 +5,8 @@ import type React from 'react'; import {useCallback, useEffect, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {focusedRHS} from 'actions/views/rhs'; import {getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs'; import {getShouldFocusRHS} from 'selectors/views/rhs'; @@ -13,7 +15,6 @@ import useDidUpdate from 'components/common/hooks/useDidUpdate'; import type TextboxClass from 'components/textbox/textbox'; import {shouldFocusMainTextbox} from 'utils/post_utils'; -import * as UserAgent from 'utils/user_agent'; const useTextboxFocus = ( textboxRef: React.RefObject, diff --git a/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx b/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx index 7d3b723cc31..394d5a3d6b4 100644 --- a/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx +++ b/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx @@ -4,6 +4,8 @@ import React from 'react'; import {useSelector} from 'react-redux'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -17,7 +19,6 @@ import { NotificationPermissionNeverGranted, getNotificationPermission, } from 'utils/notifications'; -import * as UserAgent from 'utils/user_agent'; export default function NotificationPermissionBar() { const isLoggedIn = Boolean(useSelector(getCurrentUserId)); diff --git a/webapp/channels/src/components/channel_popout/channel_popout.tsx b/webapp/channels/src/components/channel_popout/channel_popout.tsx index 5621743f999..36477b7f1d5 100644 --- a/webapp/channels/src/components/channel_popout/channel_popout.tsx +++ b/webapp/channels/src/components/channel_popout/channel_popout.tsx @@ -6,6 +6,7 @@ import React, {useEffect} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {useParams} from 'react-router-dom'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {ChannelType} from '@mattermost/types/channels'; import {fetchMyCategories} from 'mattermost-redux/actions/channel_categories'; @@ -25,7 +26,6 @@ import UnreadsStatusHandler from 'components/unreads_status_handler'; import Constants from 'utils/constants'; import usePopoutFocus from 'utils/popouts/use_popout_focus'; import usePopoutTitle from 'utils/popouts/use_popout_title'; -import {isDesktopApp} from 'utils/user_agent'; import './channel_popout.scss'; diff --git a/webapp/channels/src/components/common/hooks/use_desktop_notification_permission.ts b/webapp/channels/src/components/common/hooks/use_desktop_notification_permission.ts index 68c707079b4..829b979005b 100644 --- a/webapp/channels/src/components/common/hooks/use_desktop_notification_permission.ts +++ b/webapp/channels/src/components/common/hooks/use_desktop_notification_permission.ts @@ -3,9 +3,10 @@ import {useCallback, useEffect, useState} from 'react'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import type {NotificationPermissionNeverGranted} from 'utils/notifications'; import {isNotificationAPISupported} from 'utils/notifications'; -import {isDesktopApp} from 'utils/user_agent'; export type DesktopNotificationPermission = Exclude | undefined; diff --git a/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx b/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx index 77e4b0ce645..9dbcee3d311 100644 --- a/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx +++ b/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx @@ -6,6 +6,7 @@ import {Modal} from 'react-bootstrap'; import {FormattedMessage, useIntl} from 'react-intl'; import {matchPath} from 'react-router-dom'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {Post} from '@mattermost/types/posts'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -13,7 +14,6 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import SectionNotice from 'components/section_notice'; import {getHistory} from 'utils/browser_history'; -import * as UserAgent from 'utils/user_agent'; const urlFormatForDMGMPermalink = '/:teamName/messages/:username/:postid'; const urlFormatForChannelPermalink = '/:teamName/channels/:channelname/:postid'; diff --git a/webapp/channels/src/components/desktop_auth_token.tsx b/webapp/channels/src/components/desktop_auth_token.tsx index e5a3bbfcb08..35b71f2e1f0 100644 --- a/webapp/channels/src/components/desktop_auth_token.tsx +++ b/webapp/channels/src/components/desktop_auth_token.tsx @@ -8,13 +8,14 @@ import {FormattedMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import {useHistory, useLocation} from 'react-router-dom'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {loginWithDesktopToken} from 'actions/views/login'; import DesktopApp from 'utils/desktop_api'; -import {isDesktopApp} from 'utils/user_agent'; import './desktop_auth_token.scss'; diff --git a/webapp/channels/src/components/edit_channel_header_modal/edit_channel_header_modal.tsx b/webapp/channels/src/components/edit_channel_header_modal/edit_channel_header_modal.tsx index 10ff7033770..c1890420ccd 100644 --- a/webapp/channels/src/components/edit_channel_header_modal/edit_channel_header_modal.tsx +++ b/webapp/channels/src/components/edit_channel_header_modal/edit_channel_header_modal.tsx @@ -6,6 +6,7 @@ import {Modal} from 'react-bootstrap'; import type {WrappedComponentProps} from 'react-intl'; import {FormattedMessage, injectIntl} from 'react-intl'; +import {isMobile} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; @@ -15,7 +16,6 @@ import type TextboxClass from 'components/textbox/textbox'; import Constants from 'utils/constants'; import {isKeyPressed} from 'utils/keyboard'; -import {isMobile} from 'utils/user_agent'; import {insertLineBreakFromKeyEvent, isUnhandledLineBreakKeyCombo} from 'utils/utils'; import type {PropsFromRedux} from './index'; diff --git a/webapp/channels/src/components/edit_scheduled_post/edit_post_footer.tsx b/webapp/channels/src/components/edit_scheduled_post/edit_post_footer.tsx index cd04ddd8fa5..956d879d31a 100644 --- a/webapp/channels/src/components/edit_scheduled_post/edit_post_footer.tsx +++ b/webapp/channels/src/components/edit_scheduled_post/edit_post_footer.tsx @@ -5,11 +5,11 @@ import React, {memo} from 'react'; import {FormattedMessage} from 'react-intl'; import {useSelector} from 'react-redux'; +import {isMac} from '@mattermost/shared/utils/user_agent'; + import {Preferences} from 'mattermost-redux/constants'; import {getBool} from 'mattermost-redux/selectors/entities/preferences'; -import {isMac} from 'utils/user_agent'; - import type {GlobalState} from 'types/store'; type Props = { diff --git a/webapp/channels/src/components/file_upload/__snapshots__/file_upload.test.tsx.snap b/webapp/channels/src/components/file_upload/__snapshots__/file_upload.test.tsx.snap index ee31aa9527b..b2942e709f9 100644 --- a/webapp/channels/src/components/file_upload/__snapshots__/file_upload.test.tsx.snap +++ b/webapp/channels/src/components/file_upload/__snapshots__/file_upload.test.tsx.snap @@ -28,7 +28,6 @@ exports[`components/FileUpload should match snapshot 1`] = ` { render() { const {formatMessage} = this.props.intl; - let multiple = true; - if (isMobileApp()) { - // iOS WebViews don't upload videos properly in multiple mode - multiple = false; - } - - let accept = ''; - if (isIosChrome()) { - // iOS Chrome can't upload videos at all - accept = 'image/*'; - } const uploadsRemaining = Constants.MAX_UPLOAD_FILES - this.props.fileCount; @@ -660,8 +645,7 @@ export class FileUpload extends PureComponent { type='file' onChange={this.handleChange} onClick={this.handleLocalFileUploaded} - multiple={multiple} - accept={accept} + multiple={true} /> ); @@ -696,8 +680,7 @@ export class FileUpload extends PureComponent { className='file-attachment-menu-item-input' onChange={this.handleChange} onClick={this.handleLocalFileUploaded} - multiple={multiple} - accept={accept} + multiple={true} /> { } diff --git a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts index c5c52565b02..9db2a11d20b 100644 --- a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts +++ b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts @@ -1,8 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import {Measure, measureAndReport} from 'utils/performance_telemetry'; -import {isDesktopApp} from 'utils/user_agent'; const ANIMATION_CLASS_FOR_MATTERMOST_LOGO_HIDE = 'LoadingAnimation__compass-shrink'; const ANIMATION_CLASS_FOR_COMPLETE_LOADER_HIDE = 'LoadingAnimation__shrink'; diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx index f098fa4363a..432f4c270e4 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx @@ -6,6 +6,8 @@ import {Modal} from 'react-bootstrap'; import {defineMessages, useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {isCallsEnabled} from 'selectors/calls'; import KeyboardShortcutSequence, { @@ -14,8 +16,6 @@ import KeyboardShortcutSequence, { import type { KeyboardShortcutDescriptor} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; -import * as UserAgent from 'utils/user_agent'; - import './keyboard_shortcuts_modal.scss'; const modalMessages = defineMessages({ diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx index 3b80acfaa7a..ccb2f0393ec 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx @@ -4,10 +4,11 @@ import React, {memo} from 'react'; import {useIntl} from 'react-intl'; +import {isMac} from '@mattermost/shared/utils/user_agent'; + import {ShortcutKeyVariant, ShortcutKey} from 'components/shortcut_key'; import {isMessageDescriptor} from 'utils/i18n'; -import {isMac} from 'utils/user_agent'; import type {KeyboardShortcutDescriptor} from './keyboard_shortcuts'; diff --git a/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx b/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx index f08b95ebdcd..015d337e994 100644 --- a/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx +++ b/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx @@ -4,6 +4,8 @@ import React, {PureComponent} from 'react'; import {FormattedMessage} from 'react-intl'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import BrowserStore from 'stores/browser_store'; import ExternalLink from 'components/external_link'; @@ -12,7 +14,6 @@ import desktopImg from 'images/deep-linking/deeplinking-desktop-img.png'; import mobileImg from 'images/deep-linking/deeplinking-mobile-img.png'; import MattermostLogoSvg from 'images/logo.svg'; import {LandingPreferenceTypes} from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; type Props = { desktopAppLink?: string; @@ -199,7 +200,7 @@ export default class LinkingLandingPage extends PureComponent { this.setPreference(LandingPreferenceTypes.MATTERMOSTAPP, true); this.setState({redirectPage: true, navigating: true}); if (UserAgent.isMobile()) { - if (UserAgent.isAndroidWeb()) { + if (UserAgent.isAndroid()) { const timeout = setTimeout(() => { window.location.replace(this.getDownloadLink()!); }, 2000); @@ -218,9 +219,9 @@ export default class LinkingLandingPage extends PureComponent { }; getDownloadLink = () => { - if (UserAgent.isIosWeb()) { + if (UserAgent.isIos()) { return this.props.iosAppLink; - } else if (UserAgent.isAndroidWeb()) { + } else if (UserAgent.isAndroid()) { return this.props.androidAppLink; } diff --git a/webapp/channels/src/components/logged_in/logged_in.tsx b/webapp/channels/src/components/logged_in/logged_in.tsx index 1b2726d0cb6..6fd0d6ebf65 100644 --- a/webapp/channels/src/components/logged_in/logged_in.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {Redirect} from 'react-router-dom'; +import {isAndroid, isIos} from '@mattermost/shared/utils/user_agent'; import type {UserProfile} from '@mattermost/types/users'; import * as GlobalActions from 'actions/global_actions'; @@ -17,7 +18,6 @@ import Constants from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {isKeyPressed} from 'utils/keyboard'; import {getBrowserTimezone} from 'utils/timezone'; -import {isAndroid, isIos} from 'utils/user_agent'; import {doesCookieContainsMMUserId} from 'utils/utils'; declare global { diff --git a/webapp/channels/src/components/login/login.test.tsx b/webapp/channels/src/components/login/login.test.tsx index fc87d76a110..09f3ac2c9f8 100644 --- a/webapp/channels/src/components/login/login.test.tsx +++ b/webapp/channels/src/components/login/login.test.tsx @@ -4,6 +4,8 @@ import {createMemoryHistory} from 'history'; import React from 'react'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import {RequestStatus} from 'mattermost-redux/constants'; import * as loginActions from 'actions/views/login'; @@ -16,7 +18,6 @@ import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import Constants, {WindowSizes} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {showNotification} from 'utils/notifications'; -import {isDesktopApp} from 'utils/user_agent'; import type {GlobalState} from 'types/store'; @@ -32,7 +33,7 @@ jest.mock('utils/desktop_api', () => ({ setSessionExpired: jest.fn(), })); -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn(), })); diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index 4466cac1ca9..9715345d1a8 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -9,6 +9,7 @@ import {useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {Link, useLocation, useHistory, Route} from 'react-router-dom'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {Team} from '@mattermost/types/teams'; import {loadMe} from 'mattermost-redux/actions/users'; @@ -50,7 +51,6 @@ import DesktopApp from 'utils/desktop_api'; import {isEmbedded} from 'utils/embed'; import {DesktopNotificationSounds} from 'utils/notification_sounds'; import {showNotification} from 'utils/notifications'; -import {isDesktopApp} from 'utils/user_agent'; import {setCSRFFromCookie} from 'utils/utils'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/components/member_list_channel/member_list_channel.tsx b/webapp/channels/src/components/member_list_channel/member_list_channel.tsx index 1daed704668..58f6e9d619f 100644 --- a/webapp/channels/src/components/member_list_channel/member_list_channel.tsx +++ b/webapp/channels/src/components/member_list_channel/member_list_channel.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {Channel, ChannelStats, ChannelMembership} from '@mattermost/types/channels'; import type {UserProfile} from '@mattermost/types/users'; @@ -13,7 +14,6 @@ import LoadingScreen from 'components/loading_screen'; import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container'; import Constants from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; const USERS_PER_PAGE = 50; diff --git a/webapp/channels/src/components/member_list_team/member_list_team.tsx b/webapp/channels/src/components/member_list_team/member_list_team.tsx index 08f413393f0..c220ffb76e9 100644 --- a/webapp/channels/src/components/member_list_team/member_list_team.tsx +++ b/webapp/channels/src/components/member_list_team/member_list_team.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {TeamMembership, TeamStats, GetTeamMembersOpts} from '@mattermost/types/teams'; import type {UserProfile} from '@mattermost/types/users'; @@ -13,7 +14,6 @@ import SearchableUserList from 'components/searchable_user_list/searchable_user_ import TeamMembersDropdown from 'components/team_members_dropdown'; import Constants from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; const USERS_PER_PAGE = 50; diff --git a/webapp/channels/src/components/new_search/new_search.tsx b/webapp/channels/src/components/new_search/new_search.tsx index b9ba616b88c..006a87c8369 100644 --- a/webapp/channels/src/components/new_search/new_search.tsx +++ b/webapp/channels/src/components/new_search/new_search.tsx @@ -17,6 +17,8 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import styled from 'styled-components'; +import {isDesktopApp, getDesktopVersion, isMacApp} from '@mattermost/shared/utils/user_agent'; + import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels'; import {getIsCrossTeamSearchEnabled} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; @@ -30,7 +32,6 @@ import {focusElement} from 'utils/a11y_utils'; import {RootHtmlPortalId, Constants} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; -import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; import SearchBox from './search_box'; diff --git a/webapp/channels/src/components/popout_button.test.tsx b/webapp/channels/src/components/popout_button.test.tsx index 007d2359330..3278c5ab5a5 100644 --- a/webapp/channels/src/components/popout_button.test.tsx +++ b/webapp/channels/src/components/popout_button.test.tsx @@ -16,7 +16,7 @@ jest.mock('utils/popouts/popout_windows', () => ({ }, })); -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn(), })); diff --git a/webapp/channels/src/components/post_view/post_time/post_time.test.tsx b/webapp/channels/src/components/post_view/post_time/post_time.test.tsx index 5af9d696a0b..9578e7cfdbc 100644 --- a/webapp/channels/src/components/post_view/post_time/post_time.test.tsx +++ b/webapp/channels/src/components/post_view/post_time/post_time.test.tsx @@ -7,7 +7,7 @@ import {renderWithContext, screen, waitFor, act, userEvent} from 'tests/react_te import PostTime from './post_time'; -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isMobile: jest.fn().mockReturnValue(false), isDesktopApp: jest.fn().mockReturnValue(false), })); @@ -157,7 +157,7 @@ describe('components/post_view/post_time/PostTime', () => { }); test('should render as div when isMobile returns true', () => { - require('utils/user_agent').isMobile.mockReturnValue(true); + require('@mattermost/shared/utils/user_agent').isMobile.mockReturnValue(true); renderWithContext(, initialState); @@ -165,7 +165,7 @@ describe('components/post_view/post_time/PostTime', () => { expect(screen.getByText('12:00 AM').closest('div')).toHaveClass('post__permalink', 'post_permalink_mobile_view'); // Reset mock - require('utils/user_agent').isMobile.mockReturnValue(false); + require('@mattermost/shared/utils/user_agent').isMobile.mockReturnValue(false); }); test('should call emitCloseRightHandSide when clicked on mobile', async () => { diff --git a/webapp/channels/src/components/post_view/post_time/post_time.tsx b/webapp/channels/src/components/post_view/post_time/post_time.tsx index 5fe89c95f53..e4df7d0dc88 100644 --- a/webapp/channels/src/components/post_view/post_time/post_time.tsx +++ b/webapp/channels/src/components/post_view/post_time/post_time.tsx @@ -5,13 +5,14 @@ import React from 'react'; import type {ComponentProps} from 'react'; import {Link} from 'react-router-dom'; +import {isMobile} from '@mattermost/shared/utils/user_agent'; + import * as GlobalActions from 'actions/global_actions'; import Timestamp from 'components/timestamp'; import WithTooltip from 'components/with_tooltip'; import {Locations} from 'utils/constants'; -import {isMobile} from 'utils/user_agent'; const getTimeFormat: ComponentProps['useTime'] = (_, {hour, minute, second}) => ({hour, minute, second}); const getDateFormat: ComponentProps['useDate'] = {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}; diff --git a/webapp/channels/src/components/product_notices_modal/product_notices.test.tsx b/webapp/channels/src/components/product_notices_modal/product_notices.test.tsx index 41d349d46f5..63cae4cd9fa 100644 --- a/webapp/channels/src/components/product_notices_modal/product_notices.test.tsx +++ b/webapp/channels/src/components/product_notices_modal/product_notices.test.tsx @@ -3,12 +3,18 @@ import React from 'react'; +import {isDesktopApp, getDesktopVersion} from '@mattermost/shared/utils/user_agent'; + import {act, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; -import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; import ProductNoticesModal from './product_notices_modal'; -jest.mock('utils/user_agent'); +const getDesktopVersionMock = jest.mocked(getDesktopVersion); +const isDesktopAppMock = jest.mocked(isDesktopApp); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + getDesktopVersion: jest.fn(), + isDesktopApp: jest.fn(), +})); describe('ProductNoticesModal', () => { const noticesData = [{ @@ -50,8 +56,8 @@ describe('ProductNoticesModal', () => { beforeEach(() => { baseProps.actions.getInProductNotices.mockClear(); baseProps.actions.updateNoticesAsViewed.mockClear(); - (isDesktopApp as any).mockReset(); - (getDesktopVersion as any).mockReset(); + isDesktopAppMock.mockReset(); + getDesktopVersionMock.mockReset(); }); test('Should match snapshot when there are no notices', async () => { @@ -236,8 +242,8 @@ describe('ProductNoticesModal', () => { }); test('Should call for getInProductNotices with desktop as client if isDesktopApp returns true', () => { - (getDesktopVersion as any).mockReturnValue('4.5.0'); - (isDesktopApp as any).mockReturnValue(true); + getDesktopVersionMock.mockReturnValue('4.5.0'); + isDesktopAppMock.mockReturnValue(true); renderWithContext(); expect(baseProps.actions.getInProductNotices).toHaveBeenCalledWith(baseProps.currentTeamId, 'desktop', '4.5.0'); }); diff --git a/webapp/channels/src/components/product_notices_modal/product_notices_modal.tsx b/webapp/channels/src/components/product_notices_modal/product_notices_modal.tsx index cba73ee3461..f47e6fca825 100644 --- a/webapp/channels/src/components/product_notices_modal/product_notices_modal.tsx +++ b/webapp/channels/src/components/product_notices_modal/product_notices_modal.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import {GenericModal} from '@mattermost/components'; +import {isDesktopApp, getDesktopVersion} from '@mattermost/shared/utils/user_agent'; import type {ProductNotices, ProductNotice} from '@mattermost/types/product_notices'; import ExternalLink from 'components/external_link'; @@ -13,8 +14,6 @@ import AdminEyeIcon from 'components/widgets/icons/admin_eye_icon'; import NextIcon from 'components/widgets/icons/fa_next_icon'; import PreviousIcon from 'components/widgets/icons/fa_previous_icon'; -import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; - import type {PropsFromRedux} from './index'; import './product_notices_modal.scss'; diff --git a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx index 4c861508c84..d2eb2084206 100644 --- a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx +++ b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx @@ -6,6 +6,7 @@ import {FormattedMessage, injectIntl} from 'react-intl'; import type {WrappedComponentProps} from 'react-intl'; import {GenericModal} from '@mattermost/components'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -22,7 +23,6 @@ import SwitchChannelProvider from 'components/suggestion/switch_channel_provider import {focusElement} from 'utils/a11y_utils'; import {getHistory} from 'utils/browser_history'; import Constants, {RHSStates} from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {RhsState} from 'types/store/rhs'; diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index f7db6c6cb3f..aba07162c4a 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -6,6 +6,8 @@ import React, {lazy} from 'react'; import {Route, Switch, Redirect} from 'react-router-dom'; import type {RouteComponentProps} from 'react-router-dom'; +import {isAndroid, isChromebook, isDesktopApp, isIos} from '@mattermost/shared/utils/user_agent'; + import {setSystemEmojis} from 'mattermost-redux/actions/emojis'; import {setUrl} from 'mattermost-redux/actions/general'; import {Client4} from 'mattermost-redux/client'; @@ -34,7 +36,6 @@ import DesktopApp from 'utils/desktop_api'; import {EmojiIndicesByAlias} from 'utils/emoji'; import {TEAM_NAME_PATH_PATTERN} from 'utils/path'; import {getSiteURL} from 'utils/url'; -import {isAndroidWeb, isChromebook, isDesktopApp, isIosWeb} from 'utils/user_agent'; import {isTextDroppableEvent} from 'utils/utils'; import LuxonController from './luxon_controller'; @@ -129,12 +130,12 @@ export default class Root extends React.PureComponent { } // Nothing to link to if we've removed the Android App download link - if (isAndroidWeb() && !this.props.androidDownloadLink) { + if (isAndroid() && !this.props.androidDownloadLink) { return; } // Nothing to link to if we've removed the iOS App download link - if (isIosWeb() && !this.props.iosDownloadLink) { + if (isIos() && !this.props.iosDownloadLink) { return; } diff --git a/webapp/channels/src/components/search/search.tsx b/webapp/channels/src/components/search/search.tsx index 5417b52792c..e19f9384215 100644 --- a/webapp/channels/src/components/search/search.tsx +++ b/webapp/channels/src/components/search/search.tsx @@ -6,6 +6,8 @@ import type {FormEvent} from 'react'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; +import {isDesktopApp, getDesktopVersion, isMacApp} from '@mattermost/shared/utils/user_agent'; + import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels'; import HeaderIconWrapper from 'components/channel_header/components/header_icon_wrapper'; @@ -24,7 +26,6 @@ import Popover from 'components/widgets/popover'; import Constants, {searchHintOptions, RHSStates, searchFilesHintOptions} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; -import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; import type {SearchType} from 'types/store/rhs'; diff --git a/webapp/channels/src/components/search_shortcut/search_shortcut.test.tsx b/webapp/channels/src/components/search_shortcut/search_shortcut.test.tsx index 18494ea8e70..6214965ac12 100644 --- a/webapp/channels/src/components/search_shortcut/search_shortcut.test.tsx +++ b/webapp/channels/src/components/search_shortcut/search_shortcut.test.tsx @@ -3,39 +3,47 @@ import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {SearchShortcut} from 'components/search_shortcut'; import {render} from 'tests/react_testing_utils'; -import * as UserAgent from 'utils/user_agent'; + +const isDesktopAppMock = jest.mocked(UserAgent.isDesktopApp); +const isMacMock = jest.mocked(UserAgent.isMac); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isDesktopApp: jest.fn(), + isMac: jest.fn(), +})); describe('components/SearchShortcut', () => { test('should match snapshot on Windows webapp', () => { - jest.spyOn(UserAgent, 'isDesktopApp').mockReturnValue(false); - jest.spyOn(UserAgent, 'isMac').mockReturnValue(false); + isDesktopAppMock.mockReturnValue(false); + isMacMock.mockReturnValue(false); const {container} = render(); expect(container).toMatchSnapshot(); }); test('should match snapshot on Mac webapp', () => { - jest.spyOn(UserAgent, 'isDesktopApp').mockReturnValue(false); - jest.spyOn(UserAgent, 'isMac').mockReturnValue(true); + isDesktopAppMock.mockReturnValue(false); + isMacMock.mockReturnValue(true); const {container} = render(); expect(container).toMatchSnapshot(); }); test('should match snapshot on Windows desktop', () => { - jest.spyOn(UserAgent, 'isDesktopApp').mockReturnValue(true); - jest.spyOn(UserAgent, 'isMac').mockReturnValue(false); + isDesktopAppMock.mockReturnValue(true); + isMacMock.mockReturnValue(false); const {container} = render(); expect(container).toMatchSnapshot(); }); test('should match snapshot on Mac desktop', () => { - jest.spyOn(UserAgent, 'isDesktopApp').mockReturnValue(true); - jest.spyOn(UserAgent, 'isMac').mockReturnValue(true); + isDesktopAppMock.mockReturnValue(true); + isMacMock.mockReturnValue(true); const {container} = render(); expect(container).toMatchSnapshot(); diff --git a/webapp/channels/src/components/search_shortcut/search_shortcut.tsx b/webapp/channels/src/components/search_shortcut/search_shortcut.tsx index dc6a682c12d..9ac6c573eca 100644 --- a/webapp/channels/src/components/search_shortcut/search_shortcut.tsx +++ b/webapp/channels/src/components/search_shortcut/search_shortcut.tsx @@ -4,9 +4,9 @@ import classNames from 'classnames'; import React from 'react'; -import {ShortcutKey, ShortcutKeyVariant} from 'components/shortcut_key'; +import {isDesktopApp, isMac} from '@mattermost/shared/utils/user_agent'; -import {isDesktopApp, isMac} from 'utils/user_agent'; +import {ShortcutKey, ShortcutKeyVariant} from 'components/shortcut_key'; import './search_shortcut.scss'; diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx index 6561337d5c9..70e46ac3268 100644 --- a/webapp/channels/src/components/searchable_channel_list.tsx +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -6,6 +6,7 @@ import React from 'react'; import {FormattedMessage, defineMessages, injectIntl, type WrappedComponentProps} from 'react-intl'; import {ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIcon, AccountOutlineIcon, GlobeCheckedIcon} from '@mattermost/compass-icons/components'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {Channel, ChannelMembership} from '@mattermost/types/channels'; import type {RelationOneToOne} from '@mattermost/types/utilities'; @@ -20,7 +21,6 @@ import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; import {getChannelIconComponent} from 'utils/channel_utils'; import Constants, {ModalIdentifiers} from 'utils/constants'; import {isKeyPressed} from 'utils/keyboard'; -import * as UserAgent from 'utils/user_agent'; import type {FilterType} from './browse_channels/browse_channels'; import {Filter} from './browse_channels/browse_channels'; diff --git a/webapp/channels/src/components/select_team/select_team.tsx b/webapp/channels/src/components/select_team/select_team.tsx index 51495aace8d..f7a6ced9392 100644 --- a/webapp/channels/src/components/select_team/select_team.tsx +++ b/webapp/channels/src/components/select_team/select_team.tsx @@ -6,6 +6,7 @@ import type {ReactNode, MouseEvent} from 'react'; import {FormattedMessage} from 'react-intl'; import {Link} from 'react-router-dom'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {CloudUsage} from '@mattermost/types/cloud'; import type {Team} from '@mattermost/types/teams'; @@ -25,7 +26,6 @@ import LogoutIcon from 'components/widgets/icons/fa_logout_icon'; import logoImage from 'images/logo.png'; import Constants from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; import SelectTeamItem from './components/select_team_item'; @@ -343,7 +343,7 @@ export default class SelectTeam extends React.PureComponent { ); let adminConsoleLink; - if (!UserAgent.isMobileApp()) { + if (!UserAgent.isMobile()) { adminConsoleLink = (
diff --git a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx index 38f977ef28e..ffd997e17a3 100644 --- a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx +++ b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx @@ -5,11 +5,12 @@ import React from 'react'; import {FormattedMessage, injectIntl} from 'react-intl'; import type {WrappedComponentProps} from 'react-intl'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import QuickSwitchModal from 'components/quick_switch_modal'; import Constants, {ModalIdentifiers} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; -import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {ModalData} from 'types/actions'; diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx index f41af9cbcb2..202d64d7b25 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {ChannelType} from '@mattermost/types/channels'; import SidebarChannelLink from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link'; @@ -10,6 +11,10 @@ import SidebarChannelLink from 'components/sidebar/sidebar_channel/sidebar_chann import {defaultIntl} from 'tests/helpers/intl-test-helper'; import {renderWithContext} from 'tests/react_testing_utils'; +const isDesktopAppMock = jest.mocked(isDesktopApp); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isDesktopApp: jest.fn(), +})); jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({ getRemoteNamesForChannel: jest.fn(), })); @@ -72,8 +77,7 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => { }); test('should match snapshot for desktop', () => { - const userAgentMock = jest.requireMock('utils/user_agent'); - userAgentMock.isDesktopApp.mockImplementation(() => false); + isDesktopAppMock.mockImplementation(() => false); const {container} = renderWithContext( , diff --git a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx index b7165928960..a52e359a0e3 100644 --- a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx +++ b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import React from 'react'; +import {isMac} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {ProductIdentifier} from '@mattermost/types/products'; import type {Team} from '@mattermost/types/teams'; @@ -24,7 +25,6 @@ import a11yController from 'utils/a11y_controller_instance'; import {focusElement, getFirstFocusableChild} from 'utils/a11y_utils'; import Constants from 'utils/constants'; import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard'; -import {isMac} from 'utils/user_agent'; import type {RhsState} from 'types/store/rhs'; diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 545316cafec..d1f51212f5d 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -8,6 +8,7 @@ import {useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {useLocation, useHistory, Route} from 'react-router-dom'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {ServerError} from '@mattermost/types/errors'; import type {UserProfile} from '@mattermost/types/users'; @@ -49,7 +50,6 @@ import PasswordInput from 'components/widgets/inputs/password_input/password_inp import {Constants, ItemStatus, ValidationErrors} from 'utils/constants'; import {isValidPassword} from 'utils/password'; -import {isDesktopApp} from 'utils/user_agent'; import {isValidUsername} from 'utils/utils'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts b/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts index ed56b64cb3d..0880c41e0f7 100644 --- a/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts +++ b/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts @@ -3,6 +3,7 @@ import type {IntlShape} from 'react-intl'; +import {isMac} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {AutocompleteSuggestion} from '@mattermost/types/integrations'; import type {UserProfile} from '@mattermost/types/users'; @@ -12,7 +13,6 @@ import reduxStore from 'stores/redux_store'; import {Constants} from 'utils/constants'; import {getIntl} from 'utils/i18n'; -import {isMac} from 'utils/user_agent'; import type {ParsedCommand} from './app_command_parser'; diff --git a/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx b/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx index e27cd0f9316..c2c5f7d624c 100644 --- a/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx +++ b/webapp/channels/src/components/suggestion/command_provider/command_provider.test.tsx @@ -3,12 +3,12 @@ import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {AutocompleteSuggestion} from '@mattermost/types/integrations'; import {Client4} from 'mattermost-redux/client'; import {renderWithContext} from 'tests/react_testing_utils'; -import * as UserAgent from 'utils/user_agent'; import CommandProvider, {commandsGroup, CommandSuggestion} from './command_provider'; diff --git a/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx b/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx index 2ad723ed99f..2368035a370 100644 --- a/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx +++ b/webapp/channels/src/components/suggestion/command_provider/command_provider.tsx @@ -6,6 +6,7 @@ import {defineMessage} from 'react-intl'; import type {Store} from 'redux'; import {DockWindowIcon} from '@mattermost/compass-icons/components'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {AutocompleteSuggestion, CommandArgs} from '@mattermost/types/integrations'; import {Client4} from 'mattermost-redux/client'; @@ -17,7 +18,6 @@ import usePrefixedIds from 'components/common/hooks/usePrefixedIds'; import {Constants} from 'utils/constants'; import {getIntl} from 'utils/i18n'; -import * as UserAgent from 'utils/user_agent'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.jsx b/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.jsx index 7d19f1ac483..2e067f2b466 100644 --- a/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.jsx +++ b/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.jsx @@ -4,11 +4,12 @@ import PropTypes from 'prop-types'; import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import QuickInput from 'components/quick_input'; import Constants, {A11yCustomEventTypes} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; -import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import { diff --git a/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.test.jsx b/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.test.jsx index 24d7b261fd2..bf928bf8b02 100644 --- a/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.test.jsx +++ b/webapp/channels/src/components/suggestion/suggestion_box/suggestion_box.test.jsx @@ -23,8 +23,8 @@ jest.mock('mattermost-redux/client', () => { }; }); -jest.mock('utils/user_agent', () => { - const original = jest.requireActual('utils/user_agent'); +jest.mock('@mattermost/shared/utils/user_agent', () => { + const original = jest.requireActual('@mattermost/shared/utils/user_agent'); return { ...original, isIos: jest.fn().mockReturnValue(true), diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index 7daca5ff98e..e6b5156e963 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import iNoBounce from 'inobounce'; import React, {lazy, memo, useEffect, useRef, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {Route, Switch, useHistory, useParams} from 'react-router-dom'; @@ -27,7 +26,6 @@ import Constants from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard'; import {TEAM_NAME_PATH_PATTERN} from 'utils/path'; -import {isIosSafari} from 'utils/user_agent'; import type {OwnProps, PropsFromRedux} from './index'; @@ -148,12 +146,6 @@ function TeamController(props: Props) { // Effect runs on mount, adds active state to window useEffect(() => { - const browserIsIosSafari = isIosSafari(); - if (browserIsIosSafari) { - // Use iNoBounce to prevent scrolling past the boundaries of the page - iNoBounce.enable(); - } - // Set up tracking for whether the window is active window.isActive = true; @@ -161,10 +153,6 @@ function TeamController(props: Props) { return () => { window.isActive = false; - - if (browserIsIosSafari) { - iNoBounce.disable(); - } }; }, []); diff --git a/webapp/channels/src/components/thread_popout/thread_popout.tsx b/webapp/channels/src/components/thread_popout/thread_popout.tsx index fb2c567b87c..666a8878848 100644 --- a/webapp/channels/src/components/thread_popout/thread_popout.tsx +++ b/webapp/channels/src/components/thread_popout/thread_popout.tsx @@ -6,6 +6,7 @@ import {defineMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import {useLocation, useParams} from 'react-router-dom'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import {fetchChannelsAndMembers, selectChannel} from 'mattermost-redux/actions/channels'; @@ -35,7 +36,6 @@ import {getHistory} from 'utils/browser_history'; import {Constants} from 'utils/constants'; import usePopoutFocus from 'utils/popouts/use_popout_focus'; import usePopoutTitle from 'utils/popouts/use_popout_title'; -import {isDesktopApp} from 'utils/user_agent'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx index 29d48a7fafa..af1e37a4675 100644 --- a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx +++ b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type {ComponentProps} from 'react'; +import {isChrome, isFirefox} from '@mattermost/shared/utils/user_agent'; import type {ChannelType} from '@mattermost/types/channels'; import type {TeamType} from '@mattermost/types/teams'; @@ -12,12 +13,11 @@ import UnreadsStatusHandler, {UnreadsStatusHandlerClass} from 'components/unread import {renderWithContext} from 'tests/react_testing_utils'; import {Constants} from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; -import {isChrome, isFirefox} from 'utils/user_agent'; type Props = ComponentProps; -jest.mock('utils/user_agent', () => { - const original = jest.requireActual('utils/user_agent'); +jest.mock('@mattermost/shared/utils/user_agent', () => { + const original = jest.requireActual('@mattermost/shared/utils/user_agent'); return { ...original, isFirefox: jest.fn().mockReturnValue(true), diff --git a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx index cd3897e50ea..58db2c55917 100644 --- a/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx +++ b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {injectIntl} from 'react-intl'; import type {IntlShape} from 'react-intl'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {Team} from '@mattermost/types/teams'; @@ -29,7 +30,6 @@ import faviconUnread64x64 from 'images/favicon/favicon-unread-64x64.png'; import faviconUnread96x96 from 'images/favicon/favicon-unread-96x96.png'; import {Constants} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; -import * as UserAgent from 'utils/user_agent'; enum BadgeStatus { Mention = 'Mention', diff --git a/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.test.tsx b/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.test.tsx index 31c3358c2cb..f3292c3b57e 100644 --- a/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.test.tsx +++ b/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.test.tsx @@ -4,15 +4,21 @@ import React from 'react'; import type {ComponentProps} from 'react'; +import {isMac} from '@mattermost/shared/utils/user_agent'; + import AdvancedSettingsDisplay from 'components/user_settings/advanced/user_settings_advanced'; import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import {Preferences} from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; -import {isMac} from 'utils/user_agent'; jest.mock('actions/global_actions'); -jest.mock('utils/user_agent'); + +const isMacMock = jest.mocked(isMac); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isDesktopApp: jest.fn(() => false), + isMac: jest.fn(() => false), +})); describe('components/user_settings/display/UserSettingsDisplay', () => { const user = TestHelper.getUserMock({ @@ -126,7 +132,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => { }); test('function getCtrlSendText should return correct value for Mac', () => { - (isMac as jest.Mock).mockReturnValue(true); + isMacMock.mockReturnValue(true); const props = {...requiredProps}; renderWithContext(); @@ -134,7 +140,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => { }); test('function getCtrlSendText should return correct value for Windows', () => { - (isMac as jest.Mock).mockReturnValue(false); + isMacMock.mockReturnValue(false); const props = {...requiredProps}; renderWithContext(); diff --git a/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.tsx b/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.tsx index 1f18da1bd0e..e42fd334ae8 100644 --- a/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.tsx +++ b/webapp/channels/src/components/user_settings/advanced/user_settings_advanced.tsx @@ -7,6 +7,7 @@ import React from 'react'; import type {ReactNode} from 'react'; import {FormattedMessage, defineMessages} from 'react-intl'; +import {isMac} from '@mattermost/shared/utils/user_agent'; import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences'; import type {UserProfile} from '@mattermost/types/users'; @@ -17,7 +18,6 @@ import SettingItem from 'components/setting_item'; import SettingItemMax from 'components/setting_item_max'; import Constants, {AdvancedSections, Preferences} from 'utils/constants'; -import {isMac} from 'utils/user_agent'; import {a11yFocus} from 'utils/utils'; import JoinLeaveSection from './join_leave_section'; diff --git a/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.test.tsx b/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.test.tsx index f57a4f11bf8..8a1a40a7476 100644 --- a/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.test.tsx +++ b/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.test.tsx @@ -3,15 +3,22 @@ import React from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import * as useDesktopAppNotificationPermission from 'components/common/hooks/use_desktop_notification_permission'; import type {DesktopNotificationPermission} from 'components/common/hooks/use_desktop_notification_permission'; import {renderWithContext, screen} from 'tests/react_testing_utils'; import * as utilsNotifications from 'utils/notifications'; -import * as UserAgent from 'utils/user_agent'; import NotificationPermissionSectionNotice from './index'; +const isM365MobileMock = jest.mocked(UserAgent.isM365Mobile); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isDesktopApp: jest.fn(() => false), + isM365Mobile: jest.fn(() => false), +})); + describe('NotificationPermissionSectionNotice', () => { afterEach(() => { jest.restoreAllMocks(); @@ -27,7 +34,7 @@ describe('NotificationPermissionSectionNotice', () => { test('should not render "Unsupported" notice for MS 365 mobile apps even when notifications are not supported', () => { jest.spyOn(utilsNotifications, 'isNotificationAPISupported').mockReturnValue(false); - jest.spyOn(UserAgent, 'isM365Mobile').mockReturnValue(true); + isM365MobileMock.mockReturnValue(true); const {container} = renderWithContext(); diff --git a/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.tsx b/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.tsx index cbbcb0afdbf..fe92251bf7d 100644 --- a/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.tsx +++ b/webapp/channels/src/components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/index.tsx @@ -3,13 +3,14 @@ import React, {useState} from 'react'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import {useDesktopAppNotificationPermission} from 'components/common/hooks/use_desktop_notification_permission'; import NotificationPermissionDeniedNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_denied_section_notice'; import NotificationPermissionNeverGrantedNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_never_granted_section_notice'; import NotificationPermissionUnsupportedSectionNotice from 'components/user_settings/notifications/desktop_and_mobile_notification_setting/notification_permission_section_notice/notification_permission_unsupported_section_notice'; import {getNotificationPermission, isNotificationAPISupported, NotificationPermissionDenied, NotificationPermissionNeverGranted} from 'utils/notifications'; -import * as UserAgent from 'utils/user_agent'; import NotificationPermissionDesktopDeniedSectionNotice from './notification_permission_desktop_denied_section_notice'; diff --git a/webapp/channels/src/components/user_settings/security/user_access_token_section/user_access_token_section.tsx b/webapp/channels/src/components/user_settings/security/user_access_token_section/user_access_token_section.tsx index 7cb369a6b24..70669be8908 100644 --- a/webapp/channels/src/components/user_settings/security/user_access_token_section/user_access_token_section.tsx +++ b/webapp/channels/src/components/user_settings/security/user_access_token_section/user_access_token_section.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {isMobile} from '@mattermost/shared/utils/user_agent'; import type {UserAccessToken, UserProfile} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -19,7 +20,6 @@ import WarningIcon from 'components/widgets/icons/fa_warning_icon'; import {Constants, DeveloperLinks} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; -import {isMobile} from 'utils/user_agent'; const SECTION_TOKENS = 'tokens'; const TOKEN_CREATING = 'creating'; diff --git a/webapp/channels/src/components/with_tooltip/index.test.tsx b/webapp/channels/src/components/with_tooltip/index.test.tsx index 1d51a96a11b..089304a1cd9 100644 --- a/webapp/channels/src/components/with_tooltip/index.test.tsx +++ b/webapp/channels/src/components/with_tooltip/index.test.tsx @@ -7,7 +7,7 @@ import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing import WithTooltip from './index'; -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isMac: jest.fn().mockReturnValue(false), })); diff --git a/webapp/channels/src/components/with_tooltip/tooltip_shortcut.test.tsx b/webapp/channels/src/components/with_tooltip/tooltip_shortcut.test.tsx index 94977feb8f6..a465790f777 100644 --- a/webapp/channels/src/components/with_tooltip/tooltip_shortcut.test.tsx +++ b/webapp/channels/src/components/with_tooltip/tooltip_shortcut.test.tsx @@ -4,12 +4,13 @@ import React from 'react'; import {defineMessage} from 'react-intl'; +import * as userAgentUtils from '@mattermost/shared/utils/user_agent'; + import {renderWithContext, screen} from 'tests/react_testing_utils'; -import * as userAgentUtils from 'utils/user_agent'; import TooltipShortcut from './tooltip_shortcut'; -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isMac: jest.fn(), })); diff --git a/webapp/channels/src/components/with_tooltip/tooltip_shortcut.tsx b/webapp/channels/src/components/with_tooltip/tooltip_shortcut.tsx index 2674be3ac8f..3091c3055a9 100644 --- a/webapp/channels/src/components/with_tooltip/tooltip_shortcut.tsx +++ b/webapp/channels/src/components/with_tooltip/tooltip_shortcut.tsx @@ -5,10 +5,11 @@ import React, {memo} from 'react'; import type {MessageDescriptor} from 'react-intl'; import {defineMessage, FormattedMessage} from 'react-intl'; +import {isMac} from '@mattermost/shared/utils/user_agent'; + import {ShortcutKey, ShortcutKeyVariant} from 'components/shortcut_key'; import {isMessageDescriptor} from 'utils/i18n'; -import {isMac} from 'utils/user_agent'; export const ShortcutKeys = { alt: defineMessage({ diff --git a/webapp/channels/src/plugins/shared_dependencies.ts b/webapp/channels/src/plugins/shared_dependencies.ts index beeef4ed896..981450001e7 100644 --- a/webapp/channels/src/plugins/shared_dependencies.ts +++ b/webapp/channels/src/plugins/shared_dependencies.ts @@ -1,9 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +type Loader = () => unknown; + // Every module exported from the @mattermost/shared package must be added to this map -const sharedDependencies = new Map([ +const sharedDependencies = new Map([ ['@mattermost/shared/components/emoji', () => import('@mattermost/shared/components/emoji')], + ['@mattermost/shared/utils/user_agent', () => import('@mattermost/shared/utils/user_agent')], ]); export function loadSharedDependency(request: string) { diff --git a/webapp/channels/src/types/external/inobounce.d.ts b/webapp/channels/src/types/external/inobounce.d.ts deleted file mode 100644 index 911db7531bb..00000000000 --- a/webapp/channels/src/types/external/inobounce.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -declare module 'inobounce'; diff --git a/webapp/channels/src/utils/a11y_controller.ts b/webapp/channels/src/utils/a11y_controller.ts index 7bdc5226ffd..d820a321e17 100644 --- a/webapp/channels/src/utils/a11y_controller.ts +++ b/webapp/channels/src/utils/a11y_controller.ts @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isDesktopApp, isMac} from '@mattermost/shared/utils/user_agent'; + import Constants, {EventTypes, A11yClassNames, A11yAttributeNames, A11yCustomEventTypes, isA11yFocusEventDetail} from 'utils/constants'; import {isKeyPressed, cmdOrCtrlPressed} from 'utils/keyboard'; -import {isDesktopApp, isMac} from 'utils/user_agent'; const listenerOptions = { capture: true, diff --git a/webapp/channels/src/utils/browser_history.tsx b/webapp/channels/src/utils/browser_history.tsx index 61e26cb109b..9d6789fc8cd 100644 --- a/webapp/channels/src/utils/browser_history.tsx +++ b/webapp/channels/src/utils/browser_history.tsx @@ -4,10 +4,11 @@ import {createBrowserHistory} from 'history'; import type {History} from 'history'; +import {isDesktopApp, getDesktopVersion} from '@mattermost/shared/utils/user_agent'; + import {getModule} from 'module_registry'; import DesktopApp from 'utils/desktop_api'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; -import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; const b = createBrowserHistory({basename: window.basename}); const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '5.0.0'); diff --git a/webapp/channels/src/utils/desktop_api.ts b/webapp/channels/src/utils/desktop_api.ts index e5b7ed92ac4..a379a3c231d 100644 --- a/webapp/channels/src/utils/desktop_api.ts +++ b/webapp/channels/src/utils/desktop_api.ts @@ -4,8 +4,7 @@ import semver from 'semver'; import type {DesktopAPI, PopoutViewProps, Theme} from '@mattermost/desktop-api'; - -import {isDesktopApp} from 'utils/user_agent'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; declare global { interface Window { diff --git a/webapp/channels/src/utils/file_utils.test.ts b/webapp/channels/src/utils/file_utils.test.ts index 9a0e4dd47b3..e40e9bf376e 100644 --- a/webapp/channels/src/utils/file_utils.test.ts +++ b/webapp/channels/src/utils/file_utils.test.ts @@ -1,12 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import { trimFilename, canUploadFiles, getFileTypeFromMime, } from 'utils/file_utils'; -import * as UserAgent from 'utils/user_agent'; + +const isMobileMock = jest.mocked(UserAgent.isMobile); +jest.mock('@mattermost/shared/utils/user_agent', () => ({ + isMobile: jest.fn(() => false), +})); describe('FileUtils.trimFilename', () => { it('trimFilename: should return same filename', () => { @@ -19,7 +25,7 @@ describe('FileUtils.trimFilename', () => { }); describe('FileUtils.canUploadFiles', () => { - (UserAgent as any).isMobileApp = jest.fn().mockImplementation(() => false); // eslint-disable-line no-import-assign + isMobileMock.mockReturnValue(false); it('is false when file attachments are disabled', () => { const config = { @@ -30,10 +36,10 @@ describe('FileUtils.canUploadFiles', () => { }); describe('is true when file attachments are enabled', () => { - (UserAgent as any).isMobileApp.mockImplementation(() => false); + isMobileMock.mockReturnValue(false); it('and not on mobile', () => { - (UserAgent as any).isMobileApp.mockImplementation(() => false); + isMobileMock.mockReturnValue(false); const config = { EnableFileAttachments: 'true', @@ -43,7 +49,7 @@ describe('FileUtils.canUploadFiles', () => { }); it('and on mobile with mobile file upload enabled', () => { - (UserAgent as any).isMobileApp.mockImplementation(() => true); + isMobileMock.mockReturnValue(true); const config = { EnableFileAttachments: 'true', @@ -53,7 +59,7 @@ describe('FileUtils.canUploadFiles', () => { }); it('unless on mobile with mobile file upload disabled', () => { - (UserAgent as any).isMobileApp.mockImplementation(() => true); + isMobileMock.mockReturnValue(true); const config = { EnableFileAttachments: 'true', diff --git a/webapp/channels/src/utils/file_utils.tsx b/webapp/channels/src/utils/file_utils.tsx index 2cdc4dd1d0f..0810b9b461c 100644 --- a/webapp/channels/src/utils/file_utils.tsx +++ b/webapp/channels/src/utils/file_utils.tsx @@ -3,10 +3,10 @@ import exif2css from 'exif2css'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; import type {ClientConfig} from '@mattermost/types/config'; import Constants from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; export const FileSizes = { Bit: 1, @@ -24,7 +24,7 @@ export function canUploadFiles(config: Partial): boolean { return false; } - if (UserAgent.isMobileApp()) { + if (UserAgent.isMobile()) { return enableMobileFileUpload; } @@ -44,7 +44,7 @@ export function isPublicLinksEnabled(config: Partial): boolean { } export function canDownloadFiles(config: Partial): boolean { - if (UserAgent.isMobileApp()) { + if (UserAgent.isMobile()) { return config.EnableMobileFileDownload === 'true'; } diff --git a/webapp/channels/src/utils/keyboard.ts b/webapp/channels/src/utils/keyboard.ts index 4a978d44dbd..dedf1d34f5f 100644 --- a/webapp/channels/src/utils/keyboard.ts +++ b/webapp/channels/src/utils/keyboard.ts @@ -1,8 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + import Constants from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; export function cmdOrCtrlPressed(e: React.KeyboardEvent | KeyboardEvent, allowAlt = false) { const isMac = UserAgent.isMac(); diff --git a/webapp/channels/src/utils/notification_sounds.tsx b/webapp/channels/src/utils/notification_sounds.tsx index 66e424cf0d6..fbe6dd41e60 100644 --- a/webapp/channels/src/utils/notification_sounds.tsx +++ b/webapp/channels/src/utils/notification_sounds.tsx @@ -19,7 +19,7 @@ import hello from 'sounds/hello.mp3'; import ripple from 'sounds/ripple.mp3'; import upstairs from 'sounds/upstairs.mp3'; import {DesktopSound} from 'utils/constants'; -import * as UserAgent from 'utils/user_agent'; +import {isEdge} from 'utils/user_agent'; export const DesktopNotificationSounds = { DEFAULT: 'default', @@ -241,7 +241,7 @@ export function loopNotificationRing(name: string) { } export function hasSoundOptions() { - return (!UserAgent.isEdge()); + return (!isEdge()); } /** diff --git a/webapp/channels/src/utils/notifications.ts b/webapp/channels/src/utils/notifications.ts index 2a4ed7d6e73..253a9081832 100644 --- a/webapp/channels/src/utils/notifications.ts +++ b/webapp/channels/src/utils/notifications.ts @@ -3,7 +3,7 @@ import icon50 from 'images/icon50x50.png'; import iconWS from 'images/icon_WS.png'; -import * as UserAgent from 'utils/user_agent'; +import {isEdge} from 'utils/user_agent'; import type {ThunkActionFunc} from 'types/store'; @@ -46,7 +46,7 @@ export function showNotification( ): ThunkActionFunc void}>> { return async () => { let icon = icon50; - if (UserAgent.isEdge()) { + if (isEdge()) { icon = iconWS; } diff --git a/webapp/channels/src/utils/performance_telemetry/platform_detection.ts b/webapp/channels/src/utils/performance_telemetry/platform_detection.ts index 4dde347bc09..56f3c192362 100644 --- a/webapp/channels/src/utils/performance_telemetry/platform_detection.ts +++ b/webapp/channels/src/utils/performance_telemetry/platform_detection.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import * as UserAgent from 'utils/user_agent'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; export type PlatformLabel = ReturnType; export type UserAgentLabel = ReturnType; @@ -27,7 +27,7 @@ export function getUserAgentLabel() { return 'desktop'; } else if (UserAgent.isFirefox() || UserAgent.isIosFirefox()) { return 'firefox'; - } else if (UserAgent.isChromiumEdge()) { + } else if (UserAgent.isEdge()) { return 'edge'; } else if (UserAgent.isChrome() || UserAgent.isIosChrome()) { return 'chrome'; diff --git a/webapp/channels/src/utils/popouts/focus.test.ts b/webapp/channels/src/utils/popouts/focus.test.ts index 251e741fa40..880b9ce344b 100644 --- a/webapp/channels/src/utils/popouts/focus.test.ts +++ b/webapp/channels/src/utils/popouts/focus.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import {getBasePath} from 'utils/url'; -import {isDesktopApp} from 'utils/user_agent'; import {POPOUT_FOCUSED, POPOUT_BLURRED, getFocusedPopoutInfo} from './focus'; import {popoutChannel, popoutThread} from './popout_windows'; @@ -16,7 +17,7 @@ jest.mock('utils/desktop_api', () => ({ }, })); -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn(), })); diff --git a/webapp/channels/src/utils/popouts/popout_windows.test.ts b/webapp/channels/src/utils/popouts/popout_windows.test.ts index 742d2ec089b..02b895033b5 100644 --- a/webapp/channels/src/utils/popouts/popout_windows.test.ts +++ b/webapp/channels/src/utils/popouts/popout_windows.test.ts @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import DesktopApp from 'utils/desktop_api'; import {getBasePath} from 'utils/url'; -import {isDesktopApp} from 'utils/user_agent'; import {POPOUT_FOCUSED, POPOUT_BLURRED, getFocusedPopoutInfo} from './focus'; import {FOCUS_REPLY_POST, popoutChannel, popoutRhsPlugin, popoutRhsSearch, popoutThread} from './popout_windows'; @@ -17,7 +18,7 @@ jest.mock('utils/desktop_api', () => ({ }, })); -jest.mock('utils/user_agent', () => ({ +jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn(), })); diff --git a/webapp/channels/src/utils/popouts/popout_windows.ts b/webapp/channels/src/utils/popouts/popout_windows.ts index 3bfe5d559aa..5b316f67c65 100644 --- a/webapp/channels/src/utils/popouts/popout_windows.ts +++ b/webapp/channels/src/utils/popouts/popout_windows.ts @@ -2,13 +2,13 @@ // See LICENSE.txt for license information. import type {PopoutViewProps} from '@mattermost/desktop-api'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import {Client4} from 'mattermost-redux/client'; import {RHSStates} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {getBasePath} from 'utils/url'; -import {isDesktopApp} from 'utils/user_agent'; import type {RhsState, SearchType} from 'types/store/rhs'; diff --git a/webapp/channels/src/utils/popouts/use_browser_popout.ts b/webapp/channels/src/utils/popouts/use_browser_popout.ts index cabf130e9ae..7c27be5bdae 100644 --- a/webapp/channels/src/utils/popouts/use_browser_popout.ts +++ b/webapp/channels/src/utils/popouts/use_browser_popout.ts @@ -4,7 +4,7 @@ import {useEffect} from 'react'; import {useHistory} from 'react-router-dom'; -import {isDesktopApp} from 'utils/user_agent'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; import {NAVIGATE_CHANNEL, CLOSE_CHANNEL} from './browser_popouts'; diff --git a/webapp/channels/src/utils/popouts/use_popout_title.ts b/webapp/channels/src/utils/popouts/use_popout_title.ts index 2d46e473559..918d6ae3309 100644 --- a/webapp/channels/src/utils/popouts/use_popout_title.ts +++ b/webapp/channels/src/utils/popouts/use_popout_title.ts @@ -5,12 +5,13 @@ import {useEffect} from 'react'; import {type MessageDescriptor, useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; +import {isDesktopApp} from '@mattermost/shared/utils/user_agent'; + import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import DesktopApp from 'utils/desktop_api'; -import {isDesktopApp} from 'utils/user_agent'; export default function usePopoutTitle(titleTemplate: MessageDescriptor, params?: Record) { const intl = useIntl(); diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index 9899bcd6c04..1aa84372ffc 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -6,6 +6,7 @@ import {useIntl} from 'react-intl'; import type {IntlShape} from 'react-intl'; import {useSelector} from 'react-redux'; +import {isMobile} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {ClientConfig, ClientLicense} from '@mattermost/types/config'; import type {ServerError} from '@mattermost/types/errors'; @@ -42,7 +43,6 @@ import * as Keyboard from 'utils/keyboard'; import {formatWithRenderer} from 'utils/markdown'; import MentionableRenderer from 'utils/markdown/mentionable_renderer'; import {allAtMentions} from 'utils/text_formatting'; -import {isMobile} from 'utils/user_agent'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/utils/user_agent.tsx b/webapp/channels/src/utils/user_agent.tsx index fd98d589797..69aab209236 100644 --- a/webapp/channels/src/utils/user_agent.tsx +++ b/webapp/channels/src/utils/user_agent.tsx @@ -1,186 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* -Example User Agents --------------------- - -Chrome: - Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 - -Firefox: - Mozilla/5.0 (Windows NT 10.0; WOW64; rv:47.0) Gecko/20100101 Firefox/47.0 - -IE11: - Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko - -Edge: - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 - -ChromeOS Chromebook: - Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36 - -Desktop App: - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/1.2.1 Chrome/49.0.2623.75 Electron/0.37.8 Safari/537.36 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/53.0.2785.113 Electron/1.4.2 Safari/537.36 - Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/51.0.2704.106 Electron/1.2.8 Safari/537.36 - -Android Chrome: - Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19 - -Android Firefox: - Mozilla/5.0 (Android; U; Android; pl; rv:1.9.2.8) Gecko/20100202 Firefox/3.5.8 - Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/54.0 Firefox/54.0 - Mozilla/5.0 (Android 7.0; Mobile; rv:57.0) Gecko/57.0 Firefox/57.0 - -Android App: - Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30 - Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36 - Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36 - -iOS Safari: - Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543 Safari/419.3 - -iOS Android: - Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3 - -iOS App: - Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13F69 -*/ - -const userAgent = () => window.navigator.userAgent; - -export function isChrome(): boolean { - return userAgent().indexOf('Chrome') > -1 && userAgent().indexOf('Edge') === -1; -} - -export function isSafari(): boolean { - return userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('Chrome') === -1; -} - -export function isIosSafari(): boolean { - return (userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1) && userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('CriOS') === -1; -} - -export function isIosChrome(): boolean { - return userAgent().indexOf('CriOS') !== -1; -} - -export function isIosFirefox(): boolean { - return userAgent().indexOf('FxiOS') !== -1; -} - -export function isIosWeb(): boolean { - return isIosSafari() || isIosChrome(); -} - -export function isIos(): boolean { - return userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1; -} - -export function isAndroid(): boolean { - return userAgent().indexOf('Android') !== -1; -} - -export function isAndroidChrome(): boolean { - return userAgent().indexOf('Android') !== -1 && userAgent().indexOf('Chrome') !== -1 && userAgent().indexOf('Version') === -1; -} - -export function isAndroidFirefox(): boolean { - return userAgent().indexOf('Android') !== -1 && userAgent().indexOf('Firefox') !== -1; -} - -export function isAndroidWeb(): boolean { - return isAndroidChrome() || isAndroidFirefox(); -} - -export function isIosClassic(): boolean { - return isMobileApp() && isIos(); -} - -// Returns true if and only if the user is using a Mattermost mobile app. This will return false if the user is using the -// web browser on a mobile device. -export function isMobileApp(): boolean { - return isMobile() && !isIosWeb() && !isAndroidWeb(); -} - -// Returns true if and only if the user is using Mattermost from the web browser on a mobile device. -export function isMobile(): boolean { - return isIos() || isAndroid(); -} - -export function isFirefox(): boolean { - return userAgent().indexOf('Firefox') !== -1; -} - -export function isChromebook(): boolean { - return userAgent().indexOf('CrOS') !== -1; -} - export function isInternetExplorer(): boolean { - return userAgent().indexOf('Trident') !== -1; + return window.navigator.userAgent.indexOf('Trident') !== -1; } export function isEdge(): boolean { - return userAgent().indexOf('Edge') !== -1; -} - -export function isChromiumEdge(): boolean { - return userAgent().indexOf('Edg') !== -1 && userAgent().indexOf('Edge') === -1; -} - -export function isDesktopApp(): boolean { - return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1; -} - -export function isWindowsApp(): boolean { - return isDesktopApp() && isWindows(); -} - -export function isMacApp(): boolean { - return isDesktopApp() && isMac(); -} - -export function isWindows(): boolean { - return userAgent().indexOf('Windows') !== -1; -} - -export function isMac(): boolean { - return userAgent().indexOf('Macintosh') !== -1; -} - -export function isLinux(): boolean { - return navigator.platform.toUpperCase().indexOf('LINUX') >= 0; -} - -export function isWindows7(): boolean { - const appVersion = navigator.appVersion; - - if (!appVersion) { - return false; - } - - return (/\bWindows NT 6\.1\b/).test(appVersion); -} - -export function getDesktopVersion(): string { - // use if the value window.desktop.version is not set yet - const regex = /Mattermost\/(\d+\.\d+\.\d+)/gm; - const match = regex.exec(window.navigator.appVersion)?.[1] || ''; - return match; -} - -export function isTeamsMobile(): boolean { - return userAgent().indexOf('TeamsMobile-Android') !== -1 || - userAgent().indexOf('TeamsMobile-iOS') !== -1 || - (isMobile() && userAgent().indexOf('Teams/') !== -1); -} - -export function isOutlookMobile(): boolean { - return userAgent().indexOf('PKeyAuth/1.0') !== -1; -} - -export function isM365Mobile(): boolean { - return isTeamsMobile() || isOutlookMobile(); + return window.navigator.userAgent.indexOf('Edge') !== -1; } diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index 32f18efc186..ff8df0126da 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -11,6 +11,7 @@ import React from 'react'; import type {LinkHTMLAttributes} from 'react'; import type {MessageDescriptor} from 'react-intl'; +import {isFirefox, isSafari} from '@mattermost/shared/utils/user_agent'; import type {Channel} from '@mattermost/types/channels'; import type {Address} from '@mattermost/types/cloud'; import type {FileInfo} from '@mattermost/types/files'; @@ -60,7 +61,7 @@ import DesktopApp from 'utils/desktop_api'; import {getIntl} from 'utils/i18n'; import * as Keyboard from 'utils/keyboard'; import {FOCUS_REPLY_POST, isPopoutWindow, sendToParent} from 'utils/popouts/popout_windows'; -import * as UserAgent from 'utils/user_agent'; +import {isInternetExplorer, isEdge} from 'utils/user_agent'; import {joinPrivateChannelPrompt} from './channel_utils'; @@ -109,7 +110,7 @@ export function isUnhandledLineBreakKeyCombo(e: React.KeyboardEvent | KeyboardEv return Boolean( Keyboard.isKeyPressed(e, Constants.KeyCodes.ENTER) && !e.shiftKey && // shift + enter is already handled everywhere, so don't handle again - (e.altKey && !UserAgent.isSafari() && !Keyboard.cmdOrCtrlPressed(e)), // alt/option + enter is already handled in Safari, so don't handle again + (e.altKey && !isSafari() && !Keyboard.cmdOrCtrlPressed(e)), // alt/option + enter is already handled in Safari, so don't handle again ); } @@ -658,7 +659,7 @@ function updateCodeTheme(codeTheme: string) { xmlHTTP.onload = function onLoad() { link.href = cssPath; - if (UserAgent.isFirefox()) { + if (isFirefox()) { link.addEventListener('load', () => { changeCss('code.hljs', 'visibility: visible'); }, {once: true}); @@ -1191,7 +1192,7 @@ export function fillRecord(value: T, length: number): Record { // Checks if a data transfer contains files not text, folders, etc.. // Slightly modified from http://stackoverflow.com/questions/6848043/how-do-i-detect-a-file-is-being-dragged-rather-than-a-draggable-element-on-my-pa export function isFileTransfer(files: DataTransfer) { - if (UserAgent.isInternetExplorer() || UserAgent.isEdge()) { + if (isInternetExplorer() || isEdge()) { return files.types != null && files.types.includes('Files'); } @@ -1199,7 +1200,7 @@ export function isFileTransfer(files: DataTransfer) { } export function isUriDrop(dataTransfer: DataTransfer) { - if (UserAgent.isInternetExplorer() || UserAgent.isEdge() || UserAgent.isSafari()) { + if (isInternetExplorer() || isEdge() || isSafari()) { for (let i = 0; i < dataTransfer.items.length; i++) { if (dataTransfer.items[i].type === 'text/uri-list') { return true; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 4b062f35640..f7eda6b21de 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -96,7 +96,6 @@ "history": "4.10.1", "hoist-non-react-statics": "3.3.2", "html-to-react": "1.6.0", - "inobounce": "0.2.1", "ipaddr.js": "2.1.0", "katex": "0.16.21", "localforage": "1.10.0", @@ -15381,10 +15380,6 @@ "css-in-js-utils": "^3.1.0" } }, - "node_modules/inobounce": { - "version": "0.2.1", - "license": "BSD-2-Clause" - }, "node_modules/internal-slot": { "version": "1.0.7", "license": "MIT", diff --git a/webapp/platform/shared/src/utils/user_agent/index.ts b/webapp/platform/shared/src/utils/user_agent/index.ts new file mode 100644 index 00000000000..3d2b83b63c8 --- /dev/null +++ b/webapp/platform/shared/src/utils/user_agent/index.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* +Example User Agents +-------------------- + +Chrome: + Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 + +Firefox: + Mozilla/5.0 (Windows NT 10.0; WOW64; rv:47.0) Gecko/20100101 Firefox/47.0 + +Edge: + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 + +Desktop App: + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/1.2.1 Chrome/49.0.2623.75 Electron/0.37.8 Safari/537.36 + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 + Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/53.0.2785.113 Electron/1.4.2 Safari/537.36 + Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/51.0.2704.106 Electron/1.2.8 Safari/537.36 + +Android App: + Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30 + Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36 + Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36 + +iOS Safari: + Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543 Safari/419.3 + +iOS Chrome: + Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3 + +iOS App: + Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13F69 +*/ + +const userAgent = () => window.navigator.userAgent; + +export function isChrome(): boolean { + return userAgent().indexOf('Chrome') > -1 && userAgent().indexOf('Edge') === -1; +} + +export function isSafari(): boolean { + return userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('Chrome') === -1; +} + +export function isIosChrome(): boolean { + return userAgent().indexOf('CriOS') !== -1; +} + +export function isIosFirefox(): boolean { + return userAgent().indexOf('FxiOS') !== -1; +} + +export function isIos(): boolean { + return userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1; +} + +export function isAndroid(): boolean { + return userAgent().indexOf('Android') !== -1; +} + +// Returns true if and only if the user is using Mattermost from the web browser on a mobile device. +export function isMobile(): boolean { + return isIos() || isAndroid(); +} + +export function isFirefox(): boolean { + return userAgent().indexOf('Firefox') !== -1; +} + +export function isChromebook(): boolean { + return userAgent().indexOf('CrOS') !== -1; +} + +export function isEdge(): boolean { + return userAgent().indexOf('Edg') !== -1 && userAgent().indexOf('Edge') === -1; +} + +export function isDesktopApp(): boolean { + return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1; +} + +export function isMacApp(): boolean { + return isDesktopApp() && isMac(); +} + +export function isWindows(): boolean { + return userAgent().indexOf('Windows') !== -1; +} + +export function isMac(): boolean { + return userAgent().indexOf('Macintosh') !== -1; +} + +export function isLinux(): boolean { + return navigator.platform.toUpperCase().indexOf('LINUX') >= 0; +} + +export function getDesktopVersion(): string { + // use if the value window.desktop.version is not set yet + const regex = /Mattermost\/(\d+\.\d+\.\d+)/gm; + const match = regex.exec(window.navigator.appVersion)?.[1] || ''; + return match; +} + +function isTeamsMobile(): boolean { + return userAgent().indexOf('TeamsMobile-Android') !== -1 || + userAgent().indexOf('TeamsMobile-iOS') !== -1 || + (isMobile() && userAgent().indexOf('Teams/') !== -1); +} + +function isOutlookMobile(): boolean { + return userAgent().indexOf('PKeyAuth/1.0') !== -1; +} + +export function isM365Mobile(): boolean { + return isTeamsMobile() || isOutlookMobile(); +} From 23ab604b964a6ee8c07d56c2fced57bea621a643 Mon Sep 17 00:00:00 2001 From: Pavel Zeman Date: Thu, 16 Apr 2026 20:51:59 -0400 Subject: [PATCH 2/3] ci: pin enterprise repo to explicit commit hash (#35957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: pin enterprise repo to explicit commit hash Introduces an enterprise.pin file that explicitly pins the enterprise commit the server is built and tested against. This replaces the implicit HEAD checkout, eliminating the CI race condition for all cross-repo changes. Changes: - enterprise.pin: pinned enterprise commit SHA - server/Makefile: new bump-enterprise target to update the pin Co-authored-by: Claude * ci: fix bump-enterprise error handling and path resolution - Add set -e so recipe fails fast if git rev-parse fails - Use $(ROOT)/../enterprise.pin instead of ../enterprise.pin to resolve path relative to Makefile location, not CWD Co-authored-by: Claude * fix: add missing 'all' to .PHONY declaration in Makefile Pre-existing issue flagged by CodeRabbit — the 'all' target (line 199) was never declared phony. Out of scope for the enterprise.pin change but trivial to fix. Co-authored-by: Claude * ci: trigger Enterprise CI re-run Co-authored-by: Claude --------- Co-authored-by: Claude --- enterprise.pin | 3 +++ server/Makefile | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 enterprise.pin diff --git a/enterprise.pin b/enterprise.pin new file mode 100644 index 00000000000..03f750130d1 --- /dev/null +++ b/enterprise.pin @@ -0,0 +1,3 @@ +# Enterprise commit this server branch is tested against. +# Updated via: make bump-enterprise +b03e11ccf5dca59cc3ffd2ed8c73604c5a5a48b8 diff --git a/server/Makefile b/server/Makefile index 615e59e17af..6400a346d63 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime +.PHONY: all build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime bump-enterprise ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -431,6 +431,19 @@ ifneq ($(SKIP_SETUP_GO_WORK),true) fi endif +bump-enterprise: ## Update enterprise.pin to the current enterprise HEAD + @if [ ! -d "$(BUILD_ENTERPRISE_DIR)" ]; then \ + echo "Enterprise directory not found at $(BUILD_ENTERPRISE_DIR)"; \ + exit 1; \ + fi + @set -e; \ + SHA=$$(cd "$(BUILD_ENTERPRISE_DIR)" && git rev-parse HEAD); \ + PIN_FILE="$(ROOT)/../enterprise.pin"; \ + echo "# Enterprise commit this server branch is tested against." > "$$PIN_FILE"; \ + echo "# Updated via: make bump-enterprise" >> "$$PIN_FILE"; \ + echo "$$SHA" >> "$$PIN_FILE"; \ + echo "Updated enterprise.pin to $$SHA" + check-style: plugin-checker vet golangci-lint ## Runs style/lint checks gotestsum: From 588ee4281a915de82556dedeb790d20a9a4fc9a8 Mon Sep 17 00:00:00 2001 From: JG Heithcock Date: Thu, 16 Apr 2026 17:55:48 -0700 Subject: [PATCH 3/3] MM-68155: Add tooltip for urgent mention badges (#35912) * MM-68155: Add tooltip for urgent mention badges Display "You have an urgent mention" tooltip on hover over the red urgent mention badge in the sidebar channel list, global threads link, and team sidebar button. --- webapp/channels/jest.config.js | 1 + .../channel_mention_badge.test.tsx | 85 ++++++++ .../sidebar_channel/channel_mention_badge.tsx | 18 +- .../sidebar_channel_link.test.tsx.snap | 14 +- .../sidebar_channel_link.test.tsx | 181 ++++++++++-------- .../sidebar_channel_link.tsx | 14 +- .../components/team_button.test.tsx | 34 ++++ .../team_sidebar/components/team_button.tsx | 21 +- .../global_threads_link.tsx | 10 +- webapp/channels/src/i18n/en.json | 3 + .../src/sass/layout/_sidebar-left.scss | 6 + 11 files changed, 294 insertions(+), 93 deletions(-) create mode 100644 webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.test.tsx diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js index 9342082e697..6c65afbf94e 100644 --- a/webapp/channels/jest.config.js +++ b/webapp/channels/jest.config.js @@ -21,6 +21,7 @@ const config = { '^@mattermost/(components)$': '/../platform/$1/src', '^@mattermost/(client)$': '/../platform/$1/src', '^@mattermost/(types)/(.*)$': '/../platform/$1/src/$2', + '^@mattermost/shared/(.*)$': '/../platform/shared/src/$1', '^mattermost-redux/test/(.*)$': '/src/packages/mattermost-redux/test/$1', '^mattermost-redux/(.*)$': '/src/packages/mattermost-redux/src/$1', diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.test.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.test.tsx new file mode 100644 index 00000000000..a1f16a4ea9a --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.test.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; + +import ChannelMentionBadge from './channel_mention_badge'; + +const urgentTooltipDescriptor = { + id: 'channel_mention_badge.urgent_tooltip', + defaultMessage: 'You have an urgent mention', +}; + +describe('ChannelMentionBadge', () => { + it('should render nothing when unreadMentions is 0', () => { + const {container} = renderWithContext( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render badge when unreadMentions > 0', () => { + renderWithContext( + , + ); + + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should add urgent class when hasUrgent is true', () => { + renderWithContext( + , + ); + + expect(screen.getByText('1').closest('.badge')).toHaveClass('urgent'); + }); + + it('should not add urgent class when hasUrgent is false', () => { + renderWithContext( + , + ); + + expect(screen.getByText('1').closest('.badge')).not.toHaveClass('urgent'); + }); + + it('should show tooltip on hover when tooltip prop is provided', async () => { + jest.useFakeTimers(); + + renderWithContext( + , + ); + + const badge = screen.getByText('2').closest('.badge')!; + await userEvent.hover(badge, {advanceTimers: jest.advanceTimersByTime}); + + await waitFor(() => { + expect(screen.getByText('You have an urgent mention')).toBeInTheDocument(); + }); + }); + + it('should not render WithTooltip wrapper when tooltip prop is not provided', () => { + const {container} = renderWithContext( + , + ); + + const badge = screen.getByText('2').closest('.badge')!; + expect(badge).toBeInTheDocument(); + expect(container.querySelector('.tooltipContainer')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx index f255584a4c0..adf78a3eb31 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx @@ -3,17 +3,21 @@ import classNames from 'classnames'; import React from 'react'; +import type {MessageDescriptor} from 'react-intl'; + +import WithTooltip from 'components/with_tooltip'; type Props = { unreadMentions: number; hasUrgent?: boolean; icon?: React.ReactNode; className?: string; + tooltip?: MessageDescriptor; }; -export default function ChannelMentionBadge({unreadMentions, hasUrgent, icon, className}: Props) { +export default function ChannelMentionBadge({unreadMentions, hasUrgent, icon, className, tooltip}: Props) { if (unreadMentions > 0) { - return ( + const badge = ( ); + + if (tooltip) { + return ( + + {badge} + + ); + } + + return badge; } return null; diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap index 67a234a2429..ed1312cef2d 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap @@ -5,7 +5,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should fetch sh @@ -56,7 +56,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn @@ -103,7 +103,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn @@ -150,7 +150,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn @@ -197,7 +197,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn @@ -254,7 +254,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc @@ -305,7 +305,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx index 202d64d7b25..98f294be72d 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.test.tsx @@ -8,41 +8,36 @@ import type {ChannelType} from '@mattermost/types/channels'; import SidebarChannelLink from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link'; -import {defaultIntl} from 'tests/helpers/intl-test-helper'; -import {renderWithContext} from 'tests/react_testing_utils'; +import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; const isDesktopAppMock = jest.mocked(isDesktopApp); jest.mock('@mattermost/shared/utils/user_agent', () => ({ isDesktopApp: jest.fn(), })); -jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({ - getRemoteNamesForChannel: jest.fn(), -})); - -jest.mock('packages/mattermost-redux/src/actions/shared_channels', () => ({ - fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})), -})); describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => { + const baseChannel = { + id: 'channel_id', + display_name: 'channel_display_name', + create_at: 0, + update_at: 0, + delete_at: 0, + team_id: '', + type: 'O' as ChannelType, + name: '', + header: '', + purpose: '', + last_post_at: 0, + last_root_post_at: 0, + creator_id: '', + scheme_id: '', + group_constrained: false, + }; + const baseProps = { - channel: { - id: 'channel_id', - display_name: 'channel_display_name', - create_at: 0, - update_at: 0, - delete_at: 0, - team_id: '', - type: 'O' as ChannelType, - name: '', - header: '', - purpose: '', - last_post_at: 0, - last_root_post_at: 0, - creator_id: '', - scheme_id: '', - group_constrained: false, - }, - link: 'http://a.fake.link', + channel: baseChannel, + link: '/team/channels/town-square', label: 'channel_label', icon: null, unreadMentions: 0, @@ -51,10 +46,8 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => { isChannelSelected: false, hasUrgent: false, showChannelsTutorialStep: false, - remoteNames: [], + remoteNames: [] as string[], isSharedChannel: false, - fetchChannelRemotes: jest.fn(), - intl: defaultIntl, actions: { markMostRecentPostInChannelAsUnread: jest.fn(), multiSelectChannel: jest.fn(), @@ -68,10 +61,13 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => { }, }; + const renderLink = (props: Partial = {}) => { + const merged = mergeObjects(baseProps, props); + return renderWithContext(); + }; + test('should match snapshot', () => { - const {container} = renderWithContext( - , - ); + const {container} = renderLink(); expect(container).toMatchSnapshot(); }); @@ -79,134 +75,159 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => { test('should match snapshot for desktop', () => { isDesktopAppMock.mockImplementation(() => false); - const {container} = renderWithContext( - , - ); + const {container} = renderLink(); expect(container).toMatchSnapshot(); }); test('should match snapshot when tooltip is enabled', () => { const props = { - ...baseProps, - label: 'a'.repeat(200), // Long label to trigger tooltip + label: 'a'.repeat(200), }; - // Mock offsetWidth < scrollWidth to trigger tooltip Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {configurable: true, value: 50}); Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 200}); - const {container} = renderWithContext( - , - ); + const {container} = renderLink(props); expect(container).toMatchSnapshot(); - // Restore Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {configurable: true, value: 0}); Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 0}); }); test('should match snapshot with aria label prefix and unread mentions', () => { const props = { - ...baseProps, isUnread: true, unreadMentions: 2, ariaLabelPrefix: 'aria_label_prefix_', }; - const {container} = renderWithContext( - , - ); + const {container} = renderLink(props); expect(container).toMatchSnapshot(); }); test('should enable tooltip when needed', () => { - // Mock offsetWidth < scrollWidth to trigger tooltip Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {configurable: true, value: 50}); Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 60}); - const {container} = renderWithContext( - , - ); + const {container} = renderLink(); - // When tooltip is enabled, the label should be wrapped in a tooltip component const label = container.querySelector('.SidebarChannelLinkLabel'); expect(label).toBeInTheDocument(); - // Restore Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {configurable: true, value: 0}); Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 0}); }); test('should not fetch shared channels for non-shared channels', () => { const props = { - ...baseProps, isSharedChannel: false, }; - const {container} = renderWithContext( - , - ); + const {container} = renderLink(props); expect(container).toMatchSnapshot(); - expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled(); + expect(baseProps.actions.fetchChannelRemotes).not.toHaveBeenCalled(); }); test('should fetch shared channels data when channel is shared', () => { const props = { - ...baseProps, isSharedChannel: true, remoteNames: [], }; - const {container} = renderWithContext( - , - ); + const {container} = renderLink(props); expect(container).toMatchSnapshot(); - expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('channel_id'); + expect(baseProps.actions.fetchChannelRemotes).toHaveBeenCalledWith('channel_id'); }); test('should not fetch shared channels data when data already exists', () => { const props = { - ...baseProps, isSharedChannel: true, remoteNames: ['Remote 1', 'Remote 2'], }; - const {container} = renderWithContext( - , - ); + const {container} = renderLink(props); expect(container).toMatchSnapshot(); - expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled(); + expect(baseProps.actions.fetchChannelRemotes).not.toHaveBeenCalled(); + }); + + test('should pass urgent tooltip to ChannelMentionBadge when hasUrgent is true', async () => { + jest.useFakeTimers(); + + renderLink({ + unreadMentions: 3, + hasUrgent: true, + }); + + const badge = screen.getByText('3').closest('.badge')!; + expect(badge).toHaveClass('urgent'); + + await userEvent.hover(badge, {advanceTimers: jest.advanceTimersByTime}); + + await waitFor(() => { + expect(screen.getByText('You have an urgent mention')).toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + + test('should not show urgent mention tooltip when hasUrgent is false', async () => { + jest.useFakeTimers(); + + renderLink({ + unreadMentions: 3, + hasUrgent: false, + }); + + const badge = screen.getByText('3').closest('.badge')!; + expect(badge).not.toHaveClass('urgent'); + + await userEvent.hover(badge, {advanceTimers: jest.advanceTimersByTime}); + + expect(screen.queryByText('You have an urgent mention')).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); + + test('should include urgent mention in link accessible name when hasUrgent', () => { + renderLink({ + unreadMentions: 2, + hasUrgent: true, + }); + + expect(screen.getByRole('link')).toHaveAccessibleName(/including an urgent mention/i); + }); + + test('should not include urgent mention in link accessible name when not hasUrgent', () => { + renderLink({ + unreadMentions: 2, + hasUrgent: false, + }); + + expect(screen.getByRole('link')).not.toHaveAccessibleName(/including an urgent mention/i); }); test('should refetch when channel changes', () => { const props = { - ...baseProps, isSharedChannel: true, remoteNames: [], }; - const {rerender} = renderWithContext( - , - ); - - props.actions.fetchChannelRemotes.mockClear(); + const {rerender} = renderLink(props); rerender( , ); - expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('new_channel_id'); + expect(baseProps.actions.fetchChannelRemotes).toHaveBeenCalledWith('new_channel_id'); }); }); diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx index 886487e7500..e95e7f8aa23 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import React from 'react'; -import {type WrappedComponentProps, injectIntl} from 'react-intl'; +import {type WrappedComponentProps, defineMessages, injectIntl} from 'react-intl'; import {Link} from 'react-router-dom'; import type {Channel} from '@mattermost/types/channels'; @@ -28,6 +28,13 @@ import ChannelPencilIcon from '../channel_pencil_icon'; import SidebarChannelIcon from '../sidebar_channel_icon'; import SidebarChannelMenu from '../sidebar_channel_menu'; +const messages = defineMessages({ + urgentMentionTooltip: { + id: 'channel_mention_badge.urgent_tooltip', + defaultMessage: 'You have an urgent mention', + }, +}); + type Props = WrappedComponentProps & { channel: Channel; link: string; @@ -137,6 +144,10 @@ export class SidebarChannelLink extends React.PureComponent { ariaLabel += ` ${unreadMentions} ${intl.formatMessage({id: 'accessibility.sidebar.types.mentions', defaultMessage: 'mentions'})}`; } + if (this.props.hasUrgent && unreadMentions > 0) { + ariaLabel += ` ${intl.formatMessage({id: 'accessibility.sidebar.types.urgent_mention', defaultMessage: 'including an urgent mention'})}`; + } + if (this.props.isUnread && unreadMentions === 0) { ariaLabel += ` ${intl.formatMessage({id: 'accessibility.sidebar.types.unread', defaultMessage: 'unread'})}`; } @@ -262,6 +273,7 @@ export class SidebarChannelLink extends React.PureComponent {
{ expect(screen.getByTestId('team-container-')).not.toHaveClass('unread'); }); + it('should show urgent class on mentions badge when hasUrgent is true', () => { + const props = { + ...baseProps, + active: false, + unread: true, + mentions: 3, + hasUrgent: true, + displayName: 'Acme', + }; + + renderWithContext( + , + ); + + expect(screen.queryByTestId('team-badge-')).toHaveClass('urgent'); + expect(screen.getByRole('link')).toHaveAccessibleName(/including an urgent mention/i); + }); + + it('should not show urgent class on mentions badge when hasUrgent is false', () => { + const props = { + ...baseProps, + active: false, + unread: true, + mentions: 3, + hasUrgent: false, + }; + + renderWithContext( + , + ); + + expect(screen.queryByTestId('team-badge-')).not.toHaveClass('urgent'); + }); + describe('aria-label accessibility', () => { it('should use displayName as aria-label for create team button', () => { const props = { diff --git a/webapp/channels/src/components/team_sidebar/components/team_button.tsx b/webapp/channels/src/components/team_sidebar/components/team_button.tsx index 8fe3b9d705e..c501d954819 100644 --- a/webapp/channels/src/components/team_sidebar/components/team_button.tsx +++ b/webapp/channels/src/components/team_sidebar/components/team_button.tsx @@ -20,6 +20,10 @@ const messages = defineMessages({ id: 'team.button.name_undefined', defaultMessage: 'This team does not have a name', }, + urgentMentionTooltip: { + id: 'channel_mention_badge.urgent_tooltip', + defaultMessage: 'You have an urgent mention', + }, }); interface Props { @@ -107,7 +111,14 @@ export default function TeamButton({ } if (mentions && isNotCreateTeamButton) { - ariaLabel = formatMessage({ + ariaLabel = otherProps.hasUrgent ? formatMessage({ + id: 'team.button.mentions.urgent.ariaLabel', + defaultMessage: '{teamName} team, {mentionCount} mentions, including an urgent mention', + }, + { + teamName: displayName, + mentionCount: mentions, + }) : formatMessage({ id: 'team.button.mentions.ariaLabel', defaultMessage: '{teamName} team, {mentionCount} mentions', }, @@ -116,7 +127,7 @@ export default function TeamButton({ mentionCount: mentions, }); - badge = ( + const mentionBadge = ( 99 ? '99+' : mentions} ); + + badge = otherProps.hasUrgent ? ( + + {mentionBadge} + + ) : mentionBadge; } } diff --git a/webapp/channels/src/components/threading/global_threads_link/global_threads_link.tsx b/webapp/channels/src/components/threading/global_threads_link/global_threads_link.tsx index 13b156fc68b..6d58c4b1cf8 100644 --- a/webapp/channels/src/components/threading/global_threads_link/global_threads_link.tsx +++ b/webapp/channels/src/components/threading/global_threads_link/global_threads_link.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import React, {useCallback, useEffect} from 'react'; -import {useIntl} from 'react-intl'; +import {defineMessages, useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {Link, useRouteMatch, useLocation, matchPath} from 'react-router-dom'; @@ -25,6 +25,13 @@ import {useThreadRouting} from '../hooks'; import './global_threads_link.scss'; +const tooltipMessages = defineMessages({ + urgentMention: { + id: 'channel_mention_badge.urgent_tooltip', + defaultMessage: 'You have an urgent mention', + }, +}); + const GlobalThreadsLink = () => { const {formatMessage} = useIntl(); const dispatch = useDispatch(); @@ -93,6 +100,7 @@ const GlobalThreadsLink = () => { )} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a5d0a1ecf19..f1b4d45fab0 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -55,6 +55,7 @@ "accessibility.sidebar.types.private": "private channel", "accessibility.sidebar.types.public": "public channel", "accessibility.sidebar.types.unread": "unread", + "accessibility.sidebar.types.urgent_mention": "including an urgent mention", "activity_log_modal.android": "Android", "activity_log_modal.androidNativeApp": "Android Native App", "activity_log_modal.androidNativeClassicApp": "Android Native Classic App", @@ -3974,6 +3975,7 @@ "channel_members_rhs.policy_enforced_restrictions": "Channel access is restricted by user attributes", "channel_members_rhs.search_bar.aria.cancel_search_button": "cancel members search", "channel_members_rhs.search_bar.placeholder": "Search members", + "channel_mention_badge.urgent_tooltip": "You have an urgent mention", "channel_menu.bookmarks": "Bookmarks Bar", "channel_menu.bookmarks.addFile": "Attach a file", "channel_menu.bookmarks.addLink": "Add a link", @@ -6475,6 +6477,7 @@ "team_sidebar.join": "Other teams you can join", "team.button.ariaLabel": "{teamName} team", "team.button.mentions.ariaLabel": "{teamName} team, {mentionCount} mentions", + "team.button.mentions.urgent.ariaLabel": "{teamName} team, {mentionCount} mentions, including an urgent mention", "team.button.name_undefined": "This team does not have a name", "team.button.unread.ariaLabel": "{teamName} team unread", "terms_of_service.agreeButton": "I Agree", diff --git a/webapp/channels/src/sass/layout/_sidebar-left.scss b/webapp/channels/src/sass/layout/_sidebar-left.scss index f5838aabfb8..47f6119b8f2 100644 --- a/webapp/channels/src/sass/layout/_sidebar-left.scss +++ b/webapp/channels/src/sass/layout/_sidebar-left.scss @@ -1132,6 +1132,12 @@ $sidebarOpacityAnimationDuration: 0.15s; position: absolute; visibility: hidden; } + + // Keep urgent mention badges visible on hover so the tooltip can be discovered + .badge.urgent { + position: relative; + visibility: visible; + } } .SidebarChannel > .active.SidebarLink,