mattermost-mobile/app/managers/draft_upload_manager/index.ts
Rajat Dabade dae4b75720
Ability to show quick action and add files to edit post (#8926)
* Viewing Files in Edit mode in mobile with ability to delete and save

* Added upload attachment to keyboard tracker view

* using state instread of use ref

* Minor

* Added tests

* intl extract

* new function getFiles
ById, batch file deletion and tests

* Files fetching in edit options and tests

* Removed DeviceEventEmitter and used React context

* Added support to check minimum required version to show edit file attachments

* resolve forward ref issue

* Minor

* memotized props for context and observe config with value

* Import fixes

* Ability to show quick action and add files to edit post

* type safety for EditPostContext

* Reverted back the post priority props

* constant shift

* Added test for QuickAction to show slashcomand

* Added test for edit_post, upload_item and upload_remove

* Added test for Edit_post_input and edit_post index

* fix the height issue between attachment and keyboard due to safeArea

* Minor: removed debugging border color

* Addressed dev review comments

* Import fixes

* Address UX comments

* Fixed props for UploadItem and remove effective Edit mode

* handled save button disabled state when uploading attachments

* handled newly added and retry file removal without alert message

* Test updated

* Added test for input_quick_action index.tsx

* Added test for not in edit mode for upload_item index

* Added test for upload_remove component when not in edit mode

* added tests for file_upload_error hook

* Added test for calling callback when in edit mode

* Test for edit post input for server version check
2025-06-25 16:04:08 +05:30

208 lines
6.7 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus} from 'react-native';
import {updateDraftFile} from '@actions/local/draft';
import {uploadFile} from '@actions/remote/file';
import {PROGRESS_TIME_TO_STORE} from '@constants/files';
import {getFullErrorMessage} from '@utils/errors';
import type {ClientResponse, ClientResponseError} from '@mattermost/react-native-network-client';
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>;
isEditPost?: boolean;
updateFileCallback?: (fileInfo: FileInfo) => void;
};
}
class DraftEditPostUploadManagerSingleton {
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,
isEditPost = false,
updateFileCallback?: (fileInfo: FileInfo) => void,
) => {
this.handlers[file.clientId!] = {
fileInfo: file,
serverUrl,
channelId,
rootId,
lastTimeStored: 0,
onError: [],
onProgress: [],
isEditPost,
updateFileCallback,
};
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) => {
const message = response.message || 'Unkown error';
this.handleError(message, file.clientId!);
};
const {error, cancel} = uploadFile(serverUrl, file, channelId, onProgress, onComplete, onError, skipBytes);
if (error) {
this.handleError(getFullErrorMessage(error), file.clientId!);
return;
}
this.handlers[file.clientId!].cancel = cancel;
};
public cancel = (clientId: string) => {
if (this.handlers[clientId]?.cancel) {
this.handlers[clientId].cancel?.();
delete this.handlers[clientId];
}
};
public isUploading = (clientId: string) => {
return Boolean(this.handlers[clientId]);
};
public registerProgressHandler = (clientId: string, callback: (progress: number, bytes: number) => void) => {
if (!this.handlers[clientId]) {
return undefined;
}
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 undefined;
}
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.bytesRead = bytes;
h.onProgress.forEach((c) => c(progress, bytes));
if (AppState.currentState !== 'active' && h.lastTimeStored + PROGRESS_TIME_TO_STORE < Date.now()) {
this.handleUpdateDraftFile(h, this.handlers[clientId].fileInfo, h.isEditPost || false);
h.lastTimeStored = Date.now();
}
};
private handleComplete = async (response: ClientResponse, clientId: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
}
if (response.code !== 201) {
this.handleError((response.data?.message as string | undefined) || 'Failed to upload the file: unknown error', 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[] | undefined;
if (!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;
await this.handleUpdateDraftFile(h, fileInfo, h.isEditPost || false);
};
private handleError = async (errorMessage: string, clientId: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
}
delete this.handlers[clientId];
h.onError.forEach((c) => c(errorMessage));
const fileInfo = {...h.fileInfo};
fileInfo.failed = true;
await this.handleUpdateDraftFile(h, fileInfo, h.isEditPost || false);
};
private handleUpdateDraftFile = async (handler: FileHandler[string], fileInfo: FileInfo, isEditPost: boolean) => {
if (isEditPost && handler.updateFileCallback) {
handler.updateFileCallback(fileInfo);
} else {
await updateDraftFile(handler.serverUrl, handler.channelId, handler.rootId, fileInfo);
}
};
private onAppStateChange = async (appState: AppStateStatus) => {
if (appState !== 'active' && this.previousAppState === 'active') {
await this.storeProgress();
}
this.previousAppState = appState;
};
private storeProgress = async () => {
for (const h of Object.values(this.handlers)) {
// eslint-disable-next-line no-await-in-loop
await this.handleUpdateDraftFile(h, h.fileInfo, h.isEditPost || false);
h.lastTimeStored = Date.now();
}
};
}
const DraftEditPostUploadManager = new DraftEditPostUploadManagerSingleton();
export default DraftEditPostUploadManager;
export const exportedForTesting = {
DraftEditPostUploadManagerSingleton,
};