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:
parent
3735aa6ff8
commit
9793383832
16 changed files with 226 additions and 16 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
79
app/actions/websocket/files.ts
Normal file
79
app/actions/websocket/files.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export default keyMirror({
|
|||
KEYBOARD_STATE_CHANGED: null,
|
||||
CLOSE_INPUT_ACCESSORY_VIEW: null,
|
||||
EMOJI_PICKER_SEARCH_FOCUSED: null,
|
||||
FILE_REJECTED: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
11
types/api/files.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue