From 97933838325946dd235ee3f2464ddb0e7ca54548 Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:10:25 +0100 Subject: [PATCH] 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 --- app/actions/websocket/event.ts | 12 +++ app/actions/websocket/files.ts | 79 +++++++++++++++++++ app/components/files/file.tsx | 9 ++- app/components/files/files.tsx | 18 ++++- app/components/files/image_file.tsx | 26 ++++-- .../draft_handler/draft_handler.tsx | 4 +- app/components/pressable_opacity/index.tsx | 4 +- app/constants/events.ts | 1 + app/constants/snack_bar.ts | 22 ++++++ app/constants/websocket.ts | 4 + app/hooks/files.ts | 7 +- app/screens/post_options/post_options.tsx | 3 +- app/store/ephemeral_store.ts | 24 ++++++ app/utils/gallery/index.ts | 16 ++++ assets/base/i18n/en.json | 2 + types/api/files.d.ts | 11 +++ 16 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 app/actions/websocket/files.ts diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 3bdc62ac7..827fbfea4 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -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); } diff --git a/app/actions/websocket/files.ts b/app/actions/websocket/files.ts new file mode 100644 index 000000000..5ee3860fe --- /dev/null +++ b/app/actions/websocket/files.ts @@ -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 + } +} diff --git a/app/components/files/file.tsx b/app/components/files/file.tsx index 5c883fdcd..e9e7c1784 100644 --- a/app/components/files/file.tsx +++ b/app/components/files/file.tsx @@ -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 = ( 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 ( { + 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 { diff --git a/app/components/post_draft/draft_handler/draft_handler.tsx b/app/components/post_draft/draft_handler/draft_handler.tsx index 6393428b7..4ea1cf3aa 100644 --- a/app/components/post_draft/draft_handler/draft_handler.tsx +++ b/app/components/post_draft/draft_handler/draft_handler.tsx @@ -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. diff --git a/app/components/pressable_opacity/index.tsx b/app/components/pressable_opacity/index.tsx index 80d68cfb1..49ebc96fb 100644 --- a/app/components/pressable_opacity/index.tsx +++ b/app/components/pressable_opacity/index.tsx @@ -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 ( = { 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 = { 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', diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 99dc78dcb..cfdcff542 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -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; diff --git a/app/hooks/files.ts b/app/hooks/files.ts index 4101fee94..45ae56abd 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -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; diff --git a/app/screens/post_options/post_options.tsx b/app/screens/post_options/post_options.tsx index 712b908ee..9ac4a3ab9 100644 --- a/app/screens/post_options/post_options.tsx +++ b/app/screens/post_options/post_options.tsx @@ -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 = () => { diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index f3bb23996..7f1c89805 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -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(); + 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(); diff --git a/app/utils/gallery/index.ts b/app/utils/gallery/index.ts index 3c6ba4608..ee6aaa633 100644 --- a/app/utils/gallery/index.ts +++ b/app/utils/gallery/index.ts @@ -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): 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, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 91d2dadca..289821f20 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/types/api/files.d.ts b/types/api/files.d.ts index 0d0affb6f..484686b06 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -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;