Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

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

@@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/ThreadViewer should match snapshot 1`] = `
<Fragment>
<div
className="ThreadViewer"
>
<div
className="post-right-comments-container"
>
<FileUploadOverlay
overlayType="right"
/>
<DeferredRenderWrapper
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"status": "",
"team_id": "team_id",
"teammate_id": "",
"type": "O",
"update_at": 0,
}
}
isThreadView={false}
key="id"
onCardClick={[Function]}
postIds={
Array [
"id",
]
}
selected={
Object {
"channel_id": "channel_id",
"create_at": 1502715365009,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "id",
"is_following": true,
"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": 3,
"root_id": "",
"type": "system_add_remove",
"update_at": 1502715372443,
"user_id": "user_id",
}
}
useRelativeTimestamp={false}
/>
</div>
</div>
</Fragment>
`;

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

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getPost, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
import {getThread} from 'mattermost-redux/selectors/entities/threads';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {removePost, getNewestPostThread, getPostThread} from 'mattermost-redux/actions/posts';
import {getThread as fetchThread, updateThreadRead} from 'mattermost-redux/actions/threads';
import {GenericAction} from 'mattermost-redux/types/actions';
import {UserThread} from '@mattermost/types/threads';
import {Channel} from '@mattermost/types/channels';
import {getSocketStatus} from 'selectors/views/websocket';
import {selectPostCard} from 'actions/views/rhs';
import {getHighlightedPostId, getSelectedPostFocussedAt} from 'selectors/rhs';
import {updateThreadLastOpened} from 'actions/views/threads';
import {GlobalState} from 'types/store';
import {fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
import ThreadViewer from './thread_viewer';
type OwnProps = {
rootPostId: string;
};
function makeMapStateToProps() {
const getPostIdsForThread = makeGetPostIdsForThread();
const getChannel = makeGetChannel();
return function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const currentUserId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
const selected = getPost(state, ownProps.rootPostId);
const socketStatus = getSocketStatus(state);
const highlightedPostId = getHighlightedPostId(state);
const selectedPostFocusedAt = getSelectedPostFocussedAt(state);
let postIds: string[] = [];
let userThread: UserThread | null = null;
let channel: Channel | null = null;
if (selected) {
postIds = getPostIdsForThread(state, selected.id);
userThread = getThread(state, selected.id);
channel = getChannel(state, {id: selected.channel_id});
}
return {
isCollapsedThreadsEnabled: isCollapsedThreadsEnabled(state),
appsEnabled: appsEnabled(state),
currentUserId,
currentTeamId,
userThread,
selected,
postIds,
socketConnectionStatus: socketStatus.connected,
channel,
highlightedPostId,
selectedPostFocusedAt,
};
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators({
fetchRHSAppsBindings,
getNewestPostThread,
getPostThread,
getThread: fetchThread,
removePost,
selectPostCard,
updateThreadLastOpened,
updateThreadRead,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(ThreadViewer);

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

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ThreadViewer {
// negate conflicting css for timestamp
.post:not(.same--root.same--user) a.post__permalink {
position: initial !important;
text-align: left !important;
}
.new-separator {
.NotificationSeparator {
padding-top: 0;
}
& + .post {
padding-top: 1em !important;
}
}
.post-list__dynamic--RHS {
scrollbar-color: var(--center-channel-color-32) #fff0;
scrollbar-width: thin;
}
.post-right-comments-container > .new-separator,
.post + .new-separator {
.NotificationSeparator {
padding-top: 1em;
}
}
.Separator + .new-separator {
.NotificationSeparator {
padding-top: 2em;
}
}
> div {
flex: 1 1 auto;
}
.channel-archived-warning__container {
padding-top: 16px;
}
.channel-archived-warning__content {
display: flex;
justify-content: center;
padding: 24px;
padding-right: 47.5px;
padding-left: 46.5px;
margin: 0 auto;
line-height: 20px;
text-align: center;
svg {
margin-right: 3.4px;
}
}
}

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

@@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Channel} from '@mattermost/types/channels';
import {Post} from '@mattermost/types/posts';
import {UserThread} from '@mattermost/types/threads';
import {TestHelper} from 'utils/test_helper';
import {fakeDate} from 'tests/helpers/date';
import {FakePost} from 'types/store/rhs';
import ThreadViewer, {Props} from './thread_viewer';
describe('components/threading/ThreadViewer', () => {
const post: Post = TestHelper.getPostMock({
channel_id: 'channel_id',
create_at: 1502715365009,
update_at: 1502715372443,
is_following: true,
reply_count: 3,
});
const fakePost: FakePost = {
id: post.id,
exists: true,
type: post.type,
user_id: post.user_id,
channel_id: post.channel_id,
message: post.message,
};
const channel: Channel = TestHelper.getChannelMock({
display_name: '',
name: '',
header: '',
purpose: '',
creator_id: '',
scheme_id: '',
teammate_id: '',
status: '',
});
const actions = {
removePost: jest.fn(),
selectPostCard: jest.fn(),
getNewestPostThread: jest.fn(),
getPostThread: jest.fn(),
getThread: jest.fn(),
updateThreadRead: jest.fn(),
updateThreadLastOpened: jest.fn(),
fetchRHSAppsBindings: jest.fn(),
};
const baseProps: Props = {
selected: post,
channel,
currentUserId: 'user_id',
currentTeamId: 'team_id',
socketConnectionStatus: true,
actions,
isCollapsedThreadsEnabled: false,
postIds: [post.id],
appsEnabled: true,
};
test('should match snapshot', async () => {
const reset = fakeDate(new Date(1502715365000));
const wrapper = shallow(
<ThreadViewer {...baseProps}/>,
);
await new Promise((resolve) => setTimeout(resolve));
expect(wrapper).toMatchSnapshot();
reset();
});
test('should make api call to get thread posts on socket reconnect', () => {
const wrapper = shallow(
<ThreadViewer {...baseProps}/>,
);
wrapper.setProps({socketConnectionStatus: false});
wrapper.setProps({socketConnectionStatus: true});
return expect(actions.getPostThread).toHaveBeenCalledWith(post.id, true);
});
test('should not break if root post is a fake post', () => {
const props = {
...baseProps,
selected: fakePost,
};
expect(() => {
shallow(<ThreadViewer {...props}/>);
}).not.toThrowError("Cannot read property 'reply_count' of undefined");
});
test('should call fetchThread when no thread on mount', (done) => {
const {actions} = baseProps;
shallow(
<ThreadViewer
{...baseProps}
isCollapsedThreadsEnabled={true}
/>,
);
expect.assertions(3);
process.nextTick(() => {
expect(actions.updateThreadLastOpened).not.toHaveBeenCalled();
expect(actions.updateThreadRead).not.toHaveBeenCalled();
expect(actions.getThread).toHaveBeenCalledWith('user_id', 'team_id', 'id', true);
done();
});
});
test('should call updateThreadLastOpened on mount', () => {
jest.useFakeTimers('modern').setSystemTime(400);
const {actions} = baseProps;
const userThread = {
id: 'id',
last_viewed_at: 42,
last_reply_at: 32,
} as UserThread;
shallow(
<ThreadViewer
{...baseProps}
userThread={userThread}
isCollapsedThreadsEnabled={true}
/>,
);
expect.assertions(3);
expect(actions.updateThreadLastOpened).toHaveBeenCalledWith('id', 42);
expect(actions.updateThreadRead).not.toHaveBeenCalled();
expect(actions.getThread).not.toHaveBeenCalled();
});
test('should call updateThreadLastOpened and updateThreadRead on mount when unread replies', () => {
jest.useFakeTimers('modern').setSystemTime(400);
const {actions} = baseProps;
const userThread = {
id: 'id',
last_viewed_at: 42,
last_reply_at: 142,
} as UserThread;
shallow(
<ThreadViewer
{...baseProps}
userThread={userThread}
isCollapsedThreadsEnabled={true}
/>,
);
expect.assertions(3);
expect(actions.updateThreadLastOpened).toHaveBeenCalledWith('id', 42);
expect(actions.updateThreadRead).toHaveBeenCalledWith('user_id', 'team_id', 'id', 400);
expect(actions.getThread).not.toHaveBeenCalled();
});
test('should call updateThreadLastOpened and updateThreadRead upon thread id change', (done) => {
jest.useRealTimers();
const dateNowOrig = Date.now;
Date.now = () => new Date(400).getMilliseconds();
const {actions} = baseProps;
const userThread = {
id: 'id',
last_viewed_at: 42,
last_reply_at: 142,
} as UserThread;
const wrapper = shallow(
<ThreadViewer
{...baseProps}
isCollapsedThreadsEnabled={true}
/>,
);
expect.assertions(6);
process.nextTick(() => {
expect(actions.updateThreadLastOpened).not.toHaveBeenCalled();
expect(actions.updateThreadRead).not.toHaveBeenCalled();
expect(actions.getThread).toHaveBeenCalled();
jest.resetAllMocks();
wrapper.setProps({userThread});
expect(actions.updateThreadLastOpened).toHaveBeenCalledWith('id', 42);
expect(actions.updateThreadRead).toHaveBeenCalledWith('user_id', 'team_id', 'id', 400);
expect(actions.getThread).not.toHaveBeenCalled();
Date.now = dateNowOrig;
done();
});
});
test('should call fetchRHSAppsBindings on mount if appsEnabled', () => {
const {actions} = baseProps;
shallow(
<ThreadViewer
{...baseProps}
/>,
);
expect(actions.fetchRHSAppsBindings).toHaveBeenCalledWith('channel_id', 'id');
});
test('should not call fetchRHSAppsBindings on mount if not appsEnabled', () => {
const {actions} = baseProps;
shallow(
<ThreadViewer
{...baseProps}
appsEnabled={false}
/>,
);
expect(actions.fetchRHSAppsBindings).not.toHaveBeenCalledWith('channel_id', 'id');
});
});

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

@@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {HTMLAttributes} from 'react';
import classNames from 'classnames';
import {ActionFunc} from 'mattermost-redux/types/actions';
import {ExtendedPost} from 'mattermost-redux/actions/posts';
import deferComponentRender from 'components/deferComponentRender';
import FileUploadOverlay from 'components/file_upload_overlay';
import LoadingScreen from 'components/loading_screen';
import {FakePost} from 'types/store/rhs';
import {Channel} from '@mattermost/types/channels';
import {Post} from '@mattermost/types/posts';
import {UserThread} from '@mattermost/types/threads';
import ThreadViewerVirtualized from '../virtualized_thread_viewer';
import './thread_viewer.scss';
const DeferredThreadViewerVirt = deferComponentRender(ThreadViewerVirtualized);
type Attrs = Pick<HTMLAttributes<HTMLDivElement>, 'className' | 'id'>;
export type Props = Attrs & {
isCollapsedThreadsEnabled: boolean;
appsEnabled: boolean;
userThread?: UserThread | null;
channel: Channel | null;
selected: Post | FakePost;
previousRhsState?: string;
currentUserId: string;
currentTeamId: string;
socketConnectionStatus: boolean;
actions: {
fetchRHSAppsBindings: (channelId: string, rootID: string) => unknown;
getNewestPostThread: (rootId: string) => Promise<any>|ActionFunc;
getPostThread: (rootId: string, fetchThreads: boolean) => Promise<any>|ActionFunc;
getThread: (userId: string, teamId: string, threadId: string, extended: boolean) => Promise<any>|ActionFunc;
removePost: (post: ExtendedPost) => void;
selectPostCard: (post: Post) => void;
updateThreadLastOpened: (threadId: string, lastViewedAt: number) => unknown;
updateThreadRead: (userId: string, teamId: string, threadId: string, timestamp: number) => unknown;
};
useRelativeTimestamp?: boolean;
postIds: string[];
highlightedPostId?: Post['id'];
selectedPostFocusedAt?: number;
isThreadView?: boolean;
};
type State = {
isLoading: boolean;
}
export default class ThreadViewer extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
isLoading: false,
};
}
public componentDidMount() {
if (this.props.isCollapsedThreadsEnabled && this.props.userThread !== null) {
this.markThreadRead();
}
this.onInit();
if (this.props.appsEnabled) {
this.props.actions.fetchRHSAppsBindings(this.props.channel?.id || '', this.props.selected.id);
}
}
public componentDidUpdate(prevProps: Props) {
const reconnected = this.props.socketConnectionStatus && !prevProps.socketConnectionStatus;
if (!this.props.selected) {
return;
}
const selectedChanged = this.props.selected.id !== prevProps.selected.id;
if (reconnected || selectedChanged) {
this.onInit(reconnected);
}
if (
this.props.isCollapsedThreadsEnabled &&
this.props.userThread?.id !== prevProps.userThread?.id
) {
this.markThreadRead();
}
if (this.props.appsEnabled && (
this.props.channel?.id !== prevProps.channel?.id || this.props.selected.id !== prevProps.selected.id
)) {
this.props.actions.fetchRHSAppsBindings(this.props.channel?.id || '', this.props.selected.id);
}
}
public morePostsToFetch(): boolean {
const replyCount = this.getReplyCount();
return this.props.selected && this.props.postIds.length < (replyCount + 1);
}
public getReplyCount(): number {
return (this.props.selected as Post)?.reply_count || this.props.userThread?.reply_count || 0;
}
fetchThread() {
const {
actions: {
getThread,
},
currentUserId,
currentTeamId,
selected,
} = this.props;
if (selected && this.getReplyCount() && (this.props.selected as Post)?.is_following) {
return getThread(
currentUserId,
currentTeamId,
selected.id,
true,
);
}
return Promise.resolve({data: true});
}
markThreadRead() {
if (this.props.userThread) {
// update last viewed at for thread before marking as read.
this.props.actions.updateThreadLastOpened(
this.props.userThread.id,
this.props.userThread.last_viewed_at,
);
if (
this.props.userThread.last_viewed_at < this.props.userThread.last_reply_at ||
this.props.userThread.unread_mentions ||
this.props.userThread.unread_replies
) {
this.props.actions.updateThreadRead(
this.props.currentUserId,
this.props.currentTeamId,
this.props.selected.id,
Date.now(),
);
}
}
}
// called either after mount, socket reconnected, or selected thread changed
// fetches the thread/posts if needed and
// scrolls to either bottom or new messages line
private onInit = async (reconnected = false): Promise<void> => {
this.setState({isLoading: !reconnected});
if (reconnected || this.morePostsToFetch()) {
await this.props.actions.getPostThread(this.props.selected.id, !reconnected);
} else {
await this.props.actions.getNewestPostThread(this.props.selected.id);
}
if (
this.props.isCollapsedThreadsEnabled &&
this.props.userThread == null
) {
await this.fetchThread();
}
this.setState({isLoading: false});
}
private handleCardClick = (post: Post) => {
if (!post) {
return;
}
this.props.actions.selectPostCard(post);
}
public render(): JSX.Element {
if (this.props.postIds == null || this.props.selected == null || !this.props.channel) {
return (
<span/>
);
}
if (this.state.isLoading && this.props.postIds.length < 2) {
return (
<LoadingScreen
style={{
display: 'grid',
placeContent: 'center',
flex: '1',
}}
/>
);
}
return (
<>
<div className={classNames('ThreadViewer', this.props.className)}>
<div className='post-right-comments-container'>
<>
<FileUploadOverlay overlayType='right'/>
{this.props.selected && (
<DeferredThreadViewerVirt
key={this.props.selected.id}
channel={this.props.channel}
onCardClick={this.handleCardClick}
postIds={this.props.postIds}
selected={this.props.selected}
useRelativeTimestamp={this.props.useRelativeTimestamp || false}
highlightedPostId={this.props.highlightedPostId}
selectedPostFocusedAt={this.props.selectedPostFocusedAt}
isThreadView={Boolean(this.props.isCollapsedThreadsEnabled && this.props.isThreadView)}
/>
)}
</>
</div>
</div>
</>
);
}
}