mattermost-mobile-upstream/app/managers/draft_upload_manager/index.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix TypeScript errors in new unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

219 lines
7.5 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 {ServerErrors} from '@constants';
import {PROGRESS_TIME_TO_STORE} from '@constants/files';
import RenderPermissionsStore from '@store/render_permissions_store';
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, name?: 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 || 'Unknown error';
const errorName = (response as ClientResponseError & {name?: string}).name;
this.handleError(message, file.clientId!, errorName);
};
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, errName?: 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) {
// The network client resolves HTTP errors rather than rejecting them, so the server's error
// id is only available here; it lets callers recognize specific refusals.
const errorId = response.data?.id as string | undefined;
if (errorId === ServerErrors.UPLOAD_DENIED_BY_POLICY_ERROR) {
// The stored decision allowed an upload the server refused, so it is out of date.
RenderPermissionsStore.expireEntry(h.serverUrl, h.channelId);
}
this.handleError((response.data?.message as string | undefined) || 'Failed to upload the file: unknown error', clientId, errorId);
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, errorName?: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
}
delete this.handlers[clientId];
h.onError.forEach((c) => c(errorMessage, errorName));
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,
};