From b91e42615f9c2763b65f4c3496de2e4f1d77bfd6 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 19 Jun 2025 15:30:56 +0800 Subject: [PATCH] Secure Files and preview PDF inline (#8844) --- NOTICE.txt | 28 + android/app/build.gradle | 8 +- android/build.gradle | 3 + app/components/button/index.tsx | 4 +- .../channel_bookmark/bookmark_document.tsx | 6 +- .../channel_bookmark/bookmark_options.tsx | 13 +- .../channel_bookmark/channel_bookmark.tsx | 51 +- .../channel_bookmarks/channel_bookmarks.tsx | 14 +- app/components/channel_bookmarks/index.ts | 4 +- app/components/document/index.tsx | 8 +- .../draft_and_scheduled_post_files/index.ts | 4 +- app/components/files/audio_file.tsx | 16 +- app/components/files/document_file.tsx | 6 +- app/components/files/file.tsx | 4 + app/components/files/files.test.tsx | 26 + app/components/files/files.tsx | 3 + app/components/files/index.ts | 4 +- .../file_options/option_menus/index.tsx | 4 +- .../option_menus/option_menus.tsx | 6 +- .../files_search/file_options/toasts.tsx | 1 + app/components/files_search/file_result.tsx | 3 + app/components/files_search/file_results.tsx | 27 +- .../markdown/markdown_image/index.tsx | 20 +- .../markdown/markdown_link/markdown_link.tsx | 45 +- .../post_draft/draft_handler/index.ts | 3 +- .../post_draft/quick_actions/index.ts | 3 +- .../message_attachments/attachment_author.tsx | 20 +- .../message_attachments/attachment_title.tsx | 20 +- .../post/body/content/opengraph/opengraph.tsx | 20 +- .../post/body/content/youtube/index.tsx | 16 +- app/constants/events.ts | 1 + app/constants/license.ts | 42 +- app/constants/screens.ts | 21 +- app/hooks/files.test.ts | 2 +- app/hooks/files.ts | 28 +- app/queries/servers/security.test.ts | 438 +++++++++++++ app/queries/servers/security.ts | 55 ++ app/queries/servers/system.test.ts | 359 ++++------- app/queries/servers/system.ts | 48 +- app/screens/channel_files/channel_files.tsx | 3 + app/screens/channel_files/index.ts | 4 +- .../document_renderer/document_renderer.tsx | 58 +- .../gallery/document_renderer/index.ts | 3 +- .../footer/download_with_action/index.tsx | 34 +- app/screens/gallery/footer/footer.tsx | 12 +- app/screens/gallery/footer/index.ts | 4 +- app/screens/gallery/index.tsx | 15 +- app/screens/gallery/video_renderer/error.tsx | 65 +- app/screens/gallery/video_renderer/index.ts | 17 + .../{index.tsx => video_renderer.tsx} | 9 +- app/screens/home/search/results/index.tsx | 4 +- app/screens/home/search/results/results.tsx | 3 + app/screens/index.tsx | 3 + app/screens/pdf_viewer/error.tsx | 68 ++ app/screens/pdf_viewer/index.ts | 22 + app/screens/pdf_viewer/password.tsx | 209 ++++++ app/screens/pdf_viewer/pdf_viewer.tsx | 166 +++++ app/screens/settings/about/about.tsx | 14 +- app/utils/document/index.ts | 19 + app/utils/file/index.ts | 15 + app/utils/files.test.ts | 12 +- app/utils/files.tsx | 6 +- app/utils/navigation/index.ts | 29 +- app/utils/url/links.test.ts | 109 ++++ app/utils/url/links.ts | 41 ++ assets/base/i18n/en.json | 14 +- detox/.detoxrc.json | 4 +- ios/Podfile.lock | 33 +- jsconfig.json | 1 + .../@mattermost/secure-pdf-viewer/.gitignore | 1 + .../secure-pdf-viewer/android/build.gradle | 71 ++ .../android/src/main/AndroidManifest.xml | 2 + .../securepdfviewer/SecurePdfViewerPackage.kt | 59 ++ .../SecurePdfViewerViewManagerImpl.kt | 122 ++++ .../securepdfviewer/enums/Events.kt | 70 ++ .../securepdfviewer/event/PdfViewerEvent.kt | 39 ++ .../manager/PasswordAttemptStore.kt | 137 ++++ .../securepdfviewer/model/DeviceMemory.kt | 25 + .../securepdfviewer/pdfium/PdfDocument.kt | 177 +++++ .../securepdfviewer/pdfium/PdfPage.kt | 73 +++ .../securepdfviewer/pdfium/PdfView.kt | 227 +++++++ .../pdfium/cache/PdfCacheManager.kt | 230 +++++++ .../pdfium/gesture/ScaleListener.kt | 115 ++++ .../pdfium/gesture/ScrollGestureListener.kt | 157 +++++ .../pdfium/interaction/LinkHandler.kt | 153 +++++ .../pdfium/interaction/ScrollHandler.kt | 211 ++++++ .../pdfium/interaction/ZoomAnimator.kt | 327 ++++++++++ .../pdfium/layout/CoordinateConverter.kt | 105 +++ .../pdfium/layout/LayoutCalculator.kt | 501 ++++++++++++++ .../pdfium/manager/PdfDocumentManager.kt | 142 ++++ .../pdfium/manager/PdfRenderManager.kt | 505 +++++++++++++++ .../pdfium/shared/NativeAccessCoordinator.kt | 99 +++ .../pdfium/shared/PdfContext.kt | 80 +++ .../pdfium/shared/PdfViewInterface.kt | 7 + .../securepdfviewer/pdfium/util/ViewUtils.kt | 90 +++ .../securepdfviewer/util/FileValidator.kt | 140 ++++ .../securepdfviewer/util/HashUtils.kt | 51 ++ .../securepdfviewer/util/MemoryUtil.kt | 105 +++ .../view/SecurePdfViewerView.kt | 224 +++++++ .../view/callbacks/PdfViewCallbacks.kt | 210 ++++++ .../view/emitter/PdfEventEmitter.kt | 149 +++++ .../view/interaction/ScrollBarHandler.kt | 610 ++++++++++++++++++ .../view/manager/PdfLoadManager.kt | 96 +++ .../view/scrollhandle/BubbleView.kt | 230 +++++++ .../view/scrollhandle/ScrollBarView.kt | 116 ++++ .../view/scrollhandle/ScrollThumbView.kt | 136 ++++ .../SecurePdfViewerViewManager.kt | 174 +++++ .../SecurePdfViewerViewManager.kt | 185 ++++++ .../CustomThumbnailViewDelegate.swift | 5 + .../ios/Managers/PasswordAttemptStore.swift | 111 ++++ .../ios/RCTSecurePDFViewerComponentView.mm | 93 +++ .../ios/SecurePDFViewer-Bridging-Header.h | 2 + .../ios/SecurePDFViewerManager.h | 20 + .../ios/SecurePDFViewerManager.mm | 58 ++ ...nentView+CustomThumbnailViewDelegate.swift | 8 + ...fViewerComponentView+GestureHandling.swift | 33 + ...rePdfViewerComponentView+PDFHandling.swift | 157 +++++ ...ecurePdfViewerComponentView+Security.swift | 40 ++ .../SecurePdfViewerComponentView+Setup.swift | 207 ++++++ .../SecurePdfViewerComponentView.swift | 103 +++ .../ios/Utils/HashUtils.swift | 15 + .../ios/Utils/PDFErrorDiagnostics.swift | 205 ++++++ .../ios/Views/CustomThumbnailCell.swift | 71 ++ .../ios/Views/CustomThumbnailView.swift | 115 ++++ .../ios/Views/PageIndicatorView.swift | 91 +++ .../ios/Views/SecurePDFView.swift | 48 ++ .../secure-pdf-viewer/package.json | 23 + .../secure-pdf-viewer.podspec | 41 ++ .../secure-pdf-viewer/src/SecurePdfViewer.tsx | 12 + .../src/SecurePdfViewerNativeComponent.ts | 73 +++ .../secure-pdf-viewer/src/index.ts | 5 + package-lock.json | 25 +- package.json | 5 +- ...t+react-native-network-client+1.8.3.patch} | 0 ...6.patch => expo-file-system+18.0.12.patch} | 0 .../content_view/attachments/index.ts | 3 +- types/api/config.d.ts | 2 + 137 files changed, 9520 insertions(+), 582 deletions(-) create mode 100644 app/queries/servers/security.test.ts create mode 100644 app/queries/servers/security.ts create mode 100644 app/screens/gallery/video_renderer/index.ts rename app/screens/gallery/video_renderer/{index.tsx => video_renderer.tsx} (93%) create mode 100644 app/screens/pdf_viewer/error.tsx create mode 100644 app/screens/pdf_viewer/index.ts create mode 100644 app/screens/pdf_viewer/password.tsx create mode 100644 app/screens/pdf_viewer/pdf_viewer.tsx create mode 100644 app/utils/url/links.test.ts create mode 100644 app/utils/url/links.ts create mode 100644 libraries/@mattermost/secure-pdf-viewer/.gitignore create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/build.gradle create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/AndroidManifest.xml create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/SecurePdfViewerPackage.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/SecurePdfViewerViewManagerImpl.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/enums/Events.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/event/PdfViewerEvent.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/manager/PasswordAttemptStore.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/model/DeviceMemory.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/PdfDocument.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/PdfPage.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/PdfView.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/cache/PdfCacheManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/gesture/ScaleListener.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/gesture/ScrollGestureListener.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/interaction/LinkHandler.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/interaction/ScrollHandler.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/interaction/ZoomAnimator.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/layout/CoordinateConverter.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/layout/LayoutCalculator.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/manager/PdfDocumentManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/manager/PdfRenderManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/shared/NativeAccessCoordinator.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/shared/PdfContext.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/shared/PdfViewInterface.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/pdfium/util/ViewUtils.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/util/FileValidator.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/util/HashUtils.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/util/MemoryUtil.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/SecurePdfViewerView.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/callbacks/PdfViewCallbacks.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/emitter/PdfEventEmitter.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/interaction/ScrollBarHandler.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/manager/PdfLoadManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/scrollhandle/BubbleView.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/scrollhandle/ScrollBarView.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/main/java/com/mattermost/securepdfviewer/view/scrollhandle/ScrollThumbView.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/newarch/java/com/mattermost/securepdfviewer/SecurePdfViewerViewManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/android/src/oldarch/java/com/mattermost/securepdfviewer/SecurePdfViewerViewManager.kt create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Delegates/CustomThumbnailViewDelegate.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Managers/PasswordAttemptStore.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/RCTSecurePDFViewerComponentView.mm create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePDFViewer-Bridging-Header.h create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePDFViewerManager.h create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePDFViewerManager.mm create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView+CustomThumbnailViewDelegate.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView+GestureHandling.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView+PDFHandling.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView+Security.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView+Setup.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/SecurePdfViewerComponent/SecurePdfViewerComponentView.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Utils/HashUtils.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Utils/PDFErrorDiagnostics.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Views/CustomThumbnailCell.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Views/CustomThumbnailView.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Views/PageIndicatorView.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/ios/Views/SecurePDFView.swift create mode 100644 libraries/@mattermost/secure-pdf-viewer/package.json create mode 100644 libraries/@mattermost/secure-pdf-viewer/secure-pdf-viewer.podspec create mode 100644 libraries/@mattermost/secure-pdf-viewer/src/SecurePdfViewer.tsx create mode 100644 libraries/@mattermost/secure-pdf-viewer/src/SecurePdfViewerNativeComponent.ts create mode 100644 libraries/@mattermost/secure-pdf-viewer/src/index.ts rename patches/{@mattermost+react-native-network-client+1.8.2.patch => @mattermost+react-native-network-client+1.8.3.patch} (100%) rename patches/{expo-file-system+18.0.6.patch => expo-file-system+18.0.12.patch} (100%) diff --git a/NOTICE.txt b/NOTICE.txt index 9dd288ea2..e3f8d0ff1 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -3137,3 +3137,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Pdfium + +This product contains 'Pdfium' by The PDFium Authors. + +PDF rendering library used for displaying PDF documents. + +* HOMEPAGE: + * https://pdfium.googlesource.com/pdfium/ + +* LICENSE: Apache-2.0 + +Copyright 2014 The PDFium Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--- diff --git a/android/app/build.gradle b/android/app/build.gradle index f74077df7..036ea3c98 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -214,11 +214,11 @@ dependencies { androidTestImplementation 'com.wix:detox:20.26.2' // For animated GIF support - implementation 'com.facebook.fresco:animated-gif:3.6.0' + implementation 'com.facebook.fresco:animated-gif:3.6.0' - // For WebP support, including animated WebP - implementation 'com.facebook.fresco:animated-webp:3.6.0' - implementation 'com.facebook.fresco:webpsupport:3.6.0' + // For WebP support, including animated WebP + implementation 'com.facebook.fresco:animated-webp:3.6.0' + implementation 'com.facebook.fresco:webpsupport:3.6.0' implementation project(':reactnativenotifications') implementation project(':watermelondb-jsi') diff --git a/android/build.gradle b/android/build.gradle index 188a63bf9..21d9070cb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -33,9 +33,12 @@ buildscript { allprojects { repositories { + mavenLocal() + google() maven { url "$rootDir/../detox/node_modules/detox/Detox-android" } + maven { url 'https://jitpack.io' } } } diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index dfb9fdce8..b90aa38ff 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Button as ElementButton} from '@rneui/base'; +import {Button as ElementButton, type ButtonProps} from '@rneui/base'; import React, {useMemo, type ReactNode} from 'react'; import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle, type Insets} from 'react-native'; @@ -9,7 +9,7 @@ import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; -type Props = { +type Props = Omit & { theme: Theme; backgroundStyle?: StyleProp; buttonContainerStyle?: StyleProp; diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_document.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_document.tsx index 12d2b2b5f..6312b9440 100644 --- a/app/components/channel_bookmarks/channel_bookmark/bookmark_document.tsx +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_document.tsx @@ -18,6 +18,7 @@ import type FileModel from '@typings/database/models/servers/file'; type Props = { bookmark: ChannelBookmarkModel; canDownloadFiles: boolean; + enableSecureFilePreview: boolean; file: FileModel; onLongPress: () => void; } @@ -33,10 +34,10 @@ const styles = StyleSheet.create({ }, }); -const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props) => { +const BookmarkDocument = ({bookmark, canDownloadFiles, enableSecureFilePreview, file, onLongPress}: Props) => { const document = useRef(null); const theme = useTheme(); - const {progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(); + const {progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview); const handlePress = useCallback(async () => { if (document.current) { @@ -47,6 +48,7 @@ const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props return ( >; } @@ -54,6 +55,7 @@ const ChannelBookmarkOptions = ({ canDeleteBookmarks, canDownloadFiles, canEditBookmarks, + enableSecureFilePreview, file, setAction, }: Props) => { @@ -86,7 +88,7 @@ const ChannelBookmarkOptions = ({ return item; } return null; - }, [bookmark, file]); + }, [bookmark.ownerId, file, isImageFile, isVideoFile]); const handleDelete = useCallback(async () => { const {error} = await deleteChannelBookmark(serverUrl, bookmark.channelId, bookmark.id); @@ -141,7 +143,7 @@ const ChannelBookmarkOptions = ({ style: 'cancel', }], ); - }, [bookmark, handleDelete]); + }, [bookmark.displayName, handleDelete, intl]); const onEdit = useCallback(async () => { await dismissBottomSheet(); @@ -181,6 +183,7 @@ const ChannelBookmarkOptions = ({ children: ( @@ -226,7 +229,7 @@ const ChannelBookmarkOptions = ({ type='default' /> } - {canCopyPublicLink && + {!enableSecureFilePreview && canCopyPublicLink && } - {canShare && + {!enableSecureFilePreview && canShare && { - const url = normalizeProtocol(href); - if (!url) { - return; - } - - const match = matchDeepLink(url, serverUrl, siteURL); - - const onLinkError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); - }; - - if (match) { - const {error} = await handleDeepLink(match.url, intl); - if (error) { - tryOpenURL(match.url, onLinkError); - } - } else { - tryOpenURL(url, onLinkError); - } -}; - const ChannelBookmark = ({ - bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, + bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview, file, galleryIdentifier, index, onPress, publicLinkEnabled, siteURL, }: Props) => { const theme = useTheme(); @@ -94,7 +63,7 @@ const ChannelBookmark = ({ const intl = useIntl(); const [action, setAction] = useState('none'); const isDocumentFile = useMemo(() => isDocument(file), [file]); - const canCopyPublicLink = Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true'); + const canCopyPublicLink = !enableSecureFilePreview && Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true'); const handlePress = useCallback(() => { if (bookmark.linkUrl) { @@ -106,7 +75,7 @@ const ChannelBookmark = ({ }, [bookmark, index, intl, onPress, serverUrl, siteURL]); const handleLongPress = useCallback(() => { - const canShare = canDownloadFiles || bookmark.type === 'link'; + const canShare = !enableSecureFilePreview && (canDownloadFiles || bookmark.type === 'link'); const count = [canCopyPublicLink, canDeleteBookmarks, canShare, canEditBookmarks]. filter((e) => e).length; @@ -115,8 +84,9 @@ const ChannelBookmark = ({ bookmark={bookmark} canCopyPublicLink={canCopyPublicLink} canDeleteBookmarks={canDeleteBookmarks} - canDownloadFiles={canDownloadFiles} + canDownloadFiles={!enableSecureFilePreview && canDownloadFiles} canEditBookmarks={canEditBookmarks} + enableSecureFilePreview={enableSecureFilePreview} file={file} setAction={setAction} /> @@ -129,7 +99,7 @@ const ChannelBookmark = ({ theme, closeButtonId: 'close-channel-bookmark-actions', }); - }, [bookmark, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, file, theme]); + }, [bookmark, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview, file, theme]); const {onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index || 0, handlePress); @@ -144,6 +114,7 @@ const ChannelBookmark = ({ diff --git a/app/components/channel_bookmarks/channel_bookmarks.tsx b/app/components/channel_bookmarks/channel_bookmarks.tsx index 7fc094fe9..ef796b40f 100644 --- a/app/components/channel_bookmarks/channel_bookmarks.tsx +++ b/app/components/channel_bookmarks/channel_bookmarks.tsx @@ -9,9 +9,9 @@ import Animated from 'react-native-reanimated'; import {GalleryInit} from '@context/gallery'; import {useTheme} from '@context/theme'; import {useChannelBookmarkFiles} from '@hooks/files'; +import {usePreventDoubleTap} from '@hooks/utils'; import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; -import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import AddBookmark from './add_bookmark'; @@ -26,6 +26,7 @@ type Props = { canUploadFiles: boolean; channelId: string; currentUserId: string; + enableSecureFilePreview: boolean; publicLinkEnabled: boolean; showInInfo: boolean; separator?: boolean; @@ -68,24 +69,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ChannelBookmarks = ({ bookmarks, canAddBookmarks, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, canUploadFiles, - channelId, currentUserId, publicLinkEnabled, showInInfo, separator = true, + channelId, currentUserId, enableSecureFilePreview, publicLinkEnabled, showInInfo, separator = true, }: Props) => { const galleryIdentifier = `${channelId}-bookmarks`; const theme = useTheme(); const styles = getStyleSheet(theme); - const files = useChannelBookmarkFiles(bookmarks, publicLinkEnabled); + const files = useChannelBookmarkFiles(bookmarks); const [allowEndFade, setAllowEndFade] = useState(true); const attachmentIndex = useCallback((fileId: string) => { return files.findIndex((file) => file.id === fileId) || 0; }, [files]); - const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => { + const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => { if (files.length) { const items = files.map((f) => fileToGalleryItem(f, f.user_id)); openGalleryAtIndex(galleryIdentifier, idx, items); } - }), [files]); + }, [files, galleryIdentifier])); const renderItem = useCallback(({item}: ListRenderItemInfo) => { return ( @@ -94,6 +95,7 @@ const ChannelBookmarks = ({ canDeleteBookmarks={canDeleteBookmarks} canDownloadFiles={canDownloadFiles} canEditBookmarks={canEditBookmarks} + enableSecureFilePreview={enableSecureFilePreview} galleryIdentifier={galleryIdentifier} index={item.fileId ? attachmentIndex(item.fileId) : undefined} onPress={handlePreviewPress} @@ -101,7 +103,7 @@ const ChannelBookmarks = ({ /> ); }, [ - canDeleteBookmarks, canDownloadFiles, canEditBookmarks, + canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview, galleryIdentifier, attachmentIndex, handlePreviewPress, publicLinkEnabled, ]); diff --git a/app/components/channel_bookmarks/index.ts b/app/components/channel_bookmarks/index.ts index 1248da186..1a9f652a0 100644 --- a/app/components/channel_bookmarks/index.ts +++ b/app/components/channel_bookmarks/index.ts @@ -4,7 +4,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {observeBookmarks, observeCanDeleteBookmarks, observeCanEditBookmarks} from '@queries/servers/channel_bookmark'; -import {observeCanDownloadFiles, observeCanUploadFiles, observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system'; +import {observeCanDownloadFiles, observeCanUploadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; +import {observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system'; import ChannelBookmarks from './channel_bookmarks'; @@ -19,6 +20,7 @@ const enhanced = withObservables([], ({channelId, database}: Props) => { bookmarks: observeBookmarks(database, channelId), canDeleteBookmarks: observeCanDeleteBookmarks(database, channelId), canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), canEditBookmarks: observeCanEditBookmarks(database, channelId), canUploadFiles: observeCanUploadFiles(database), currentUserId: observeCurrentUserId(database), diff --git a/app/components/document/index.tsx b/app/components/document/index.tsx index 1b2b5bdb0..4a5c5ceea 100644 --- a/app/components/document/index.tsx +++ b/app/components/document/index.tsx @@ -5,6 +5,7 @@ import {forwardRef, useImperativeHandle, type ReactNode, useCallback} from 'reac import {useIntl} from 'react-intl'; import {alertDownloadDocumentDisabled} from '@utils/document'; +import {isPdf} from '@utils/file'; export type DocumentRef = { handlePreviewPress: () => void; @@ -12,22 +13,23 @@ export type DocumentRef = { type DocumentProps = { canDownloadFiles: boolean; + enableSecureFilePreview: boolean; file: FileInfo; children: ReactNode; downloadAndPreviewFile: (file: FileInfo) => void; } -const Document = forwardRef(({canDownloadFiles, children, downloadAndPreviewFile, file}: DocumentProps, ref) => { +const Document = forwardRef(({canDownloadFiles, children, downloadAndPreviewFile, enableSecureFilePreview, file}: DocumentProps, ref) => { const intl = useIntl(); const handlePreviewPress = useCallback(async () => { - if (!canDownloadFiles) { + if (!canDownloadFiles || (enableSecureFilePreview && !isPdf(file))) { alertDownloadDocumentDisabled(intl); return; } downloadAndPreviewFile(file); - }, [canDownloadFiles, downloadAndPreviewFile, intl, file]); + }, [canDownloadFiles, enableSecureFilePreview, downloadAndPreviewFile, file, intl]); useImperativeHandle(ref, () => ({ handlePreviewPress, diff --git a/app/components/draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_files/index.ts b/app/components/draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_files/index.ts index ab603b8ea..9642593b9 100644 --- a/app/components/draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_files/index.ts +++ b/app/components/draft_scheduled_post/draft_scheduled_post_container/draft_and_scheduled_post_files/index.ts @@ -4,13 +4,15 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import Files from '@components/files/files'; -import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system'; +import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; +import {observeConfigBooleanValue} from '@queries/servers/system'; import type {WithDatabaseArgs} from '@typings/database/database'; const enhance = withObservables([], ({database}: WithDatabaseArgs) => { return { canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), }; }); diff --git a/app/components/files/audio_file.tsx b/app/components/files/audio_file.tsx index a31d28630..91c26906b 100644 --- a/app/components/files/audio_file.tsx +++ b/app/components/files/audio_file.tsx @@ -15,7 +15,7 @@ import Video, {type OnLoadData, type OnProgressData, type OnVideoErrorData, type import {useTheme} from '@context/theme'; import {useDownloadFileAndPreview} from '@hooks/files'; import useThrottled from '@hooks/throttled'; -import {alertDownloadDocumentDisabled} from '@utils/document'; +import {alertDownloadDocumentDisabled, alertOnlyPDFSupported} from '@utils/document'; import {logDebug} from '@utils/log'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -27,6 +27,7 @@ import ProgressBar from '../progress_bar'; type Props = { file: FileInfo; canDownloadFiles: boolean; + enableSecureFilePreview: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -65,7 +66,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const AudioFile = ({file, canDownloadFiles}: Props) => { +const AudioFile = ({file, canDownloadFiles, enableSecureFilePreview}: Props) => { const intl = useIntl(); const theme = useTheme(); const style = getStyleSheet(theme); @@ -93,20 +94,25 @@ const AudioFile = ({file, canDownloadFiles}: Props) => { const source = useMemo(() => ({uri: file.uri}), [file.uri]); - const {toggleDownloadAndPreview} = useDownloadFileAndPreview(); + const {toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview); const onPlayPress = () => { setHasPaused(!hasPaused); }; const onDownloadPress = useCallback(async () => { + if (enableSecureFilePreview) { + alertOnlyPDFSupported(intl); + return; + } + if (!canDownloadFiles) { alertDownloadDocumentDisabled(intl); return; } toggleDownloadAndPreview(file); - }, [canDownloadFiles, intl, file, toggleDownloadAndPreview]); + }, [enableSecureFilePreview, canDownloadFiles, toggleDownloadAndPreview, file, intl]); const loadTimeInMinutes = useCallback((timeInSeconds: number) => { const minutes = Math.floor(timeInSeconds / 60); @@ -209,6 +215,7 @@ const AudioFile = ({file, canDownloadFiles}: Props) => { {timeInMinutes} + {!enableSecureFilePreview && @@ -218,6 +225,7 @@ const AudioFile = ({file, canDownloadFiles}: Props) => { style={style.downloadIcon} /> + } ); diff --git a/app/components/files/document_file.tsx b/app/components/files/document_file.tsx index 6a7dea889..354d70150 100644 --- a/app/components/files/document_file.tsx +++ b/app/components/files/document_file.tsx @@ -19,6 +19,7 @@ type DocumentFileProps = { backgroundColor?: string; disabled?: boolean; canDownloadFiles: boolean; + enableSecureFilePreview: boolean; file: FileInfo; } @@ -32,10 +33,10 @@ const styles = StyleSheet.create({ }, }); -const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, disabled = false, file}: DocumentFileProps, ref) => { +const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, disabled = false, enableSecureFilePreview, file}: DocumentFileProps, ref) => { const theme = useTheme(); const document = useRef(null); - const {downloading, progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(); + const {downloading, progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview); const handlePreviewPress = async () => { document.current?.handlePreviewPress(); @@ -70,6 +71,7 @@ const DocumentFile = forwardRef(({backgroundColo return ( @@ -238,6 +241,7 @@ const File = ({ ); diff --git a/app/components/files/files.test.tsx b/app/components/files/files.test.tsx index dc9c65153..944e7f406 100644 --- a/app/components/files/files.test.tsx +++ b/app/components/files/files.test.tsx @@ -60,6 +60,7 @@ jest.mocked(File).mockImplementation((props) => ( {String(props.nonVisibleImagesCount)} {props.wrapperWidth} {String(props.inViewPort)} + {String(props.enableSecureFilePreview)} props.onPress(props.index)} @@ -74,6 +75,7 @@ jest.mocked(File).mockImplementation((props) => ( function getBaseProps(): ComponentProps { return { canDownloadFiles: true, + enableSecureFilePreview: false, failed: false, filesInfo: [], isReplyPost: false, @@ -170,16 +172,40 @@ describe('Files', () => { const baseProps = getBaseProps(); baseProps.filesInfo = filesInfo; baseProps.canDownloadFiles = false; + baseProps.enableSecureFilePreview = false; const {getByTestId, rerender} = render(); expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false'); + expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('false'); expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false'); + expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('false'); baseProps.canDownloadFiles = true; + baseProps.enableSecureFilePreview = true; rerender(); expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true'); + expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('true'); expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true'); + expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('true'); + + baseProps.canDownloadFiles = true; + baseProps.enableSecureFilePreview = true; + + rerender(); + expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true'); + expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('true'); + expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true'); + expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('true'); + + baseProps.canDownloadFiles = false; + baseProps.enableSecureFilePreview = false; + + rerender(); + expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false'); + expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('false'); + expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false'); + expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('false'); }); it('should set layoutWidth if provided', () => { diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 64f6a8353..e9467c797 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -18,6 +18,7 @@ import File from './file'; type FilesProps = { canDownloadFiles: boolean; + enableSecureFilePreview: boolean; failed?: boolean; filesInfo: FileInfo[]; layoutWidth?: number; @@ -50,6 +51,7 @@ const styles = StyleSheet.create({ const Files = ({ canDownloadFiles, + enableSecureFilePreview, failed, filesInfo, isReplyPost, @@ -106,6 +108,7 @@ const Files = ({ galleryIdentifier={galleryIdentifier} key={file.id} canDownloadFiles={canDownloadFiles} + enableSecureFilePreview={enableSecureFilePreview} file={file} index={attachmentIndex(file.id!)} onPress={handlePreviewPress} diff --git a/app/components/files/index.ts b/app/components/files/index.ts index f2b7b15b7..c7984cec6 100644 --- a/app/components/files/index.ts +++ b/app/components/files/index.ts @@ -6,7 +6,8 @@ import {of as of$, from as from$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {queryFilesForPost} from '@queries/servers/file'; -import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system'; +import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; +import {observeConfigBooleanValue} from '@queries/servers/system'; import {fileExists} from '@utils/file'; import Files from './files'; @@ -44,6 +45,7 @@ const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => { return { canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), postId: of$(post.id), postProps: of$(post.props), publicLinkEnabled, diff --git a/app/components/files_search/file_options/option_menus/index.tsx b/app/components/files_search/file_options/option_menus/index.tsx index 8bed07c1b..8e490d197 100644 --- a/app/components/files_search/file_options/option_menus/index.tsx +++ b/app/components/files_search/file_options/option_menus/index.tsx @@ -3,7 +3,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {observeConfigBooleanValue, observeCanDownloadFiles} from '@queries/servers/system'; +import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; +import {observeConfigBooleanValue} from '@queries/servers/system'; import OptionMenus from './option_menus'; @@ -13,6 +14,7 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { return { canDownloadFiles: observeCanDownloadFiles(database), enablePublicLink: observeConfigBooleanValue(database, 'EnablePublicLink'), + enableSecureFilePreview: observeEnableSecureFilePreview(database), }; }); diff --git a/app/components/files_search/file_options/option_menus/option_menus.tsx b/app/components/files_search/file_options/option_menus/option_menus.tsx index 948603c3f..3e8af4f30 100644 --- a/app/components/files_search/file_options/option_menus/option_menus.tsx +++ b/app/components/files_search/file_options/option_menus/option_menus.tsx @@ -14,12 +14,14 @@ import type {GalleryAction} from '@typings/screens/gallery'; type Props = { canDownloadFiles?: boolean; enablePublicLink?: boolean; + enableSecureFilePreview: boolean; fileInfo: FileInfo; setAction: (action: GalleryAction) => void; } const OptionMenus = ({ canDownloadFiles, enablePublicLink, + enableSecureFilePreview, fileInfo, setAction, }: Props) => { @@ -53,7 +55,7 @@ const OptionMenus = ({ return ( <> - {canDownloadFiles && + {(!enableSecureFilePreview && canDownloadFiles) && - {enablePublicLink && + {(!enableSecureFilePreview && enablePublicLink) && ({ type Props = { canDownloadFiles: boolean; + enableSecureFilePreview: boolean; fileChannels: ChannelModel[]; fileInfos: FileInfo[]; paddingTop: StyleProp; @@ -57,6 +57,7 @@ const Separator = () => ; const FileResults = ({ canDownloadFiles, + enableSecureFilePreview, fileChannels, fileInfos, paddingTop, @@ -67,14 +68,13 @@ const FileResults = ({ }: Props) => { const theme = useTheme(); const styles = getStyles(theme); - const insets = useSafeAreaInsets(); const isTablet = useIsTablet(); const [action, setAction] = useState('none'); const [lastViewedFileInfo, setLastViewedFileInfo] = useState(undefined); const containerStyle = useMemo(() => ([paddingTop, {flexGrow: 1}]), [paddingTop]); - const numOptions = getNumberFileMenuOptions(canDownloadFiles, publicLinkEnabled); + const numOptions = getNumberFileMenuOptions(canDownloadFiles, enableSecureFilePreview, publicLinkEnabled); const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos); const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments), [imageAttachments, nonImageAttachments]); @@ -84,14 +84,14 @@ const FileResults = ({ const fileInfosIndexes = useMemo(() => getFileInfosIndexes(orderedFileInfos), [orderedFileInfos]); const orderedGalleryItems = useMemo(() => getOrderedGalleryItems(orderedFileInfos), [orderedFileInfos]); - const onPreviewPress = useCallback(preventDoubleTap((idx: number) => { + const onPreviewPress = usePreventDoubleTap(useCallback((idx: number) => { openGalleryAtIndex(galleryIdentifier, idx, orderedGalleryItems); - }), [orderedGalleryItems]); + }, [orderedGalleryItems])); - const updateFileForGallery = (idx: number, file: FileInfo) => { + const updateFileForGallery = useCallback((idx: number, file: FileInfo) => { 'worklet'; orderedFileInfos[idx] = file; - }; + }, [orderedFileInfos]); const onOptionsPress = useCallback((fInfo: FileInfo) => { setLastViewedFileInfo(fInfo); @@ -104,7 +104,7 @@ const FileResults = ({ theme, }); } - }, [insets, isTablet, numOptions, theme]); + }, [isTablet, numOptions, theme]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { let channelName: string | undefined; @@ -115,6 +115,7 @@ const FileResults = ({ return ( ); }, [ - (orderedFileInfos.length === 1) && orderedFileInfos[0].mime_type, canDownloadFiles, + enableSecureFilePreview, channelNames, fileInfosIndexes, onPreviewPress, onOptionsPress, numOptions, + isChannelFiles, + updateFileForGallery, ]); const noResults = useMemo(() => { @@ -148,7 +151,7 @@ const FileResults = ({ type={TabTypes.FILES} /> ); - }, [searchValue]); + }, [isChannelFiles, isFilterEnabled, searchValue]); return ( <> @@ -180,11 +183,13 @@ const FileResults = ({ showsVerticalScrollIndicator={true} testID='search_results.post_list.flat_list' /> + {!enableSecureFilePreview && + } ); }; diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index 7c14c0e97..d34f24267 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -5,7 +5,7 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-clipboard/clipboard'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, Platform, type StyleProp, Text, type TextStyle, TouchableWithoutFeedback, View} from 'react-native'; +import {Platform, type StyleProp, Text, type TextStyle, TouchableWithoutFeedback, View} from 'react-native'; import Animated from 'react-native-reanimated'; import {SvgUri} from 'react-native-svg'; import parseUrl from 'url-parse'; @@ -30,6 +30,7 @@ import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url'; +import {onOpenLinkError} from '@utils/url/links'; import type {GalleryItemType} from '@typings/screens/gallery'; @@ -130,22 +131,9 @@ const MarkdownImage = ({ if (linkDestination) { const url = normalizeProtocol(linkDestination); - const onError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); - }; - - tryOpenURL(url, onError); + tryOpenURL(url, () => onOpenLinkError(intl)); } - }, [linkDestination]); + }, [intl, linkDestination]); const handleLinkLongPress = useCallback(() => { if (managedConfig?.copyAndPasteProtection !== 'true') { diff --git a/app/components/markdown/markdown_link/markdown_link.tsx b/app/components/markdown/markdown_link/markdown_link.tsx index f36ccd6bb..bad509358 100644 --- a/app/components/markdown/markdown_link/markdown_link.tsx +++ b/app/components/markdown/markdown_link/markdown_link.tsx @@ -5,17 +5,16 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-clipboard/clipboard'; import React, {Children, type ReactElement, useCallback} from 'react'; import {useIntl, defineMessages} from 'react-intl'; -import {Alert, StyleSheet, Text, View} from 'react-native'; +import {StyleSheet, Text, View} from 'react-native'; import urlParse from 'url-parse'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import {usePreventDoubleTap} from '@hooks/utils'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; -import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; import {bottomSheetSnapPoint, isEmail} from '@utils/helpers'; -import {preventDoubleTap} from '@utils/tap'; -import {normalizeProtocol, tryOpenURL} from '@utils/url'; +import {openLink} from '@utils/url/links'; type MarkdownLinkProps = { children: ReactElement; @@ -61,39 +60,9 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU const serverUrl = useServerUrl(); const theme = useTheme(); - const {formatMessage} = intl; - - const handlePress = useCallback(preventDoubleTap(async () => { - const url = normalizeProtocol(href); - - if (!url) { - return; - } - - const onError = () => { - Alert.alert( - formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); - }; - - const match = matchDeepLink(url, serverUrl, siteURL); - - if (match) { - const {error} = await handleDeepLink(match.url, intl); - if (error) { - tryOpenURL(match.url, onError); - } - } else { - tryOpenURL(url, onError); - } - }), [href, intl.locale, serverUrl, siteURL]); + const handlePress = usePreventDoubleTap(useCallback(() => { + openLink(href, serverUrl, siteURL, intl); + }, [href, intl, serverUrl, siteURL])); const parseChildren = useCallback(() => { return Children.map(children, (child: ReactElement) => { @@ -102,7 +71,7 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU } const {props, ...otherChildProps} = child; - // eslint-disable-next-line react/prop-types + const {literal, ...otherProps} = props; const nextProps = { diff --git a/app/components/post_draft/draft_handler/index.ts b/app/components/post_draft/draft_handler/index.ts index bf8eb7d2d..5a0baa830 100644 --- a/app/components/post_draft/draft_handler/index.ts +++ b/app/components/post_draft/draft_handler/index.ts @@ -5,7 +5,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft'; -import {observeCanUploadFiles, observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system'; +import {observeCanUploadFiles} from '@queries/servers/security'; +import {observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system'; import DraftHandler from './draft_handler'; diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts index ed39b2ff0..7ebfec66e 100644 --- a/app/components/post_draft/quick_actions/index.ts +++ b/app/components/post_draft/quick_actions/index.ts @@ -5,7 +5,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {observeIsPostPriorityEnabled} from '@queries/servers/post'; -import {observeCanUploadFiles, observeMaxFileCount} from '@queries/servers/system'; +import {observeCanUploadFiles} from '@queries/servers/security'; +import {observeMaxFileCount} from '@queries/servers/system'; import QuickActions from './quick_actions'; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx index 3176a1364..34a25c55d 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx @@ -2,12 +2,13 @@ // See LICENSE.txt for license information. import {Image} from 'expo-image'; -import React from 'react'; +import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, Text, View} from 'react-native'; +import {Text, View} from 'react-native'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; +import {onOpenLinkError} from '@utils/url/links'; type Props = { icon?: string; @@ -39,24 +40,15 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => { const intl = useIntl(); const style = getStyleSheet(theme); - const openLink = () => { + const openLink = useCallback(() => { if (link) { const onError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); + onOpenLinkError(intl); }; tryOpenURL(link, onError); } - }; + }, [intl, link]); return ( diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx index f61e06f03..e9a4f05d9 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, Text, View} from 'react-native'; +import {Text, View} from 'react-native'; import Markdown from '@components/markdown'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; +import {onOpenLinkError} from '@utils/url/links'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -41,24 +42,15 @@ const AttachmentTitle = ({channelId, link, location, theme, value}: Props) => { const intl = useIntl(); const style = getStyleSheet(theme); - const openLink = () => { + const openLink = useCallback(() => { if (link) { const onError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); + onOpenLinkError(intl); }; tryOpenURL(link, onError); } - }; + }, [intl, link]); let title; if (link) { diff --git a/app/components/post_list/post/body/content/opengraph/opengraph.tsx b/app/components/post_list/post/body/content/opengraph/opengraph.tsx index 5da6c274e..1f621c63d 100644 --- a/app/components/post_list/post/body/content/opengraph/opengraph.tsx +++ b/app/components/post_list/post/body/content/opengraph/opengraph.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, Text, TouchableOpacity, View} from 'react-native'; +import {Text, TouchableOpacity, View} from 'react-native'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; +import {onOpenLinkError} from '@utils/url/links'; import OpengraphImage from './opengraph_image'; @@ -75,22 +76,13 @@ const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLi openGraphData.images.length && metadata?.images); - const goToLink = () => { + const goToLink = useCallback(() => { const onError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); + onOpenLinkError(intl); }; tryOpenURL(link, onError); - }; + }, [intl, link]); let siteName; if (openGraphData.site_name) { diff --git a/app/components/post_list/post/body/content/youtube/index.tsx b/app/components/post_list/post/body/content/youtube/index.tsx index efa2307b8..502558be4 100644 --- a/app/components/post_list/post/body/content/youtube/index.tsx +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -4,12 +4,13 @@ import {ImageBackground} from 'expo-image'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, StyleSheet, TouchableOpacity} from 'react-native'; +import {StyleSheet, TouchableOpacity} from 'react-native'; import {useIsTablet} from '@hooks/device'; import {calculateDimensions, getViewPortWidth} from '@utils/images'; import {changeOpacity} from '@utils/theme'; import {getYouTubeVideoId, tryOpenURL} from '@utils/url'; +import {onOpenLinkError} from '@utils/url/links'; import YouTubeLogo from './youtube_logo'; @@ -62,20 +63,11 @@ const YouTube = ({isReplyPost, layoutWidth, metadata}: YouTubeProps) => { } const onError = () => { - Alert.alert( - intl.formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - intl.formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); + onOpenLinkError(intl); }; tryOpenURL(link, onError); - }, [link, intl.locale]); + }, [link, intl]); let imgUrl; if (metadata?.images) { diff --git a/app/constants/events.ts b/app/constants/events.ts index 657c9c964..44476c060 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -8,6 +8,7 @@ export default keyMirror({ CHANNEL_ARCHIVED: null, CHANNEL_SWITCH: null, CLOSE_BOTTOM_SHEET: null, + CLOSE_GALLERY: null, CONFIG_CHANGED: null, FREEZE_SCREEN: null, GALLERY_ACTIONS: null, diff --git a/app/constants/license.ts b/app/constants/license.ts index 9427529d6..1a06f5b73 100644 --- a/app/constants/license.ts +++ b/app/constants/license.ts @@ -1,16 +1,34 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -export default { - SKU_SHORT_NAME: { - E10: 'E10', - E20: 'E20', - Starter: 'starter', - Professional: 'professional', - Enterprise: 'enterprise', - EnterpriseAdvanced: 'advanced', - }, - SelfHostedProducts: { - STARTER: 'starter', - }, +export const SKU_SHORT_NAME = { + E10: 'E10', + E20: 'E20', + Starter: 'starter', + Professional: 'professional', + Enterprise: 'enterprise', + EnterpriseAdvanced: 'advanced', +}; + +export const SelfHostedProducts = { + STARTER: 'starter', +}; + +export const TIER = { + ProfessionalTier: 10, + EnterpriseTier: 20, + EnterpriseAdvancedTier: 30, +}; + +export const LicenseSkuTier = { + [SKU_SHORT_NAME.Professional]: TIER.ProfessionalTier, + [SKU_SHORT_NAME.Enterprise]: TIER.EnterpriseTier, + [SKU_SHORT_NAME.EnterpriseAdvanced]: TIER.EnterpriseAdvancedTier, +}; + +export default { + SKU_SHORT_NAME, + SelfHostedProducts, + TIER, + LicenseSkuTier, }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index ca0be0fc6..bf18d738e 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -11,6 +11,8 @@ export const CALL_PARTICIPANTS = 'CallParticipants'; export const CALL_HOST_CONTROLS = 'CallHostControls'; export const CHANNEL = 'Channel'; export const CHANNEL_ADD_MEMBERS = 'ChannelAddMembers'; +export const CHANNEL_BANNER = 'ChannelBanner'; +export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit'; export const CHANNEL_FILES = 'ChannelFiles'; export const CHANNEL_INFO = 'ChannelInfo'; export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences'; @@ -31,7 +33,9 @@ export const EMOJI_PICKER = 'EmojiPicker'; export const FIND_CHANNELS = 'FindChannels'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const GALLERY = 'Gallery'; +export const GENERIC_OVERLAY = 'GenericOverlay'; export const GLOBAL_DRAFTS = 'GlobalDrafts'; +export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts'; export const GLOBAL_THREADS = 'GlobalThreads'; export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; @@ -45,6 +49,7 @@ export const MANAGE_CHANNEL_MEMBERS = 'ManageChannelMembers'; export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; export const ONBOARDING = 'Onboarding'; +export const PDF_VIEWER = 'PdfViewer'; export const PERMALINK = 'Permalink'; export const PINNED_MESSAGES = 'PinnedMessages'; export const POST_OPTIONS = 'PostOptions'; @@ -82,10 +87,6 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; -export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit'; -export const GENERIC_OVERLAY = 'GenericOverlay'; -export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts'; -export const CHANNEL_BANNER = 'ChannelBanner'; export default { ABOUT, @@ -98,6 +99,7 @@ export default { CALL_HOST_CONTROLS, CHANNEL, CHANNEL_ADD_MEMBERS, + CHANNEL_BANNER, CHANNEL_BOOKMARK, CHANNEL_FILES, CHANNEL_INFO, @@ -118,7 +120,9 @@ export default { FIND_CHANNELS, FORGOT_PASSWORD, GALLERY, + GENERIC_OVERLAY, GLOBAL_DRAFTS, + GLOBAL_DRAFTS_AND_SCHEDULED_POSTS, GLOBAL_THREADS, HOME, INTEGRATION_SELECTOR, @@ -132,6 +136,7 @@ export default { MENTIONS, MFA, ONBOARDING, + PDF_VIEWER, PERMALINK, PINNED_MESSAGES, POST_OPTIONS, @@ -141,6 +146,7 @@ export default { RESCHEDULE_DRAFT, REVIEW_APP, SAVED_MESSAGES, + SCHEDULED_POST_OPTIONS, SEARCH, SELECT_TEAM, SERVER, @@ -168,10 +174,6 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, - GENERIC_OVERLAY, - SCHEDULED_POST_OPTIONS, - GLOBAL_DRAFTS_AND_SCHEDULED_POSTS, - CHANNEL_BANNER, } as const; export const MODAL_SCREENS_WITHOUT_BACK = new Set([ @@ -186,8 +188,9 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set([ EDIT_SERVER, FIND_CHANNELS, GALLERY, - MANAGE_CHANNEL_MEMBERS, INVITE, + MANAGE_CHANNEL_MEMBERS, + PDF_VIEWER, PERMALINK, RESCHEDULE_DRAFT, ]); diff --git a/app/hooks/files.test.ts b/app/hooks/files.test.ts index 498e56a4b..0522c810d 100644 --- a/app/hooks/files.test.ts +++ b/app/hooks/files.test.ts @@ -181,7 +181,7 @@ describe('useChannelBookmarkFiles', () => { (buildFilePreviewUrl as jest.Mock).mockImplementation((url, id) => `${url}/files/${id}/preview`); (getImageSize as jest.Mock).mockImplementation(() => ({width: 100, height: 100})); - const {result, waitForNextUpdate} = renderHook(() => useChannelBookmarkFiles(bookmarks, true)); + const {result, waitForNextUpdate} = renderHook(() => useChannelBookmarkFiles(bookmarks)); await waitForNextUpdate(); diff --git a/app/hooks/files.ts b/app/hooks/files.ts index 2054b6a8a..f12bc5030 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -12,16 +12,17 @@ import {getLocalFileInfo} from '@actions/local/file'; import {buildFilePreviewUrl, buildFileUrl, downloadFile} from '@actions/remote/file'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; +import {alertDownloadFailed, alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document'; import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors'; -import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isVideo} from '@utils/file'; +import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isPdf, isVideo} from '@utils/file'; import {getImageSize} from '@utils/gallery'; import {logDebug} from '@utils/log'; +import {previewPdf} from '@utils/navigation'; import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; -const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean, cb: (files: FileInfo[]) => void) => { +const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], cb: (files: FileInfo[]) => void) => { const fileInfos: FileInfo[] = []; for await (const b of bookmarks) { if (b.fileId) { @@ -32,7 +33,7 @@ const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], const videoFile = isVideo(fileInfo); let uri; - if (imageFile || (videoFile && publicLinkEnabled)) { + if (imageFile || videoFile) { if (fileInfo.localPath) { uri = fileInfo.localPath; } else { @@ -87,18 +88,18 @@ export const useImageAttachments = (filesInfo: FileInfo[]) => { }, [filesInfo, serverUrl]); }; -export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean) => { +export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[]) => { const serverUrl = useServerUrl(); const [files, setFiles] = useState([]); useEffect(() => { - getFileInfo(serverUrl, bookmarks, publicLinkEnabled, setFiles); - }, [serverUrl, bookmarks, publicLinkEnabled]); + getFileInfo(serverUrl, bookmarks, setFiles); + }, [serverUrl, bookmarks]); return files; }; -export const useDownloadFileAndPreview = () => { +export const useDownloadFileAndPreview = (enableSecureFilePreview: boolean) => { const serverUrl = useServerUrl(); const intl = useIntl(); const theme = useTheme(); @@ -144,6 +145,15 @@ export const useDownloadFileAndPreview = () => { path = getLocalFilePathFromFile(serverUrl, file); } + if (enableSecureFilePreview) { + if (isPdf(file)) { + previewPdf(file, path, theme, onDonePreviewingFile); + } else { + alertOnlyPDFSupported(intl); + } + return; + } + setPreview(true); setStatusBarColor('dark-content'); FileViewer.open(path!.replace('file://', ''), { @@ -163,7 +173,7 @@ export const useDownloadFileAndPreview = () => { } }); } - }, [didCancel, preview, serverUrl, intl, onDonePreviewingFile, setStatusBarColor]); + }, [didCancel, preview, enableSecureFilePreview, setStatusBarColor, onDonePreviewingFile, serverUrl, theme, intl]); const downloadAndPreviewFile = useCallback(async (file: FileInfo) => { setDidCancel(false); diff --git a/app/queries/servers/security.test.ts b/app/queries/servers/security.test.ts new file mode 100644 index 000000000..34e67d034 --- /dev/null +++ b/app/queries/servers/security.test.ts @@ -0,0 +1,438 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {License} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +import {observeAllowPdfLinkNavigation, observeCanDownloadFiles, observeCanUploadFiles, observeEnableSecureFilePreview} from './security'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +describe('observeCanUploadFiles', () => { + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return true if no file attachment config value', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }, 1500); + + it('should return false if file attachment config value is false', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'false'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and there is no license', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and server is not licensed', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and server is licensed, but no compliance is set', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and server is licensed, but compliance is set to false', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is not set', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return false if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to false', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to true', (done) => { + operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanUploadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); +}); + +describe('observeCanDownloadFiles', () => { + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return true if there is no license', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }, 1500); + + it('should return true if server is not licensed', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if server is licensed, but no compliance is set', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if server is licensed, but compliance is set to false', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if is licensed and compliance is set to true, but EnableMobileFileDownload is not set', (done) => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }, 1500); + + it('should return false if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to false', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to true', (done) => { + operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeCanDownloadFiles(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); +}); + +describe('observeEnableSecureFilePreview', () => { + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return false if MobileEnableSecureFilePreview is false and license tier is below EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileEnableSecureFilePreview', value: 'false'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeEnableSecureFilePreview(database).subscribe((data) => { + if (data === false) { + done(); + } + }); + }); + }); + }, 1500); + + it('should return true if MobileEnableSecureFilePreview is true and license tier is EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileEnableSecureFilePreview', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeEnableSecureFilePreview(database).subscribe((data) => { + console.log('data', data); + if (data === true) { + done(); + } + }); + }); + }); + }, 1500); + + it('should return false if MobileEnableSecureFilePreview is false even if license tier is EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileEnableSecureFilePreview', value: 'false'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeEnableSecureFilePreview(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return false if MobileEnableSecureFilePreview is true but license tier is below EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileEnableSecureFilePreview', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeEnableSecureFilePreview(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); +}); + +describe('observeAllowPdfLinkNavigation', () => { + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return false if MobileAllowPdfLinkNavigation is false and license tier is below EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileAllowPdfLinkNavigation', value: 'false'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeAllowPdfLinkNavigation(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return true if MobileAllowPdfLinkNavigation is true and license tier is EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileAllowPdfLinkNavigation', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeAllowPdfLinkNavigation(database).subscribe((data) => { + if (data === true) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return false if MobileAllowPdfLinkNavigation is false even if license tier is EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileAllowPdfLinkNavigation', value: 'false'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeAllowPdfLinkNavigation(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); + + it('should return false if MobileAllowPdfLinkNavigation is true but license tier is below EnterpriseAdvanced', (done) => { + operator.handleConfigs({configs: [ + {id: 'MobileAllowPdfLinkNavigation', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { + observeAllowPdfLinkNavigation(database).subscribe((data) => { + if (data === false) { + done(); + } else { + done.fail(); + } + }); + }); + }); + }, 1500); +}); diff --git a/app/queries/servers/security.ts b/app/queries/servers/security.ts new file mode 100644 index 000000000..f32f9319e --- /dev/null +++ b/app/queries/servers/security.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {of as of$, combineLatest} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {License} from '@constants'; + +import {observeConfigBooleanValue, observeIsMinimumLicenseTier, observeLicense} from './system'; + +import type {Database} from '@nozbe/watermelondb'; + +export const observeCanUploadFiles = (database: Database) => { + const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments', true); + const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload', true); + const license = observeLicense(database); + + return combineLatest([enableFileAttachments, enableMobileFileUpload, license]).pipe( + switchMap(([efa, emfu, l]) => of$( + efa && + (l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfu), + )), + distinctUntilChanged(), + ); +}; + +export const observeCanDownloadFiles = (database: Database) => { + const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload', true); + const license = observeLicense(database); + + return combineLatest([enableMobileFileDownload, license]).pipe( + switchMap(([emfd, l]) => of$((l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfd))), + distinctUntilChanged(), + ); +}; + +export const observeEnableSecureFilePreview = (database: Database) => { + const enableSecureFilePreview = observeConfigBooleanValue(database, 'MobileEnableSecureFilePreview', false); + const isAvailable = observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.EnterpriseAdvanced); + + return combineLatest([enableSecureFilePreview, isAvailable]).pipe( + switchMap(([esfp, is]) => of$(is && esfp)), + distinctUntilChanged(), + ); +}; + +export const observeAllowPdfLinkNavigation = (database: Database) => { + const allowPdfLinkNavigation = observeConfigBooleanValue(database, 'MobileAllowPdfLinkNavigation', false); + const isAvailable = observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.EnterpriseAdvanced); + + return combineLatest([allowPdfLinkNavigation, isAvailable]).pipe( + switchMap(([esfp, is]) => of$(is && esfp)), + distinctUntilChanged(), + ); +}; diff --git a/app/queries/servers/system.test.ts b/app/queries/servers/system.test.ts index 9d0b859e8..845f149f8 100644 --- a/app/queries/servers/system.test.ts +++ b/app/queries/servers/system.test.ts @@ -3,10 +3,11 @@ import {Platform} from 'react-native'; +import {License} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; -import {observeCanDownloadFiles, observeCanUploadFiles, observeReportAProblemMetadata} from './system'; +import {observeIsMinimumLicenseTier, observeReportAProblemMetadata} from './system'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Database} from '@nozbe/watermelondb'; @@ -18,250 +19,6 @@ jest.mock('expo-application', () => { }; }); -describe('observeCanUploadFiles', () => { - const serverUrl = 'baseHandler.test.com'; - let database: Database; - let operator: ServerDataOperator; - beforeEach(async () => { - await DatabaseManager.init([serverUrl]); - const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - database = serverDatabaseAndOperator.database; - operator = serverDatabaseAndOperator.operator; - }); - afterEach(async () => { - await DatabaseManager.destroyServerDatabase(serverUrl); - }); - - it('should return true if no file attachment config value', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }, 1500); - - it('should return false if file attachment config value is false', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'false'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === false) { - done(); - } else { - done.fail(); - } - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and there is no license', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and server is not licensed', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and server is licensed, but no compliance is set', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and server is licensed, but compliance is set to false', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is not set', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return false if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to false', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === false) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to true', (done) => { - operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanUploadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); -}); - -describe('observeCanDownloadFiles', () => { - const serverUrl = 'baseHandler.test.com'; - let database: Database; - let operator: ServerDataOperator; - beforeEach(async () => { - await DatabaseManager.init([serverUrl]); - const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - database = serverDatabaseAndOperator.database; - operator = serverDatabaseAndOperator.operator; - }); - afterEach(async () => { - await DatabaseManager.destroyServerDatabase(serverUrl); - }); - - it('should return true if there is no license', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }, 1500); - - it('should return true if server is not licensed', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if server is licensed, but no compliance is set', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if server is licensed, but compliance is set to false', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if is licensed and compliance is set to true, but EnableMobileFileDownload is not set', (done) => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }, 1500); - - it('should return false if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to false', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === false) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); - - it('should return true if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to true', (done) => { - operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => { - operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => { - observeCanDownloadFiles(database).subscribe((data) => { - if (data === true) { - done(); - } else { - done.fail(); - } - }); - }); - }); - }, 1500); -}); - describe('observeReportAProblemMetadata', () => { const serverUrl = 'baseHandler.test.com'; let database: Database; @@ -326,3 +83,115 @@ describe('observeReportAProblemMetadata', () => { }); }); }); + +describe('observeIsMinimumLicenseTier', () => { + const serverUrl = 'baseHandler.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return false if no license is present', (done) => { + operator.handleConfigs({configs: [ + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => { + expect(isMinimumTier).toBe(false); + done(); + }); + }); + }); + + it('should return false if license tier is below the required tier', (done) => { + operator.handleConfigs({configs: [ + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({ + systems: [ + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Starter}}, + ], + prepareRecordsOnly: false, + }).then(() => { + observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => { + expect(isMinimumTier).toBe(false); + done(); + }); + }); + }); + }); + + it('should return true if license tier matches the required tier', (done) => { + operator.handleConfigs({configs: [ + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({ + systems: [ + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Professional}}, + ], + prepareRecordsOnly: false, + }).then(() => { + observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => { + expect(isMinimumTier).toBe(true); + done(); + }); + }); + }); + }); + + it('should return true if license tier is above the required tier', (done) => { + operator.handleConfigs({configs: [ + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + prepareRecordsOnly: false, + configsToDelete: []}).then(() => { + operator.handleSystem({ + systems: [ + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Enterprise}}, + ], + prepareRecordsOnly: false, + }).then(() => { + observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => { + expect(isMinimumTier).toBe(true); + done(); + }); + }); + }); + }); + + it('should return false if BuildEnterpriseReady is false', (done) => { + operator.handleConfigs({ + configs: [ + {id: 'BuildEnterpriseReady', value: 'false'}, + ], + prepareRecordsOnly: false, + configsToDelete: [], + }).then(() => { + operator.handleSystem({ + systems: [ + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'Professional'}}, + ], + prepareRecordsOnly: false, + }).then(() => { + observeIsMinimumLicenseTier(database, 'Professional').subscribe((isMinimumTier) => { + expect(isMinimumTier).toBe(false); + done(); + }); + }); + }); + }); +}); diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index f3e970f81..af766bbc3 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import {Database, Q} from '@nozbe/watermelondb'; import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'; import {Platform} from 'react-native'; @@ -541,28 +543,6 @@ export const observeLastDismissedAnnouncement = (database: Database) => { ); }; -export const observeCanUploadFiles = (database: Database) => { - const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments', true); - const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload', true); - const license = observeLicense(database); - - return combineLatest([enableFileAttachments, enableMobileFileUpload, license]).pipe( - switchMap(([efa, emfu, l]) => of$( - efa && - (l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfu), - )), - ); -}; - -export const observeCanDownloadFiles = (database: Database) => { - const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload', true); - const license = observeLicense(database); - - return combineLatest([enableMobileFileDownload, license]).pipe( - switchMap(([emfd, l]) => of$((l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfd))), - ); -}; - export const observeLastServerVersionCheck = (database: Database) => { return querySystemValue(database, SYSTEM_IDENTIFIERS.LAST_SERVER_VERSION_CHECK).observeWithColumns(['value']).pipe( switchMap((result) => (result.length ? result[0].observe() : of$({value: 0}))), @@ -647,3 +627,27 @@ export const observeReportAProblemMetadata = (database: Database) => { })), ); }; + +export const observeIsMinimumLicenseTier = (database: Database, shortSku: string) => { + const license = observeLicense(database); + const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false); + + return combineLatest([license, isEnterpriseReady]).pipe( + switchMap(([lic, isEnt]) => { + if (!shortSku) { + return of$(false); + } + const isLicensed = lic?.IsLicensed === 'true'; + if (!isEnt || !isLicensed) { + return of$(false); + } + + const tier = License.LicenseSkuTier[lic.SkuShortName]; + const targetTier = License.LicenseSkuTier[shortSku] || 0; + const isMinimumTier = Boolean(tier) && Boolean(targetTier) && isEnt && isLicensed && tier >= targetTier; + + return of$(isMinimumTier); + }), + distinctUntilChanged(), + ); +}; diff --git a/app/screens/channel_files/channel_files.tsx b/app/screens/channel_files/channel_files.tsx index 89726eb75..d68aacaa8 100644 --- a/app/screens/channel_files/channel_files.tsx +++ b/app/screens/channel_files/channel_files.tsx @@ -30,6 +30,7 @@ type Props = { channel: ChannelModel; componentId: AvailableScreens; canDownloadFiles: boolean; + enableSecureFilePreview: boolean; publicLinkEnabled: boolean; } @@ -76,6 +77,7 @@ function ChannelFiles({ channel, componentId, canDownloadFiles, + enableSecureFilePreview, publicLinkEnabled, }: Props) { const theme = useTheme(); @@ -172,6 +174,7 @@ function ChannelFiles({ {!loading && return { channel, canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), }; }); diff --git a/app/screens/gallery/document_renderer/document_renderer.tsx b/app/screens/gallery/document_renderer/document_renderer.tsx index cd94fd7d5..abb34dddf 100644 --- a/app/screens/gallery/document_renderer/document_renderer.tsx +++ b/app/screens/gallery/document_renderer/document_renderer.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {defineMessages, useIntl} from 'react-intl'; import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native'; import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; @@ -10,7 +10,7 @@ import Animated from 'react-native-reanimated'; import FileIcon from '@components/files/file_icon'; import {Events, Preferences} from '@constants'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; -import {isDocument} from '@utils/file'; +import {isDocument, isPdf} from '@utils/file'; import {galleryItemToFileInfo} from '@utils/gallery'; import {changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -21,6 +21,7 @@ import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery'; type Props = { canDownloadFiles: boolean; + enableSecureFilePreview: boolean; item: GalleryItemType; onShouldHideControls: (hide: boolean) => void; } @@ -49,19 +50,46 @@ const styles = StyleSheet.create({ }, }); -const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props) => { +const messages = defineMessages({ + unsupported: { + id: 'gallery.unsupported', + defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", + }, + openFile: { + id: 'gallery.open_file', + defaultMessage: 'Open file', + }, + onlyPdf: { + id: 'gallery.only_pdf', + defaultMessage: 'Only PDF files are supported for secure file preview.', + }, +}); + +const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onShouldHideControls}: Props) => { const {formatMessage} = useIntl(); const file = useMemo(() => galleryItemToFileInfo(item), [item]); const [controls, setControls] = useState(true); const [enabled, setEnabled] = useState(true); const isSupported = useMemo(() => isDocument(file), [file]); - const optionText = isSupported ? formatMessage({ - id: 'gallery.open_file', - defaultMessage: 'Open file', - }) : formatMessage({ - id: 'gallery.unsupported', - defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", - }); + const canOpenFile = useMemo(() => { + if (!isSupported) { + return false; + } + if (enableSecureFilePreview && isPdf(file)) { + return true; + } + + return !enableSecureFilePreview && canDownloadFiles; + }, [canDownloadFiles, enableSecureFilePreview, file, isSupported]); + + const optionText = useMemo(() => { + if (enableSecureFilePreview && !isPdf(file)) { + return formatMessage(messages.onlyPdf); + } else if (!isSupported) { + return formatMessage(messages.unsupported); + } + return formatMessage(messages.openFile); + }, [enableSecureFilePreview, file, formatMessage, isSupported]); const handleHideControls = useCallback(() => { onShouldHideControls(controls); @@ -79,6 +107,12 @@ const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props) setEnabled(false); }, []); + const handlePdfPreview = useCallback(() => { + if (enableSecureFilePreview && isPdf(file)) { + DeviceEventEmitter.emit(Events.CLOSE_GALLERY); + } + }, [file, enableSecureFilePreview]); + return ( <> @@ -97,7 +131,7 @@ const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props) {!isSupported && {optionText} } - {isSupported && canDownloadFiles && + {canOpenFile && } diff --git a/app/screens/gallery/document_renderer/index.ts b/app/screens/gallery/document_renderer/index.ts index 6384856b0..9e878d5d2 100644 --- a/app/screens/gallery/document_renderer/index.ts +++ b/app/screens/gallery/document_renderer/index.ts @@ -3,7 +3,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {observeCanDownloadFiles} from '@queries/servers/system'; +import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; import DocumentRenderer from './document_renderer'; @@ -12,6 +12,7 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { return { canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), }; }); diff --git a/app/screens/gallery/footer/download_with_action/index.tsx b/app/screens/gallery/footer/download_with_action/index.tsx index 365495417..142ff7bcb 100644 --- a/app/screens/gallery/footer/download_with_action/index.tsx +++ b/app/screens/gallery/footer/download_with_action/index.tsx @@ -21,11 +21,13 @@ import ProgressBar from '@components/progress_bar'; import Toast from '@components/toast'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; import {useServerUrl} from '@context/server'; -import {alertFailedToOpenDocument} from '@utils/document'; +import {useTheme} from '@context/theme'; +import {alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document'; import {getFullErrorMessage} from '@utils/errors'; -import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, pathWithPrefix} from '@utils/file'; +import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, isPdf, pathWithPrefix} from '@utils/file'; import {galleryItemToFileInfo} from '@utils/gallery'; import {logDebug} from '@utils/log'; +import {previewPdf} from '@utils/navigation'; import {typography} from '@utils/typography'; import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; @@ -34,6 +36,7 @@ import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery'; type Props = { action: GalleryAction; galleryView?: boolean; + enableSecureFilePreview: boolean; item: GalleryItemType; setAction: (action: GalleryAction) => void; onDownloadSuccess?: (path: string) => void; @@ -70,9 +73,10 @@ const styles = StyleSheet.create({ }, }); -const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, galleryView = true}: Props) => { +const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSuccess, setAction, galleryView = true}: Props) => { const intl = useIntl(); const serverUrl = useServerUrl(); + const theme = useTheme(); const insets = useSafeAreaInsets(); const [showToast, setShowToast] = useState(); const [error, setError] = useState(''); @@ -153,14 +157,22 @@ const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, gallery if (response.data?.path) { const path = response.data.path as string; onDownloadSuccess?.(path); - FileViewer.open(path, { - displayName: item.name, - showAppsSuggestions: true, - showOpenWithDialog: true, - }).catch(() => { - const file = galleryItemToFileInfo(item); - alertFailedToOpenDocument(file, intl); - }); + if (enableSecureFilePreview) { + if (isPdf(galleryItemToFileInfo(item))) { + previewPdf(item, path, theme); + } else { + alertOnlyPDFSupported(intl); + } + } else { + FileViewer.open(path, { + displayName: item.name, + showAppsSuggestions: true, + showOpenWithDialog: true, + }).catch(() => { + const file = galleryItemToFileInfo(item); + alertFailedToOpenDocument(file, intl); + }); + } } setShowToast(false); } diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx index a12b0e0c1..a581c0ec8 100644 --- a/app/screens/gallery/footer/footer.tsx +++ b/app/screens/gallery/footer/footer.tsx @@ -30,6 +30,7 @@ type Props = { enablePostIconOverride: boolean; enablePostUsernameOverride: boolean; enablePublicLink: boolean; + enableSecureFilePreview: boolean; hideActions: boolean; isDirectChannel: boolean; item: GalleryItemType; @@ -60,7 +61,7 @@ const styles = StyleSheet.create({ const Footer = ({ author, canDownloadFiles, channelName, currentUserId, - enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, + enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview, hideActions, isDirectChannel, item, post, style, teammateNameDisplay, hasCaptions, captionEnabled, onCaptionsPress, }: Props) => { @@ -110,14 +111,15 @@ const Footer = ({ edges={edges} style={[style]} > - {['downloading', 'sharing'].includes(action) && + {['downloading', 'sharing'].includes(action) && !enableSecureFilePreview && canDownloadFiles && } - {action === 'copying' && + {action === 'copying' && !enableSecureFilePreview && enablePublicLink && { enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, + enableSecureFilePreview: observeEnableSecureFilePreview(database), isDirectChannel, post, teammateNameDisplay, diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index 430444678..aadc50a00 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -3,10 +3,11 @@ import RNUtils from '@mattermost/rnutils'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Platform, StyleSheet, View} from 'react-native'; +import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native'; import {CaptionsEnabledContext} from '@calls/context'; import {hasCaptions} from '@calls/utils'; +import {Events} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet, useWindowDimensions} from '@hooks/device'; import {useGalleryControls} from '@hooks/gallery'; @@ -79,12 +80,22 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde requestAnimationFrame(async () => { dismissOverlay(componentId); }); - }, [isTablet]); + }, [componentId, isTablet]); const onIndexChange = useCallback((index: number) => { setLocalIndex(index); }, []); + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.CLOSE_GALLERY, () => { + onClose(); + }); + + return () => { + listener.remove(); + }; + }, [onClose]); + useAndroidHardwareBackHandler(componentId, close); return ( diff --git a/app/screens/gallery/video_renderer/error.tsx b/app/screens/gallery/video_renderer/error.tsx index 008ff1699..f52360093 100644 --- a/app/screens/gallery/video_renderer/error.tsx +++ b/app/screens/gallery/video_renderer/error.tsx @@ -44,6 +44,8 @@ const styles = StyleSheet.create({ }); type Props = { + canDownloadFiles: boolean; + enableSecureFilePreview: boolean; filename: string; height: number; isDownloading: boolean; @@ -54,7 +56,7 @@ type Props = { width: number; } -const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideControls, posterUri, setDownloading, width}: Props) => { +const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, onShouldHideControls, posterUri, setDownloading, width}: Props) => { const [hasPoster, setHasPoster] = useState(false); const [loadPosterError, setLoadPosterError] = useState(false); const dimensions = useWindowDimensions(); @@ -93,20 +95,22 @@ const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideCont ); } - return ( - - - {poster} - - {filename} - - {isRemote && + let remoteVideo; + if (isRemote) { + if (enableSecureFilePreview) { + remoteVideo = ( + + + + + + ); + } else if (canDownloadFiles) { + remoteVideo = ( - } + ); + } else { + remoteVideo = ( + + + + + + ); + } + } + + return ( + + + {poster} + + {filename} + + {isRemote && remoteVideo} {!isRemote && ({ + canDownloadFiles: observeCanDownloadFiles(database), + enableSecureFilePreview: observeEnableSecureFilePreview(database), +})); + +export default withDatabase(enhance(VideoRenderer)); diff --git a/app/screens/gallery/video_renderer/index.tsx b/app/screens/gallery/video_renderer/video_renderer.tsx similarity index 93% rename from app/screens/gallery/video_renderer/index.tsx rename to app/screens/gallery/video_renderer/video_renderer.tsx index ec9459dd1..d8b541142 100644 --- a/app/screens/gallery/video_renderer/index.tsx +++ b/app/screens/gallery/video_renderer/video_renderer.tsx @@ -27,7 +27,9 @@ import VideoError from './error'; import type {ImageRendererProps} from '../image_renderer'; import type {GalleryAction} from '@typings/screens/gallery'; -interface VideoRendererProps extends ImageRendererProps { +export interface VideoRendererProps extends ImageRendererProps { + canDownloadFiles: boolean; + enableSecureFilePreview: boolean; index: number; initialIndex: number; onShouldHideControls: (hide: boolean) => void; @@ -46,7 +48,7 @@ const styles = StyleSheet.create({ }, }); -const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => { +const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => { const fullscreen = useSharedValue(false); const {bottom} = useSafeAreaInsets(); const serverUrl = useServerUrl(); @@ -176,6 +178,8 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul /> {hasError && { case Screens.SELECT_TEAM: screen = withServerDatabase(require('@screens/select_team').default); break; + case Screens.PDF_VIEWER: + screen = withServerDatabase(require('@screens/pdf_viewer').default); + break; case Screens.PERMALINK: screen = withServerDatabase(require('@screens/permalink').default); break; diff --git a/app/screens/pdf_viewer/error.tsx b/app/screens/pdf_viewer/error.tsx new file mode 100644 index 000000000..0aedc5880 --- /dev/null +++ b/app/screens/pdf_viewer/error.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {View, StyleSheet, Text} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + message?: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + zIndex: 1, + }, + icon: { + marginBottom: 10, + }, + title: { + ...typography('Body', 100, 'Regular'), + color: theme.centerChannelColor, + marginBottom: 5, + textAlign: 'center', + }, + message: { + ...typography('Body', 75, 'Regular'), + color: theme.centerChannelColor, + marginBottom: 15, + textAlign: 'center', + paddingHorizontal: 20, + }, +})); + +const PdfLoadError = ({message}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + + {Boolean(message) && ( + + {message} + + )} + + ); +}; + +export default PdfLoadError; diff --git a/app/screens/pdf_viewer/index.ts b/app/screens/pdf_viewer/index.ts new file mode 100644 index 000000000..148e19a67 --- /dev/null +++ b/app/screens/pdf_viewer/index.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {observeAllowPdfLinkNavigation} from '@queries/servers/security'; +import {observeConfigValue} from '@queries/servers/system'; + +import PdfViewer from './pdf_viewer'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + fileId: string; +} + +const enhance = withObservables([], ({database}: Props) => ({ + siteURL: observeConfigValue(database, 'SiteURL'), + allowPdfLinkNavigation: observeAllowPdfLinkNavigation(database), +})); + +export default withDatabase(enhance(PdfViewer)); diff --git a/app/screens/pdf_viewer/password.tsx b/app/screens/pdf_viewer/password.tsx new file mode 100644 index 000000000..17b05e849 --- /dev/null +++ b/app/screens/pdf_viewer/password.tsx @@ -0,0 +1,209 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard'; +import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {StyleSheet, TextInput, View, type Insets, type NativeSyntheticEvent, type TextInputSubmitEditingEventData} from 'react-native'; + +import Button from '@components/button'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + setPassword: (text: string | undefined) => void; + maxAttempts?: number; + remainingAttempts?: number; +} + +export type PasswordRef = { + clear: () => void; +} + +const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + backgroundColor: theme.centerChannelBg, + }, + required: { + ...typography('Body', 100, 'Regular'), + color: theme.centerChannelColor, + marginVertical: 10, + }, + unlock: { + ...typography('Body', 50, 'Regular'), + color: theme.centerChannelColor, + }, + limit: { + ...typography('Body', 100, 'Regular'), + color: theme.errorTextColor, + }, + input: { + ...typography('Body', 100, 'Regular'), + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + color: theme.centerChannelColor, + borderRadius: 4, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + paddingHorizontal: 10, + marginTop: 10, + textAlign: 'center', + textAlignVertical: 'center', + height: 40, + width: '80%', + maxWidth: 400, + marginBottom: 10, + }, +})); + +const messages = defineMessages({ + password: { + id: 'mobile.pdf_viewer.password', + defaultMessage: 'Password', + }, + enter_password: { + id: 'mobile.pdf_viewer.enter_password', + defaultMessage: 'Please enter the password to unlock it.', + }, + password_failed: { + id: 'mobile.pdf_viewer.password_failed', + defaultMessage: 'Incorrect password. You have {count} {count, plural, one {attempt} other {attempts}} remaining.', + }, + password_limit_reached: { + id: 'mobile.pdf_viewer.password_limit_reached', + defaultMessage: 'You’ve entered the wrong password too many times.', + }, + unlock: { + id: 'mobile.pdf_viewer.unlock', + defaultMessage: 'Unlock', + }, +}); + +const PdfPassword = forwardRef(({maxAttempts, remainingAttempts, setPassword}, ref) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const [inputValue, setInputValue] = useState(undefined); + const limitReached = (maxAttempts !== undefined && maxAttempts > 0 && remainingAttempts === 0); + const hasFailed = maxAttempts !== undefined && maxAttempts > 0 && remainingAttempts !== undefined && remainingAttempts < maxAttempts; + const disabled = inputValue === undefined || inputValue.length === 0 || limitReached; + + let subtextComponent; + if (limitReached) { + subtextComponent = ( + + ); + } else if (hasFailed) { + subtextComponent = ( + + ); + } else { + subtextComponent = ( + + ); + } + + useImperativeHandle(ref, () => ({ + clear: () => { + setInputValue(undefined); + }, + }), []); + + const onSubmitEditing = useCallback((e: NativeSyntheticEvent) => { + const value = e.nativeEvent.text.trim(); + if (value) { + setPassword(value); + } + }, [setPassword]); + + const onPress = useCallback(() => { + setPassword(inputValue?.trim()); + }, [inputValue, setPassword]); + + useHardwareKeyboardEvents({onEnterPressed: onPress}); + + return ( + + + + {subtextComponent} + +