[Gekidou] Add Draft Upload Manager (#5910)

* Add Draft Upload Manager

* Address feedback

* Use callbacks instead of events and add byteCount

* Address feedback
This commit is contained in:
Daniel Espino García
2022-02-01 17:26:26 +01:00
committed by GitHub
parent c9ca9fa093
commit bebfccb964
22 changed files with 320 additions and 18 deletions

View File

@@ -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};
}
};

View File

@@ -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)};
};

View File

@@ -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';

View File

@@ -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<void> {
const database = DatabaseManager.serverDatabases[serverUrl];

View File

@@ -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<any>;
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<ClientResponse>;
promise.progress!(onProgress).then(onComplete).catch(onError);
return promise.cancel!;
};
};
export default ClientFiles;

View File

@@ -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;

View File

@@ -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';

View File

@@ -42,4 +42,6 @@ const Files: Record<string, string[]> = {
ZIP_TYPES: ['zip'],
};
export const PROGRESS_TIME_TO_STORE = 60000; // 60 * 1000 (60s)
export default Files;

View File

@@ -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();

View File

@@ -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<DraftModel>(DRAFT).query(
Q.where('channel_id', channelId),
Q.where('root_id', rootId),
).fetch();
return record?.[0];
} catch {
return undefined;
}
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;