* Updated patch/update post API to allow file modification (#29447)

* WIP

* WIP

* Atatched new files ton post

* WIP: deleting removed files

* Deleted removed files and invalidated file metadata cache

* removed file ignore logif from update post API

* Added TestFindExclusives

* Added tests for DeleteForPostByIds

* Added app layer tests

* Added tests

* Added API level tests

* test enhancements

* Fixed a test

* Edit history include file metadata (#29505)

* Send file metadata in edit history metadata

* Added app tests

* Added store tests

* Added tests for populateEditHistoryFileMetadata{

* Added cache to avoid repetitigve DB calls for edits with only message changes

* Added API tests

* i18m fix

* removed commented code

* Improved test helper

* Show attachments in edit history RHS (#29519)

* Send file metadata in edit history metadata

* Added app tests

* Added store tests

* Added tests for populateEditHistoryFileMetadata{

* Added cache to avoid repetitigve DB calls for edits with only message changes

* Added API tests

* i18m fix

* WIUP: displa files in edit

* removed commented code

* Displayed file in edit history

* Handled file icon

* Fixed closing history component on clicking on file

* Simplified selector

* Simplified selector

* Improved test helper

* Disabled action menu on edit history file

* Added tests

* Improved selector

* Updated snapshot

* review Fixes

* restructured componnets

* Updated test

* Updated test

* Restore post api (#29643)

* Restore post version API WIP

* Undelete files WIP

* Added store tests

* Created post restore API

* Updated updatepost safeUpdate signature

* review fixex and improvements

* Fixed an app test

* Added API laer tests

* Added API tests and OpenAPI specs

* Fixed a typo

* Allow editing files when editing posts (#29709)

* WIP - basic view files when editing post

* Cleanup

* bg color

* Added text editor tests for files

* WIP

* WIP

* removed debug log

* Allowed admin to add and remove files on someone else's post

* Handled drafts and scheduled posts

* linter fixes

* Updated snapshot

* server test fix

* CI

* Added doc

* Restore post api integration (#29719)

* WIP - basic view files when editing post

* Cleanup

* bg color

* Added text editor tests for files

* WIP

* WIP

* removed debug log

* Allowed admin to add and remove files on someone else's post

* Handled drafts and scheduled posts

* linter fixes

* Updated snapshot

* server test fix

* Used new API to restore post

* handled edut limit and undo

* lint fix

* added comments

* Fixed edit post item tests

* Fixed buttons

* Aded snapshots

* fix test

* Updated snapshot

* Minor fixes

* fixed snapshot

* Edit file dnd area (#29763)

* dnd wip

* DND continued

* Supported multiple unbind dragster funcs

* lint fixes

* Got center channel file drop working when editing a post

* file dnd working with center channel and rhs

* file dnd working with center channel and rhs

* removed unneeded stopPropogation calls

* cleanup

* DND overlay fix

* Lint fix

* Advanced text editor test updates for file upload overlay

* fixed use upload hook tests

* Updated some more snapshots

* minor cleanup

* Updated i18n

* removed need of array for dragster unbind events

* lint fixes

* edit history cursor

* Fixed bugu causing faliure to delete empty posts (#29778)

* Files in restore confirmation (#29781)

* Added files to restore post confirmation dialog

* Fixed post restore toast colors

* Fixed restore bug

* Fixed restore confirmation toast tests

* a11y improvement and modal width fix

* Edit attachment misc fixes (#29808)

* Removed single image actions in restore post confirmation dialog

* Fixed file drop overlay size and position

* Made edit indiator accessible

* Lint fix

* Added bunch of more tests

* ANother test migrated from enzyme to react testing library

* More test enhancements

* More test enhancements

* More test enhancements

* lint fixes

* Fixed  a test

* Added missing snapshots

* Test fixes
Этот коммит содержится в:
Harshil Sharma
2025-01-13 18:16:56 +05:30
коммит произвёл GitHub
родитель ecdce71fc4
Коммит 6e5a67caec
91 изменённых файлов: 4945 добавлений и 1284 удалений

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {FileInfo} from '@mattermost/types/files';
import type {FileInfo, FilesState} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {ChannelTypes, SearchTypes} from 'mattermost-redux/action_types';
@@ -10,6 +10,7 @@ import {Posts} from 'mattermost-redux/constants';
import * as Actions from 'actions/post_actions';
import test_helper from 'packages/mattermost-redux/test/test_helper';
import mockStore from 'tests/test_store';
import {Constants, ActionTypes, RHSStates} from 'utils/constants';
import * as PostUtils from 'utils/post_utils';
@@ -300,8 +301,25 @@ describe('Actions.Posts', () => {
});
test('setEditingPost', async () => {
const state = JSON.parse(JSON.stringify(initialState)) as GlobalState;
state.entities.posts.posts[latestPost.id] = {
...latestPost,
file_ids: ['file_id_1', 'file_id_2'],
} as Post;
state.entities.files = {
files: {
file_id_1: test_helper.getFileInfoMock({id: 'file_id_1', post_id: 'latest_post_id'}),
file_id_2: test_helper.getFileInfoMock({id: 'file_id_2', post_id: 'latest_post_id'}),
},
fileIdsByPostId: {
[latestPost.id]: ['file_id_1', 'file_id_2'],
},
} as unknown as FilesState;
// should allow to edit and should fire an action
let testStore = mockStore({...initialState});
let testStore = mockStore({...state});
const {data} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
expect(data).toEqual(true);
@@ -313,7 +331,29 @@ describe('Actions.Posts', () => {
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
);
expect(actions[0].payload[1]).toEqual(
{args: ['edit_draft_latest_post_id', {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'}], type: 'MOCK_SET_GLOBAL_ITEM'},
{
args: [
'edit_draft_latest_post_id',
{
id: 'latest_post_id',
user_id: 'current_user_id',
message: 'test msg',
channel_id: 'current_channel_id',
type: 'normal',
file_ids: [
'file_id_1',
'file_id_2',
],
metadata: {
files: [
test_helper.getFileInfoMock({id: 'file_id_1', post_id: 'latest_post_id'}),
test_helper.getFileInfoMock({id: 'file_id_2', post_id: 'latest_post_id'}),
],
},
},
],
type: 'MOCK_SET_GLOBAL_ITEM',
},
);
const general = {
@@ -321,7 +361,7 @@ describe('Actions.Posts', () => {
serverVersion: '5.4.0',
config: {PostEditTimeLimit: -1},
} as unknown as GlobalState['entities']['general'];
const withLicenseState = {...initialState};
const withLicenseState = {...state};
withLicenseState.entities.general = {
...withLicenseState.entities.general,
...general,
@@ -338,7 +378,7 @@ describe('Actions.Posts', () => {
// should not allow edit for pending post
const newLatestPost = {...latestPost, pending_post_id: latestPost.id} as Post;
const withPendingPostState = {...initialState};
const withPendingPostState = {...state};
withPendingPostState.entities.posts.posts[latestPost.id] = newLatestPost;
testStore = mockStore(withPendingPostState);
@@ -349,11 +389,11 @@ describe('Actions.Posts', () => {
// should not save draft when it already exists
const stateWithDraft = {
...initialState,
...state,
storage: {
...initialState.storage,
...state.storage,
storage: {
...initialState.storage.storage,
...state.storage.storage,
edit_draft_latest_post_id: {
timestamp: new Date(),
value: {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'},

Просмотреть файл

@@ -15,6 +15,7 @@ import * as PostActions from 'mattermost-redux/actions/posts';
import {createSchedulePost} from 'mattermost-redux/actions/scheduled_posts';
import * as ThreadActions from 'mattermost-redux/actions/threads';
import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import * as PostSelectors from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
@@ -323,9 +324,18 @@ export function unpinPost(postId: string): ActionFuncAsync<boolean> {
}
export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc<boolean, GlobalState> {
const getFilesForPost = makeGetFilesForPost();
return (dispatch, getState) => {
const state = getState();
const post = PostSelectors.getPost(state, postId);
let post = PostSelectors.getPost(state, postId);
// getPost selectors doesn't include post's file metadata, so we need to add it manually
if (post.file_ids?.length) {
// if the post has files, get their metadata and insert it into the post object
const files = getFilesForPost(state, postId);
post = {...post, metadata: {...post.metadata, files}};
}
if (!post || post.pending_post_id === postId) {
return {data: false};

Просмотреть файл

@@ -1,112 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with no overlay type 1`] = `
<div
className="file-overlay hidden"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle"
>
<img
alt="Files"
className="overlay__files"
loading="lazy"
src=""
/>
<span>
<i
className="fa fa-upload"
title="Upload Icon"
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</span>
<img
alt="Logo"
className="overlay__logo"
loading="lazy"
src=""
/>
</div>
</div>
</div>
`;
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with overlay type of center 1`] = `
<div
className="file-overlay hidden center-file-overlay"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle"
>
<img
alt="Files"
className="overlay__files"
loading="lazy"
src=""
/>
<span>
<i
className="fa fa-upload"
title="Upload Icon"
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</span>
<img
alt="Logo"
className="overlay__logo"
loading="lazy"
src=""
/>
</div>
</div>
</div>
`;
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with overlay type of right 1`] = `
<div
className="file-overlay hidden right-file-overlay"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle"
>
<img
alt="Files"
className="overlay__files"
loading="lazy"
src=""
/>
<span>
<i
className="fa fa-upload"
title="Upload Icon"
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</span>
<img
alt="Logo"
className="overlay__logo"
loading="lazy"
src=""
/>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -22,6 +22,7 @@
padding: unset;
}
}
position: relative;
display: flex;
width: 100%;
@@ -94,6 +95,7 @@
flex: 1;
border: 2px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background-color: var(--center-channel-bg);
&:focus-visible,
&:focus-within,

Просмотреть файл

@@ -14,12 +14,13 @@ import type Textbox from 'components/textbox/textbox';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils';
import {StoragePrefixes} from 'utils/constants';
import {Locations, StoragePrefixes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import type {PostDraft} from 'types/store/draft';
import AdvancedTextEditor from './advanced_text_editor';
import type {Props} from './advanced_text_editor';
jest.mock('actions/views/drafts', () => ({
...jest.requireActual('actions/views/drafts'),
@@ -412,4 +413,36 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
expect(screen.getByText('Editing this message with an \'@mention\' will not notify the recipient.')).toBeVisible();
});
it('should have file upload overlay', () => {
const props: Props = {
...baseProps,
};
const {container, rerender} = renderWithContext(
<AdvancedTextEditor
{...props}
/>,
);
expect(container.querySelector('#createPostFileDropOverlay')).toBeVisible();
props.postId = 'post_id_1';
rerender(<AdvancedTextEditor {...props}/>);
expect(container.querySelector('#createCommentFileDropOverlay')).toBeVisible();
// in center channel editing a post
props.isInEditMode = true;
rerender(<AdvancedTextEditor {...props}/>);
expect(container.querySelector('#editPostFileDropOverlay')).toBeVisible();
// in RHS editing a post
props.location = Locations.RHS_COMMENT;
rerender(<AdvancedTextEditor {...props}/>);
expect(container.querySelector('#editPostFileDropOverlay')).toBeVisible();
// in threads
props.isThreadView = true;
rerender(<AdvancedTextEditor {...props}/>);
expect(container.querySelector('#editPostFileDropOverlay')).toBeVisible();
});
});

Просмотреть файл

@@ -33,6 +33,11 @@ import {makeAsyncComponent} from 'components/async_load';
import AutoHeightSwitcher from 'components/common/auto_height_switcher';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
import DeletePostModal from 'components/delete_post_modal';
import {
DropOverlayIdCreateComment,
DropOverlayIdCreatePost,
DropOverlayIdEditPost, FileUploadOverlay,
} from 'components/file_upload_overlay/file_upload_overlay';
import RhsSuggestionList from 'components/suggestion/rhs_suggestion_list';
import SuggestionList from 'components/suggestion/suggestion_list';
import Textbox from 'components/textbox';
@@ -83,7 +88,7 @@ import './advanced_text_editor.scss';
const FileLimitStickyBanner = makeAsyncComponent('FileLimitStickyBanner', lazy(() => import('components/file_limit_sticky_banner')));
type Props = {
export type Props = {
/**
* location of the advanced text editor in the UI (center channel / RHS)
@@ -667,6 +672,27 @@ const AdvancedTextEditor = ({
/>
);
const fileUploadOverlay = useMemo(() => {
const overlayType = isRHS ? 'right' : 'center';
const direction = 'horizontal';
return isInEditMode ? (
<FileUploadOverlay
overlayType={overlayType}
isInEditMode={true}
id={DropOverlayIdEditPost}
direction={direction}
/>
) : (
<FileUploadOverlay
overlayType={overlayType}
isInEditMode={false}
id={isRHS ? DropOverlayIdCreateComment : DropOverlayIdCreatePost}
direction={direction}
/>
);
}, [isInEditMode, isRHS]);
const showFormattingSpacer = isMessageLong || showPreview || attachmentPreview || isRHS || isThreadView;
const containsAtMentionsInMessage = allAtMentions(draft?.message)?.length > 0;
@@ -712,6 +738,7 @@ const AdvancedTextEditor = ({
className={'AdvancedTextEditor__body'}
disabled={isDisabled}
>
{fileUploadOverlay}
<div
ref={editorBodyRef}
role='application'

Просмотреть файл

@@ -235,6 +235,17 @@ const useSubmit = (
isInEditMode,
]);
const setUpdatedFileIds = useCallback((draft: PostDraft) => {
// new object creation is needed here to support sending a draft with files.
// In case of draft, the PostDraft object is fetched from the redux store, which is immutable.
// When user clicks 'Send Now' in drafts list, it will otherwise try to seta field on an immutable object.
// Hence, creating a new object here.
return {
...draft,
file_ids: draft.fileInfos.map((fileInfo) => fileInfo.id),
};
}, []);
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number, onConfirm: () => void) => {
dispatch(openModal({
modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL,
@@ -248,7 +259,7 @@ const useSubmit = (
}));
}, [dispatch]);
const handleSubmit = useCallback(async (submittingDraft = draft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => {
const handleSubmit = useCallback(async (submittingDraftParam = draft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => {
if (!channel) {
return;
}
@@ -257,6 +268,7 @@ const useSubmit = (
return;
}
const submittingDraft = setUpdatedFileIds(submittingDraftParam);
setShowPreview(false);
isDraftSubmitting.current = true;

Просмотреть файл

@@ -14,7 +14,7 @@ import {getCurrentLocale} from 'selectors/i18n';
import FilePreview from 'components/file_preview';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import FileUpload from 'components/file_upload';
import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload';
import type {FileUpload as FileUploadClass, TextEditorLocationType} from 'components/file_upload/file_upload';
import type TextboxClass from 'components/textbox/textbox';
import type {PostDraft} from 'types/store/draft';
@@ -34,7 +34,7 @@ const useUploadFiles = (
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
focusTextbox: (forceFocust?: boolean) => void,
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
isInEditMode: boolean,
isPostBeingEdited?: boolean,
): [React.ReactNode, React.ReactNode] => {
const locale = useSelector(getCurrentLocale);
@@ -153,12 +153,14 @@ const useUploadFiles = (
);
}
let postType = 'post';
if (postId) {
let postType: TextEditorLocationType = 'post';
if (isPostBeingEdited) {
postType = 'edit_post';
} else if (postId) {
postType = isThreadView ? 'thread' : 'comment';
}
const fileUploadJSX = isDisabled || isInEditMode ? null : (
const fileUploadJSX = isDisabled ? null : (
<FileUpload
ref={fileUploadRef}
fileCount={getFileCount(draft)}

Просмотреть файл

@@ -6,6 +6,7 @@ exports[`components/channel_view Should match snapshot if channel is archived 1`
id="app-content"
>
<FileUploadOverlay
id="centerChannelFileDropOverlay"
overlayType="center"
/>
<ChannelHeader
@@ -70,6 +71,7 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
id="app-content"
>
<FileUploadOverlay
id="centerChannelFileDropOverlay"
overlayType="center"
/>
<ChannelHeader
@@ -133,6 +135,7 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
id="app-content"
>
<FileUploadOverlay
id="centerChannelFileDropOverlay"
overlayType="center"
/>
<ChannelHeader

Просмотреть файл

@@ -7,6 +7,7 @@ import type {RouteComponentProps} from 'react-router-dom';
import {makeAsyncComponent} from 'components/async_load';
import deferComponentRender from 'components/deferComponentRender';
import {DropOverlayIdCenterChannel} from 'components/file_upload_overlay/file_upload_overlay';
import PostView from 'components/post_view';
import WebSocketClient from 'client/web_websocket_client';
@@ -186,7 +187,10 @@ export default class ChannelView extends React.PureComponent<Props, State> {
id='app-content'
className='app__content'
>
<FileUploadOverlay overlayType='center'/>
<FileUploadOverlay
overlayType='center'
id={DropOverlayIdCenterChannel}
/>
<ChannelHeader {...this.props}/>
{this.props.isChannelBookmarksEnabled && <ChannelBookmarks channelId={this.props.channelId}/>}
<DeferredPostView

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -1,13 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {screen, fireEvent} from '@testing-library/react';
import React from 'react';
import type {GlobalState} from '@mattermost/types/store';
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {renderWithContext} from 'tests/react_testing_utils';
import FileAttachment from './file_attachment';
@@ -65,8 +65,8 @@ describe('FileAttachment', () => {
};
test('should match snapshot, regular file', () => {
const wrapper = shallow(<FileAttachment {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...baseProps}/>);
expect(container).toMatchSnapshot();
});
test('non archived file does not show archived elements', () => {
@@ -103,8 +103,8 @@ describe('FileAttachment', () => {
size: 100,
};
const props = {...baseProps, fileInfo};
const wrapper = shallow(<FileAttachment {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, small image', () => {
@@ -117,8 +117,8 @@ describe('FileAttachment', () => {
size: 100,
};
const props = {...baseProps, fileInfo};
const wrapper = shallow(<FileAttachment {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, svg image', () => {
@@ -131,8 +131,8 @@ describe('FileAttachment', () => {
size: 100,
};
const props = {...baseProps, fileInfo};
const wrapper = shallow(<FileAttachment {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, after change from file to image', () => {
@@ -144,26 +144,26 @@ describe('FileAttachment', () => {
height: 400,
size: 100,
};
const wrapper = shallow(<FileAttachment {...baseProps}/>);
wrapper.setProps({...baseProps, fileInfo});
expect(wrapper).toMatchSnapshot();
const {rerender, container} = renderWithContext(<FileAttachment {...baseProps}/>);
rerender(<FileAttachment {...{...baseProps, fileInfo}}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, with compact display', () => {
const props = {...baseProps, compactDisplay: true};
const wrapper = shallow(<FileAttachment {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, without compact display and without can download', () => {
const props = {...baseProps, canDownloadFiles: false};
const wrapper = shallow(<FileAttachment {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot, when file is not loaded', () => {
const wrapper = shallow(<FileAttachment {...{...baseProps, fileInfo: {...baseProps.fileInfo, id: 'noLoad', extension: 'jpg'}, enableSVGs: true}}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<FileAttachment {...{...baseProps, fileInfo: {...baseProps.fileInfo, id: 'noLoad', extension: 'jpg'}, enableSVGs: true}}/>);
expect(container).toMatchSnapshot();
});
test('should blur file attachment link after click', () => {
@@ -172,12 +172,12 @@ describe('FileAttachment', () => {
const link = screen.getByText(baseProps.fileInfo.name);
const blur = jest.spyOn(link, 'blur');
screen.getByText(baseProps.fileInfo.name).click();
fireEvent.click(link);
expect(blur).toHaveBeenCalled();
});
describe('archived file', () => {
test('shows archived image instead of real image and explanatory test in compact mode', () => {
test('shows archived image instead of real image and explanatory text in compact mode', () => {
const props = {
...baseProps,
fileInfo: {
@@ -192,7 +192,7 @@ describe('FileAttachment', () => {
screen.getByText(/archived/);
});
test('shows archived image instead of real image and explanatory test in full mode', () => {
test('shows archived image instead of real image and explanatory text in full mode', () => {
const props = {
...baseProps,
fileInfo: {
@@ -207,4 +207,34 @@ describe('FileAttachment', () => {
screen.getByText(/This file is archived/);
});
});
test('should match snapshot when file is deleted', () => {
const props = {
...baseProps,
fileInfo: {
...baseFileInfo,
delete_at: 10000000,
},
};
const {container} = renderWithContext(<FileAttachment {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot with thumbnail disabled', () => {
const {container} = renderWithContext(
<FileAttachment
{...baseProps}
disableThumbnail={true}
/>);
expect(container).toMatchSnapshot();
});
test('should not render menu items when disable actions is set', () => {
const {container} = renderWithContext(
<FileAttachment
{...baseProps}
disableActions={true}
/>);
expect(container).toMatchSnapshot();
});
});

Просмотреть файл

@@ -52,6 +52,8 @@ type Props = PropsFromRedux & {
compactDisplay?: boolean;
disablePreview?: boolean;
handleFileDropdownOpened?: (open: boolean) => void;
disableThumbnail?: boolean;
disableActions?: boolean;
};
export default function FileAttachment(props: Props) {
@@ -81,12 +83,14 @@ export default function FileAttachment(props: Props) {
}
const fileType = getFileType(fileInfo.extension);
if (fileType === FileTypes.IMAGE) {
const thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
if (!props.disableThumbnail) {
if (fileType === FileTypes.IMAGE) {
const thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
loadImage(thumbnailUrl, handleImageLoaded);
} else if (fileInfo.extension === FileTypes.SVG && props.enableSVGs) {
loadImage(getFileUrl(fileInfo.id), handleImageLoaded);
loadImage(thumbnailUrl, handleImageLoaded);
} else if (fileInfo.extension === FileTypes.SVG && props.enableSVGs) {
loadImage(getFileUrl(fileInfo.id), handleImageLoaded);
}
}
};
@@ -116,10 +120,12 @@ export default function FileAttachment(props: Props) {
}, [props.fileInfo.extension, props.fileInfo.id, props.enableSVGs]);
const onAttachmentClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (props.fileInfo.archived) {
e.preventDefault();
e.stopPropagation();
if (props.fileInfo.archived || props.disablePreview) {
return;
}
e.preventDefault();
if ('blur' in e.target) {
(e.target as HTMLElement).blur();
@@ -264,13 +270,16 @@ export default function FileAttachment(props: Props) {
href='#'
onClick={onAttachmentClick}
>
{loaded ? (
{loaded && !props.disableThumbnail ? (
<FileThumbnail
fileInfo={fileInfo}
disablePreview={props.disablePreview}
/>
) : (
<div className='post-image__load'/>
<FileThumbnail
fileInfo={props.fileInfo}
disablePreview={true}
/>
)}
</a>
);
@@ -313,7 +322,7 @@ export default function FileAttachment(props: Props) {
</div>
);
if (!fileInfo.archived) {
if (!fileInfo.archived && !props.disableActions) {
fileActions = renderFileMenuItems();
}
}

Просмотреть файл

@@ -17,11 +17,15 @@ import type {GlobalState} from 'types/store';
import FileAttachment from './file_attachment';
function mapStateToProps(state: GlobalState) {
export type OwnProps = {
preventDownload?: boolean;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const config = getConfig(state);
return {
canDownloadFiles: canDownloadFiles(config),
canDownloadFiles: !ownProps.preventDownload && canDownloadFiles(config),
enableSVGs: config.EnableSVGs === 'true',
enablePublicLink: config.EnablePublicLink === 'true',
pluginMenuItems: getFilesDropdownPluginMenuItems(state),

Просмотреть файл

@@ -1,15 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {screen} from '@testing-library/react';
import React from 'react';
import FileAttachment from 'components/file_attachment';
import SingleImageView from 'components/single_image_view';
import type {PostMetadata} from '@mattermost/types/posts';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import FileAttachmentList from './file_attachment_list';
import type {GlobalState} from 'types/store';
import FileAttachmentList from './index';
describe('FileAttachmentList', () => {
const post = TestHelper.getPostMock({
@@ -17,9 +19,9 @@ describe('FileAttachmentList', () => {
file_ids: ['file_id_1', 'file_id_2', 'file_id_3'],
});
const fileInfos = [
TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3}),
TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2}),
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1}),
TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3, post_id: post.id}),
TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2, post_id: post.id}),
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1, post_id: post.id}),
];
const baseProps = {
post,
@@ -35,83 +37,218 @@ describe('FileAttachmentList', () => {
},
};
const defaultState = {
entities: {
general: {
config: {
EnableSVGs: 'true',
},
},
posts: {
posts: {
post_id: post,
},
},
files: {
files: {
file_id_1: fileInfos[2],
file_id_2: fileInfos[1],
file_id_3: fileInfos[0],
},
fileIdsByPostId: {
post_id: ['file_id_1', 'file_id_2', 'file_id_3'],
},
},
},
} as unknown as GlobalState;
test('should render a FileAttachment for a single file', () => {
const props = {
...baseProps,
fileCount: 1,
fileInfos: [
TestHelper.getFileInfoMock({
id: 'file_id_1',
name: 'file.txt',
extension: 'txt',
}),
],
};
const wrapper = shallow(
<FileAttachmentList {...props}/>,
);
renderWithContext(<FileAttachmentList {...props}/>, defaultState);
expect(wrapper.find(FileAttachment).exists()).toBe(true);
expect(screen.getByTestId('fileAttachmentList').querySelectorAll('.post-image__column').length).toBe(3);
});
test('should render multiple, sorted FileAttachments for multiple files', () => {
const wrapper = shallow(
<FileAttachmentList {...baseProps}/>,
);
renderWithContext(<FileAttachmentList {...baseProps}/>, defaultState);
expect(wrapper.find(FileAttachment)).toHaveLength(3);
expect(wrapper.find(FileAttachment).first().prop('fileInfo').id).toBe('file_id_1');
expect(wrapper.find(FileAttachment).last().prop('fileInfo').id).toBe('file_id_3');
const fileAttachments = Array.from(screen.getByTestId('fileAttachmentList').querySelectorAll('.post-image__column'));
expect(fileAttachments.length).toBe(3);
expect(fileAttachments[0]?.textContent?.includes('image_1.png')).toBe(true);
expect(fileAttachments[1]?.textContent?.includes('image_2.png')).toBe(true);
expect(fileAttachments[2]?.textContent?.includes('image_3.png')).toBe(true);
});
test('should render a SingleImageView for a single image', () => {
const props = {
...baseProps,
fileCount: 1,
fileInfos: [
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image.png', extension: 'png'}),
],
post: {
...baseProps.post,
file_ids: ['file_id_1'],
},
};
const wrapper = shallow(
<FileAttachmentList {...props}/>,
);
const state = {
...defaultState,
entities: {
files: {
files: {
file_id_1: fileInfos[0],
},
fileIdsByPostId: {
post_id: ['file_id_1'],
},
},
},
} as unknown as GlobalState;
expect(wrapper.find(SingleImageView).exists()).toBe(true);
const {container} = renderWithContext(<FileAttachmentList {...props}/>, state);
expect(container.querySelector('.file-view--single')).toBeInTheDocument();
});
test('should render a SingleImageView for an SVG with SVG previews enabled', () => {
const state = {
...defaultState,
entities: {
general: {
config: {
EnableSVGs: 'true',
},
},
files: {
files: {
file_id_1: TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image.svg', extension: 'svg'}),
},
fileIdsByPostId: {
post_id: ['file_id_1'],
},
},
},
} as unknown as GlobalState;
const props = {
...baseProps,
enableSVGs: true,
fileCount: 1,
fileInfos: [
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image.svg', extension: 'svg'}),
],
};
const wrapper = shallow(
<FileAttachmentList {...props}/>,
);
const {container} = renderWithContext(<FileAttachmentList {...props}/>, state);
expect(wrapper.find(SingleImageView).exists()).toBe(true);
expect(container.querySelector('.file-view--single')).toBeInTheDocument();
});
test('should render a FileAttachment for an SVG with SVG previews disabled', () => {
const state = {
...defaultState,
entities: {
general: {
config: {
EnableSVGs: 'false',
},
},
files: {
files: {
file_id_1: TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image.svg', extension: 'svg'}),
},
fileIdsByPostId: {
post_id: ['file_id_1'],
},
},
},
} as unknown as GlobalState;
const props = {
...baseProps,
fileCount: 1,
fileInfos: [
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image.svg', extension: 'svg'}),
],
};
const wrapper = shallow(
<FileAttachmentList {...props}/>,
);
renderWithContext(<FileAttachmentList {...props}/>, state);
expect(wrapper.find(SingleImageView).exists()).toBe(false);
expect(wrapper.find(FileAttachment).exists()).toBe(true);
expect(screen.getByTestId('fileAttachmentList').querySelector('.file-view--single')).not.toBeInTheDocument();
expect(screen.getByTestId('fileAttachmentList').querySelector('.post-image__column')).toBeInTheDocument();
});
test('should render deleted files', () => {
const state = {
...defaultState,
entities: {
files: {
files: {
file_id_1: TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1, delete_at: 4}),
file_id_2: TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2, delete_at: 4}),
file_id_3: TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3, delete_at: 4}),
},
fileIdsByPostId: {
post_id: ['file_id_1', 'file_id_2', 'file_id_3'],
},
},
},
} as unknown as GlobalState;
const props = {
...baseProps,
};
renderWithContext(<FileAttachmentList {...props}/>, state);
const fileAttachments = screen.getByTestId('fileAttachmentList').querySelectorAll('.post-image__column');
expect(fileAttachments.length).toBe(3);
expect(fileAttachments[0]?.textContent?.includes('image_1.png')).toBe(true);
expect(fileAttachments[1]?.textContent?.includes('image_2.png')).toBe(true);
expect(fileAttachments[2]?.textContent?.includes('image_3.png')).toBe(true);
});
test('should render file list in edit history RHS', () => {
const fileInfo1 = TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1, delete_at: 4});
const fileInfo2 = TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2, delete_at: 4});
const fileInfo3 = TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3, delete_at: 4});
const state = {
...defaultState,
entities: {
files: {
files: {
file_id_1: fileInfo1,
file_id_2: fileInfo2,
file_id_3: fileInfo3,
},
fileIdsByPostId: {
post_id: ['file_id_1', 'file_id_2', 'file_id_3'],
},
},
posts: {
posts: {
post_id: {
...post,
metadata: {
files: [fileInfo1, fileInfo2, fileInfo3],
},
},
},
},
},
} as unknown as GlobalState;
// in edit history RHS, files are deleted and download and context menus are disabled
const props = {
...baseProps,
isEditHistory: true,
disableDownload: true,
disableActions: true,
post: {
...post,
metadata: {
files: [fileInfo3, fileInfo2, fileInfo1],
} as PostMetadata,
},
};
renderWithContext(<FileAttachmentList {...props}/>, state);
const fileAttachments = screen.getByTestId('fileAttachmentList').querySelectorAll('.post-image__column');
expect(fileAttachments.length).toBe(3);
expect(fileAttachments[0]?.textContent?.includes('image_1.png')).toBe(true);
expect(fileAttachments[1]?.textContent?.includes('image_2.png')).toBe(true);
expect(fileAttachments[2]?.textContent?.includes('image_3.png')).toBe(true);
});
});

Просмотреть файл

@@ -39,6 +39,11 @@ export default function FileAttachmentList(props: Props) {
} = props;
const sortedFileInfos = useMemo(() => sortFileInfos(fileInfos ? [...fileInfos] : [], locale), [fileInfos, locale]);
if (fileInfos.length === 0) {
return null;
}
if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived) {
const fileType = getFileType(fileInfos[0].extension);
@@ -50,6 +55,7 @@ export default function FileAttachmentList(props: Props) {
postId={props.post.id}
compactDisplay={compactDisplay}
isInPermalink={isInPermalink}
disableActions={props.disableActions}
/>
);
}
@@ -63,6 +69,7 @@ export default function FileAttachmentList(props: Props) {
if (sortedFileInfos && sortedFileInfos.length > 0) {
for (let i = 0; i < sortedFileInfos.length; i++) {
const fileInfo = sortedFileInfos[i];
const isDeleted = fileInfo.delete_at > 0;
postFiles.push(
<FileAttachment
key={fileInfo.id}
@@ -71,6 +78,10 @@ export default function FileAttachmentList(props: Props) {
handleImageClick={handleImageClick}
compactDisplay={compactDisplay}
handleFileDropdownOpened={props.handleFileDropdownOpened}
preventDownload={props.disableDownload}
disableActions={props.disableActions}
disableThumbnail={isDeleted}
disablePreview={isDeleted}
/>,
);
}

Просмотреть файл

@@ -6,9 +6,13 @@ import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {
makeGetFilesForEditHistory,
makeGetFilesForPost,
} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {openModal} from 'actions/views/modals';
@@ -24,14 +28,25 @@ export type OwnProps = {
compactDisplay?: boolean;
isInPermalink?: boolean;
handleFileDropdownOpened?: (open: boolean) => void;
isEditHistory?: boolean;
disableDownload?: boolean;
disableActions?: boolean;
}
function makeMapStateToProps() {
const selectFilesForPost = makeGetFilesForPost();
const getFilesForEditHistory = makeGetFilesForEditHistory();
return function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const postId = ownProps.post ? ownProps.post.id : '';
const fileInfos = selectFilesForPost(state, postId);
var fileInfos: FileInfo[];
if (ownProps.isEditHistory) {
fileInfos = getFilesForEditHistory(state, ownProps.post);
} else {
fileInfos = selectFilesForPost(state, postId);
}
let fileCount = 0;
if (ownProps.post.metadata && ownProps.post.metadata.files) {

Просмотреть файл

@@ -8,13 +8,13 @@ import type {FileInfo} from '@mattermost/types/files';
import {General} from 'mattermost-redux/constants';
import FileUpload, {type FileUpload as FileUploadClass} from 'components/file_upload/file_upload';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {clearFileInput} from 'utils/utils';
import type {FilesWillUploadHook} from 'types/store/plugins';
import FileUpload, {type FileUpload as FileUploadClass} from './file_upload';
const generatedIdRegex = /[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}/;
jest.mock('utils/file_utils', () => {
@@ -66,12 +66,14 @@ describe('components/FileUpload', () => {
onUploadError: jest.fn(),
onUploadStart: jest.fn(),
onUploadProgress: jest.fn(),
postType: 'post',
postType: 'post' as const,
maxFileSize: MaxFileSize,
canUploadFiles: true,
rootId: 'root_id',
pluginFileUploadMethods: [],
pluginFilesWillUploadHooks: [],
centerChannelPostBeingEdited: false,
rhsPostBeingEdited: false,
actions: {
uploadFile,
},

Просмотреть файл

@@ -14,6 +14,11 @@ import type {FileInfo, FileUploadResponse} from '@mattermost/types/files';
import type {UploadFile} from 'actions/file_actions';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import {
DropOverlayIdCreateComment,
DropOverlayIdEditPost,
DropOverlayIdRHS,
} from 'components/file_upload_overlay/file_upload_overlay';
import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
@@ -75,6 +80,8 @@ const customStyles = {
top: 'auto',
};
export type TextEditorLocationType = 'post' | 'comment' | 'thread' | 'edit_post';
export type Props = {
channelId: string;
@@ -125,7 +132,7 @@ export type Props = {
/**
* Type of the object which the uploaded file is attached to
*/
postType: string;
postType: TextEditorLocationType;
/**
* The maximum uploaded file size.
@@ -147,6 +154,10 @@ export type Props = {
* Function called when xhr fires progress event.
*/
onUploadProgress: (filePreviewInfo: FilePreviewInfo) => void;
centerChannelPostBeingEdited: boolean;
rhsPostBeingEdited: boolean;
actions: {
/**
@@ -179,19 +190,60 @@ export class FileUpload extends PureComponent<Props, State> {
this.fileInput = React.createRef();
}
componentDidMount() {
if (this.props.postType === 'post') {
this.registerDragEvents('.row.main', '.center-file-overlay');
} else if (this.props.postType === 'comment') {
this.registerDragEvents('.post-right__container', '.right-file-overlay');
} else if (this.props.postType === 'thread') {
this.registerDragEvents('.ThreadPane', '.right-file-overlay');
getDragEventDefinition = () => {
let containerSelector: string;
let overlaySelector: string;
switch (this.props.postType) {
case 'post': {
containerSelector = this.props.centerChannelPostBeingEdited ? 'form#create_post .AdvancedTextEditor__body' : '.row.main';
overlaySelector = this.props.centerChannelPostBeingEdited ? '#createPostFileDropOverlay' : '.center-file-overlay';
break;
}
case 'comment': {
containerSelector = this.props.rhsPostBeingEdited ? '#sidebar-right .post-create__container .AdvancedTextEditor__body' : '.post-right__container';
overlaySelector = this.props.rhsPostBeingEdited ? '#' + DropOverlayIdCreateComment : '#' + DropOverlayIdRHS;
break;
}
case 'thread': {
containerSelector = this.props.rhsPostBeingEdited ? '.post-create__container .AdvancedTextEditor__body' : '.ThreadPane';
overlaySelector = this.props.rhsPostBeingEdited ? '#createPostFileDropOverlay' : '.right-file-overlay';
break;
}
case 'edit_post': {
containerSelector = '.post--editing';
overlaySelector = '#' + DropOverlayIdEditPost;
break;
}
}
return {
containerSelector,
overlaySelector,
};
};
componentDidMount() {
const {containerSelector, overlaySelector} = this.getDragEventDefinition();
this.registerDragEvents(containerSelector, overlaySelector);
document.addEventListener('paste', this.pasteUpload);
document.addEventListener('keydown', this.keyUpload);
}
componentDidUpdate(prevProps: Readonly<Props>) {
// when a post starts or finishes being edited, we need to
// clear existing drag handlers and register fresh ones in the right place.
if (
prevProps.centerChannelPostBeingEdited !== this.props.centerChannelPostBeingEdited ||
prevProps.rhsPostBeingEdited !== this.props.rhsPostBeingEdited
) {
this.unbindDragsterEvents?.();
const {containerSelector, overlaySelector} = this.getDragEventDefinition();
this.registerDragEvents(containerSelector, overlaySelector);
}
}
componentWillUnmount() {
document.removeEventListener('paste', this.pasteUpload);
document.removeEventListener('keydown', this.keyUpload);
@@ -368,13 +420,19 @@ export class FileUpload extends PureComponent<Props, State> {
};
registerDragEvents = (containerSelector: string, overlaySelector: string) => {
const overlay = document.querySelector(overlaySelector);
let overlay = document.querySelector(overlaySelector);
const dragTimeout = new DelayedAction(() => {
overlay?.classList.add('hidden');
});
const enter = (e: CustomEvent) => {
// this null check is to deal with the race condition between rendering the post edit advanced text editor
// and this hook querying the same in DOM to register event handler on it.
if (!overlay) {
overlay = document.querySelector(overlaySelector);
}
const files = e.detail.dataTransfer;
if (!isUriDrop(files) && isFileTransfer(files)) {
overlay?.classList.remove('hidden');

Просмотреть файл

@@ -9,6 +9,7 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {uploadFile} from 'actions/file_actions';
import {getCurrentLocale} from 'selectors/i18n';
import {getEditingPostDetailsAndPost} from 'selectors/posts';
import {canUploadFiles} from 'utils/file_utils';
@@ -21,12 +22,18 @@ function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const maxFileSize = parseInt(config.MaxFileSize || '', 10);
const editingPost = getEditingPostDetailsAndPost(state);
const centerChannelPostBeingEdited = editingPost.show && !editingPost.isRHS;
const rhsPostBeingEdited = editingPost.show && editingPost.isRHS;
return {
maxFileSize,
canUploadFiles: canUploadFiles(config),
locale: getCurrentLocale(state),
pluginFileUploadMethods: state.plugins.components.FileUploadMethod,
pluginFilesWillUploadHooks: state.plugins.components.FilesWillUploadHook as unknown as FilesWillUploadHook[],
centerChannelPostBeingEdited,
rhsPostBeingEdited,
};
}

Просмотреть файл

@@ -1,56 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import fileOverlayImage from 'images/filesOverlay.png';
import overlayLogoImage from 'images/logoWhite.png';
type Props = {
overlayType: string;
}
const FileUploadOverlay = (props: Props) => {
const {formatMessage} = useIntl();
let overlayClass = 'file-overlay hidden';
if (props.overlayType === 'right') {
overlayClass += ' right-file-overlay';
} else if (props.overlayType === 'center') {
overlayClass += ' center-file-overlay';
}
return (
<div className={overlayClass}>
<div className='overlay__indent'>
<div className='overlay__circle'>
<img
className='overlay__files'
src={fileOverlayImage}
alt='Files'
loading='lazy'
/>
<span>
<i
className='fa fa-upload'
title={formatMessage({id: 'generic_icons.upload', defaultMessage: 'Upload Icon'})}
/>
<FormattedMessage
id='upload_overlay.info'
defaultMessage='Drop a file to upload it.'
/>
</span>
<img
className='overlay__logo'
src={overlayLogoImage}
alt='Logo'
loading='lazy'
/>
</div>
</div>
</div>
);
};
export default FileUploadOverlay;

Просмотреть файл

@@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with no overlay type 1`] = `
<div
className="file-overlay hidden"
id="fileUploadOverlay"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle vertical"
>
<img
alt=""
className="overlay__files"
loading="lazy"
src=""
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</div>
</div>
</div>
`;
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with overlay type of center 1`] = `
<div
className="file-overlay hidden center-file-overlay"
id="fileUploadOverlay"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle vertical"
>
<img
alt=""
className="overlay__files"
loading="lazy"
src=""
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</div>
</div>
</div>
`;
exports[`components/FileUploadOverlay should match snapshot when file upload is showing with overlay type of right 1`] = `
<div
className="file-overlay hidden right-file-overlay"
id="fileUploadOverlay"
>
<div
className="overlay__indent"
>
<div
className="overlay__circle vertical"
>
<img
alt=""
className="overlay__files"
loading="lazy"
src=""
/>
<MemoizedFormattedMessage
defaultMessage="Drop a file to upload it."
id="upload_overlay.info"
/>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@use "utils/functions";
@use "utils/mixins";
@use "utils/variables";
.file-overlay {
position: absolute;
z-index: 13;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: variables.$white;
font-size: functions.em(20px);
font-weight: 600;
pointer-events: none;
text-align: center;
.overlay__indent {
@include mixins.clearfix;
@include mixins.alpha-property(background-color, variables.$black, 0.75);
position: relative;
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
&.right-file-overlay {
font-size: functions.em(18px);
.overlay__files {
width: 150px;
}
}
.overlay__circle {
display: flex;
width: 300px;
height: 300px;
max-height: 100%;
flex-direction: column;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 20px;
pointer-events: none;
&.horizontal {
width: max-content;
flex-direction: row;
.overlay__files {
width: auto;
height: 96px;
max-height: 80%;
}
span {
display: flex;
align-items: center;
}
}
}
.overlay__files {
display: block;
width: 128px;
}
.overlay__logo {
position: absolute;
bottom: 30px;
left: 50%;
width: 100px;
margin-left: -50px;
opacity: 0.3;
}
.fa {
display: inline-block;
margin-right: 8px;
font-size: 1.1em;
}
}

Просмотреть файл

@@ -4,13 +4,14 @@
import {shallow} from 'enzyme';
import React from 'react';
import FileUploadOverlay from 'components/file_upload_overlay';
import FileUploadOverlay from 'components/file_upload_overlay/index';
describe('components/FileUploadOverlay', () => {
test('should match snapshot when file upload is showing with no overlay type', () => {
const wrapper = shallow(
<FileUploadOverlay
overlayType=''
id={'fileUploadOverlay'}
/>,
);
@@ -21,6 +22,7 @@ describe('components/FileUploadOverlay', () => {
const wrapper = shallow(
<FileUploadOverlay
overlayType='right'
id={'fileUploadOverlay'}
/>,
);
@@ -31,6 +33,7 @@ describe('components/FileUploadOverlay', () => {
const wrapper = shallow(
<FileUploadOverlay
overlayType='center'
id={'fileUploadOverlay'}
/>,
);

Просмотреть файл

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import fileOverlayImage from 'images/fileOverlay.svg';
import './file_upload_overlay.scss';
export const DropOverlayIdEditPost = 'editPostFileDropOverlay';
export const DropOverlayIdCreateComment = 'createCommentFileDropOverlay';
export const DropOverlayIdCreatePost = 'createPostFileDropOverlay';
export const DropOverlayIdThreads = 'threadView';
export const DropOverlayIdCenterChannel = 'centerChannelFileDropOverlay';
export const DropOverlayIdRHS = 'rhsFileDropOverlay';
type Props = {
overlayType: string;
id: string;
isInEditMode?: boolean;
direction?: 'horizontal' | 'vertical';
}
export const FileUploadOverlay = (props: Props) => {
let overlayClass = 'file-overlay hidden';
if (props.overlayType === 'right') {
overlayClass += ' right-file-overlay';
} else if (props.overlayType === 'center') {
overlayClass += ' center-file-overlay';
}
if (props.isInEditMode) {
overlayClass += ' post_edit_mode';
}
const mode = props.direction || 'vertical';
return (
<div
id={props.id}
className={overlayClass}
>
<div className='overlay__indent'>
<div className={classNames('overlay__circle', mode)}>
<img
className='overlay__files'
src={fileOverlayImage}
alt=''
loading='lazy'
/>
<FormattedMessage
id='upload_overlay.info'
defaultMessage='Drop a file to upload it.'
/>
</div>
</div>
</div>
);
};

Просмотреть файл

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {FileUploadOverlay} from './file_upload_overlay';
export default FileUploadOverlay;

Просмотреть файл

@@ -1,34 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/InfoToast should match snapshot 1`] = `
<CSSTransition
appear={true}
classNames="toast"
in={true}
mountOnEnter={true}
timeout={300}
unmountOnExit={true}
>
<div>
<div
className="info-toast className"
class="info-toast className toast-appear toast-appear-active"
>
<CheckIcon />
<svg
fill="currentColor"
height="1em"
version="1.1"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
/>
</svg>
<span>
test
</span>
<button
className="info-toast__undo"
onClick={[Function]}
class="info-toast__undo"
>
Undo
</button>
<ForwardRef
className="info-toast__icon_button"
icon="close"
inverted={true}
onClick={[Function]}
size="sm"
/>
<button
aria-label="Close"
class="info-toast__icon_button"
>
<i
class="icon icon-close"
/>
</button>
</div>
</CSSTransition>
</div>
`;

Просмотреть файл

@@ -9,14 +9,16 @@
align-items: center;
padding: 8px;
border-radius: 4px;
background: var(--center-channel-text);
background: rgb(var(--center-channel-color-rgb));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.12);
color: var(--sidebar-text);
color: rgb(var(--center-channel-bg-rgb));
font-weight: 600;
grid-template-columns: min-content auto min-content auto;
line-height: 20px;
.info-toast__icon_button {
border: none;
background: none;
color: inherit;
}

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {render, screen, fireEvent} from '@testing-library/react';
import React from 'react';
import type {ComponentProps} from 'react';
@@ -21,34 +21,22 @@ describe('components/InfoToast', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(
<InfoToast
{...baseProps}
/>,
);
expect(wrapper).toMatchSnapshot();
const {container} = render(<InfoToast {...baseProps}/>);
expect(container).toMatchSnapshot();
});
test('should close the toast on undo', () => {
const wrapper = shallow(
<InfoToast
{...baseProps}
/>,
);
render(<InfoToast {...baseProps}/>);
wrapper.find('button').simulate('click');
fireEvent.click(screen.getByText(/undo/i));
expect(baseProps.content.undo).toHaveBeenCalled();
expect(baseProps.onExited).toHaveBeenCalled();
});
test('should close the toast on close button click', () => {
const wrapper = shallow(
<InfoToast
{...baseProps}
/>,
);
render(<InfoToast {...baseProps}/>);
wrapper.find('.info-toast__icon_button').simulate('click');
fireEvent.click(screen.getByRole('button', {name: /close/i}));
expect(baseProps.onExited).toHaveBeenCalled();
});
});

Просмотреть файл

@@ -6,8 +6,6 @@ import React, {useEffect, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {CSSTransition} from 'react-transition-group';
import IconButton from '@mattermost/compass-components/components/icon-button'; // eslint-disable-line no-restricted-imports
import './info_toast.scss';
type Props = {
@@ -64,13 +62,13 @@ function InfoToast({content, onExited, className}: Props): JSX.Element {
})}
</button>
)}
<IconButton
<button
className='info-toast__icon_button'
onClick={closeToast}
icon='close'
size='sm'
inverted={true}
/>
aria-label={formatMessage({id: 'general_button.close', defaultMessage: 'Close'})}
>
<i className='icon icon-close'/>
</button>
</div>
</CSSTransition>
);

Просмотреть файл

@@ -324,4 +324,139 @@ describe('PostComponent', () => {
});
});
});
describe('file list', () => {
test('should show file list in post', () => {
const fileInfo1 = TestHelper.getFileInfoMock({id: 'fileId1', name: 'file1.jpg'});
const fileInfo2 = TestHelper.getFileInfoMock({id: 'fileId2', name: 'file2.jpg'});
const fileInfo3 = TestHelper.getFileInfoMock({id: 'fileId3', name: 'file3.jpg'});
const post = TestHelper.getPostMock({file_ids: [fileInfo1.id, fileInfo2.id, fileInfo3.id]});
const state: DeepPartial<GlobalState> = {
entities: {
posts: {
posts: {
[post.id]: post,
},
},
files: {
files: {
[fileInfo1.id]: fileInfo1,
[fileInfo2.id]: fileInfo2,
[fileInfo3.id]: fileInfo3,
},
fileIdsByPostId: {
[baseProps.post.id]: ['fileId1', 'fileId2', 'fileId3'],
},
},
},
};
const props = {
...baseProps,
post,
};
const {container} = renderWithContext(<PostComponent {...props}/>, state);
expect(screen.getByTestId('fileAttachmentList')).toBeInTheDocument();
expect(container.querySelectorAll('.post-image__column')).toHaveLength(3);
expect(container.querySelectorAll('.post-image__column')[0]).toHaveTextContent(fileInfo1.name);
expect(container.querySelectorAll('.post-image__column')[1]).toHaveTextContent(fileInfo2.name);
expect(container.querySelectorAll('.post-image__column')[2]).toHaveTextContent(fileInfo3.name);
});
test('should show file list in edit container when editing', () => {
const fileInfo1 = TestHelper.getFileInfoMock({id: 'fileId1', name: 'file1.jpg'});
const fileInfo2 = TestHelper.getFileInfoMock({id: 'fileId2', name: 'file2.jpg'});
const fileInfo3 = TestHelper.getFileInfoMock({id: 'fileId3', name: 'file3.jpg'});
const team = TestHelper.getTeamMock({id: 'team_id'});
const channel = TestHelper.getChannelMock({team_id: team.id});
const post = TestHelper.getPostMock({
file_ids: [fileInfo1.id, fileInfo2.id, fileInfo3.id],
channel_id: channel.id,
metadata: {
files: [fileInfo1, fileInfo2, fileInfo3],
},
});
const state: DeepPartial<GlobalState> = {
entities: {
posts: {
posts: {
[post.id]: post,
},
},
files: {
files: {
[fileInfo1.id]: fileInfo1,
[fileInfo2.id]: fileInfo2,
[fileInfo3.id]: fileInfo3,
},
fileIdsByPostId: {
[post.id]: [fileInfo1.id, fileInfo2.id, fileInfo3.id],
},
},
channels: {
channels: {
[channel.id]: channel,
},
roles: {
[channel.id]: new Set(['channel_member']),
},
},
teams: {
teams: {
[team.id]: team,
},
},
roles: {
roles: {
channel_member: {permissions: ['create_post']},
},
},
},
views: {
posts: {
editingPost: {
postId: post.id,
show: true,
},
},
},
storage: {
storage: {
edit_draft_id: {
value: {
...post,
},
},
},
},
};
const props = {
...baseProps,
post,
isPostBeingEdited: true,
};
const {container} = renderWithContext(<PostComponent {...props}/>, state);
// advanced text editor should be visible
expect(container.querySelector('.AdvancedTextEditor__body')).toBeInTheDocument();
// file attachment list should be visible inside advanced text editor
expect(container.querySelector('.AdvancedTextEditor__body .file-preview__container')).toBeInTheDocument();
expect(container.querySelectorAll('.post-image__column')).toHaveLength(3);
expect(container.querySelectorAll('.post-image__column')[0]).toHaveTextContent(fileInfo1.name);
expect(container.querySelectorAll('.post-image__column')[1]).toHaveTextContent(fileInfo2.name);
expect(container.querySelectorAll('.post-image__column')[2]).toHaveTextContent(fileInfo3.name);
// additionally, files should not be visible outside the advanced text editor
expect(screen.queryByTestId('fileAttachmentList')).not.toBeInTheDocument();
});
});
});

Просмотреть файл

@@ -516,6 +516,8 @@ const PostComponent = (props: Props): JSX.Element => {
postAriaLabelDivTestId = 'rhsPostView';
}
const showFileAttachments = post.file_ids && post.file_ids.length > 0 && !props.isPostBeingEdited;
return (
<>
{(isSearchResultItem || (props.location !== Locations.CENTER && (props.isPinnedPosts || props.isFlaggedPosts))) && <DateSeparator date={currentPostDay}/>}
@@ -643,12 +645,13 @@ const PostComponent = (props: Props): JSX.Element => {
slot2={<EditPost/>}
onTransitionEnd={() => document.dispatchEvent(new Event(AppEvents.FOCUS_EDIT_TEXTBOX))}
/>
{post.file_ids && post.file_ids.length > 0 &&
<FileAttachmentListContainer
post={post}
compactDisplay={props.compactDisplay}
handleFileDropdownOpened={handleFileDropdownOpened}
/>
{
showFileAttachments &&
<FileAttachmentListContainer
post={post}
compactDisplay={props.compactDisplay}
handleFileDropdownOpened={handleFileDropdownOpened}
/>
}
<div className='post__body-reactions-acks'>
{props.isPostAcknowledgementsEnabled && post.metadata?.priority?.requested_ack && (

Просмотреть файл

@@ -226,7 +226,6 @@ exports[`components/post_edit_history should match snapshot 1`] = `
id="searchResult_post_id"
>
<div
aria-hidden="true"
class="edit-post-history__title__container"
>
<div
@@ -234,13 +233,10 @@ exports[`components/post_edit_history should match snapshot 1`] = `
>
<button
aria-label="Toggle to see an old message."
class="IconButtonRoot-hOPlUx gmplBN edit-post-history__icon__button"
disabled=""
class="edit-post-history__icon__button toggleCollapseButton"
>
<i
class="IconRoot-jSSaqj bQHSub icon-chevron-down"
color="inherit"
size="16"
class="icon icon-chevron-down"
/>
</button>
<span
@@ -324,7 +320,6 @@ exports[`components/post_edit_history should match snapshot 1`] = `
id="searchResult_post_id_1"
>
<div
aria-hidden="true"
class="edit-post-history__title__container"
>
<div
@@ -332,13 +327,10 @@ exports[`components/post_edit_history should match snapshot 1`] = `
>
<button
aria-label="Toggle to see an old message."
class="IconButtonRoot-hOPlUx gmplBN edit-post-history__icon__button"
disabled=""
class="edit-post-history__icon__button toggleCollapseButton"
>
<i
class="IconRoot-jSSaqj bQHSub icon-chevron-right"
color="inherit"
size="16"
class="icon icon-chevron-right"
/>
</button>
<span
@@ -353,12 +345,10 @@ exports[`components/post_edit_history should match snapshot 1`] = `
</div>
<button
aria-label="Select to restore an old message."
class="IconButtonRoot-hOPlUx gBoGLw edit-post-history__icon__button restore-icon"
class="edit-post-history__icon__button restore-icon"
>
<i
class="IconRoot-jSSaqj bQHSub icon-restore"
color="inherit"
size="16"
class="icon icon-restore"
/>
</button>
</div>
@@ -373,7 +363,6 @@ exports[`components/post_edit_history should match snapshot 1`] = `
id="searchResult_post_id_2"
>
<div
aria-hidden="true"
class="edit-post-history__title__container"
>
<div
@@ -381,13 +370,10 @@ exports[`components/post_edit_history should match snapshot 1`] = `
>
<button
aria-label="Toggle to see an old message."
class="IconButtonRoot-hOPlUx gmplBN edit-post-history__icon__button"
disabled=""
class="edit-post-history__icon__button toggleCollapseButton"
>
<i
class="IconRoot-jSSaqj bQHSub icon-chevron-right"
color="inherit"
size="16"
class="icon icon-chevron-right"
/>
</button>
<span
@@ -402,12 +388,10 @@ exports[`components/post_edit_history should match snapshot 1`] = `
</div>
<button
aria-label="Select to restore an old message."
class="IconButtonRoot-hOPlUx gBoGLw edit-post-history__icon__button restore-icon"
class="edit-post-history__icon__button restore-icon"
>
<i
class="IconRoot-jSSaqj bQHSub icon-restore"
color="inherit"
size="16"
class="icon icon-restore"
/>
</button>
</div>

Просмотреть файл

@@ -1,265 +1,244 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/post_edit_history/edited_post_item should match snapshot 1`] = `
<CompassThemeProvider
theme={Object {}}
>
<div>
<div
className="edit-post-history__container"
onClick={[Function]}
class="edit-post-history__container"
>
<PostAriaLabelDiv
className="a11y__section post"
<div
aria-label="At 12:00 AM Thursday, January 1, Someone wrote, post message"
class="a11y__section post"
id="searchResult_post_id"
post={
Object {
"channel_id": "",
"create_at": 0,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "post_id",
"is_pinned": false,
"message": "post message",
"metadata": Object {
"embeds": Array [],
"emojis": Array [],
"files": Array [],
"images": Object {},
"reactions": Array [],
},
"original_id": "",
"pending_post_id": "",
"props": Object {},
"reply_count": 0,
"root_id": "",
"type": "system_add_remove",
"update_at": 0,
"user_id": "user_id",
}
}
>
<div
aria-hidden="true"
className="edit-post-history__title__container"
class="edit-post-history__title__container"
>
<div
className="edit-post-history__date__badge__container"
class="edit-post-history__date__badge__container"
>
<ForwardRef
<button
aria-label="Toggle to see an old message."
className="edit-post-history__icon__button"
compact={true}
icon="chevron-right"
size="sm"
/>
<span
className="edit-post-history__date"
class="edit-post-history__icon__button toggleCollapseButton"
>
<Connect(injectIntl(Timestamp))
ranges={
Array [
Object {
"display": <Memo(MemoizedFormattedMessage)
defaultMessage="Today"
id="date_separator.today"
/>,
"equals": Array [
"day",
0,
],
},
Object {
"display": <Memo(MemoizedFormattedMessage)
defaultMessage="Yesterday"
id="date_separator.yesterday"
/>,
"equals": Array [
"day",
-1,
],
},
]
}
value={0}
<i
class="icon icon-chevron-right"
/>
</button>
<span
class="edit-post-history__date"
>
<time
datetime="1970-01-01T00:00:00.000"
>
January 01, 1970 at 12:00 AM
</time>
</span>
</div>
<WithTooltip
title="Restore this version"
<button
aria-label="Select to restore an old message."
class="edit-post-history__icon__button restore-icon"
>
<ForwardRef
aria-label="Select to restore an old message."
className="edit-post-history__icon__button restore-icon"
compact={true}
icon="restore"
onClick={[Function]}
size="sm"
<i
class="icon icon-restore"
/>
</WithTooltip>
</button>
</div>
</PostAriaLabelDiv>
</div>
</div>
</CompassThemeProvider>
</div>
`;
exports[`components/post_edit_history/edited_post_item should match snapshot when isCurrent is true 1`] = `
<CompassThemeProvider
theme={Object {}}
>
<div>
<div
className="edit-post-history__container edit-post-history__container__background"
onClick={[Function]}
class="edit-post-history__container edit-post-history__container__background"
>
<PostAriaLabelDiv
className="a11y__section post"
<div
aria-label="At 12:00 AM Thursday, January 1, Someone wrote, post message"
class="a11y__section post"
id="searchResult_post_id"
post={
Object {
"channel_id": "",
"create_at": 0,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "post_id",
"is_pinned": false,
"message": "post message",
"metadata": Object {
"embeds": Array [],
"emojis": Array [],
"files": Array [],
"images": Object {},
"reactions": Array [],
},
"original_id": "",
"pending_post_id": "",
"props": Object {},
"reply_count": 0,
"root_id": "",
"type": "system_add_remove",
"update_at": 0,
"user_id": "user_id",
}
}
>
<div
aria-hidden="true"
className="edit-post-history__title__container"
class="edit-post-history__title__container"
>
<div
className="edit-post-history__date__badge__container"
class="edit-post-history__date__badge__container"
>
<ForwardRef
<button
aria-label="Toggle to see an old message."
className="edit-post-history__icon__button"
compact={true}
icon="chevron-down"
size="sm"
/>
<span
className="edit-post-history__date"
class="edit-post-history__icon__button toggleCollapseButton"
>
<Connect(injectIntl(Timestamp))
ranges={
Array [
Object {
"display": <Memo(MemoizedFormattedMessage)
defaultMessage="Today"
id="date_separator.today"
/>,
"equals": Array [
"day",
0,
],
},
Object {
"display": <Memo(MemoizedFormattedMessage)
defaultMessage="Yesterday"
id="date_separator.yesterday"
/>,
"equals": Array [
"day",
-1,
],
},
]
}
value={0}
<i
class="icon icon-chevron-down"
/>
</button>
<span
class="edit-post-history__date"
>
<time
datetime="1970-01-01T00:00:00.000"
>
January 01, 1970 at 12:00 AM
</time>
</span>
<div
className="edit-post-history__current__indicator"
class="edit-post-history__current__indicator"
>
Current Version
</div>
</div>
</div>
<div
className="edit-post-history__content_container"
class="edit-post-history__content_container"
>
<div
className="edit-post-history__header"
class="edit-post-history__header"
>
<span
className="profile-icon"
class="profile-icon"
>
<Memo(Avatar)
className="avatar-post-preview"
size="sm"
url="/api/v4/users/user_id/image?_=0"
<img
alt="user profile image"
class="Avatar Avatar-sm avatar-post-preview"
loading="lazy"
src="/api/v4/users/user_id/image?_=0"
/>
</span>
<div
className="edit-post-history__header__username"
class="edit-post-history__header__username"
>
<Connect(UserProfile)
disablePopover={true}
overwriteName=""
userId="user_id"
/>
<div
class="user-popover"
>
Someone
</div>
</div>
</div>
<div
className="post__content"
class="post__content"
>
<div
className="search-item-snippet post__body"
class="search-item-snippet post__body"
>
<Connect(PostMessageView)
isRHS={true}
post={
Object {
"channel_id": "",
"create_at": 0,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "post_id",
"is_pinned": false,
"message": "post message",
"metadata": Object {
"embeds": Array [],
"emojis": Array [],
"files": Array [],
"images": Object {},
"reactions": Array [],
},
"original_id": "",
"pending_post_id": "",
"props": Object {},
"reply_count": 0,
"root_id": "",
"type": "system_add_remove",
"update_at": 0,
"user_id": "user_id",
}
}
showPostEditedIndicator={false}
/>
<div
class="post-message post-message--collapsed"
>
<div
class="post-message__text-container"
style="max-height: 600px;"
>
<div
class="post-message__text"
dir="auto"
id="rhsPostMessageText_post_id"
tabindex="0"
>
<p>
post message
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</PostAriaLabelDiv>
</div>
</div>
</CompassThemeProvider>
</div>
`;
exports[`components/post_edit_history/edited_post_item should match snapshot with file metadata 1`] = `
<div>
<div
class="edit-post-history__container"
>
<div
aria-label="At 12:00 AM Thursday, January 1, Someone wrote, post message"
class="a11y__section post"
id="searchResult_post_id"
>
<div
class="edit-post-history__title__container"
>
<div
class="edit-post-history__date__badge__container"
>
<button
aria-label="Toggle to see an old message."
class="edit-post-history__icon__button toggleCollapseButton"
>
<i
class="icon icon-chevron-right"
/>
</button>
<span
class="edit-post-history__date"
>
<time
datetime="1970-01-01T00:00:00.000"
>
January 01, 1970 at 12:00 AM
</time>
</span>
</div>
<button
aria-label="Select to restore an old message."
class="edit-post-history__icon__button restore-icon"
>
<i
class="icon icon-restore"
/>
</button>
</div>
</div>
</div>
</div>
`;
exports[`components/post_edit_history/edited_post_item should match snapshot with file metadata with some deleted files 1`] = `
<div>
<div
class="edit-post-history__container"
>
<div
aria-label="At 12:00 AM Thursday, January 1, Someone wrote, post message"
class="a11y__section post"
id="searchResult_post_id"
>
<div
class="edit-post-history__title__container"
>
<div
class="edit-post-history__date__badge__container"
>
<button
aria-label="Toggle to see an old message."
class="edit-post-history__icon__button toggleCollapseButton"
>
<i
class="icon icon-chevron-right"
/>
</button>
<span
class="edit-post-history__date"
>
<time
datetime="1970-01-01T00:00:00.000"
>
January 01, 1970 at 12:00 AM
</time>
</span>
</div>
<button
aria-label="Select to restore an old message."
class="edit-post-history__icon__button restore-icon"
>
<i
class="icon icon-restore"
/>
</button>
</div>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {screen, fireEvent} from '@testing-library/react';
import React from 'react';
import type {ComponentProps} from 'react';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {renderWithContext} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
@@ -34,22 +35,26 @@ describe('components/post_edit_history/edited_post_item', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(<EditedPostItem {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<EditedPostItem {...baseProps}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot when isCurrent is true', () => {
const props = {
...baseProps,
isCurrent: true,
};
const wrapper = shallow(<EditedPostItem {...props}/>);
expect(wrapper).toMatchSnapshot();
const {container} = renderWithContext(<EditedPostItem {...props}/>);
expect(container).toMatchSnapshot();
});
test('clicking on the restore button should call openRestorePostModal', () => {
const wrapper = shallow(<EditedPostItem {...baseProps}/>);
renderWithContext(<EditedPostItem {...baseProps}/>);
// find the button with restore icon and click it
wrapper.find('ForwardRef').filterWhere((button) => button.prop('icon') === 'restore').simulate('click');
const restoreButton = screen.getByRole('button', {name: /restore/i});
fireEvent.click(restoreButton);
expect(baseProps.actions.openModal).toHaveBeenCalledWith(
expect.objectContaining({
modalId: ModalIdentifiers.RESTORE_POST_MODAL,
@@ -58,21 +63,61 @@ describe('components/post_edit_history/edited_post_item', () => {
);
});
test('when isCurrent is true, should not render the restore button', () => {
test('when isCurrent is true, should not renderWithContext the restore button', () => {
const props = {
...baseProps,
isCurrent: true,
};
const wrapper = shallow(<EditedPostItem {...props}/>);
expect(wrapper.find('ForwardRef').filterWhere((button) => button.prop('icon') === 'refresh')).toHaveLength(0);
renderWithContext(<EditedPostItem {...props}/>);
expect(screen.queryByRole('button', {name: /restore/i})).toBeNull();
});
test('when isCurrent is true, should render the current version text', () => {
test('when isCurrent is true, should renderWithContext the current version text', () => {
const props = {
...baseProps,
isCurrent: true,
};
const wrapper = shallow(<EditedPostItem {...props}/>);
expect(wrapper.find('.edit-post-history__current__indicator')).toHaveLength(1);
renderWithContext(<EditedPostItem {...props}/>);
expect(screen.getByText(/current version/i)).toBeInTheDocument();
});
test('should match snapshot with file metadata', () => {
const props = {
...baseProps,
post: {
...baseProps.post,
metadata: {
...baseProps.post.metadata,
files: [
TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3}),
TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2}),
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1}),
],
},
},
};
const {container} = renderWithContext(<EditedPostItem {...props}/>);
expect(container).toMatchSnapshot();
});
test('should match snapshot with file metadata with some deleted files', () => {
const props = {
...baseProps,
post: {
...baseProps.post,
metadata: {
...baseProps.post.metadata,
files: [
TestHelper.getFileInfoMock({id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3}),
TestHelper.getFileInfoMock({id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2, delete_at: 4}),
TestHelper.getFileInfoMock({id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1, delete_at: 4}),
],
},
},
};
const {container} = renderWithContext(<EditedPostItem {...props}/>);
expect(container).toMatchSnapshot();
});
});

Просмотреть файл

@@ -4,15 +4,19 @@
import classNames from 'classnames';
import React, {memo, useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import IconButton from '@mattermost/compass-components/components/icon-button'; // eslint-disable-line no-restricted-imports
import {CheckIcon} from '@mattermost/compass-icons/components';
import type {Post} from '@mattermost/types/posts';
import {getPostEditHistory, restorePostVersion} from 'mattermost-redux/actions/posts';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {ensureString} from 'mattermost-redux/utils/post_utils';
import {getConnectionId} from 'selectors/general';
import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider';
import FileAttachmentListContainer from 'components/file_attachment_list';
import InfoToast from 'components/info_toast/info_toast';
import PostAriaLabelDiv from 'components/post_view/post_aria_label_div';
import PostMessageContainer from 'components/post_view/post_message_view';
@@ -26,6 +30,8 @@ import {imageURLForUser} from 'utils/utils';
import RestorePostModal from '../restore_post_modal';
import './edited_post_items.scss';
import type {PropsFromRedux} from './index';
const DATE_RANGES = [
@@ -58,7 +64,15 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
const {formatMessage} = useIntl();
const [open, setOpen] = useState(isCurrent);
const openRestorePostModal = useCallback(() => {
const dispatch = useDispatch();
const connectionId = useSelector(getConnectionId);
const openRestorePostModal = useCallback((e) => {
// this prevents history item from
// collapsing and closing when clicking on restore button
e.stopPropagation();
const restorePostModalData = {
modalId: ModalIdentifiers.RESTORE_POST_MODAL,
dialogType: RestorePostModal,
@@ -74,7 +88,9 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
actions.openModal(restorePostModalData);
}, [actions, post]);
const togglePost = () => setOpen((prevState) => !prevState);
const togglePost = () => {
setOpen((prevState) => !prevState);
};
if (!post) {
return null;
@@ -97,18 +113,12 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
};
const handleRestore = async () => {
if (!postCurrentVersion || !post || postCurrentVersion.message === post.message) {
if (!postCurrentVersion || !post) {
actions.closeRightHandSide();
return;
}
const updatedPost = {
message: post.message,
id: postCurrentVersion.id,
channel_id: postCurrentVersion.channel_id,
};
const result = await actions.editPost(updatedPost as Post);
const result = await dispatch(restorePostVersion(post.original_id, post.id, connectionId));
if (result.data) {
actions.closeRightHandSide();
showInfoTooltip();
@@ -121,7 +131,16 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
return;
}
await actions.editPost(postCurrentVersion);
// To undo a recent restore, you need to restore the previous version of the post right before this restore.
// That would be the first history item in post's edit history as it is the most recent edit
// and edit history is sorted from most recent first to oldest.
const result = await dispatch(getPostEditHistory(post.original_id));
if (!result.data || result.data.length === 0) {
return;
}
const previousPostVersion = result.data[0];
await dispatch(restorePostVersion(previousPostVersion.original_id, previousPostVersion.id, connectionId));
};
const currentVersionIndicator = isCurrent ? (
@@ -160,6 +179,8 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
/>
);
const isFileDeleted = post.delete_at > 0;
const messageContainer = (
<div className='edit-post-history__content_container'>
{postHeader}
@@ -168,6 +189,12 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
{message}
</div>
</div>
<FileAttachmentListContainer
post={post}
isEditHistory={isFileDeleted}
disableDownload={isFileDeleted}
disableActions={isFileDeleted}
/>
</div>
);
@@ -175,14 +202,13 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
<WithTooltip
title={formatMessage(itemMessages.helpText)}
>
<IconButton
<button
className='edit-post-history__icon__button restore-icon'
size={'sm'}
icon={'restore'}
onClick={openRestorePostModal}
compact={true}
aria-label={formatMessage(itemMessages.ariaLabelMessage)}
/>
>
<i className={'icon icon-restore'}/>
</button>
</WithTooltip>
);
@@ -202,16 +228,14 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, theme, act
>
<div
className='edit-post-history__title__container'
aria-hidden='true'
>
<div className='edit-post-history__date__badge__container'>
<IconButton
size={'sm'}
icon={open ? 'chevron-down' : 'chevron-right'}
compact={true}
<button
aria-label='Toggle to see an old message.'
className='edit-post-history__icon__button'
/>
className='edit-post-history__icon__button toggleCollapseButton'
>
<i className={`icon ${open ? 'icon-chevron-down' : 'icon-chevron-right'}`}/>
</button>
<span className='edit-post-history__date'>
<Timestamp
value={timeStampValue}

Просмотреть файл

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.edit-post-history__container {
.edit-post-history__icon__button.toggleCollapseButton {
border: none;
background: none;
pointer-events: none;
}
.edit-post-history__icon__button.restore-icon {
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: none;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.75);
}
i.icon.icon-restore {
width: 16px;
height: 16px;
}
}
.post-image__columns {
.post-image__column, .post-image__thumbnail {
cursor: default;
}
}
}

Просмотреть файл

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@media (min-width: 758px) {
#restorePostModal {
.modal-dialog, .modal-content {
width: 832px;
}
.modal-content {
max-width: 100%;
}
}
}

Просмотреть файл

@@ -7,8 +7,11 @@ import {defineMessages, useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {Post} from '@mattermost/types/posts';
import FileAttachmentListContainer from 'components/file_attachment_list';
import PostMessageView from 'components/post_view/post_message_view';
import './restore_post_history.scss';
const modalMessages = defineMessages({
title: {
id: 'post_info.edit.restore',
@@ -64,6 +67,12 @@ const RestorePostModal = ({post, postHeader, actions, onExited}: Props) => {
maxHeight={100}
showPostEditedIndicator={false}
/>
<FileAttachmentListContainer
post={post}
isEditHistory={true}
disableDownload={true}
disableActions={true}
/>
</div>
</GenericModal>
);

Просмотреть файл

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import type {MouseEvent} from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {useIntl} from 'react-intl';
import {PencilOutlineIcon} from '@mattermost/compass-icons/components';
@@ -11,6 +11,7 @@ import {getDateForTimezone} from 'mattermost-redux/utils/timezone_utils';
import WithTooltip from 'components/with_tooltip';
import Constants from 'utils/constants';
import {isSameDay, isWithinLastWeek, isYesterday} from 'utils/datetime';
import type {Props} from './index';
@@ -64,13 +65,19 @@ const PostEditedIndicator = ({postId, isMilitaryTime, timeZone, editedAt = 0, po
<span className='view-history__text'>{viewHistoryText}</span>
) : null;
const showPostEditHistory = (e: MouseEvent<HTMLButtonElement>) => {
const showPostEditHistory = (e: MouseEvent<HTMLButtonElement> | KeyboardEvent<unknown>) => {
e.preventDefault();
if (post?.id) {
actions.openShowEditHistory(post);
}
};
const handleKeyPress = (e: KeyboardEvent<unknown>) => {
if (e.key === Constants.KeyCodes.ENTER[0] || e.key === Constants.KeyCodes.SPACE[0]) {
showPostEditHistory(e);
}
};
const editedIndicatorContent = (
<span
id={`postEdited_${postId}`}
@@ -86,8 +93,10 @@ const PostEditedIndicator = ({postId, isMilitaryTime, timeZone, editedAt = 0, po
const editedIndicator = (postOwner && canEdit) ? (
<button
className={'style--none'}
tabIndex={-1}
tabIndex={0}
onClick={showPostEditHistory}
onKeyUp={handleKeyPress}
aria-label={editedText}
>
{editedIndicatorContent}
</button>

Просмотреть файл

@@ -13,6 +13,7 @@ import {trackEvent} from 'actions/telemetry_actions.jsx';
import ChannelInfoRhs from 'components/channel_info_rhs';
import ChannelMembersRhs from 'components/channel_members_rhs';
import FileUploadOverlay from 'components/file_upload_overlay';
import {DropOverlayIdRHS} from 'components/file_upload_overlay/file_upload_overlay';
import LoadingScreen from 'components/loading_screen';
import PostEditHistory from 'components/post_edit_history';
import ResizableRhs from 'components/resizable_sidebar/resizable_rhs';
@@ -293,7 +294,10 @@ export default class SidebarRight extends React.PureComponent<Props, State> {
selectedChannelNeeded = true;
content = (
<div className='post-right__container'>
<FileUploadOverlay overlayType='right'/>
<FileUploadOverlay
overlayType='right'
id={DropOverlayIdRHS}
/>
<RhsThread previousRhsState={previousRhsState}/>
</div>
);

Просмотреть файл

@@ -75,7 +75,10 @@ describe('components/SingleImageView', () => {
);
const instance = wrapper.instance() as SingleImageView;
instance.toggleEmbedVisibility();
const event = {
stopPropagation: jest.fn(),
} as unknown as React.MouseEvent<HTMLButtonElement>;
instance.toggleEmbedVisibility(event);
expect(props.actions.toggleEmbedVisibility).toHaveBeenCalledTimes(1);
expect(props.actions.toggleEmbedVisibility).toBeCalledWith('original_post_id');
});

Просмотреть файл

@@ -30,6 +30,7 @@ export interface Props extends PropsFromRedux {
compactDisplay?: boolean;
isEmbedVisible?: boolean;
isInPermalink?: boolean;
disableActions?: boolean;
}
type State = {
@@ -97,7 +98,10 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
});
};
toggleEmbedVisibility = () => {
toggleEmbedVisibility = (e: React.MouseEvent) => {
// stopping propagation to avoid accidentally closing edit history
// section when clicking on image collapse/expand button.
e.stopPropagation();
this.props.actions.toggleEmbedVisibility(this.props.postId);
};
@@ -241,6 +245,7 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
handleSmallImageContainer={true}
enablePublicLink={this.props.enablePublicLink}
getFilePublicLink={this.getFilePublicLink}
hideUtilities={this.props.disableActions}
/>
</div>
</div>

Просмотреть файл

@@ -9,6 +9,7 @@ exports[`components/threading/ThreadViewer should match snapshot 1`] = `
className="post-right-comments-container"
>
<FileUploadOverlay
id="threadView"
overlayType="right"
/>
<DeferredRenderWrapper

Просмотреть файл

@@ -13,6 +13,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import deferComponentRender from 'components/deferComponentRender';
import FileUploadOverlay from 'components/file_upload_overlay';
import {DropOverlayIdThreads} from 'components/file_upload_overlay/file_upload_overlay';
import LoadingScreen from 'components/loading_screen';
import WebSocketClient from 'client/web_websocket_client';
@@ -219,7 +220,10 @@ export default class ThreadViewer extends React.PureComponent<Props, State> {
<div className={classNames('ThreadViewer', this.props.className)}>
<div className='post-right-comments-container'>
<>
<FileUploadOverlay overlayType='right'/>
<FileUploadOverlay
overlayType='right'
id={DropOverlayIdThreads}
/>
{this.props.selected && (
<DeferredThreadViewerVirt
inputPlaceholder={this.props.inputPlaceholder}

Просмотреть файл

@@ -3924,7 +3924,6 @@
"generic_icons.search": "Search Icon",
"generic_icons.success": "Success Icon",
"generic_icons.upgradeBadge": "Upgrade badge",
"generic_icons.upload": "Upload Icon",
"generic_icons.user_groups": "User Groups Icon",
"generic_icons.userGuide": "Help",
"generic_icons.warning": "Warning Icon",

35
webapp/channels/src/images/fileOverlay.svg Обычный файл
Просмотреть файл

@@ -0,0 +1,35 @@
<svg width="128" height="109" viewBox="0 0 128 109" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2287_27883)">
<rect opacity="0.16" x="-0.106445" y="16.9929" width="63.8884" height="72.6693" rx="2.14226" transform="rotate(-15 -0.106445 16.9929)" fill="#090A0B"/>
<rect x="0.535645" y="15.8806" width="63.8884" height="72.6693" rx="2.14226" transform="rotate(-15 0.535645 15.8806)" fill="#F4F4F6"/>
<rect x="5.51318" y="19.2488" width="55.7131" height="55.7131" rx="1.60669" transform="rotate(-15 5.51318 19.2488)" fill="#32A4EC"/>
<mask id="mask0_2287_27883" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="5" y="5" width="69" height="68">
<rect x="5.51318" y="19.2488" width="55.7131" height="55.7131" rx="1.07113" transform="rotate(-15 5.51318 19.2488)" fill="#32A4EC"/>
</mask>
<g mask="url(#mask0_2287_27883)">
<path d="M59.7467 36.5718C60.0253 36.8862 60.1878 37.2164 60.2343 37.5624C60.1646 37.9242 60.0949 38.286 59.7776 38.564L56.5891 41.2288C56.0551 41.6537 55.8848 42.1466 56.1944 42.6917L57.4325 46.6335C57.6104 47.079 57.6569 47.425 57.355 47.8184C57.1693 48.1959 56.8365 48.3586 56.3722 48.4218L52.2396 49.336C51.6591 49.4149 51.2412 49.824 51.0864 50.4322L50.3511 54.6424C50.1653 55.02 49.9796 55.3975 49.6468 55.5602C49.314 55.7229 48.9658 55.7702 48.486 55.718L44.5237 54.3778C43.9123 54.2261 43.4634 54.4045 43.0455 54.8137L40.3987 57.992C40.0814 58.27 39.7486 58.4327 39.2843 58.4958C38.8045 58.4436 38.4407 58.3756 38.1621 58.0612L35.4923 54.9012C35.0667 54.3719 34.5714 54.2044 34.0219 54.514L30.1679 55.7426C29.719 55.921 29.3708 55.9684 28.9761 55.6698C28.5969 55.4864 28.3183 55.172 28.2564 54.7107L27.3356 50.4908C27.1422 49.93 26.8481 49.5002 26.2367 49.3485L22.1196 48.6165C21.6398 48.5643 21.2451 48.2657 21.0826 47.9355C20.9201 47.6053 20.8737 47.2593 21.044 46.7664L22.2901 42.8391C22.561 42.2151 22.383 41.7695 21.8567 41.3713L19.0012 38.5889C18.7226 38.2745 18.5601 37.9442 18.4982 37.4829C18.4363 37.0216 18.622 36.6441 19.0554 36.3503L22.2594 33.8008C22.6773 33.3917 22.8321 32.7835 22.6387 32.2226L21.2999 28.4119C21.238 27.9505 21.1916 27.6045 21.4935 27.2112C21.6792 26.8336 21.9965 26.5556 22.4608 26.4925L26.5083 25.8247C27.0733 25.6305 27.5067 25.3367 27.6615 24.7285L28.4123 20.6336C28.4665 20.1565 28.7683 19.7632 29.1011 19.6005C29.4339 19.4378 29.8982 19.3747 30.2774 19.558L34.1236 20.914C34.7504 21.1811 35.1993 21.0026 35.6018 20.4781L38.3801 17.3994C38.6974 17.1214 39.0302 16.9587 39.3785 16.9113C39.7267 16.864 40.1059 17.0473 40.5161 17.4613L43.1859 20.6213C43.5961 21.0352 44.0913 21.2028 44.6563 21.0085L48.6109 19.6488C49.0753 19.5856 49.4235 19.5383 49.8182 19.8369C50.1974 20.0202 50.3599 20.3504 50.4218 20.8118L51.1956 24.8167C51.2884 25.5087 51.6986 25.9227 52.3099 26.0744L56.2955 26.7069C56.6747 26.8902 57.0539 27.0735 57.2164 27.4037C57.3789 27.7339 57.4408 28.1952 57.3711 28.557L55.8774 32.4006C55.7226 33.0088 55.9006 33.4543 56.3107 33.8683L59.7467 36.5718Z" fill="#FFBC1F"/>
<path d="M67.7685 31.8217L74.5367 57.5839C74.6495 58.4214 74.3538 58.9487 73.5458 59.3019L43.0935 67.3271C40.8436 66.0483 38.9377 64.6011 37.2235 62.7624C35.6455 61.027 34.4993 58.8677 33.9532 56.6271C33.0293 52.4884 34.37 47.9211 38.1273 43.148C41.8846 38.375 47.1556 34.8587 53.9724 32.8385C58.9331 31.5571 63.5264 31.1783 67.7685 31.8217Z" fill="#297A5A"/>
<path d="M73.5385 59.3006L20.299 73.355C19.4568 73.4695 18.9275 73.1764 18.5747 72.3725L12.7213 50.656C14.9029 48.6556 17.1647 47.2529 19.7633 46.5344C26.0433 44.9503 32.1067 44.4909 38.2101 45.2429C44.2974 45.8754 50.1521 47.513 55.6058 49.8136C59.1105 51.2841 62.3747 52.7872 65.5346 54.4262C68.6784 55.9458 71.5817 57.498 74.0759 58.7408C73.9877 58.9962 73.7791 59.2679 73.5385 59.3006Z" fill="#339970"/>
</g>
<rect opacity="0.16" x="65.6118" y="22.8103" width="63.8884" height="72.6693" rx="2.14226" transform="rotate(15 65.6118 22.8103)" fill="#090A0B"/>
<rect x="66.7246" y="22.168" width="63.8884" height="72.6693" rx="2.14226" transform="rotate(15 66.7246 22.168)" fill="#F4F4F6"/>
<rect x="69.3511" y="27.574" width="55.7131" height="55.7131" rx="1.60669" transform="rotate(15 69.3511 27.574)" fill="#32A4EC"/>
<mask id="mask1_2287_27883" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="55" y="27" width="68" height="69">
<rect x="69.3511" y="27.574" width="55.7131" height="55.7131" rx="1.07113" transform="rotate(15 69.3511 27.574)" fill="#32A4EC"/>
</mask>
<g mask="url(#mask1_2287_27883)">
<mask id="mask2_2287_27883" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="53" y="26" width="72" height="71">
<rect x="68.4932" y="26.0874" width="57.9522" height="57.9522" rx="1.07113" transform="rotate(15 68.4932 26.0874)" fill="#32A4EC"/>
</mask>
<g mask="url(#mask2_2287_27883)">
<path d="M110.113 68.486C112.233 71.1751 114.151 73.3469 115.609 75.1022L109.882 96.6468C109.555 97.4895 109.026 97.815 108.173 97.6117L53.8144 83.0056C52.9722 82.6786 52.6472 82.1499 52.8506 81.2957L58.5776 59.7512C60.7696 58.9554 63.4788 57.9578 66.705 56.7584C69.9313 55.559 73.4275 54.1351 77.3173 52.498C79.1386 51.6681 80.9599 50.8382 82.5455 49.8619C84.1196 49.0092 85.3002 48.3697 86.2109 47.9547C87.133 47.4161 88.2679 47.2712 89.3687 47.4972C90.4808 47.5996 91.5816 47.8256 92.4352 48.0289C93.2888 48.2322 94.2545 48.5705 95.3325 49.0438C96.4104 49.5172 97.2183 50.2151 97.8799 51.1489C98.3171 51.8126 99.0794 53.0051 100.055 54.5913C101.03 56.1774 102.005 57.7636 103.227 59.3725C105.526 62.8263 107.87 65.7855 110.113 68.486Z" fill="#3F4350"/>
<path d="M107.726 65.7026C106.602 64.3437 106.028 63.727 105.327 64.5425C104.49 65.4712 103.022 66.4679 100.663 67.6346C97.7962 68.8806 95.2355 69.5263 92.9691 69.6963C90.7026 69.8663 89.0829 68.4621 88.0981 65.6082C87.1134 62.7543 85.7648 61.1235 84.3002 60.7385C82.7117 60.3422 81.0419 60.818 79.1787 62.03C77.3155 63.2419 75.8937 63.7403 75.0491 63.4119C74.0804 63.0722 73.3945 62.3814 72.7666 61.0677C72.185 59.2557 71.9638 57.6022 72.2037 56.3677C72.4552 55.0087 71.8172 54.7757 70.5194 55.2855L80.8536 50.7498L84.1502 48.9149C85.076 48.3712 85.8547 48.0654 86.5094 47.7482C87.4236 47.3291 88.5626 47.1818 89.6784 47.2837C90.7942 47.3855 91.8984 47.612 92.7547 47.8158C93.611 48.0196 94.5796 48.3593 95.6491 48.9594C96.7301 49.435 97.54 50.1372 98.3383 50.9638C98.7762 51.632 99.3266 52.4361 99.9661 53.6252C100.606 54.8143 101.369 56.0147 102.245 57.3511L107.726 65.7026Z" fill="white"/>
</g>
</g>
</g>
<defs>
<clipPath id="clip0_2287_27883">
<rect width="128" height="108.987" fill="white" transform="translate(0 0.00634766)"/>
</clipPath>
</defs>
</svg>

После

Ширина:  |  Высота:  |  Размер: 6.4 KiB

Двоичные данные
webapp/channels/src/images/filesOverlay.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 4.0 KiB

Просмотреть файл

@@ -1317,3 +1317,23 @@ export function unacknowledgePost(postId: string): ActionFuncAsync {
return {data};
};
}
export function restorePostVersion(postId: string, restoreVersionId: string, connectionId: string): ActionFuncAsync {
return async (dispatch, getState) => {
try {
await Client4.restorePostVersion(postId, restoreVersionId, connectionId);
} catch (error) {
// Send to error bar if it's an edit post error about time limit.
if (error.server_error_id === 'api.post.update_post.permissions_time_limit.app_error') {
dispatch(logError({type: 'announcement', message: error.message}, true));
} else {
dispatch(logError(error));
}
forceLogoutIfNecessary(error, dispatch, getState);
return {error};
}
return {data: true};
};
}

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import type {GlobalState} from '@mattermost/types/store';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
@@ -37,7 +38,7 @@ export function makeGetFilesForPost(): (state: GlobalState, postId: string) => F
'makeGetFilesForPost',
getAllFiles,
getFilesIdsForPost,
getCurrentUserLocale,
(state) => getCurrentUserLocale(state),
(allFiles, fileIdsForPost, locale) => {
const fileInfos = fileIdsForPost.map((id) => allFiles[id]).filter((id) => Boolean(id));
@@ -46,6 +47,18 @@ export function makeGetFilesForPost(): (state: GlobalState, postId: string) => F
);
}
export function makeGetFilesForEditHistory(): (state: GlobalState, editHistoryPost: Post) => FileInfo[] {
return createSelector(
'makeGetFilesForEditHistory',
(state) => getCurrentUserLocale(state),
(state: GlobalState, editHistoryPost: Post) => editHistoryPost,
(userLocal, editHistoryPost) => {
const fileInfos = editHistoryPost?.metadata?.files ? [...editHistoryPost.metadata.files] : [];
return sortFileInfos(fileInfos, userLocal);
},
);
}
export const getSearchFilesResults: (state: GlobalState) => FileSearchResultItem[] = createSelector(
'getSearchFilesResults',
getAllFilesFromSearch,

Просмотреть файл

@@ -133,76 +133,6 @@
}
}
.file-overlay {
position: absolute;
z-index: 13;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: variables.$white;
font-size: functions.em(20px);
font-weight: 600;
pointer-events: none;
text-align: center;
.overlay__indent {
@include mixins.clearfix;
@include mixins.alpha-property(background-color, variables.$black, 0.75);
position: relative;
height: 100%;
}
&.right-file-overlay {
font-size: functions.em(18px);
.overlay__circle {
width: 300px;
height: 300px;
margin: -150px 0 0 -150px;
}
.overlay__files {
width: 150px;
margin: 60px auto 15px;
}
}
.overlay__circle {
position: absolute;
top: 50%;
left: 50%;
width: 370px;
height: 370px;
border-radius: 500px;
margin: -185px 0 0 -185px;
pointer-events: none;
@include mixins.alpha-property(background, variables.$black, 0.7);
}
.overlay__files {
display: block;
margin: 75px auto 20px;
}
.overlay__logo {
position: absolute;
bottom: 30px;
left: 50%;
width: 100px;
margin-left: -50px;
opacity: 0.3;
}
.fa {
display: inline-block;
margin-right: 8px;
font-size: 1.1em;
}
}
#post-list {
position: relative;
height: 100%;

Просмотреть файл

@@ -1278,12 +1278,6 @@
.overlay__circle {
width: 300px;
height: 300px;
margin: -150px 0 0 -150px;
}
.overlay__files {
width: 150px;
margin: 60px auto 15px;
}
}

Просмотреть файл

@@ -115,32 +115,43 @@ export function makeGetDraft() {
rootId: '',
});
return (state: GlobalState, channelId: string, rootId = '', storageKey = ''): PostDraft => {
let prefixStorageKey = StoragePrefixes.DRAFT;
let suffixStorageKey = channelId;
if (rootId) {
prefixStorageKey = StoragePrefixes.COMMENT_DRAFT;
suffixStorageKey = rootId;
}
const key = storageKey || `${prefixStorageKey}${suffixStorageKey}`;
return createSelector(
'makeGetDraft',
(_: GlobalState, channelId: string) => channelId,
(_: GlobalState, channelId: string, rootId = '') => rootId,
(state: GlobalState, channelId: string, rootId = '', storageKey = '') => {
let prefixStorageKey = StoragePrefixes.DRAFT;
let suffixStorageKey = channelId;
if (rootId) {
prefixStorageKey = StoragePrefixes.COMMENT_DRAFT;
suffixStorageKey = rootId;
}
const key = storageKey || `${prefixStorageKey}${suffixStorageKey}`;
const retrievedDraft = getGlobalItem<PostDraft>(state, key, DEFAULT_DRAFT);
return getGlobalItem<PostDraft>(state, key, DEFAULT_DRAFT);
},
(channelId, rootId, retrievedDraftParam) => {
let retrievedDraft = retrievedDraftParam;
if (retrievedDraft.metadata?.files) {
retrievedDraft = {...retrievedDraft, fileInfos: retrievedDraft.metadata.files};
}
// Check if the draft has the required values in its properties
const isDraftWithRequiredValues = typeof retrievedDraft.message !== 'undefined' && typeof retrievedDraft.uploadsInProgress !== 'undefined' && typeof retrievedDraft.fileInfos !== 'undefined';
// Check if the draft has the required values in its properties
const isDraftWithRequiredValues = typeof retrievedDraft.message !== 'undefined' && typeof retrievedDraft.uploadsInProgress !== 'undefined' && typeof retrievedDraft.fileInfos !== 'undefined';
// Check if draft's channelId or rootId mismatches with the passed one
const isDraftMismatched = retrievedDraft.channelId !== channelId || retrievedDraft.rootId !== rootId;
// Check if draft's channelId or rootId mismatches with the passed one
const isDraftMismatched = retrievedDraft.channelId !== channelId || retrievedDraft.rootId !== rootId;
if (isDraftWithRequiredValues && !isDraftMismatched) {
return retrievedDraft;
}
if (isDraftWithRequiredValues && !isDraftMismatched) {
return retrievedDraft;
}
return {
...DEFAULT_DRAFT,
...retrievedDraft,
channelId,
rootId,
};
};
return {
...DEFAULT_DRAFT,
...retrievedDraft,
channelId,
rootId,
};
},
);
}

Просмотреть файл

@@ -29,13 +29,14 @@ export type PostDraft = {
requested_ack?: boolean;
persistent_notifications?: boolean;
};
files?: FileInfo[];
};
};
export function isPostDraftEmpty(draft: PostDraft): boolean {
const hasMessage = draft.message.trim() !== '';
const hasAttachment = draft.fileInfos.length > 0 || draft.file_ids?.length;
const hasUploadingFiles = draft.uploadsInProgress.length > 0;
const hasAttachment = draft.fileInfos?.length > 0;
const hasUploadingFiles = draft.uploadsInProgress?.length > 0;
return !hasMessage && !hasAttachment && !hasUploadingFiles;
}