diff --git a/app/actions/local/file.ts b/app/actions/local/file.ts index d08fff4534..215518cd4e 100644 --- a/app/actions/local/file.ts +++ b/app/actions/local/file.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; +import {getFileById} from '@queries/servers/file'; export const updateLocalFile = async (serverUrl: string, file: FileInfo) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -11,3 +12,25 @@ export const updateLocalFile = async (serverUrl: string, file: FileInfo) => { return operator.handleFiles({files: [file], prepareRecordsOnly: false}); }; + +export const updateLocalFilePath = async (serverUrl: string, fileId: string, localPath: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + try { + const file = await getFileById(database, fileId); + if (file) { + await database.write(async () => { + await file.update((r) => { + r.localPath = localPath; + }); + }); + } + + return {error: undefined}; + } catch (error) { + return {error}; + } +}; diff --git a/app/components/post_list/post/body/files/files.tsx b/app/components/post_list/post/body/files/files.tsx index f62f173a5c..d1c9a0b9b8 100644 --- a/app/components/post_list/post/body/files/files.tsx +++ b/app/components/post_list/post/body/files/files.tsx @@ -17,13 +17,10 @@ import {preventDoubleTap} from '@utils/tap'; import File from './file'; -import type FileModel from '@typings/database/models/servers/file'; - type FilesProps = { - authorId: string; canDownloadFiles: boolean; failed?: boolean; - files: FileModel[]; + filesInfo: FileInfo[]; layoutWidth?: number; location: string; isReplyPost: boolean; @@ -50,12 +47,11 @@ const styles = StyleSheet.create({ }, }); -const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => { +const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => { const galleryIdentifier = `${postId}-fileAttachments-${location}`; const [inViewPort, setInViewPort] = useState(false); const serverUrl = useServerUrl(); const isTablet = useIsTablet(); - const filesInfo: FileInfo[] = useMemo(() => files.map((f) => f.toFileInfo(authorId)), [authorId, files]); const {images: imageAttachments, nonImages: nonImageAttachments} = useMemo(() => { return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => { @@ -79,7 +75,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWi } return {images, nonImages}; }, {images: [], nonImages: []}); - }, [files, publicLinkEnabled, serverUrl]); + }, [filesInfo, publicLinkEnabled, serverUrl]); const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments), [imageAttachments, nonImageAttachments]); @@ -89,7 +85,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWi }; const handlePreviewPress = preventDoubleTap((idx: number) => { - const items = filesForGallery.value.map((f) => fileToGalleryItem(f, authorId)); + const items = filesForGallery.value.map((f) => fileToGalleryItem(f, f.user_id)); openGalleryAtIndex(galleryIdentifier, idx, items); }); @@ -99,7 +95,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWi filesForGallery.value[idx] = file; }; - const isSingleImage = () => (files.length === 1 && isImage(files[0])); + const isSingleImage = () => (filesInfo.length === 1 && isImage(filesInfo[0])); const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => { const singleImage = isSingleImage(); diff --git a/app/components/post_list/post/body/files/index.ts b/app/components/post_list/post/body/files/index.ts index 1b69b25a4c..fe23f096f4 100644 --- a/app/components/post_list/post/body/files/index.ts +++ b/app/components/post_list/post/body/files/index.ts @@ -3,18 +3,40 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {combineLatest, of as of$} from 'rxjs'; +import {combineLatest, of as of$, from as from$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {fileExists} from '@utils/file'; import Files from './files'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; import type SystemModel from '@typings/database/models/servers/system'; -const enhance = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { +type EnhanceProps = WithDatabaseArgs & { + post: PostModel; +} + +const filesLocalPathValidation = async (files: FileModel[], authorId: string) => { + const filesInfo: FileInfo[] = []; + for await (const f of files) { + const info = f.toFileInfo(authorId); + if (info.localPath) { + const exists = await fileExists(info.localPath); + if (!exists) { + info.localPath = ''; + } + } + filesInfo.push(info); + } + + return filesInfo; +}; + +const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => { const config = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); const enableMobileFileDownload = config.pipe( switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')), @@ -32,11 +54,15 @@ const enhance = withObservables(['post'], ({database, post}: {post: PostModel} & map(([download, compliance]) => compliance || download), ); + const filesInfo = post.files.observeWithColumns(['local_path']).pipe( + switchMap((fs) => from$(filesLocalPathValidation(fs, post.userId))), + ); + return { - authorId: of$(post.userId), canDownloadFiles, postId: of$(post.id), publicLinkEnabled, + filesInfo, }; }); diff --git a/app/components/post_list/post/body/files/video_file.tsx b/app/components/post_list/post/body/files/video_file.tsx index 8a4489cc5c..59d74b049f 100644 --- a/app/components/post_list/post/body/files/video_file.tsx +++ b/app/components/post_list/post/body/files/video_file.tsx @@ -89,13 +89,14 @@ const VideoFile = ({ // library const publicUri = await fetchPublicLink(serverUrl, data.id!); if (('link') in publicUri) { - const {uri, height, width} = await getThumbnailAsync(publicUri.link, {time: 2000}); + const {uri, height, width} = await getThumbnailAsync(data.localPath || publicUri.link, {time: 2000}); data.mini_preview = uri; data.height = height; data.width = width; updateLocalFile(serverUrl, data); if (mounted.current) { setVideo(data); + setFailed(false); } } } diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 55e6851085..d440b50ca7 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -18,12 +18,11 @@ import Files from './files'; import Message from './message'; import Reactions from './reactions'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; type BodyProps = { appsEnabled: boolean; - files: FileModel[]; + filesCount: number; hasReactions: boolean; highlight: boolean; highlightReplyBar: boolean; @@ -73,7 +72,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Body = ({ - appsEnabled, files, hasReactions, highlight, highlightReplyBar, + appsEnabled, filesCount, hasReactions, highlight, highlightReplyBar, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember, location, post, showAddReaction, theme, }: BodyProps) => { @@ -166,10 +165,9 @@ const Body = ({ theme={theme} /> } - {files.length > 0 && + {filesCount > 0 && { }); const Post = ({ - appsEnabled, canDelete, currentUser, differentThreadSequence, files, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, + appsEnabled, canDelete, currentUser, differentThreadSequence, filesCount, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember, location, post, reactionsCount, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, testID, previousPost, @@ -247,7 +246,7 @@ const Post = ({ body = ( 0} highlight={Boolean(highlightedStyle)} highlightReplyBar={highlightReplyBar} diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index c9aa731605..403e4b0e81 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -112,7 +112,7 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs): file.width = raw?.width || record?.width || 0; file.height = raw?.height || record?.height || 0; file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; - file.localPath = raw?.localPath ?? ''; + file.localPath = raw?.localPath || record?.localPath || ''; }; return prepareBaseRecord({ diff --git a/app/queries/servers/file.ts b/app/queries/servers/file.ts new file mode 100644 index 0000000000..035a784717 --- /dev/null +++ b/app/queries/servers/file.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; + +import type {Database} from '@nozbe/watermelondb'; +import type FileModel from '@typings/database/models/servers/file'; + +const {SERVER: {FILE}} = MM_TABLES; + +export const getFileById = async (database: Database, fileId: string) => { + try { + const record = (await database.get(FILE).find(fileId)); + return record; + } catch { + return undefined; + } +}; diff --git a/app/screens/gallery/footer/download_with_action/index.tsx b/app/screens/gallery/footer/download_with_action/index.tsx index 6a22177ad8..b10f5da36e 100644 --- a/app/screens/gallery/footer/download_with_action/index.tsx +++ b/app/screens/gallery/footer/download_with_action/index.tsx @@ -29,6 +29,7 @@ type Props = { action: GalleryAction; item: GalleryItemType; setAction: (action: GalleryAction) => void; + onDownloadSuccess?: (path: string) => void; } const styles = StyleSheet.create({ @@ -62,7 +63,7 @@ const styles = StyleSheet.create({ }, }); -const DownloadWithAction = ({action, item, setAction}: Props) => { +const DownloadWithAction = ({action, item, onDownloadSuccess, setAction}: Props) => { const intl = useIntl(); const serverUrl = useServerUrl(); const [showToast, setShowToast] = useState(); @@ -129,10 +130,18 @@ const DownloadWithAction = ({action, item, setAction}: Props) => { } }; + const externalAction = async (response: ClientResponse) => { + if (response.data?.path && onDownloadSuccess) { + onDownloadSuccess(response.data.path as string); + } + setShowToast(false); + }; + const openFile = async (response: ClientResponse) => { if (mounted.current) { if (response.data?.path) { const path = response.data.path as string; + onDownloadSuccess?.(path); FileViewer.open(path, { displayName: item.name, showAppsSuggestions: true, @@ -187,6 +196,7 @@ const DownloadWithAction = ({action, item, setAction}: Props) => { const save = async (response: ClientResponse) => { if (response.data?.path) { const path = response.data.path as string; + onDownloadSuccess?.(path); const hasPermission = await hasWriteStoragePermission(intl); if (hasPermission) { @@ -206,6 +216,7 @@ const DownloadWithAction = ({action, item, setAction}: Props) => { if (mounted.current) { if (response.data?.path) { const path = response.data.path as string; + onDownloadSuccess?.(path); Share.open({ message: '', title: '', @@ -224,7 +235,7 @@ const DownloadWithAction = ({action, item, setAction}: Props) => { const path = getLocalFilePathFromFile(serverUrl, galleryItemToFileInfo(item)); if (path) { const exists = await fileExists(path); - let actionToExecute: (request: ClientResponse) => Promise; + let actionToExecute: (response: ClientResponse) => Promise; switch (action) { case 'sharing': actionToExecute = shareFile; @@ -232,6 +243,9 @@ const DownloadWithAction = ({action, item, setAction}: Props) => { case 'opening': actionToExecute = openFile; break; + case 'external': + actionToExecute = externalAction; + break; default: actionToExecute = save; break; diff --git a/app/screens/gallery/video_renderer/index.tsx b/app/screens/gallery/video_renderer/index.tsx index ca3cd72b72..eaff9201ce 100644 --- a/app/screens/gallery/video_renderer/index.tsx +++ b/app/screens/gallery/video_renderer/index.tsx @@ -2,16 +2,22 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {Platform, StyleSheet, useWindowDimensions} from 'react-native'; +import {useIntl} from 'react-intl'; +import {Alert, DeviceEventEmitter, Platform, StyleSheet, useWindowDimensions} from 'react-native'; import Animated, {Easing, useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming, WithTimingConfig} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import Video, {LoadError, OnPlaybackRateData} from 'react-native-video'; +import Video, {OnPlaybackRateData} from 'react-native-video'; +import {updateLocalFilePath} from '@actions/local/file'; import CompassIcon from '@components/compass_icon'; +import {Events} from '@constants'; import {GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery'; +import {useServerUrl} from '@context/server'; import {changeOpacity} from '@utils/theme'; -import {ImageRendererProps} from '../image_renderer'; +import DownloadWithAction from '../footer/download_with_action'; + +import type {ImageRendererProps} from '../image_renderer'; interface VideoRendererProps extends ImageRendererProps { index: number; @@ -50,15 +56,24 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul const dimensions = useWindowDimensions(); const fullscreen = useSharedValue(false); const {bottom} = useSafeAreaInsets(); + const {formatMessage} = useIntl(); + const serverUrl = useServerUrl(); const videoRef = useAnimatedRef