feat: support fillewillbedownloaded and sendtoastmessage pluginapi (#9416)

* Handle FileWillBeDownloaded plugin hook rejections

- Add WebSocket handler for file_download_rejected events
- Show rejected files as file cards instead of broken thumbnails
- Display plugin rejection message in snackbar
- Store rejection reason in EphemeralStore for later retrieval
- Re-render file components when rejection events arrive
- Remove blurred preview to prevent visual blink on rejection

* chore: lint

* chore: lint and test

* fix: removed event from useEffect
This commit is contained in:
Felipe Martin 2026-02-16 17:10:25 +01:00 committed by GitHub
parent 3735aa6ff8
commit 9793383832
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 226 additions and 16 deletions

View file

@ -17,6 +17,7 @@ import {handlePlaybookEvents} from '@playbooks/actions/websocket/events';
import * as category from './category';
import * as channel from './channel';
import * as files from './files';
import * as group from './group';
import {handleOpenDialogEvent} from './integrations';
import * as posts from './posts';
@ -331,6 +332,17 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.POST_TRANSLATION_UPDATED:
handlePostTranslationUpdatedEvent(serverUrl, msg);
break;
// File access control
case WebsocketEvents.FILE_DOWNLOAD_REJECTED:
files.handleFileDownloadRejected(serverUrl, msg);
break;
// Toast control
case WebsocketEvents.SHOW_TOAST:
files.handleShowToast(serverUrl, msg);
break;
}
handlePlaybookEvents(serverUrl, msg);
}

View file

@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import EphemeralStore from '@store/ephemeral_store';
import {logDebug} from '@utils/log';
import {showSnackBar} from '@utils/snack_bar';
export async function handleFileDownloadRejected(serverUrl: string, msg: WebSocketMessage) {
try {
const data = msg.data as FileDownloadRejectedEvent;
logDebug('[handleFileDownloadRejected] Received event data:', JSON.stringify(data));
const {file_id: fileId, rejection_reason: rejectionReason, download_type: downloadType} = data;
if (!fileId) {
logDebug('[handleFileDownloadRejected] No file_id in event, skipping');
return;
}
// Track this file as rejected in ephemeral store (with the rejection reason)
EphemeralStore.addRejectedFile(fileId, rejectionReason);
// Handle different download types with different UX:
// - Thumbnail: Background load, silent failure (no notification)
// - Preview: Could be auto-load or user click, only notify if seems user-initiated
// - File: User explicitly clicked download, always notify
// - Public: User requested public link, always notify
if (downloadType === 'thumbnail') {
// Thumbnails are loaded automatically in the background
// Don't show notification to avoid annoying users
return;
}
if (downloadType === 'preview') {
// Preview could be:
// a) Auto-loaded in SingleImageView in channel list (background)
// b) User opened gallery/preview modal
// For mobile, we can't easily distinguish, so we'll show notification
// but only if rejection reason looks user-facing
// Fall through to show snackbar
}
logDebug('[handleFileDownloadRejected] Showing snackbar with rejection reason:', rejectionReason);
// Show snackbar with the plugin's rejection message directly
// Only pass customMessage if there's actually a message to show
showSnackBar({
barType: SNACK_BAR_TYPE.FILE_DOWNLOAD_REJECTED,
customMessage: rejectionReason || undefined,
type: 'error',
});
} catch (error) {
logDebug('[handleFileDownloadRejected] Error handling event:', error);
// Silently fail - don't crash the app for file rejection events
}
}
export async function handleShowToast(serverUrl: string, msg: WebSocketMessage) {
try {
const {message} = msg.data;
if (!message) {
return;
}
// Display generic toast from plugin using dedicated snackbar type
showSnackBar({
barType: SNACK_BAR_TYPE.PLUGIN_TOAST,
customMessage: message,
type: 'default',
});
} catch (error) {
// Silently fail - don't crash the app for toast events
}
}

View file

