diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts new file mode 100644 index 0000000000..e840d1cdda --- /dev/null +++ b/app/actions/local/draft.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {queryDraft} from '@app/queries/servers/drafts'; +import DatabaseManager from '@database/manager'; + +export const updateDraftFile = async (serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const draft = await queryDraft(operator.database, channelId, rootId); + if (!draft) { + return {error: 'no draft'}; + } + + const i = draft.files.findIndex((v) => v.clientId === file.clientId); + if (i === -1) { + return {error: 'file not found'}; + } + + draft.prepareUpdate((d) => { + d.files[i] = file; + }); + + try { + if (!prepareRecordsOnly) { + await operator.batchRecords([draft]); + } + + return {draft}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/file.ts b/app/actions/remote/file.ts new file mode 100644 index 0000000000..a5bddd2a71 --- /dev/null +++ b/app/actions/remote/file.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ClientResponse, ClientResponseError} from '@mattermost/react-native-network-client'; + +import {Client} from '@client/rest'; +import ClientError from '@client/rest/error'; +import NetworkManager from '@init/network_manager'; + +export const uploadFile = ( + serverUrl: string, + file: FileInfo, + channelId: string, + onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void = () => {/*Do Nothing*/}, + onComplete: (response: ClientResponse) => void = () => {/*Do Nothing*/}, + onError: (response: ClientResponseError) => void = () => {/*Do Nothing*/}, + skipBytes = 0, +) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error: error as ClientError}; + } + return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)}; +}; diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 467e192b02..fc725b7f04 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -2,10 +2,10 @@ // See LICENSE.txt for license information. import {processPostsFetched} from '@actions/local/post'; -import {prepareMissingChannelsForAllTeams} from '@app/queries/servers/channel'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; +import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel'; import {queryCurrentUser} from '@queries/servers/user'; import {fetchPostAuthors, getMissingChannelsFromPosts} from './post'; diff --git a/app/actions/websocket/preferences.ts b/app/actions/websocket/preferences.ts index 9fda7351dd..9d8034f89b 100644 --- a/app/actions/websocket/preferences.ts +++ b/app/actions/websocket/preferences.ts @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {deletePreferences} from '@app/queries/servers/preference'; import DatabaseManager from '@database/manager'; +import {deletePreferences} from '@queries/servers/preference'; export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise { const database = DatabaseManager.serverDatabases[serverUrl]; diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index 699941868a..e40986a8c0 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -1,11 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {ClientResponse, ClientResponseError, ProgressPromise, UploadRequestOptions} from '@mattermost/react-native-network-client'; + export interface ClientFilesMix { getFileUrl: (fileId: string, timestamp: number) => string; getFileThumbnailUrl: (fileId: string, timestamp: number) => string; getFilePreviewUrl: (fileId: string, timestamp: number) => string; getFilePublicLink: (fileId: string) => Promise; + uploadPostAttachment: ( + file: FileInfo, + channelId: string, + onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, + onComplete: (response: ClientResponse) => void, + onError: (response: ClientResponseError) => void, + skipBytes?: number, + ) => () => void; } const ClientFiles = (superclass: any) => class extends superclass { @@ -42,6 +52,29 @@ const ClientFiles = (superclass: any) => class extends superclass { {method: 'get'}, ); }; + + uploadPostAttachment = async ( + file: FileInfo, + channelId: string, + onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, + onComplete: (response: ClientResponse) => void, + onError: (response: ClientResponseError) => void, + skipBytes = 0, + ) => { + const url = `${this.apiClient.baseUrl}${this.getFilesRoute()}`; + const options: UploadRequestOptions = { + skipBytes, + method: 'POST', + multipart: { + data: { + channel_id: channelId, + }, + }, + }; + const promise = this.apiClient.upload(url, file.localPath, options) as ProgressPromise; + promise.progress!(onProgress).then(onComplete).catch(onError); + return promise.cancel!; + }; }; export default ClientFiles; diff --git a/app/components/post_list/new_message_line/index.tsx b/app/components/post_list/new_message_line/index.tsx index 0912d88fa9..ef07a2aa86 100644 --- a/app/components/post_list/new_message_line/index.tsx +++ b/app/components/post_list/new_message_line/index.tsx @@ -4,9 +4,9 @@ import React from 'react'; import {StyleProp, View, ViewStyle} from 'react-native'; -import {typography} from '@app/utils/typography'; import FormattedText from '@components/formatted_text'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; type NewMessagesLineProps = { moreMessages: boolean; diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 4346f4e4bf..52a3ec43f4 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -4,13 +4,13 @@ import React from 'react'; import {View} from 'react-native'; -import {typography} from '@app/utils/typography'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import FormattedTime from '@components/formatted_time'; import {CHANNEL, THREAD} from '@constants/screens'; import {useTheme} from '@context/theme'; import {postUserDisplayName} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user'; import HeaderCommentedOn from './commented_on'; diff --git a/app/constants/files.ts b/app/constants/files.ts index adf0f3a36f..8968d9934f 100644 --- a/app/constants/files.ts +++ b/app/constants/files.ts @@ -42,4 +42,6 @@ const Files: Record = { ZIP_TYPES: ['zip'], }; +export const PROGRESS_TIME_TO_STORE = 60000; // 60 * 1000 (60s) + export default Files; diff --git a/app/init/draft_upload_manager.ts b/app/init/draft_upload_manager.ts new file mode 100644 index 0000000000..c57562e144 --- /dev/null +++ b/app/init/draft_upload_manager.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ClientResponse, ClientResponseError} from '@mattermost/react-native-network-client'; +import {AppState, AppStateStatus} from 'react-native'; + +import {updateDraftFile} from '@actions/local/draft'; +import {uploadFile} from '@actions/remote/file'; +import {PROGRESS_TIME_TO_STORE} from '@constants/files'; + +type FileHandler = { + [clientId: string]: { + cancel?: () => void; + fileInfo: FileInfo; + serverUrl: string; + channelId: string; + rootId: string; + lastTimeStored: number; + onError: Array<(msg: string) => void>; + onProgress: Array<(p: number, b: number) => void>; + }; +} + +class DraftUploadManager { + private handlers: FileHandler = {}; + private previousAppState: AppStateStatus; + + constructor() { + this.previousAppState = AppState.currentState; + AppState.addEventListener('change', this.onAppStateChange); + } + + public prepareUpload = ( + serverUrl: string, + file: FileInfo, + channelId: string, + rootId: string, + skipBytes = 0, + ) => { + this.handlers[file.clientId!] = { + fileInfo: file, + serverUrl, + channelId, + rootId, + lastTimeStored: 0, + onError: [], + onProgress: [], + }; + + const onProgress = (progress: number, bytesRead?: number | null | undefined) => { + this.handleProgress(file.clientId!, progress, bytesRead || 0); + }; + + const onComplete = (response: ClientResponse) => { + this.handleComplete(response, file.clientId!); + }; + + const onError = (response: ClientResponseError) => { + this.handleError(response.message, file.clientId!); + }; + + const {error, cancel} = uploadFile(serverUrl, file, channelId, onProgress, onComplete, onError, skipBytes); + if (error) { + this.handleError(error.message, file.clientId!); + return; + } + this.handlers[file.clientId!].cancel = cancel; + }; + + public cancel = (clientId: string) => { + const {cancel} = this.handlers[clientId]; + delete this.handlers[clientId]; + cancel?.(); + }; + + public isUploading = (clientId: string) => { + return Boolean(this.handlers[clientId]); + }; + + public registerProgressHandler = (clientId: string, callback: (progress: number, bytes: number) => void) => { + if (!this.handlers[clientId]) { + return null; + } + + this.handlers[clientId].onProgress.push(callback); + return () => { + if (!this.handlers[clientId]) { + return; + } + + this.handlers[clientId].onProgress = this.handlers[clientId].onProgress.filter((v) => v !== callback); + }; + }; + + public registerErrorHandler = (clientId: string, callback: (errMessage: string) => void) => { + if (!this.handlers[clientId]) { + return null; + } + + this.handlers[clientId].onError.push(callback); + return () => { + if (!this.handlers[clientId]) { + return; + } + + this.handlers[clientId].onError = this.handlers[clientId].onError.filter((v) => v !== callback); + }; + }; + + private handleProgress = (clientId: string, progress: number, bytes: number) => { + const h = this.handlers[clientId]; + if (!h) { + return; + } + + h.fileInfo.progress = progress; + h.fileInfo.bytesRead = bytes; + + h.onProgress.forEach((c) => c(progress, bytes)); + if (AppState.currentState !== 'active' && h.lastTimeStored + PROGRESS_TIME_TO_STORE < Date.now()) { + updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo); + h.lastTimeStored = Date.now(); + } + }; + + private handleComplete = (response: ClientResponse, clientId: string) => { + const h = this.handlers[clientId]; + if (!h) { + return; + } + if (response.code !== 201) { + this.handleError((response.data as any).message, clientId); + return; + } + if (!response.data) { + this.handleError('Failed to upload the file: no data received', clientId); + return; + } + const data = response.data.file_infos as FileInfo[]; + if (!data || !data.length) { + this.handleError('Failed to upload the file: no data received', clientId); + return; + } + + delete this.handlers[clientId!]; + + const fileInfo = data[0]; + fileInfo.clientId = h.fileInfo.clientId; + fileInfo.localPath = h.fileInfo.localPath; + + updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo); + }; + + private handleError = (errorMessage: string, clientId: string) => { + const h = this.handlers[clientId]; + delete this.handlers[clientId]; + + h.onError.forEach((c) => c(errorMessage)); + + const fileInfo = {...h.fileInfo}; + fileInfo.failed = true; + updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo); + }; + + private onAppStateChange = async (appState: AppStateStatus) => { + if (appState !== 'active' && this.previousAppState === 'active') { + this.storeProgress(); + } + + this.previousAppState = appState; + }; + + private storeProgress = () => { + for (const h of Object.values(this.handlers)) { + updateDraftFile(h.serverUrl, h.channelId, h.rootId, h.fileInfo); + } + }; +} + +export default new DraftUploadManager(); diff --git a/app/queries/servers/drafts.ts b/app/queries/servers/drafts.ts new file mode 100644 index 0000000000..d0829915b4 --- /dev/null +++ b/app/queries/servers/drafts.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +import type DraftModel from '@typings/database/models/servers/draft'; + +const {SERVER: {DRAFT}} = MM_TABLES; + +export const queryDraft = async (database: Database, channelId: string, rootId = '') => { + try { + const record = await database.collections.get(DRAFT).query( + Q.where('channel_id', channelId), + Q.where('root_id', rootId), + ).fetch(); + return record?.[0]; + } catch { + return undefined; + } +}; diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/index.ts index f24a666608..70df012e0c 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/index.ts +++ b/app/screens/channel/channel_post_list/intro/direct_channel/index.ts @@ -6,8 +6,8 @@ import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; import {catchError, switchMap} from 'rxjs/operators'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; import {General} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {getUserIdFromChannelName} from '@utils/user'; import DirectChannel from './direct_channel'; diff --git a/app/screens/channel/channel_post_list/intro/index.ts b/app/screens/channel/channel_post_list/intro/index.ts index 02623184a7..8fce3966ec 100644 --- a/app/screens/channel/channel_post_list/intro/index.ts +++ b/app/screens/channel/channel_post_list/intro/index.ts @@ -7,7 +7,7 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import Intro from './intro'; diff --git a/app/screens/channel/channel_post_list/intro/options/favorite/index.ts b/app/screens/channel/channel_post_list/intro/options/favorite/index.ts index 5f4a0eec3f..5db9164aa7 100644 --- a/app/screens/channel/channel_post_list/intro/options/favorite/index.ts +++ b/app/screens/channel/channel_post_list/intro/options/favorite/index.ts @@ -7,8 +7,8 @@ import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {MM_TABLES} from '@app/constants/database'; import {Preferences} from '@constants'; +import {MM_TABLES} from '@constants/database'; import FavoriteItem from './favorite'; diff --git a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx index a85f3da91c..e6558fa5cb 100644 --- a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx @@ -6,7 +6,7 @@ import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; import {fetchChannelCreator} from '@actions/remote/channel'; -import CompassIcon from '@app/components/compass_icon'; +import CompassIcon from '@components/compass_icon'; import {General, Permissions} from '@constants'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index e24890256d..3c73ca4001 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -7,8 +7,8 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {getUserIdFromChannelName} from '@app/utils/user'; import {Database, General} from '@constants'; +import {getUserIdFromChannelName} from '@utils/user'; import Channel from './channel'; diff --git a/app/screens/channel/intro/direct_channel/index.ts b/app/screens/channel/intro/direct_channel/index.ts index f24a666608..70df012e0c 100644 --- a/app/screens/channel/intro/direct_channel/index.ts +++ b/app/screens/channel/intro/direct_channel/index.ts @@ -6,8 +6,8 @@ import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; import {catchError, switchMap} from 'rxjs/operators'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; import {General} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {getUserIdFromChannelName} from '@utils/user'; import DirectChannel from './direct_channel'; diff --git a/app/screens/channel/intro/index.ts b/app/screens/channel/intro/index.ts index 02623184a7..8fce3966ec 100644 --- a/app/screens/channel/intro/index.ts +++ b/app/screens/channel/intro/index.ts @@ -7,7 +7,7 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import Intro from './intro'; diff --git a/app/screens/channel/intro/options/favorite/index.ts b/app/screens/channel/intro/options/favorite/index.ts index 5f4a0eec3f..5db9164aa7 100644 --- a/app/screens/channel/intro/options/favorite/index.ts +++ b/app/screens/channel/intro/options/favorite/index.ts @@ -7,8 +7,8 @@ import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {MM_TABLES} from '@app/constants/database'; import {Preferences} from '@constants'; +import {MM_TABLES} from '@constants/database'; import FavoriteItem from './favorite'; diff --git a/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx index a85f3da91c..e6558fa5cb 100644 --- a/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx +++ b/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx @@ -6,7 +6,7 @@ import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; import {fetchChannelCreator} from '@actions/remote/channel'; -import CompassIcon from '@app/components/compass_icon'; +import CompassIcon from '@components/compass_icon'; import {General, Permissions} from '@constants'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; diff --git a/app/screens/home/recent_mentions/components/empty.tsx b/app/screens/home/recent_mentions/components/empty.tsx index b8947c7836..43684ac4a0 100644 --- a/app/screens/home/recent_mentions/components/empty.tsx +++ b/app/screens/home/recent_mentions/components/empty.tsx @@ -3,10 +3,10 @@ import React from 'react'; import {View} from 'react-native'; -import {useTheme} from '@app/context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; -import {typography} from '@app/utils/typography'; import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; // @ts-expect-error svg extension import Mention from './mention_icon.svg'; diff --git a/app/screens/home/recent_mentions/index.ts b/app/screens/home/recent_mentions/index.ts index d7edb6aa08..23757a7c90 100644 --- a/app/screens/home/recent_mentions/index.ts +++ b/app/screens/home/recent_mentions/index.ts @@ -8,9 +8,9 @@ import compose from 'lodash/fp/compose'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {SystemModel, UserModel} from '@app/database/models/server'; -import {getTimezone} from '@app/utils/user'; import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database'; +import {SystemModel, UserModel} from '@database/models/server'; +import {getTimezone} from '@utils/user'; import RecentMentionsScreen from './recent_mentions'; diff --git a/types/api/files.d.ts b/types/api/files.d.ts index 2aca2de376..b78574b417 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -7,6 +7,7 @@ type FileInfo = { create_at: number; delete_at: number; extension: string; + failed?: boolean; has_preview_image: boolean; height: number; loading?: boolean; @@ -15,6 +16,8 @@ type FileInfo = { mini_preview?: string; name: string; post_id: string; + progress?: number; + bytesRead?: number; size: number; update_at: number; uri?: string;