forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
c9ca9fa093
commit
bebfccb964
36
app/actions/local/draft.ts
Normal file
36
app/actions/local/draft.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
26
app/actions/remote/file.ts
Normal file
26
app/actions/remote/file.ts
Normal 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)};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
180
app/init/draft_upload_manager.ts
Normal file
180
app/init/draft_upload_manager.ts
Normal 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();
|
||||
22
app/queries/servers/drafts.ts
Normal file
22
app/queries/servers/drafts.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
3
types/api/files.d.ts
vendored
3
types/api/files.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user