@ -8,6 +8,7 @@ import Animated from 'react-native-reanimated';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {useGalleryItem} from '@hooks/gallery';
import EphemeralStore from '@store/ephemeral_store';
import {isAudio, isDocument, isImage, isVideo} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -92,6 +93,9 @@ const File = ({
const theme = useTheme();
const style = getStyleSheet(theme);
// Check if file is rejected by plugin - render as card instead of image/video
const isRejected = file.id && EphemeralStore.isFileRejected(file.id);
const handlePreviewPress = useCallback(() => {
if (document.current) {
document.current.handlePreviewPress();
@ -146,7 +150,10 @@ const File = ({
);
let fileComponent;
if (isVideo(file)) {
if (isRejected) {
// Rejected files render as generic card regardless of file type
fileComponent = renderCardWithImage(touchableWithPreview);
} else if (isVideo(file)) {
const renderVideoFile = (
<TouchableWithoutFeedback
disabled={isPressDisabled}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
import {DeviceEventEmitter, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
@ -69,6 +69,9 @@ const Files = ({
const [inViewPort, setInViewPort] = useState(false);
const isTablet = useIsTablet();
// Force re-render when a file is rejected (to move it from images to nonImages)
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo);
const [filesForGallery, setFilesForGallery] = useState(() => [...imageAttachments, ...nonImageAttachments]);
@ -178,6 +181,19 @@ const Files = ({
setFilesForGallery([...imageAttachments, ...nonImageAttachments]);
}, [imageAttachments, nonImageAttachments]);
// Listen for file rejection events and re-render if one of our files is rejected
// This handles the race condition where files render before rejection status is known
useEffect(() => {
const fileIds = new Set(filesInfo.map((f) => f.id));
const onFileRejected = ({fileId}: {fileId: string}) => {
if (fileIds.has(fileId)) {
forceUpdate();
}
};
const listener = DeviceEventEmitter.addListener(Events.FILE_REJECTED, onFileRejected);
return () => listener.remove();
}, [filesInfo]);
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View

View file

@ -2,14 +2,15 @@
// See LICENSE.txt for license information.
import {LinearGradient, type LinearGradientProps} from 'expo-linear-gradient';
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {StyleSheet, useWindowDimensions, View} from 'react-native';
import {buildFilePreviewUrl, buildFileThumbnailUrl, buildFileUrl} from '@actions/remote/file';
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import EphemeralStore from '@store/ephemeral_store';
import {isGif as isGifImage} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -90,18 +91,27 @@ const ImageFile = ({
setFailed(true);
}, []);
// Check if file is rejected and show file icon instead
useEffect(() => {
const isRejected = file.id && EphemeralStore.isFileRejected(file.id);
if (isRejected) {
setFailed(true);
}
}, [file.id]);
const imageProps = useMemo(() => {
const props: ProgressiveImageProps = {};
// Check if file is rejected by plugin before attempting to load
const isRejected = file.id && EphemeralStore.isFileRejected(file.id);
if (file.localPath) {
const prefix = file.localPath.startsWith('file://') ? '' : 'file://';
props.defaultSource = {uri: prefix + file.localPath};
} else if (file.id) {
if (file.mini_preview && file.mime_type) {
props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
} else if (file.has_preview_image) {
props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id);
}
} else if (file.id && !isRejected) {
// Don't set thumbnailUri - show neutral placeholder instead of blurred preview
// This prevents the visual blink when a file is rejected by a plugin
// (the blurred preview would show briefly before being replaced by file card)
if (file.has_preview_image) {
props.imageUri = buildFilePreviewUrl(serverUrl, file.id);
} else {

View file

@ -63,7 +63,7 @@ export default function DraftHandler(props: Props) {
const clearDraft = useCallback(() => {
removeDraft(serverUrl, channelId, rootId);
updateValue('');
}, [serverUrl, channelId, rootId]);
}, [serverUrl, channelId, rootId, updateValue]);
const addFiles = useCallback((newFiles: FileInfo[]) => {
if (!newFiles.length) {
@ -96,7 +96,7 @@ export default function DraftHandler(props: Props) {
}
newUploadError(null);
}, [intl, newUploadError, maxFileSize, serverUrl, files?.length, channelId, rootId]);
}, [intl, newUploadError, maxFileSize, serverUrl, files?.length, channelId, rootId, canUploadFiles, maxFileCount]);
// This effect mainly handles keeping clean the uploadErrorHandlers, and
// reinstantiate them on component mount and file retry.

View file

@ -24,11 +24,11 @@ export default function PressableOpacity({children, onPress, style}: Props) {
const cancelPressIn = useCallback(() => {
cancelOpacity.value = withTiming(0.5, {duration: 100});
}, []);
}, [cancelOpacity]);
const cancelPressOut = useCallback(() => {
cancelOpacity.value = withTiming(1, {duration: 300});
}, []);
}, [cancelOpacity]);
return (
<Pressable

View file

@ -41,4 +41,5 @@ export default keyMirror({
KEYBOARD_STATE_CHANGED: null,
CLOSE_INPUT_ACCESSORY_VIEW: null,
EMOJI_PICKER_SEARCH_FOCUSED: null,
FILE_REJECTED: null,
});

View file

@ -12,12 +12,14 @@ export const SNACK_BAR_TYPE = keyMirror({
AGENT_TOOL_APPROVAL_ERROR: null,
CODE_COPIED: null,
FAVORITE_CHANNEL: null,
FILE_DOWNLOAD_REJECTED: null,
FOLLOW_THREAD: null,
INFO_COPIED: null,
LINK_COPIED: null,
LINK_COPY_FAILED: null,
MESSAGE_COPIED: null,
MUTE_CHANNEL: null,
PLUGIN_TOAST: null,
REMOVE_CHANNEL_USER: null,
TEXT_COPIED: null,
UNFAVORITE_CHANNEL: null,
@ -71,6 +73,10 @@ const messages = defineMessages({
id: 'snack.bar.favorited.channel',
defaultMessage: 'This channel was favorited',
},
FILE_DOWNLOAD_REJECTED: {
id: 'snack.bar.file.download.rejected',
defaultMessage: 'File access blocked by plugin',
},
FOLLOW_THREAD: {
id: 'snack.bar.following.thread',
defaultMessage: 'Thread followed',
@ -115,6 +121,10 @@ const messages = defineMessages({
id: 'snack.bar.unfollow.thread',
defaultMessage: 'Thread unfollowed',
},
PLUGIN_TOAST: {
id: 'snack.bar.plugin.toast',
defaultMessage: 'Notification',
},
PLAYBOOK_ERROR: {
id: 'snack.bar.playbook.error',
defaultMessage: 'Unable to perform action. Please try again later.',
@ -163,6 +173,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
iconName: 'star',
hasAction: true,
},
FILE_DOWNLOAD_REJECTED: {
message: messages.FILE_DOWNLOAD_REJECTED,
iconName: 'alert-circle-outline',
hasAction: false,
type: MESSAGE_TYPE.ERROR,
},
FOLLOW_THREAD: {
message: messages.FOLLOW_THREAD,
iconName: 'check',
@ -221,6 +237,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
iconName: 'check',
hasAction: true,
},
PLUGIN_TOAST: {
message: messages.PLUGIN_TOAST,
iconName: 'information-outline',
hasAction: false,
type: MESSAGE_TYPE.DEFAULT,
},
PLAYBOOK_ERROR: {
message: messages.PLAYBOOK_ERROR,
iconName: 'alert-outline',

View file

@ -114,6 +114,10 @@ const WebsocketEvents = {
BOR_POST_REVEALED: 'post_revealed',
BOR_POST_BURNED: 'post_burned',
BURN_ON_READ_ALL_REVEALED: 'burn_on_read_all_revealed',
// File access control
FILE_DOWNLOAD_REJECTED: 'file_download_rejected',
SHOW_TOAST: 'show_toast',
};
export default WebsocketEvents;

View file

@ -12,6 +12,7 @@ import {getLocalFileInfo} from '@actions/local/file';
import {buildFilePreviewUrl, buildFileUrl, downloadFile} from '@actions/remote/file';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import EphemeralStore from '@store/ephemeral_store';
import {alertDownloadFailed, alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document';
import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors';
import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isPdf, isVideo} from '@utils/file';
@ -68,7 +69,11 @@ export const useImageAttachments = (filesInfo: FileInfo[]) => {
const videoFile = isVideo(file);
const audioFile = isAudio(file);
if (imageFile || videoFile || audioFile) {
// Check if file is rejected by plugin - treat rejected files as non-images
// so they display as file cards instead of broken image previews
const isRejected = file.id && EphemeralStore.isFileRejected(file.id);
if ((imageFile || videoFile || audioFile) && !isRejected) {
let uri;
if (file.localPath) {
uri = file.localPath;

View file

@ -111,7 +111,8 @@ const PostOptions = ({
}, [
canAddReaction, canCopyPermalink, canCopyText,
canDelete, canEdit, shouldRenderFollow, shouldShowBindings,
canMarkAsUnread, canPin, canReply, canSavePost, canViewTranslation,
canMarkAsUnread, canPin, canReply, canSavePost, canViewTranslation, shouldShowBORReadReceipts,
]);
const renderContent = () => {

View file

@ -66,6 +66,10 @@ class EphemeralStoreSingleton {
return this.runningTranslations.size;
};
// Track files that have been rejected by plugins (transient state)
// Maps file ID to rejection reason
private rejectedFiles = new Map<string, string>();
setProcessingNotification = (v: string) => {
this.processingNotification = v;
};
@ -319,6 +323,26 @@ class EphemeralStoreSingleton {
clearChannelPlaybooksSynced = (serverUrl: string) => {
delete this.channelPlaybooksSynced[serverUrl];
};
// Ephemeral control for rejected files
addRejectedFile = (fileId: string, rejectionReason?: string) => {
this.rejectedFiles.set(fileId, rejectionReason || '');
// Emit event so components can re-render with the updated rejection status
DeviceEventEmitter.emit(Events.FILE_REJECTED, {fileId});
};
isFileRejected = (fileId: string) => {
return this.rejectedFiles.has(fileId);
};
getRejectionReason = (fileId: string) => {
return this.rejectedFiles.get(fileId);
};
clearRejectedFiles = () => {
this.rejectedFiles.clear();
};
}
const EphemeralStore = new EphemeralStoreSingleton();

View file

@ -9,10 +9,13 @@ import {Navigation, type Options, type OptionsLayout} from 'react-native-navigat
import {measure, type AnimatedRef} from 'react-native-reanimated';
import {Events, Screens} from '@constants';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {allOrientations, showOverlay} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {isImage, isVideo} from '@utils/file';
import {generateId} from '@utils/general';
import {urlSafeBase64Encode} from '@utils/security';
import {showSnackBar} from '@utils/snack_bar';
import type {GalleryItemType, GalleryManagerSharedValues} from '@typings/screens/gallery';
@ -146,6 +149,19 @@ export function measureViewInWindow(ref: RefObject<View>): Promise<{x: number; y
}
export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: number, items: GalleryItemType[], hideActions = false) {
// Check if the file at initialIndex is rejected by plugin
const initialItem = items[initialIndex];
if (initialItem?.id && EphemeralStore.isFileRejected(initialItem.id)) {
// Show error snackbar with the plugin's rejection reason
const rejectionReason = EphemeralStore.getRejectionReason(initialItem.id);
showSnackBar({
barType: SNACK_BAR_TYPE.FILE_DOWNLOAD_REJECTED,
customMessage: rejectionReason || undefined,
type: 'error',
});
return;
}
Keyboard.dismiss();
const props = {
galleryIdentifier,

View file

@ -1500,12 +1500,14 @@
"snack.bar.default": "Error",
"snack.bar.enable.translation": "Enable auto-translation?",
"snack.bar.favorited.channel": "This channel was favorited",
"snack.bar.file.download.rejected": "File access blocked by plugin",
"snack.bar.following.thread": "Thread followed",
"snack.bar.info.copied": "Info copied to clipboard",
"snack.bar.link.copied": "Link copied to clipboard",
"snack.bar.message.copied": "Text copied to clipboard",
"snack.bar.mute.channel": "This channel was muted",
"snack.bar.playbook.error": "Unable to perform action. Please try again later.",
"snack.bar.plugin.toast": "Notification",
"snack.bar.remove.user": "1 member was removed from the channel",
"snack.bar.text.copied": "Copied to clipboard",
"snack.bar.undo": "Undo",

11
types/api/files.d.ts vendored
View file

@ -1,6 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type FileDownloadType = 'file' | 'thumbnail' | 'preview' | 'public';
type FileDownloadRejectedEvent = {
file_id: string;
file_name: string;
rejection_reason: string;
channel_id: string;
post_id: string;
download_type: FileDownloadType;
};
type FileInfo = {
id?: string;
bytesRead?: number;