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

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

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

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

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useRef} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {resetReloadPostsInChannel} from 'mattermost-redux/actions/posts';
const PostsChannelResetWatcher = () => {
const dispatch = useDispatch();
const isCRTEnabled = useSelector(isCollapsedThreadsEnabled);
const loaded = useRef(false);
useEffect(() => {
if (loaded.current) {
dispatch(resetReloadPostsInChannel());
} else {
loaded.current = true;
}
}, [isCRTEnabled]);
return null;
};
export default PostsChannelResetWatcher;

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

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

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

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

@@ -0,0 +1,72 @@
.ThreadFooter {
--button-separator-height: 15px;
--button-separator-gap: 4px;
position: relative;
left: -34px;
display: inline-flex;
width: calc(100% + 15px);
align-items: center;
padding: 0 0 5px 10px;
margin-top: 1px;
margin-bottom: 5px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
line-height: 15px;
white-space: nowrap;
.indicator {
width: 24px;
height: 24px;
padding: 8px;
}
.dot-unreads {
width: 8px;
height: 8px;
background: rgba(var(--sidebar-text-active-border-rgb), 1);
border-radius: 50%;
}
.Avatars {
margin-right: 4px;
font-weight: 600;
}
.icon {
width: 14px;
height: 13px;
font-size: 14px;
}
.alt-visible {
opacity: 0;
}
&:hover,
&:focus-within,
&.is-active,
.post:hover &,
.post:focus-within & {
.alt-visible {
opacity: 1;
}
.alt-hidden {
display: none;
}
}
.Timestamp {
display: inline-grid;
padding-left: 1rem;
font-weight: 400;
> span {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

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

@@ -0,0 +1,290 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {mount} from 'enzyme';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import Timestamp from 'components/timestamp';
import Avatars from 'components/widgets/users/avatars';
import FollowButton from '../../common/follow_button';
import {mockStore} from 'tests/test_store';
import {UserThread} from '@mattermost/types/threads';
import {fakeDate} from 'tests/helpers/date';
import ThreadFooter from './thread_footer';
describe('components/threading/channel_threads/thread_footer', () => {
const baseState = {
entities: {
general: {
config: {},
},
users: {
currentUserId: 'uid',
profiles: {
1: {
id: '1',
username: 'first.last1',
nickname: 'nickname1',
first_name: 'First1',
last_name: 'Last1',
},
2: {
id: '2',
username: 'first.last2',
nickname: 'nickname2',
first_name: 'First2',
last_name: 'Last2',
},
3: {
id: '3',
username: 'first.last3',
nickname: 'nickname3',
first_name: 'First3',
last_name: 'Last3',
},
4: {
id: '4',
username: 'first.last4',
nickname: 'nickname4',
first_name: 'First4',
last_name: 'Last4',
},
5: {
id: '5',
username: 'first.last5',
nickname: 'nickname5',
first_name: 'First5',
last_name: 'Last5',
},
},
},
teams: {
currentTeamId: 'tid',
},
preferences: {
myPreferences: {},
},
posts: {
posts: {
postthreadid: {
id: 'postthreadid',
reply_count: 9,
last_reply_at: 1554161504000,
is_following: true,
channel_id: 'cid',
user_id: '1',
},
singlemessageid: {
id: 'singlemessageid',
reply_count: 0,
last_reply_at: 0,
is_following: true,
channel_id: 'cid',
user_id: '1',
},
},
},
threads: {
threads: {
postthreadid: {
id: 'postthreadid',
participants: [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
],
reply_count: 9,
unread_replies: 0,
unread_mentions: 0,
last_reply_at: 1554161504000,
last_viewed_at: 1554161505000,
is_following: true,
post: {
channel_id: 'cid',
user_id: '1',
},
},
},
},
},
};
let resetFakeDate: () => void;
let state: any;
let thread: UserThread;
let props: ComponentProps<typeof ThreadFooter>;
beforeEach(() => {
resetFakeDate = fakeDate(new Date('2020-05-03T13:20:00Z'));
state = {...baseState};
thread = state.entities.threads.threads.postthreadid;
props = {threadId: thread.id};
});
afterEach(() => {
resetFakeDate();
});
test('should report total number of replies', () => {
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists('.dot-unreads')).toBe(false);
expect(wrapper.exists('FormattedMessage[id="threading.numReplies"]')).toBe(true);
});
test('should show unread indicator', () => {
thread.unread_replies = 2;
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(SimpleTooltip).find('.dot-unreads').exists()).toBe(true);
});
test('should not show unread indicator if not following', () => {
thread.unread_replies = 2;
thread.is_following = false;
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper.find(SimpleTooltip).find('.dot-unreads').exists()).toBe(false);
});
test('should have avatars', () => {
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper.find(Avatars).props()).toHaveProperty('userIds', ['5', '4', '3', '2', '1']);
});
test('should have a timestamp', () => {
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper.find(Timestamp).props()).toHaveProperty('value', thread.last_reply_at);
});
test('should have a reply button', () => {
const {store, mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
wrapper.find('button.separated').first().simulate('click');
expect(store.getActions()).toEqual([
{
type: 'SELECT_POST',
channelId: 'cid',
postId: 'postthreadid',
timestamp: 1588512000000,
},
]);
});
test('should have a follow button', () => {
thread.is_following = false;
const {store, mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper.exists(FollowButton)).toBe(true);
expect(wrapper.find(FollowButton).props()).toHaveProperty('isFollowing', thread.is_following);
wrapper.find('button.separated').last().simulate('click');
expect(store.getActions()).toEqual([
{
type: 'FOLLOW_CHANGED_THREAD',
data: {
following: true,
id: 'postthreadid',
team_id: 'tid',
},
},
]);
});
test('should have an unfollow button', () => {
thread.is_following = true;
const {store, mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
{...props}
/>,
mountOptions,
);
expect(wrapper.exists(FollowButton)).toBe(true);
expect(wrapper.find(FollowButton).props()).toHaveProperty('isFollowing', thread.is_following);
wrapper.find('button.separated').last().simulate('click');
expect(store.getActions()).toEqual([
{
type: 'FOLLOW_CHANGED_THREAD',
data: {
following: false,
id: 'postthreadid',
team_id: 'tid',
},
},
]);
});
test('should match snapshot when a single message is followed', () => {
const {mountOptions} = mockStore(state);
const wrapper = mount(
<ThreadFooter
threadId='singlemessageid'
/>,
mountOptions,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists(FollowButton)).toBe(true);
expect(wrapper.find(FollowButton).props()).toHaveProperty('isFollowing', true);
});
});

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

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useEffect, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Post} from '@mattermost/types/posts';
import {threadIsSynthetic, UserThread} from '@mattermost/types/threads';
import {setThreadFollow, getThread as fetchThread} from 'mattermost-redux/actions/threads';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {GlobalState} from 'types/store';
import {selectPost} from 'actions/views/rhs';
import {trackEvent} from 'actions/telemetry_actions';
import Avatars from 'components/widgets/users/avatars';
import Timestamp from 'components/timestamp';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import Button from 'components/threading/common/button';
import FollowButton from 'components/threading/common/follow_button';
import {THREADING_TIME} from 'components/threading/common/options';
import './thread_footer.scss';
type Props = {
threadId: UserThread['id'];
replyClick?: React.EventHandler<React.MouseEvent>;
};
function ThreadFooter({
threadId,
replyClick,
}: Props) {
const dispatch = useDispatch();
const currentTeamId = useSelector(getCurrentTeamId);
const currentUserId = useSelector(getCurrentUserId);
const post = useSelector((state: GlobalState) => getPost(state, threadId));
const getThreadOrSynthetic = useMemo(makeGetThreadOrSynthetic, [post.id]);
const thread = useSelector((state: GlobalState) => getThreadOrSynthetic(state, post));
useEffect(() => {
if (threadIsSynthetic(thread) && thread.is_following && thread.reply_count > 0) {
dispatch(fetchThread(currentUserId, currentTeamId, threadId));
}
}, []);
const {
participants,
reply_count: totalReplies = 0,
last_reply_at: lastReplyAt,
is_following: isFollowing = false,
post: {
channel_id: channelId,
},
} = thread;
const participantIds = useMemo(() => (participants || []).map(({id}) => id).reverse(), [participants]);
const handleReply = useCallback((e) => {
if (replyClick) {
replyClick(e);
return;
}
trackEvent('crt', 'replied_using_footer');
e.stopPropagation();
dispatch(selectPost({id: threadId, channel_id: channelId} as Post));
}, [dispatch, replyClick, threadId, channelId]);
const handleFollowing = useCallback((e) => {
e.stopPropagation();
dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing));
}, [isFollowing]);
return (
<div className='ThreadFooter'>
{!isFollowing || threadIsSynthetic(thread) || !thread.unread_replies ? (
<div className='indicator'/>
) : (
<SimpleTooltip
id='threadFooterIndicator'
content={
<FormattedMessage
id='threading.numNewMessages'
defaultMessage='{newReplies, plural, =0 {no unread messages} =1 {one unread message} other {# unread messages}}'
values={{newReplies: thread.unread_replies}}
/>
}
>
<div
className='indicator'
tabIndex={0}
>
<div className='dot-unreads'/>
</div>
</SimpleTooltip>
)}
{participantIds && participantIds.length > 0 ? (
<Avatars
userIds={participantIds}
size='sm'
/>
) : null}
{thread.reply_count > 0 && (
<Button
onClick={handleReply}
className='ReplyButton separated'
prepend={
<span className='icon'>
<i className='icon-reply-outline'/>
</span>
}
>
<FormattedMessage
id='threading.numReplies'
defaultMessage='{totalReplies, plural, =0 {Reply} =1 {# reply} other {# replies}}'
values={{totalReplies}}
/>
</Button>
)}
<FollowButton
isFollowing={isFollowing}
className='separated'
onClick={handleFollowing}
/>
{Boolean(lastReplyAt) && (
<Timestamp
value={lastReplyAt}
{...THREADING_TIME}
>
{({formatted}) => (
<span className='Timestamp separated alt-visible'>
<FormattedMessage
id='threading.footer.lastReplyAt'
defaultMessage='Last reply {formatted}'
values={{formatted}}
/>
</span>
)}
</Timestamp>
)}
</div>
);
}
export default memo(ThreadFooter);

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

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
const BalloonIllustration = (
<svg
width='175'
height='207'
viewBox='0 0 175 207'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath='url(#clip0)'>
<path
d='M86.3343 124.207C72.3574 144.087 72.8886 156.7 88.105 165.391C109.903 177.797 107.967 199.007 84.6344 206.581'
stroke='var(--center-channel-color)'
strokeOpacity='0.56'
strokeWidth='1.5'
strokeMiterlimit='10'
/>
<g opacity='0.7'>
<path
opacity='0.7'
d='M138.21 70.6109C149.153 73.169 155.369 84.7898 147.176 96.9187C138.984 109.048 130.514 100.032 134.468 94.9396C138.423 89.847 156.484 102.508 144.355 117C133.465 129.997 134.468 149.493 145.199 152.766'
stroke='var(--center-channel-color)'
strokeOpacity='0.56'
strokeWidth='1.34'
strokeMiterlimit='10'
/>
<path
opacity='0.72'
d='M142.224 70.6109C142.23 70.82 142.291 71.0239 142.401 71.2017L142.755 72.0938C142.807 72.1836 142.834 72.2854 142.834 72.3892C142.834 72.4929 142.807 72.5947 142.755 72.6846V72.9799C142.262 73.5564 141.572 73.929 140.819 74.0257C140.038 74.1697 139.232 74.047 138.529 73.6771C138.193 73.5052 137.894 73.2682 137.65 72.9799L137.473 72.6905V72.0997L138.181 70.5991C134.893 69.5062 131.352 67.3084 127.616 63.5097C123.806 59.544 121.085 54.6596 119.718 49.3308C119.163 47.3606 118.884 45.3224 118.892 43.2752C119.004 37.2492 120.987 31.2172 124.422 27.7788C129.074 23.1234 134.008 22.1368 140.111 22.0364C146.338 22.1545 151.255 23.8442 155.363 27.7966C158.845 31.1463 161.265 37.2492 161.513 43.2752C161.518 45.3227 161.238 47.3608 160.681 49.3308C159.203 54.6365 156.432 59.4921 152.618 63.4624C148.876 67.3143 145.388 69.5179 142.224 70.6109Z'
fill='var(--button-bg)'
/>
<path
opacity='0.7'
d='M129.073 41.9104C129.112 43.0661 128.875 44.2143 128.383 45.2602C127.922 46.1641 127.35 46.6131 126.677 46.6131C126.004 46.6131 125.414 46.1641 124.906 45.2602C124.379 44.2248 124.123 43.0721 124.163 41.9104C124.119 40.7674 124.376 39.6329 124.906 38.6198C125.402 37.7632 125.992 37.3318 126.677 37.3318C127.362 37.3318 127.922 37.7632 128.383 38.6198C128.878 39.6438 129.115 40.7736 129.073 41.9104Z'
fill='white'
fillOpacity='0.32'
/>
</g>
<g opacity='0.3'>
<path
opacity='0.3'
d='M30.6157 37.9462C41.7063 46.808 39.9887 65.595 24.247 70.7881C1.26311 78.3738 -3.29944 95.991 2.93349 103.281'
stroke='var(--center-channel-color)'
strokeOpacity='0.56'
strokeWidth='1.34'
strokeMiterlimit='10'
/>
<path
opacity='0.64'
d='M32.9058 37.6863C32.8762 37.8466 32.8905 38.012 32.9472 38.1649C32.9885 38.413 33.0298 38.6552 33.077 38.9033C33.1053 38.9785 33.1114 39.0602 33.0947 39.1387C33.078 39.2173 33.039 39.2894 32.9826 39.3464L32.9354 39.5709C32.4729 39.9334 31.8921 40.1103 31.3063 40.0672C30.6895 40.0583 30.0942 39.8395 29.6182 39.4468C29.3799 39.2649 29.1794 39.0381 29.028 38.7792L28.9394 38.5311C28.9689 38.3834 29.0044 38.2357 29.0339 38.0881C29.2877 37.7395 29.5415 37.3968 29.8012 37.0601C27.4756 35.719 25.1206 33.5154 22.8777 29.9706C20.601 26.3712 19.2917 22.2442 19.0765 17.9895C18.9611 16.4071 19.0664 14.8164 19.3894 13.2632C20.4158 9.03049 22.7248 5.2191 26 2.35135C28.8745 -0.177223 34.1572 -0.443077 38.8141 0.431288C43.5361 1.4888 47.0008 3.53287 49.5093 7.17803C51.6341 10.2619 52.5372 15.2955 51.7758 19.8977C51.4632 21.4544 50.9347 22.9597 50.2057 24.37C48.2581 28.1712 45.3985 31.4292 41.8834 33.8521C38.46 36.2034 35.5088 37.3495 32.9058 37.6863Z'
fill='var(--button-bg)'
/>
<path
opacity='0.3'
d='M27.3635 13.8539C27.2136 14.7369 26.8555 15.5714 26.3188 16.288C26.168 16.5467 25.9457 16.7563 25.6787 16.8915C25.4116 17.0267 25.1112 17.0818 24.8137 17.0501C24.3002 16.9438 23.9224 16.5125 23.6863 15.7503C23.4459 14.8825 23.4296 13.9677 23.6391 13.0918C23.786 12.2229 24.1585 11.408 24.7192 10.7287C24.8858 10.4813 25.1169 10.2844 25.3875 10.1594C25.6581 10.0344 25.9577 9.9861 26.2539 10.0197C26.7674 10.1261 27.1333 10.5396 27.3517 11.2663C27.5632 12.1154 27.5673 13.003 27.3635 13.8539Z'
fill='white'
fillOpacity='0.32'
/>
</g>
<path
d='M83.6605 122.943C83.6572 123.268 83.5784 123.587 83.4303 123.876L82.964 125.27C82.8885 125.413 82.8491 125.573 82.8491 125.734C82.8491 125.896 82.8885 126.055 82.964 126.198V126.665C83.2501 127.116 83.6236 127.506 84.0626 127.81C84.5016 128.114 84.997 128.328 85.5197 128.437C86.0358 128.556 86.5709 128.564 87.0905 128.463C87.6101 128.361 88.1025 128.151 88.5358 127.846C89.0062 127.538 89.408 127.136 89.7163 126.665L89.9465 126.198V125.27L89.0198 122.943C93.3483 121.088 98.0308 116.952 103.068 110.536C108.004 104.297 111.671 97.1493 113.863 89.4984C114.647 86.8581 115.045 84.1178 115.043 81.3632C114.89 73.3088 112.104 66.5699 106.686 61.1465C101.267 55.7231 94.5327 52.9326 86.4818 52.775C78.2775 52.9326 71.5055 55.7231 66.1658 61.1465C60.8261 66.5699 58.0008 73.3088 57.69 81.3632C57.6912 84.1176 58.0888 86.8574 58.8704 89.4984C61.2112 97.1344 64.95 104.268 69.8961 110.536C74.9092 116.948 79.4973 121.084 83.6605 122.943Z'
fill='var(--button-bg)'
/>
<path
d='M63.3681 81.3337C63.291 83.6349 63.8116 85.9168 64.8791 87.9564C65.8884 89.7287 67.1279 90.6267 68.5976 90.6267C70.0673 90.6267 71.3481 89.7405 72.4282 87.9564C73.5697 85.9411 74.1315 83.6489 74.0514 81.3337C74.1396 79.0569 73.5765 76.8023 72.4282 74.835C71.3481 73.1335 70.0673 72.2769 68.5976 72.2769C67.1279 72.2769 65.8884 73.1335 64.8791 74.835C63.805 76.8274 63.2833 79.0713 63.3681 81.3337Z'
fill='white'
fillOpacity='0.32'
/>
<path
d='M136.091 182.801C135.148 183.665 134.069 184.367 132.898 184.881C131.761 185.4 130.551 185.741 129.31 185.891C128.272 186.031 127.223 186.049 126.181 185.944C125.774 185.944 125.449 185.909 125.207 185.879L124.818 185.767V185.383C124.818 185.165 124.771 184.846 124.753 184.414C124.79 183.378 124.923 182.348 125.148 181.336C125.405 180.114 125.851 178.94 126.471 177.857C127.104 176.729 127.913 175.71 128.867 174.838C129.816 173.969 130.893 173.252 132.06 172.711C133.178 172.182 134.374 171.837 135.602 171.689C136.639 171.549 137.689 171.531 138.73 171.636C139.072 171.647 139.412 171.677 139.751 171.724L140.087 171.813L140.158 172.22C140.183 172.535 140.183 172.851 140.158 173.166C140.16 174.207 140.037 175.244 139.792 176.256C139.518 177.472 139.065 178.641 138.447 179.723C137.82 180.862 137.026 181.899 136.091 182.801Z'
fill='var(--online-indicator)'
/>
<path
d='M135.566 175.233C135.69 175.136 135.846 175.089 136.004 175.101C136.161 175.113 136.308 175.183 136.416 175.298C136.488 175.377 136.535 175.474 136.553 175.579C136.57 175.684 136.558 175.791 136.516 175.889C136.469 175.975 136.412 176.054 136.345 176.126L132.373 179.777L133.984 179.818C134.044 179.822 134.102 179.839 134.155 179.866C134.208 179.894 134.255 179.932 134.293 179.979C134.33 180.025 134.358 180.079 134.374 180.137C134.39 180.194 134.394 180.255 134.386 180.314L134.315 180.468C134.282 180.551 134.223 180.621 134.146 180.666C134.068 180.711 133.978 180.729 133.89 180.716L131.434 180.61L128.991 182.825L132.237 182.979C132.297 182.982 132.356 182.998 132.41 183.025C132.464 183.053 132.511 183.091 132.549 183.138C132.588 183.184 132.616 183.238 132.632 183.296C132.649 183.354 132.653 183.415 132.644 183.475V183.652C132.599 183.725 132.537 183.786 132.463 183.829C132.389 183.872 132.305 183.897 132.219 183.9L128.052 183.705L122.841 188.432C122.781 188.484 122.712 188.524 122.638 188.549C122.563 188.574 122.484 188.584 122.405 188.578C122.326 188.572 122.249 188.55 122.179 188.513C122.109 188.477 122.047 188.427 121.997 188.367C121.939 188.315 121.892 188.252 121.861 188.181C121.829 188.11 121.812 188.034 121.812 187.956C121.812 187.878 121.829 187.802 121.861 187.731C121.892 187.66 121.939 187.597 121.997 187.545L127.38 182.713L127.557 178.719C127.586 178.477 127.734 178.359 128.005 178.353C128.277 178.347 128.412 178.5 128.407 178.79L128.265 181.862L130.785 179.617L130.88 177.337C130.881 177.28 130.894 177.224 130.918 177.173C130.942 177.122 130.977 177.077 131.02 177.04C131.063 177.003 131.113 176.976 131.167 176.96C131.221 176.944 131.278 176.939 131.334 176.947C131.389 176.943 131.443 176.951 131.495 176.971C131.546 176.99 131.593 177.02 131.632 177.058C131.671 177.097 131.701 177.143 131.721 177.194C131.741 177.245 131.75 177.3 131.747 177.354L131.671 178.766L135.596 175.222L135.566 175.233Z'
fill='black'
fillOpacity='0.48'
/>
<path
d='M27.3812 111.275C26.618 111.155 25.8435 111.126 25.0734 111.186C24.43 111.257 24.0936 111.298 24.0582 111.316C23.9945 111.647 23.9727 111.984 23.9932 112.321C23.9933 113.1 24.0704 113.878 24.2234 114.642C24.3947 115.55 24.7074 116.425 25.1501 117.236C25.6043 118.079 26.2038 118.835 26.9208 119.469C27.6276 120.111 28.4227 120.648 29.2818 121.064C30.1151 121.473 31.0074 121.748 31.926 121.879C32.6809 122.002 33.4476 122.036 34.2103 121.98C34.5594 121.96 34.9066 121.914 35.2491 121.844C35.312 121.511 35.3338 121.172 35.314 120.834C35.3113 120.059 35.2262 119.287 35.0602 118.53C34.8697 117.628 34.5497 116.758 34.1099 115.948C33.6481 115.107 33.0497 114.348 32.3392 113.703C31.6377 113.05 30.8418 112.506 29.9783 112.09C29.1578 111.691 28.2825 111.416 27.3812 111.275Z'
fill='var(--online-indicator)'
/>
<path
d='M36.9844 124.065C37.2028 124.254 37.3976 124.272 37.5746 124.107C37.6182 124.066 37.653 124.016 37.6768 123.961C37.7005 123.907 37.7128 123.847 37.7128 123.788C37.7128 123.728 37.7005 123.669 37.6768 123.614C37.653 123.559 37.6182 123.51 37.5746 123.469L34.0627 120.125L33.9919 117.129C33.9919 116.929 33.862 116.828 33.6319 116.828C33.5843 116.822 33.5361 116.827 33.4904 116.841C33.4447 116.855 33.4025 116.879 33.3668 116.911C33.331 116.943 33.3025 116.982 33.283 117.026C33.2636 117.07 33.2537 117.117 33.2541 117.165L33.3426 119.386L31.749 117.862V116.09C31.7501 116.04 31.7407 115.991 31.7214 115.946C31.7021 115.9 31.6733 115.859 31.637 115.826C31.6008 115.792 31.5578 115.767 31.5109 115.751C31.4641 115.736 31.4144 115.73 31.3653 115.735C31.318 115.729 31.27 115.734 31.2244 115.747C31.1788 115.761 31.1366 115.785 31.1004 115.816C31.0643 115.847 31.0351 115.885 31.0147 115.928C30.9942 115.971 30.983 116.018 30.9817 116.066V117.141L28.3433 114.589C28.3095 114.551 28.2682 114.521 28.2219 114.5C28.1756 114.479 28.1255 114.469 28.0747 114.469C28.024 114.469 27.9739 114.479 27.9276 114.5C27.8814 114.521 27.84 114.551 27.8062 114.589C27.7389 114.639 27.6889 114.708 27.6635 114.788C27.638 114.867 27.6384 114.953 27.6645 115.032L27.7413 115.186L30.3501 117.685H29.4057C29.3147 117.705 29.2333 117.756 29.1753 117.829C29.1173 117.902 29.0861 117.993 29.087 118.087V118.211C29.1165 118.278 29.1655 118.335 29.2276 118.373C29.2897 118.412 29.362 118.432 29.4353 118.429L31.1233 118.394L32.6639 119.948H30.598C30.4971 119.947 30.3999 119.986 30.3264 120.055C30.2529 120.124 30.2086 120.219 30.2026 120.32L30.2734 120.479C30.2928 120.536 30.3306 120.585 30.3808 120.618C30.431 120.651 30.4908 120.667 30.5508 120.662H33.4371L36.9785 124.059L36.9844 124.065Z'
fill='black'
fillOpacity='0.48'
/>
<path
d='M174.864 65.0161C174.767 64.5516 174.613 64.1011 174.404 63.675C174.227 63.3264 174.126 63.1433 174.115 63.1256C173.911 63.1618 173.713 63.2233 173.524 63.3087C173.08 63.4828 172.653 63.6984 172.25 63.9527C171.768 64.2474 171.337 64.6185 170.975 65.0516C170.596 65.4952 170.296 66.0008 170.089 66.5462C169.878 67.0958 169.747 67.6727 169.7 68.2595C169.652 68.8255 169.692 69.3954 169.818 69.9492C169.913 70.408 170.062 70.8541 170.26 71.2784C170.348 71.4828 170.45 71.6803 170.567 71.8692C170.771 71.833 170.969 71.7715 171.158 71.6861C171.601 71.5132 172.024 71.2933 172.421 71.0303C172.893 70.7228 173.318 70.3487 173.684 69.9197C174.059 69.476 174.362 68.9755 174.581 68.4368C174.799 67.8929 174.934 67.3194 174.982 66.7353C175.033 66.1596 174.993 65.5794 174.864 65.0161Z'
fill='var(--online-indicator)'
/>
<path
d='M169.682 73.3167C169.623 73.4821 169.652 73.5944 169.788 73.6594C169.821 73.6761 169.856 73.6861 169.892 73.6886C169.929 73.6911 169.965 73.6861 169.999 73.6739C170.034 73.6617 170.065 73.6426 170.092 73.6177C170.118 73.5929 170.14 73.5628 170.154 73.5294L171.293 70.7881L172.981 70.091C173.008 70.0819 173.031 70.0671 173.051 70.0476C173.071 70.028 173.085 70.0042 173.094 69.978C173.104 69.9519 173.107 69.924 173.103 69.8965C173.1 69.869 173.091 69.8426 173.076 69.8192C173.069 69.79 173.057 69.7626 173.039 69.7388C173.021 69.715 172.998 69.6954 172.971 69.6813C172.945 69.6673 172.916 69.6592 172.886 69.6575C172.856 69.6558 172.826 69.6606 172.798 69.6715L171.553 70.2151L172.072 68.9685L173.076 68.5786C173.104 68.5684 173.13 68.5524 173.151 68.5315C173.173 68.5106 173.19 68.4853 173.201 68.4574C173.212 68.4295 173.217 68.3996 173.216 68.3695C173.215 68.3395 173.207 68.3101 173.194 68.2832C173.187 68.2553 173.174 68.2291 173.156 68.2065C173.138 68.184 173.115 68.1654 173.09 68.152C173.064 68.1387 173.036 68.1308 173.007 68.129C172.978 68.1272 172.949 68.1314 172.922 68.1414L172.332 68.3718L173.206 66.3041C173.22 66.2765 173.229 66.2461 173.231 66.215C173.232 66.1839 173.228 66.1527 173.216 66.1236C173.205 66.0945 173.188 66.0682 173.166 66.0463C173.144 66.0245 173.117 66.0076 173.088 65.9969C173.046 65.9671 172.997 65.951 172.946 65.951C172.895 65.951 172.846 65.9671 172.804 65.9969L172.733 66.0737L171.884 68.1178L171.677 67.5742C171.643 67.5286 171.596 67.4953 171.541 67.4793C171.487 67.4633 171.429 67.4655 171.376 67.4857H171.305C171.273 67.517 171.251 67.5578 171.242 67.6022C171.234 67.6466 171.239 67.6926 171.258 67.7337L171.647 68.6908L171.099 69.9138L170.632 68.7322C170.611 68.675 170.568 68.6283 170.513 68.6018C170.458 68.5754 170.395 68.5713 170.337 68.5904L170.266 68.6672C170.236 68.6887 170.215 68.7201 170.206 68.7559C170.198 68.7917 170.202 68.8294 170.219 68.8622L170.862 70.5105L169.682 73.2753V73.3167Z'
fill='black'
fillOpacity='0.48'
/>
</g>
<defs>
<clipPath id='clip0'>
<rect
width='175'
height='207'
fill='white'
/>
</clipPath>
</defs>
</svg>
);
export default BalloonIllustration;

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

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/common/button should support appended content 1`] = `
<button
className="Button Button___transparent"
>
<span
className="Button_label"
/>
<span
className="Button_appended"
>
<ReplyIcon
className="Icon"
/>
</span>
</button>
`;
exports[`components/threading/common/button should support children 1`] = `
<button
className="Button Button___transparent"
>
<span
className="Button_label"
>
text-goes-here
</span>
</button>
`;
exports[`components/threading/common/button should support className 1`] = `
<button
className="Button Button___transparent test-class other-test-class"
>
<span
className="Button_label"
/>
</button>
`;
exports[`components/threading/common/button should support onClick 1`] = `
<button
className="Button Button___transparent"
onClick={[MockFunction]}
>
<span
className="Button_label"
/>
</button>
`;
exports[`components/threading/common/button should support prepended content 1`] = `
<button
className="Button Button___transparent"
>
<span
className="Button_prepended"
>
<ReplyIcon
className="Icon"
/>
</span>
<span
className="Button_label"
/>
</button>
`;

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

@@ -0,0 +1,152 @@
@mixin leveled-bg($hover, $active) {
&:hover {
&:not(:disabled) {
background: rgba($hover, 0.08);
}
}
&:active,
&.is-active {
&:not(:disabled) {
background: rgba($active, 0.08);
}
}
&.is-active {
&:active,
&:hover {
&:not(:disabled) {
background: rgba($active, 0.16);
}
}
&:hover {
&:active:not(:disabled) {
background: rgba($active, 0.24);
}
}
}
}
@mixin leveled-color($hover, $active) {
&:hover {
&:not(:disabled) {
color: rgba($hover, 0.72);
}
}
&:active,
&.is-active {
&:not(:disabled) {
color: rgba($active, 1);
}
}
}
.Button {
position: relative;
padding: 4px 10px;
border-radius: 4px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-family: Open Sans;
font-style: normal;
font-weight: 600;
&.allowTextOverflow {
min-width: 4rem;
.Button_label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&:hover {
text-decoration: none;
}
&:disabled {
opacity: 32%;
}
&.Button___transparent {
border: none;
background: transparent;
@include leveled-bg(
$hover: var(--center-channel-color-rgb),
$active: var(--button-bg-rgb),
);
@include leveled-color(
$hover: var(--center-channel-color-rgb),
$active: var(--button-bg-rgb),
);
}
&.Button___large {
font-size: 14px;
&.Button___icon {
width: 32px;
height: 32px;
padding: 7px 0;
font-size: 18px;
line-height: 18px;
}
}
.Button_label {
display: inline-block;
max-width: 100%;
&.margin_top {
margin-top: 4px;
}
}
.Button_prepended {
margin-right: 0.5em;
}
.Button_appended {
margin-left: 0.5em;
}
.dot {
position: absolute;
top: 6px;
right: 6px;
width: 4px;
height: 4px;
background: rgba(var(--sidebar-text-active-border-rgb), 1);
border-radius: 50%;
}
}
.separated + .separated {
// --button-separator-gap should always be even
margin-left: var(--button-separator-gap, 0);
}
.separated:not(.Button),
.separated:not(:hover):not(.is-active) {
+ .separated:not(.Button),
+ .separated:not(:hover):not(.is-active) {
position: relative;
&::before {
position: absolute;
top: 50%;
left: calc(-0.5px - var(--button-separator-gap, 0px) / 2);
display: inline-block;
width: 1px;
height: var(--button-separator-height, 100%);
background: rgba(var(--center-channel-color-rgb), 0.16);
content: '';
pointer-events: none;
transform: translateY(-50%);
}
}
}

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import ReplyIcon from 'components/widgets/icons/reply_icon';
import Button from './button';
describe('components/threading/common/button', () => {
test('should support onClick', () => {
const action = jest.fn();
const wrapper = shallow<typeof Button>(
<Button
onClick={action}
/>,
);
expect(wrapper).toMatchSnapshot();
wrapper.simulate('click');
expect(action).toHaveBeenCalled();
});
test('should support className', () => {
const className = 'test-class other-test-class';
const wrapper = shallow<typeof Button>(
<Button
className={className}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.hasClass('test-class')).toBe(true);
expect(wrapper.hasClass('other-test-class')).toBe(true);
});
test('should support prepended content', () => {
const wrapper = shallow<typeof Button>(
<Button
prepend={<ReplyIcon className='Icon'/>}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists('.Button_prepended ReplyIcon')).toBe(true);
});
test('should support appended content', () => {
const wrapper = shallow<typeof Button>(
<Button
append={<ReplyIcon className='Icon'/>}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists('.Button_appended ReplyIcon')).toBe(true);
});
test('should support children', () => {
const wrapper = shallow<typeof Button>(
<Button>
{'text-goes-here'}
</Button>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.text()).toBe('text-goes-here');
});
});

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

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, ButtonHTMLAttributes, ReactNode} from 'react';
import classNames from 'classnames';
import './button.scss';
type Props = {
prepend?: ReactNode;
append?: ReactNode;
isActive?: boolean;
hasDot?: boolean;
allowTextOverflow?: boolean;
marginTop?: boolean;
}
type Attrs = Exclude<ButtonHTMLAttributes<HTMLButtonElement>, Props>
function Button({
prepend,
append,
children,
isActive,
hasDot,
marginTop,
allowTextOverflow = false,
...attrs
}: Props & Attrs) {
return (
<button
{...attrs}
className={classNames('Button Button___transparent', {'is-active': isActive, allowTextOverflow}, attrs.className)}
>
{prepend && (
<span className='Button_prepended'>
{prepend}
</span>
)}
<span className={classNames('Button_label', {margin_top: marginTop})}>
{children}
{hasDot && <span className='dot'/>}
</span>
{append && (
<span className='Button_appended'>
{append}
</span>
)}
</button>
);
}
export default memo(Button);

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

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

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

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

@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/common/follow_button should say follow 1`] = `
<Memo(FollowButton)
isFollowing={false}
onClick={[MockFunction]}
>
<Memo(Button)
className="FollowButton"
disabled={false}
isActive={false}
onClick={[MockFunction]}
>
<button
className="Button Button___transparent FollowButton"
disabled={false}
onClick={[MockFunction]}
>
<span
className="Button_label"
>
Follow
</span>
</button>
</Memo(Button)>
</Memo(FollowButton)>
`;
exports[`components/threading/common/follow_button should say following 1`] = `
<Memo(FollowButton)
isFollowing={true}
>
<Memo(Button)
className="FollowButton"
disabled={false}
isActive={true}
>
<button
className="Button Button___transparent is-active FollowButton"
disabled={false}
>
<span
className="Button_label"
>
Following
</span>
</button>
</Memo(Button)>
</Memo(FollowButton)>
`;

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

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import Button from '../button';
import FollowButton from './follow_button';
describe('components/threading/common/follow_button', () => {
test('should say follow', () => {
const clickHandler = jest.fn();
const wrapper = mountWithIntl(
<FollowButton
isFollowing={false}
onClick={clickHandler}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(Button).text()).toBe('Follow');
wrapper.find(Button).simulate('click');
expect(clickHandler).toHaveBeenCalled();
});
test('should say following', () => {
const wrapper = mountWithIntl(
<FollowButton
isFollowing={true}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(Button).text()).toBe('Following');
});
test('should fire click handler', () => {
const clickHandler = jest.fn();
const wrapper = mountWithIntl(
<FollowButton
isFollowing={false}
onClick={clickHandler}
/>,
);
wrapper.find(Button).simulate('click');
expect(clickHandler).toHaveBeenCalled();
});
});

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

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, ComponentProps} from 'react';
import {useIntl} from 'react-intl';
import classNames from 'classnames';
import Button from '../button';
import {t} from 'utils/i18n';
type Props = {
isFollowing: boolean | null | undefined;
}
function FollowButton({
isFollowing,
...props
}: Props & Exclude<ComponentProps<typeof Button>, Props>) {
const {formatMessage} = useIntl();
return (
<Button
{...props}
className={classNames(props.className, 'FollowButton')}
disabled={Boolean(props.disabled)}
isActive={isFollowing ?? false}
>
{formatMessage(isFollowing ? {
id: t('threading.following'),
defaultMessage: 'Following',
} : {
id: t('threading.notFollowing'),
defaultMessage: 'Follow',
})}
</Button>
);
}
export default memo(FollowButton);

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

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

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

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ComponentProps} from 'react';
import Timestamp from 'components/timestamp';
export const THREADING_TIME: Partial<ComponentProps<typeof Timestamp>> = {
units: [
'now',
'minute',
'hour',
'day',
'week',
],
useTime: false,
day: 'numeric',
};

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

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.GlobalThreads___title {
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.GlobalThreads {
--border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
.GlobalThreads___header {
border-bottom: var(--border);
grid-area: header;
}
.channel-header__icon,
.search__form {
margin-top: 1px;
}
.ThreadList {
border-right: var(--border);
grid-area: list;
}
.ThreadPane,
.ThreadList + .no-results__holder {
grid-area: pane;
}
> .no-results__holder {
grid-area: list/list/pane/pane;
@media screen and (max-width: 1020px) {
grid-area: main;
}
}
.no-results__wrapper svg {
margin-bottom: 20px;
}
.no-results__subtitle {
max-width: 378px;
}
.ThreadPane {
position: relative;
overflow: hidden;
}
/* === Responsiveness === */
--list: minmax(min-content, 300px);
--pane: minmax(min-content, auto);
display: grid;
overflow: hidden;
grid-template-areas:
'header header'
'list pane';
// 2-column
grid-template-columns: var(--list) var(--pane);
grid-template-rows: 63px 1fr;
// single column
@media screen and (max-width: 1020px) {
grid-template-areas:
'header header'
'main main';
&:not(.thread-selected) {
.ThreadList {
grid-area: main;
}
.ThreadPane,
.ThreadList + .no-results__holder {
display: none;
}
}
&.thread-selected {
.ThreadList {
display: none;
}
.ThreadPane {
grid-area: main;
.Header {
padding-left: 5px;
}
.back {
display: unset;
}
}
}
}
@media screen and (max-width: 768px) {
grid-template-rows: 0 1fr;
> .Header {
display: none;
}
.Header {
border-top: var(--border);
}
}
@media screen and (min-width: 1021px) {
--list: minmax(min-content, 350px);
}
@media screen and (min-width: 1267px) {
--list: minmax(min-content, 400px);
}
@media screen and (min-width: 1680px) {
--list: minmax(min-content, 500px);
}
}

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

@@ -0,0 +1,241 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {isEmpty} from 'lodash';
import {Link, useRouteMatch} from 'react-router-dom';
import {useSelector, useDispatch, shallowEqual} from 'react-redux';
import classNames from 'classnames';
import {
getThreadOrderInCurrentTeam,
getUnreadThreadOrderInCurrentTeam,
getThreadCountsInCurrentTeam,
getThread,
} from 'mattermost-redux/selectors/entities/threads';
import {getThreadCounts, getThreads} from 'mattermost-redux/actions/threads';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {GlobalState} from 'types/store/index';
import {useGlobalState} from 'stores/hooks';
import LocalStorageStore from 'stores/local_storage_store';
import {clearLastUnreadChannel} from 'actions/global_actions';
import {setSelectedThreadId} from 'actions/views/threads';
import {selectLhsItem} from 'actions/views/lhs';
import {suppressRHS, unsuppressRHS} from 'actions/views/rhs';
import {loadProfilesForSidebar} from 'actions/user_actions';
import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
import {LhsItemType, LhsPage} from 'types/store/lhs';
import {Constants, PreviousViewedTypes} from 'utils/constants';
import Header from 'components/widgets/header';
import LoadingScreen from 'components/loading_screen';
import NoResultsIndicator from 'components/no_results_indicator';
import {useThreadRouting} from '../hooks';
import ChatIllustration from '../common/chat_illustration';
import ThreadViewer from '../thread_viewer';
import ThreadList, {ThreadFilter, FILTER_STORAGE_KEY} from './thread_list';
import ThreadPane from './thread_pane';
import './global_threads.scss';
const GlobalThreads = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const {url, params: {threadIdentifier}} = useRouteMatch<{threadIdentifier?: string}>();
const [filter, setFilter] = useGlobalState(ThreadFilter.none, FILTER_STORAGE_KEY);
const {currentTeamId, currentUserId, clear} = useThreadRouting();
const counts = useSelector(getThreadCountsInCurrentTeam);
const selectedThread = useSelector((state: GlobalState) => getThread(state, threadIdentifier));
const selectedThreadId = useSelector(getSelectedThreadIdInCurrentTeam);
const selectedPost = useSelector((state: GlobalState) => getPost(state, threadIdentifier!));
const threadIds = useSelector((state: GlobalState) => getThreadOrderInCurrentTeam(state, selectedThread?.id), shallowEqual);
const unreadThreadIds = useSelector((state: GlobalState) => getUnreadThreadOrderInCurrentTeam(state, selectedThread?.id), shallowEqual);
const numUnread = counts?.total_unread_threads || 0;
useEffect(() => {
dispatch(suppressRHS);
dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Threads));
dispatch(clearLastUnreadChannel);
loadProfilesForSidebar();
const penultimateType = LocalStorageStore.getPreviousViewedType(currentUserId, currentTeamId);
if (penultimateType !== PreviousViewedTypes.THREADS) {
LocalStorageStore.setPenultimateViewedType(currentUserId, currentTeamId, penultimateType);
LocalStorageStore.setPreviousViewedType(currentUserId, currentTeamId, PreviousViewedTypes.THREADS);
}
// unsuppresses RHS on navigating away (unmount)
return () => {
dispatch(unsuppressRHS);
};
}, []);
useEffect(() => {
dispatch(getThreadCounts(currentUserId, currentTeamId));
}, [currentTeamId, currentUserId]);
useEffect(() => {
if (!selectedThreadId || selectedThreadId !== threadIdentifier) {
dispatch(setSelectedThreadId(currentTeamId, selectedThread?.id));
}
}, [currentTeamId, selectedThreadId, threadIdentifier]);
const isEmptyList = isEmpty(threadIds) && isEmpty(unreadThreadIds);
const [isLoading, setLoading] = useState(isEmptyList);
const fetchThreads = useCallback(async (unread): Promise<{data: any}> => {
await dispatch(getThreads(
currentUserId,
currentTeamId,
{
unread,
perPage: Constants.THREADS_PAGE_SIZE,
},
));
return {data: true};
}, [currentUserId, currentTeamId]);
const isOnlySelectedThreadInList = (list: string[]) => {
return selectedThreadId && list.length === 1 && list[0] === selectedThreadId;
};
const shouldLoadThreads = isEmpty(threadIds) || isOnlySelectedThreadInList(threadIds);
const shouldLoadUnreadThreads = isEmpty(unreadThreadIds) || isOnlySelectedThreadInList(unreadThreadIds);
useEffect(() => {
const promises = [];
// this is needed to jump start threads fetching
if (shouldLoadThreads) {
promises.push(fetchThreads(false));
}
if (filter === ThreadFilter.unread && shouldLoadUnreadThreads) {
promises.push(fetchThreads(true));
}
Promise.all(promises).then(() => {
setLoading(false);
});
}, [fetchThreads, filter, threadIds, unreadThreadIds]);
useEffect(() => {
if (!selectedThread && !selectedPost && !isLoading) {
clear();
}
}, [currentTeamId, selectedThread, selectedPost, isLoading, counts, filter]);
// cleanup on unmount
useEffect(() => {
return () => {
dispatch(setSelectedThreadId(currentTeamId, ''));
};
}, []);
const handleSelectUnread = useCallback(() => {
setFilter(ThreadFilter.unread);
}, []);
return (
<div
id='app-content'
className={classNames('GlobalThreads app__content', {'thread-selected': Boolean(selectedThread)})}
>
<Header
level={2}
className={'GlobalThreads___header'}
heading={formatMessage({
id: 'globalThreads.heading',
defaultMessage: 'Followed threads',
})}
subtitle={formatMessage({
id: 'globalThreads.subtitle',
defaultMessage: 'Threads youre participating in will automatically show here',
})}
/>
{isLoading || isEmptyList ? (
<div className='no-results__holder'>
{isLoading ? (
<LoadingScreen/>
) : (
<NoResultsIndicator
expanded={true}
iconGraphic={ChatIllustration}
title={formatMessage({
id: 'globalThreads.noThreads.title',
defaultMessage: 'No followed threads yet',
})}
subtitle={formatMessage({
id: 'globalThreads.noThreads.subtitle',
defaultMessage: 'Any threads you are mentioned in or have participated in will show here along with any threads you have followed.',
})}
/>
)}
</div>
) : (
<>
<ThreadList
currentFilter={filter}
setFilter={setFilter}
someUnread={Boolean(numUnread)}
selectedThreadId={threadIdentifier}
ids={threadIds}
unreadIds={unreadThreadIds}
/>
{selectedThread && selectedPost ? (
<ThreadPane
thread={selectedThread}
>
<ThreadViewer
rootPostId={selectedThread.id}
useRelativeTimestamp={true}
isThreadView={true}
/>
</ThreadPane>
) : (
<NoResultsIndicator
expanded={true}
iconGraphic={ChatIllustration}
title={formatMessage({
id: 'globalThreads.threadPane.unselectedTitle',
defaultMessage: '{numUnread, plural, =0 {Looks like youre all caught up} other {Catch up on your threads}}',
}, {numUnread})}
subtitle={formatMessage({
id: 'globalThreads.threadPane.unreadMessageLink',
defaultMessage: 'You have {numUnread, plural, =0 {no unread threads} =1 {<link>{numUnread} thread</link>} other {<link>{numUnread} threads</link>}} {numUnread, plural, =0 {} other {with unread messages}}',
}, {
numUnread,
link: (chunks) => (
<Link
key='single'
to={`${url}/${unreadThreadIds[0]}`}
onClick={handleSelectUnread}
>
{chunks}
</Link>
),
})}
/>
)}
</>
)}
</div>
);
};
export default memo(GlobalThreads);

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

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

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

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

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

@@ -0,0 +1,27 @@
.console__body .modal .GenericModal.GenericModal__compassDesign.mark-all-threads-as-read .modal-content,
.app__body .modal .GenericModal.GenericModal__compassDesign.mark-all-threads-as-read .modal-content {
max-width: 512px;
margin: 0 auto;
.modal-footer {
padding-top: 32px;
padding-bottom: 48px;
text-align: center;
}
.modal-header {
padding-top: 48px;
.GenericModal__header {
margin: 0 auto;
}
@media screen and (max-width: 640px) {
box-shadow: none;
}
}
}
.mark_all_threads_as_read_modal__body {
text-align: center;
}

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

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as React from 'react';
import {useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import './mark_all_threads_as_read_modal.scss';
export type MarkAllThreadsAsReadModalProps = {
onConfirm: () => void;
onCancel: () => void;
}
function MarkAllThreadsAsReadModal({
onConfirm,
onCancel,
}: MarkAllThreadsAsReadModalProps) {
const {formatMessage} = useIntl();
return (
<GenericModal
className='mark-all-threads-as-read'
id='mark-all-threads-as-read-modal'
compassDesign={true}
modalHeaderText={formatMessage({
id: 'mark_all_threads_as_read_modal.title',
defaultMessage: 'Mark all your threads as read?',
})}
confirmButtonText={formatMessage({
id: 'mark_all_threads_as_read_modal.confirm',
defaultMessage: 'Mark all as read',
})}
cancelButtonText={formatMessage({
id: 'mark_all_threads_as_read_modal.cancel',
defaultMessage: 'Cancel',
})}
onExited={onCancel}
handleCancel={onCancel}
handleConfirm={onConfirm}
>
<div className='mark_all_threads_as_read_modal__body'>
<span>
{formatMessage({
id: 'mark_all_threads_as_read_modal.description',
defaultMessage: 'This will clear the unread state and mention badges on all your threads. Are you sure?',
})}
</span>
</div>
</GenericModal>
);
}
export default MarkAllThreadsAsReadModal;

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

@@ -0,0 +1,362 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_item should report total number of replies 1`] = `
<article
className="ThreadItem"
id=""
onClick={[Function]}
tabIndex={0}
>
<h1>
<div
className="ThreadItem__author"
>
Someone
</div>
<div
className="ThreadItem__tags"
>
<Memo(Tag)
onClick={[Function]}
text="Team name"
/>
</div>
<Connect(injectIntl(Timestamp))
className="alt-hidden"
day="numeric"
units={
Array [
"now",
"minute",
"hour",
"day",
"week",
]
}
useTime={false}
/>
</h1>
<div
className="menu-anchor alt-visible"
>
<Memo(ThreadMenu)
hasUnreads={false}
isFollowing={true}
threadId="1y8hpek81byspd4enyk9mp1ncw"
unreadTimestamp={1611786714912}
>
<SimpleTooltip
content={
<Memo(MemoizedFormattedMessage)
defaultMessage="Actions"
id="threading.threadItem.menu"
/>
}
id="threadActionMenu"
>
<Memo(Button)
className="Button___icon"
marginTop={true}
>
<DotsVerticalIcon
size={18}
/>
</Memo(Button)>
</SimpleTooltip>
</Memo(ThreadMenu)>
</div>
<div
aria-readonly="true"
className="preview"
dir="auto"
onClick={[Function]}
tabIndex={0}
>
<Connect(Markdown)
imageProps={
Object {
"onImageHeightChanged": [Function],
"onImageLoaded": [Function],
}
}
message="test msg"
options={
Object {
"atMentions": false,
"mentionHighlight": false,
"singleline": true,
}
}
/>
</div>
<div
className="activity"
>
<Memo(Avatars)
size="xs"
userIds={
Array [
"mt5td9mdriyapmwuh5pc84dmhr",
"ij61jet1bbdk8fhhxitywdj4ih",
"7n4ach3i53bbmj84dfmu5b7c1c",
]
}
/>
<MemoizedFormattedMessage
defaultMessage="{totalReplies, plural, =0 {Reply} =1 {# reply} other {# replies}}"
id="threading.numReplies"
values={
Object {
"totalReplies": 9,
}
}
/>
</div>
</article>
`;
exports[`components/threading/global_threads/thread_item should report unread mentions 1`] = `
<article
className="ThreadItem has-unreads"
id=""
onClick={[Function]}
tabIndex={0}
>
<h1>
<div
className="indicator"
>
<div
className="dot-mentions"
>
2
</div>
</div>
<div
className="ThreadItem__author"
>
Someone
</div>
<div
className="ThreadItem__tags"
>
<Memo(Tag)
onClick={[Function]}
text="Team name"
/>
</div>
<Connect(injectIntl(Timestamp))
className="alt-hidden"
day="numeric"
units={
Array [
"now",
"minute",
"hour",
"day",
"week",
]
}
useTime={false}
/>
</h1>
<div
className="menu-anchor alt-visible"
>
<Memo(ThreadMenu)
hasUnreads={true}
isFollowing={true}
threadId="1y8hpek81byspd4enyk9mp1ncw"
unreadTimestamp={1611786714912}
>
<SimpleTooltip
content={
<Memo(MemoizedFormattedMessage)
defaultMessage="Actions"
id="threading.threadItem.menu"
/>
}
id="threadActionMenu"
>
<Memo(Button)
className="Button___icon"
marginTop={true}
>
<DotsVerticalIcon
size={18}
/>
</Memo(Button)>
</SimpleTooltip>
</Memo(ThreadMenu)>
</div>
<div
aria-readonly="true"
className="preview"
dir="auto"
onClick={[Function]}
tabIndex={0}
>
<Connect(Markdown)
imageProps={
Object {
"onImageHeightChanged": [Function],
"onImageLoaded": [Function],
}
}
message="test msg"
options={
Object {
"atMentions": false,
"mentionHighlight": false,
"singleline": true,
}
}
/>
</div>
<div
className="activity"
>
<Memo(Avatars)
size="xs"
userIds={
Array [
"mt5td9mdriyapmwuh5pc84dmhr",
"ij61jet1bbdk8fhhxitywdj4ih",
"7n4ach3i53bbmj84dfmu5b7c1c",
]
}
/>
<MemoizedFormattedMessage
defaultMessage="{newReplies, plural, =1 {# new reply} other {# new replies}}"
id="threading.numNewReplies"
values={
Object {
"newReplies": 5,
}
}
/>
</div>
</article>
`;
exports[`components/threading/global_threads/thread_item should report unread messages 1`] = `
<article
className="ThreadItem has-unreads"
id=""
onClick={[Function]}
tabIndex={0}
>
<h1>
<div
className="indicator"
>
<div
className="dot-unreads"
/>
</div>
<div
className="ThreadItem__author"
>
Someone
</div>
<div
className="ThreadItem__tags"
>
<Memo(Tag)
onClick={[Function]}
text="Team name"
/>
</div>
<Connect(injectIntl(Timestamp))
className="alt-hidden"
day="numeric"
units={
Array [
"now",
"minute",
"hour",
"day",
"week",
]
}
useTime={false}
/>
</h1>
<div
className="menu-anchor alt-visible"
>
<Memo(ThreadMenu)
hasUnreads={true}
isFollowing={true}
threadId="1y8hpek81byspd4enyk9mp1ncw"
unreadTimestamp={1611786714912}
>
<SimpleTooltip
content={
<Memo(MemoizedFormattedMessage)
defaultMessage="Actions"
id="threading.threadItem.menu"
/>
}
id="threadActionMenu"
>
<Memo(Button)
className="Button___icon"
marginTop={true}
>
<DotsVerticalIcon
size={18}
/>
</Memo(Button)>
</SimpleTooltip>
</Memo(ThreadMenu)>
</div>
<div
aria-readonly="true"
className="preview"
dir="auto"
onClick={[Function]}
tabIndex={0}
>
<Connect(Markdown)
imageProps={
Object {
"onImageHeightChanged": [Function],
"onImageLoaded": [Function],
}
}
message="test msg"
options={
Object {
"atMentions": false,
"mentionHighlight": false,
"singleline": true,
}
}
/>
</div>
<div
className="activity"
>
<Memo(Avatars)
size="xs"
userIds={
Array [
"mt5td9mdriyapmwuh5pc84dmhr",
"ij61jet1bbdk8fhhxitywdj4ih",
"7n4ach3i53bbmj84dfmu5b7c1c",
]
}
/>
<MemoizedFormattedMessage
defaultMessage="{newReplies, plural, =1 {# new reply} other {# new replies}}"
id="threading.numNewReplies"
values={
Object {
"newReplies": 2,
}
}
/>
</div>
</article>
`;

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

@@ -0,0 +1,6 @@
.attachment__truncated {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}

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

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {stripMarkdown} from 'utils/markdown';
import './attachment_card.scss';
type Props = {
fallback: string;
pretext: string;
title: string;
text: string;
author_name: string;
}
function AttachmentCard({
fallback,
title,
text,
author_name: authorName,
pretext,
}: Props) {
return (
<div>
<div className='attachment__truncated'>
{`${authorName}: ${title}`}
</div>
<div className='attachment__truncated'>
{stripMarkdown(text || pretext || fallback)}
</div>
</div>
);
}
export default AttachmentCard;

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

@@ -0,0 +1,66 @@
.file_card {
display: flex;
width: 300px;
height: 40px;
flex-direction: row;
align-items: center;
padding: 0 6px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
margin: 0 2px;
border-radius: 4px;
color: var(--center-channel-color);
&__name {
overflow: hidden;
min-width: 0;
flex-shrink: 1;
margin-right: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__size {
flex-shrink: 0;
margin-left: auto;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 12px;
}
&__image {
min-width: 28px;
max-width: 28px;
min-height: 28px;
max-height: 28px;
margin-right: 8px;
border-radius: 2px;
}
&__attachment {
margin-right: 4px;
color: rgba(var(--center-channel-color-rgb), 0.5);
&[class*='text'] {
color: rgba(var(--center-channel-color-rgb), 0.5);
}
&[class*='excel'] {
color: #1ca660;
}
&[class*='pdf'],
&[class*='powerpoint'] {
color: #ed522a;
}
&[class*='generic'],
&[class*='code'],
&[class*='image'],
&[class*='audio'],
&[class*='video'],
&[class*='word'],
&[class*='patch'],
&[class*='svg'] {
color: #338aff;
}
}
}

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

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo, memo} from 'react';
import cn from 'classnames';
import {FileInfo} from '@mattermost/types/files';
import {fileSizeToString, getCompassIconClassName, getFileType} from 'utils/utils';
import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
import {FileTypes} from 'utils/constants';
import './file_card.scss';
type Props = {
file?: FileInfo;
enableSVGs: boolean;
}
type FileProps = FileInfo & {
enableSVGs: boolean;
}
type CardProps = {
children?: React.ReactElement<typeof Image>;
title: string;
size?: number;
}
function File({
id,
has_preview_image: hasPreviewImage,
mini_preview: miniPreview,
mime_type: mimeType,
extension,
enableSVGs,
}: FileProps) {
const imgSrc = useMemo(() => {
if (!hasPreviewImage) {
return undefined;
}
if (miniPreview) {
return `data:${mimeType};base64,${miniPreview}`;
}
return getFileThumbnailUrl(id);
}, [id, miniPreview, mimeType, hasPreviewImage]);
const fileType = getFileType(extension);
switch (fileType) {
case FileTypes.SVG:
if (enableSVGs) {
return (
<img
alt='file preview'
className='file_card__image post-image small'
src={getFileUrl(id)}
/>
);
}
return (
<div
className={cn(
'icon',
'icon-20',
getCompassIconClassName(fileType),
'file_card__attachment',
)}
/>
);
case FileTypes.IMAGE:
return (
<img
alt='file preview'
className='file_card__image post-image small'
src={imgSrc}
/>
);
default:
return (
<i
className={cn(
'icon',
'icon-20',
getCompassIconClassName(fileType),
'file_card__attachment',
)}
/>
);
}
}
function Card({children, title, size}: CardProps) {
return (
<div
className='file_card'
title={title}
>
{children}
<div className='file_card__name'>
{title}
</div>
{size != null && (
<div className='file_card__size'>
{fileSizeToString(size)}
</div>
)}
</div>
);
}
function FileCard({file, enableSVGs}: Props) {
if (!file) {
return null;
}
return (
<Card
title={file.name}
size={file.size}
>
<File
enableSVGs={enableSVGs}
{...file}
/>
</Card>
);
}
export default memo(FileCard);

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

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getFile} from 'mattermost-redux/selectors/entities/files';
import {FileInfo} from '@mattermost/types/files';
import {GlobalState} from 'types/store';
import FileCard from './file_card';
type OwnProps = {
id: FileInfo['id'];
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const file = getFile(state, ownProps.id);
const config = getConfig(state);
return {
file,
enableSVGs: config.EnableSVGs === 'true',
};
}
export default connect(mapStateToProps)(FileCard);

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

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Post} from '@mattermost/types/posts';
import FileCard from './file_card';
import AttachmentCard from './attachment_card';
type Props = {
post: Post;
}
function Attachment({post}: Props) {
if (post.file_ids?.length) {
return <FileCard id={post.file_ids[0]}/>;
}
if (post.props.attachments && post.props.attachments.length) {
return <AttachmentCard {...post.props.attachments[0]}/>;
}
return null;
}
export default Attachment;

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

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {memo} from 'react';
import {compose} from 'redux';
import {connect} from 'react-redux';
import {getPost, isPostPriorityEnabled, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
import {getThread} from 'mattermost-redux/selectors/entities/threads';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {GlobalState} from 'types/store';
import ThreadItem, {OwnProps} from './thread_item';
function makeMapStateToProps() {
const getPostsForThread = makeGetPostsForThread();
const getChannel = makeGetChannel();
const getDisplayName = makeGetDisplayName();
return (state: GlobalState, ownProps: OwnProps) => {
const {threadId} = ownProps;
const post = getPost(state, threadId);
if (!post) {
return {};
}
return {
post,
channel: getChannel(state, {id: post.channel_id}),
currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state),
displayName: getDisplayName(state, post.user_id, true),
postsInThread: getPostsForThread(state, post.id),
thread: getThread(state, threadId),
isPostPriorityEnabled: isPostPriorityEnabled(state),
};
};
}
export default compose(
connect(makeMapStateToProps),
memo,
)(ThreadItem) as React.FunctionComponent<OwnProps>;

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

@@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@import "sass/utils/_mixins";
.ThreadItem {
--border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
position: relative;
padding: 18px 18px 18px 28px;
cursor: pointer;
text-align: left;
.alt-visible {
visibility: hidden;
}
&:hover,
&:focus,
&:focus-within {
background: rgba(var(--center-channel-color-rgb), 0.04);
.alt-visible {
visibility: unset;
}
.alt-hidden {
visibility: hidden;
}
}
&__author {
@include clearfix;
overflow: hidden;
max-width: 100%;
text-align: left;
text-overflow: ellipsis;
-moz-user-select: all; /* Firefox all */
white-space: nowrap;
}
&__tags {
display: flex;
max-width: 100%;
align-items: center;
gap: 4px;
}
&.is-selected {
background: rgba(var(--button-bg-rgb), 0.04);
&:hover,
&:focus {
background: rgba(var(--button-bg-rgb), 0.08);
}
}
.activity {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 600;
line-height: 15px;
}
&.has-unreads {
.activity {
color: rgba(var(--sidebar-text-active-border-rgb), 1);
}
}
.dot-unreads {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 8px;
background: rgba(var(--sidebar-text-active-border-rgb), 1);
border-radius: 50%;
text-align: center;
}
.dot-mentions {
display: inline-block;
width: 16px;
height: 16px;
background: rgba(var(--button-bg-rgb), 1);
border-radius: 50%;
color: rgba(var(--button-color-rgb), 1);
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
&.over {
font-size: 8px;
}
}
.indicator {
position: absolute;
left: -24px;
display: grid;
width: 24px;
height: 20px;
place-content: center;
}
h1 {
position: relative;
display: grid;
margin: 0 0 6px;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
font-weight: 600;
gap: 10px;
grid-template-columns: minmax(0, min-content) minmax(0, auto) min-content;
justify-items: start;
line-height: 20px;
white-space: nowrap;
}
div.preview {
height: 40px;
margin: 0 0 10px;
color: rgba(var(--center-channel-color-rgb), 1);
@include text-clamp(2, 20);
}
time {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: normal;
grid-column: 3/4;
line-height: 20px;
}
.menu-anchor {
position: absolute;
top: 12px;
right: 14px;
.Button {
width: 28px;
height: 28px;
padding: 0;
font-size: 18px;
}
}
.MenuWrapper .dropdown-menu {
min-width: 250px;
}
.Avatars {
margin-right: 10px;
}
}

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

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import Tag from 'components/widgets/tag/tag';
import {UserThread} from '@mattermost/types/threads';
import {Post} from '@mattermost/types/posts';
import {Channel} from '@mattermost/types/channels';
import * as Utils from 'utils/utils';
import ThreadMenu from '../thread_menu';
import {WindowSizes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import {markLastPostInThreadAsUnread, updateThreadRead} from 'mattermost-redux/actions/threads';
jest.mock('mattermost-redux/actions/threads');
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
jest.mock('actions/views/threads');
import ThreadItem from './thread_item';
const mockRouting = {
currentUserId: '7n4ach3i53bbmj84dfmu5b7c1c',
currentTeamId: 'tid',
goToInChannel: jest.fn(),
select: jest.fn(),
};
jest.mock('../../hooks', () => {
return {
useThreadRouting: () => mockRouting,
};
});
const mockDispatch = jest.fn();
let mockThread: UserThread;
let mockPost: Post;
let mockChannel: Channel;
let mockState: any;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useDispatch: () => mockDispatch,
}));
describe('components/threading/global_threads/thread_item', () => {
let props: ComponentProps<typeof ThreadItem>;
beforeEach(() => {
mockThread = {
id: '1y8hpek81byspd4enyk9mp1ncw',
reply_count: 0,
unread_replies: 0,
unread_mentions: 0,
is_following: true,
participants: [
{
id: '7n4ach3i53bbmj84dfmu5b7c1c',
username: 'frodo.baggins',
first_name: 'Frodo',
last_name: 'Baggins',
},
{
id: 'ij61jet1bbdk8fhhxitywdj4ih',
username: 'samwise.gamgee',
first_name: 'Samwise',
last_name: 'Gamgee',
},
],
post: {
user_id: 'mt5td9mdriyapmwuh5pc84dmhr',
channel_id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
},
} as UserThread;
mockPost = {
id: '1y8hpek81byspd4enyk9mp1ncw',
user_id: 'mt5td9mdriyapmwuh5pc84dmhr',
channel_id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
message: 'test msg',
create_at: 1610486901110,
edit_at: 1611786714912,
} as Post;
const user = TestHelper.getUserMock();
mockChannel = {
id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
name: 'test-team',
display_name: 'Team name',
} as Channel;
mockState = {
entities: {
users: {
currentUserId: user.id,
},
preferences: {
myPreferences: {},
},
},
views: {
browser: {
windowSize: WindowSizes.DESKTOP_VIEW,
},
},
};
props = {
isFirstThreadInList: false,
channel: mockChannel,
currentRelativeTeamUrl: '/tname',
displayName: 'Someone',
isSelected: false,
post: mockPost,
postsInThread: [],
thread: mockThread,
threadId: mockThread.id,
isPostPriorityEnabled: false,
};
});
test('should report total number of replies', () => {
mockThread.reply_count = 9;
const wrapper = shallow(<ThreadItem {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('id', 'threading.numReplies');
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('values.totalReplies', 9);
});
test('should report unread messages', () => {
mockThread.reply_count = 11;
mockThread.unread_replies = 2;
const wrapper = shallow(<ThreadItem {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists('.dot-unreads')).toBe(true);
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('id', 'threading.numNewReplies');
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('values.newReplies', 2);
});
test('should report unread mentions', () => {
mockThread.reply_count = 16;
mockThread.unread_replies = 5;
mockThread.unread_mentions = 2;
const wrapper = shallow(<ThreadItem {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('.dot-mentions').text()).toBe('2');
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('id', 'threading.numNewReplies');
expect(wrapper.find('.activity MemoizedFormattedMessage').props()).toHaveProperty('values.newReplies', 5);
});
test('should show channel name', () => {
const wrapper = shallow(<ThreadItem {...props}/>);
expect(wrapper.find(Tag).props().text).toContain('Team name');
});
test('should pass required props to ThreadMenu', () => {
const wrapper = shallow(<ThreadItem {...props}/>);
// verify ThreadMenu received transient/required props
new Map<string, any>([
['hasUnreads', Boolean(mockThread.unread_replies)],
['threadId', mockThread.id],
['isFollowing', mockThread.is_following],
['unreadTimestamp', 1611786714912],
]).forEach((val, prop) => {
expect(wrapper.find(ThreadMenu).props()).toHaveProperty(prop, val);
});
});
test('should call Utils.handleFormattedTextClick on click', () => {
const wrapper = shallow(<ThreadItem {...props}/>);
const spy = jest.spyOn(Utils, 'handleFormattedTextClick').mockImplementationOnce(jest.fn());
wrapper.find('.preview').simulate('click', {});
expect(spy).toHaveBeenCalledWith({}, '/tname');
});
test('should allow marking as unread on alt + click', () => {
const wrapper = shallow(<ThreadItem {...props}/>);
wrapper.simulate('click', {altKey: true});
expect(updateThreadRead).not.toHaveBeenCalled();
expect(markLastPostInThreadAsUnread).toHaveBeenCalledWith('user_id', 'tid', '1y8hpek81byspd4enyk9mp1ncw');
expect(manuallyMarkThreadAsUnread).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw', 1611786714912);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});

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

@@ -0,0 +1,289 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useEffect, MouseEvent, useMemo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import classNames from 'classnames';
import {useDispatch, useSelector} from 'react-redux';
import {DotsVerticalIcon} from '@mattermost/compass-icons/components';
import {getChannel as fetchChannel} from 'mattermost-redux/actions/channels';
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
import {markLastPostInThreadAsUnread, updateThreadRead} from 'mattermost-redux/actions/threads';
import {Posts} from 'mattermost-redux/constants';
import * as Utils from 'utils/utils';
import {CrtTutorialSteps, Preferences} from 'utils/constants';
import {GlobalState} from 'types/store';
import {getIsMobileView} from 'selectors/views/browser';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import Timestamp from 'components/timestamp';
import Avatars from 'components/widgets/users/avatars';
import Button from 'components/threading/common/button';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import CRTListTutorialTip from 'components/tours/crt_tour/crt_list_tutorial_tip';
import Markdown from 'components/markdown';
import Tag from 'components/widgets/tag/tag';
import PriorityBadge from 'components/post_priority/post_priority_badge';
import {Channel} from '@mattermost/types/channels';
import {Post, PostPriority} from '@mattermost/types/posts';
import {UserThread} from '@mattermost/types/threads';
import {THREADING_TIME} from '../../common/options';
import {useThreadRouting} from '../../hooks';
import ThreadMenu from '../thread_menu';
import Attachment from './attachments';
import './thread_item.scss';
export type OwnProps = {
isSelected: boolean;
threadId: UserThread['id'];
style?: any;
isFirstThreadInList: boolean;
};
type Props = {
channel: Channel;
currentRelativeTeamUrl: string;
displayName: string;
post: Post;
postsInThread: Post[];
thread: UserThread;
isPostPriorityEnabled: boolean;
};
const markdownPreviewOptions = {
singleline: true,
mentionHighlight: false,
atMentions: false,
};
function ThreadItem({
channel,
currentRelativeTeamUrl,
displayName,
isSelected,
post,
postsInThread,
style,
thread,
threadId,
isFirstThreadInList,
isPostPriorityEnabled,
}: Props & OwnProps): React.ReactElement|null {
const dispatch = useDispatch();
const {select, goToInChannel, currentTeamId} = useThreadRouting();
const {formatMessage} = useIntl();
const isMobileView = useSelector(getIsMobileView);
const currentUserId = useSelector(getCurrentUserId);
const tipStep = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_STEP, currentUserId));
const showListTutorialTip = tipStep === CrtTutorialSteps.LIST_POPOVER;
const msgDeleted = formatMessage({id: 'post_body.deleted', defaultMessage: '(message deleted)'});
const postAuthor = post.props?.override_username || displayName;
useEffect(() => {
if (channel?.teammate_id) {
dispatch(getMissingProfilesByIds([channel.teammate_id]));
}
}, [channel?.teammate_id]);
useEffect(() => {
if (!channel && thread?.post.channel_id) {
dispatch(fetchChannel(thread.post.channel_id));
}
}, [channel, thread?.post.channel_id]);
const participantIds = useMemo(() => {
const ids = (thread?.participants || []).flatMap(({id}) => {
if (id === post.user_id) {
return [];
}
return id;
}).reverse();
return [post.user_id, ...ids];
}, [thread?.participants]);
let unreadTimestamp = post.edit_at || post.create_at;
const selectHandler = useCallback((e: MouseEvent<HTMLDivElement>) => {
if (e.altKey) {
const hasUnreads = thread ? Boolean(thread.unread_replies) : false;
const lastViewedAt = hasUnreads ? Date.now() : unreadTimestamp;
dispatch(manuallyMarkThreadAsUnread(threadId, lastViewedAt));
if (hasUnreads) {
dispatch(updateThreadRead(currentUserId, currentTeamId, threadId, Date.now()));
} else {
dispatch(markLastPostInThreadAsUnread(currentUserId, currentTeamId, threadId));
}
} else {
select(threadId);
}
}, [
currentUserId,
currentTeamId,
threadId,
thread,
updateThreadRead,
unreadTimestamp,
]);
const imageProps = useMemo(() => ({
onImageHeightChanged: () => {},
onImageLoaded: () => {},
}), []);
const goToInChannelHandler = useCallback((e: MouseEvent) => {
e.stopPropagation();
goToInChannel(threadId);
}, [threadId]);
const handleFormattedTextClick = useCallback((e) => {
Utils.handleFormattedTextClick(e, currentRelativeTeamUrl);
}, [currentRelativeTeamUrl]);
if (!thread || !post) {
return null;
}
const {
unread_replies: newReplies,
unread_mentions: newMentions,
last_reply_at: lastReplyAt,
reply_count: totalReplies,
is_following: isFollowing,
} = thread;
// if we have the whole thread, get the posts in it, sorted from newest to oldest.
// First post is latest reply. Use that timestamp
if (postsInThread.length > 1) {
const p = postsInThread[0];
unreadTimestamp = p.edit_at || p.create_at;
}
return (
<article
style={style}
className={classNames('ThreadItem', {
'has-unreads': newReplies,
'is-selected': isSelected,
})}
tabIndex={0}
id={isFirstThreadInList ? 'tutorial-threads-mobile-list' : ''}
onClick={selectHandler}
>
<h1>
{Boolean(newMentions || newReplies) && (
<div className='indicator'>
{newMentions ? (
<div className={classNames('dot-mentions', {over: newMentions > 99})}>
{Math.min(newMentions, 99)}
{newMentions > 99 && '+'}
</div>
) : (
<div className='dot-unreads'/>
)}
</div>
)}
<div className='ThreadItem__author'>{postAuthor}</div>
<div className='ThreadItem__tags'>
{channel && postAuthor !== channel?.display_name && (
<Tag
onClick={goToInChannelHandler}
text={channel?.display_name}
/>
)}
{isPostPriorityEnabled && (
thread.is_urgent && (
<PriorityBadge
className={postAuthor === channel?.display_name ? 'ml-2' : ''}
priority={PostPriority.URGENT}
/>
)
)}
</div>
<Timestamp
{...THREADING_TIME}
className='alt-hidden'
value={lastReplyAt}
/>
</h1>
<div className='menu-anchor alt-visible'>
<ThreadMenu
threadId={threadId}
isFollowing={isFollowing ?? false}
hasUnreads={Boolean(newReplies)}
unreadTimestamp={unreadTimestamp}
>
<SimpleTooltip
id='threadActionMenu'
content={(
<FormattedMessage
id='threading.threadItem.menu'
defaultMessage='Actions'
/>
)}
>
<Button
marginTop={true}
className='Button___icon'
>
<DotsVerticalIcon size={18}/>
</Button>
</SimpleTooltip>
</ThreadMenu>
</div>
<div
aria-readonly='true'
className='preview'
dir='auto'
tabIndex={0}
onClick={handleFormattedTextClick}
>
{post.message ? (
<Markdown
message={post.state === Posts.POST_DELETED ? msgDeleted : post.message}
options={markdownPreviewOptions}
imagesMetadata={post?.metadata && post?.metadata?.images}
imageProps={imageProps}
/>
) : (
<Attachment post={post}/>
)}
</div>
<div className='activity'>
{participantIds?.length ? (
<Avatars
userIds={participantIds}
size='xs'
/>
) : null}
{Boolean(totalReplies) && (
<>
{newReplies ? (
<FormattedMessage
id='threading.numNewReplies'
defaultMessage='{newReplies, plural, =1 {# new reply} other {# new replies}}'
values={{newReplies}}
/>
) : (
<FormattedMessage
id='threading.numReplies'
defaultMessage='{totalReplies, plural, =0 {Reply} =1 {# reply} other {# replies}}'
values={{totalReplies}}
/>
)}
</>
)}
</div>
{showListTutorialTip && isFirstThreadInList && isMobileView && (<CRTListTutorialTip/>)}
</article>
);
}
export default memo(ThreadItem);

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

@@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_list should match snapshot 1`] = `
<div
className="ThreadList"
id="threads-list-container"
tabIndex={0}
>
<Header
heading={
<React.Fragment>
<div
className="tab-button-wrapper"
>
<Memo(Button)
className="Button___large Margined"
isActive={true}
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="All your threads"
id="threading.filters.allThreads"
/>
</Memo(Button)>
</div>
<div
className="tab-button-wrapper"
id="threads-list-unread-button"
>
<Memo(Button)
className="Button___large Margined"
hasDot={true}
isActive={false}
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Unreads"
id="threading.filters.unreads"
/>
</Memo(Button)>
</div>
</React.Fragment>
}
id="tutorial-threads-mobile-header"
right={
<div
className="right-anchor"
>
<SimpleTooltip
content="Mark all as read"
id="threadListMarkRead"
>
<Memo(Button)
className="Button___large Button___icon"
disabled={false}
id="threads-list__mark-all-as-read"
marginTop={true}
onClick={[Function]}
>
<span
className="icon"
>
<PlaylistCheckIcon
size={18}
/>
</span>
</Memo(Button)>
</SimpleTooltip>
</div>
}
/>
<div
className="threads"
data-testid="threads_list"
>
<Memo(VirtualizedThreadList)
addNoMoreResultsItem={false}
ids={
Array [
"1",
"2",
"3",
]
}
isLoading={false}
key="threads_list_"
loadMoreItems={[Function]}
total={0}
/>
</div>
</div>
`;

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

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_list/virtualized_thread_list should match snapshot 1`] = `
<AutoSizer
disableHeight={false}
disableWidth={false}
onResize={[Function]}
style={Object {}}
>
<Component />
</AutoSizer>
`;

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

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_list/virtualized_thread_list_row should match snapshot 1`] = `
<Connect(Component)
isFirstThreadInList={false}
isSelected={false}
key="2"
style={Object {}}
threadId="2"
/>
`;
exports[`components/threading/global_threads/thread_list/virtualized_thread_list_row should support item loading indicator 1`] = `
<LoadingScreen
message={<React.Fragment />}
style={Object {}}
/>
`;
exports[`components/threading/global_threads/thread_list/virtualized_thread_list_row should support item search guidance 1`] = `
<NoResultsIndicator
iconGraphic={<SearchHintSVG />}
layout={1}
style={
Object {
"background": "rgba(var(--center-channel-color-rgb), 0.04)",
"padding": "16px 16px 16px 24px",
}
}
subtitle={
<Memo(MemoizedFormattedMessage)
defaultMessage="If youre looking for older conversations, try searching with {searchShortcut}"
id="globalThreads.searchGuidance.subtitle"
values={
Object {
"searchShortcut": <SearchShortcut
className="thread-no-results-subtitle-shortcut"
variant="tutorialTip"
/>,
}
}
/>
}
subtitleClassName="thread-no-results-subtitle"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Thats the end of the list"
id="globalThreads.searchGuidance.title"
/>
}
titleClassName="thread-no-results-title"
/>
`;

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

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

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

@@ -0,0 +1,79 @@
.ThreadList {
display: grid;
overflow: hidden;
grid-template-areas:
'header'
'list';
grid-template-rows: 56px 1fr;
.Header {
padding-right: 12px;
padding-left: 14px;
border-bottom: var(--border);
color: rgba(var(--center-channel-color-rgb), 0.56);
grid-area: header;
.tab-button-wrapper {
position: relative;
display: flex;
height: 100%;
justify-content: center;
padding: 13px 0;
+ .tab-button-wrapper {
margin-left: 4px;
}
}
.left {
display: flex;
height: 100%;
align-items: center;
}
.right-anchor {
.Button {
padding: 4px 0;
&:disabled {
cursor: not-allowed;
}
}
}
}
.threads {
margin-right: 5px;
grid-area: list;
.no-results__wrapper {
position: relative;
top: -28px;
max-width: 100%;
.thread-no-results-title {
margin-bottom: 0;
font-size: 16px;
line-height: 24px;
}
.thread-no-results-subtitle {
max-width: 100%;
font-size: 12px;
.thread-no-results-subtitle-shortcut {
margin: 0;
}
}
}
}
.ThreadItem {
border-bottom: var(--border);
}
.virtualized-thread-list {
scrollbar-color: var(--center-channel-color-32) #fff0;
scrollbar-width: thin;
}
}

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

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import {getThreads} from 'mattermost-redux/actions/threads';
import {TestHelper} from 'utils/test_helper';
jest.mock('mattermost-redux/actions/threads');
jest.mock('actions/views/modals');
import Header from 'components/widgets/header';
import {Constants, WindowSizes} from 'utils/constants';
import Button from '../../common/button';
import {openModal} from 'actions/views/modals';
import ThreadList, {ThreadFilter} from './thread_list';
import VirtualizedThreadList from './virtualized_thread_list';
const mockRouting = {
currentUserId: 'uid',
currentTeamId: 'tid',
goToInChannel: jest.fn(),
select: jest.fn(),
};
jest.mock('../../hooks', () => {
return {
useThreadRouting: () => mockRouting,
};
});
const mockDispatch = jest.fn();
let mockState: any;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useDispatch: () => mockDispatch,
}));
describe('components/threading/global_threads/thread_list', () => {
let props: ComponentProps<typeof ThreadList>;
beforeEach(() => {
props = {
currentFilter: ThreadFilter.none,
someUnread: true,
ids: ['1', '2', '3'],
unreadIds: ['2'],
setFilter: jest.fn(),
};
const user = TestHelper.getUserMock();
const profiles = {
[user.id]: user,
};
mockState = {
entities: {
users: {
currentUserId: user.id,
profiles,
},
preferences: {
myPreferences: {},
},
threads: {
countsIncludingDirect: {
tid: {
total: 0,
total_unread_threads: 0,
total_unread_mentions: 0,
},
},
},
teams: {
currentTeamId: 'tid',
},
},
views: {
browser: {
windowSize: WindowSizes.DESKTOP_VIEW,
},
},
};
});
test('should match snapshot', () => {
const wrapper = shallow(
<ThreadList {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should support filter:all', () => {
const wrapper = shallow(
<ThreadList {...props}/>,
);
wrapper.find(Header).shallow().find(Button).first().shallow().simulate('click');
expect(props.setFilter).toHaveBeenCalledWith('');
});
test('should support filter:unread', () => {
const wrapper = shallow(
<ThreadList {...props}/>,
);
wrapper.find(Header).shallow().find(Button).find({hasDot: true}).simulate('click');
expect(props.setFilter).toHaveBeenCalledWith('unread');
});
test('should support openModal', () => {
const wrapper = shallow(
<ThreadList {...props}/>,
);
wrapper.find(Header).shallow().find({content: 'Mark all as read'}).find(Button).simulate('click');
expect(openModal).toHaveBeenCalledTimes(1);
});
test('should support getThreads', async () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementation((init = false) => [init, setState]);
const wrapper = shallow(
<ThreadList {...props}/>,
);
const handleLoadMoreItems = wrapper.find(VirtualizedThreadList).prop('loadMoreItems');
const loadMoreItems = await handleLoadMoreItems(2, 3);
expect(loadMoreItems).toEqual({data: true});
expect(getThreads).toHaveBeenCalledWith('uid', 'tid', {unread: false, perPage: Constants.THREADS_PAGE_SIZE, before: '2'});
expect(setState.mock.calls).toEqual([[true], [false], [true]]);
});
});

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

@@ -0,0 +1,277 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, PropsWithChildren, useEffect} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {isEmpty} from 'lodash';
import {PlaylistCheckIcon} from '@mattermost/compass-icons/components';
import * as Utils from 'utils/utils';
import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads';
import {getThreads, markAllThreadsInTeamRead} from 'mattermost-redux/actions/threads';
import {trackEvent} from 'actions/telemetry_actions';
import {A11yClassNames, Constants, CrtTutorialSteps, ModalIdentifiers, Preferences} from 'utils/constants';
import NoResultsIndicator from 'components/no_results_indicator';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import Header from 'components/widgets/header';
import CRTListTutorialTip from 'components/tours/crt_tour/crt_list_tutorial_tip';
import {GlobalState} from 'types/store';
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
import CRTUnreadTutorialTip from 'components/tours/crt_tour/crt_unread_tutorial_tip';
import {getIsMobileView} from 'selectors/views/browser';
import {closeModal, openModal} from 'actions/views/modals';
import {UserThread} from '@mattermost/types/threads';
import MarkAllThreadsAsReadModal, {MarkAllThreadsAsReadModalProps} from '../mark_all_threads_as_read_modal';
import Button from '../../common/button';
import BalloonIllustration from '../../common/balloon_illustration';
import {useThreadRouting} from '../../hooks';
import VirtualizedThreadList from './virtualized_thread_list';
import './thread_list.scss';
export enum ThreadFilter {
none = '',
unread = 'unread'
}
export const FILTER_STORAGE_KEY = 'globalThreads_filter';
type Props = {
currentFilter: ThreadFilter;
someUnread: boolean;
setFilter: (filter: ThreadFilter) => void;
selectedThreadId?: UserThread['id'];
ids: Array<UserThread['id']>;
unreadIds: Array<UserThread['id']>;
};
const ThreadList = ({
currentFilter = ThreadFilter.none,
someUnread,
setFilter,
selectedThreadId,
unreadIds,
ids,
}: PropsWithChildren<Props>) => {
const isMobileView = useSelector(getIsMobileView);
const unread = ThreadFilter.unread === currentFilter;
const data = unread ? unreadIds : ids;
const ref = React.useRef<HTMLDivElement>(null);
const {currentTeamId, currentUserId, clear, select} = useThreadRouting();
const tipStep = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_STEP, currentUserId));
const showListTutorialTip = tipStep === CrtTutorialSteps.LIST_POPOVER;
const showUnreadTutorialTip = tipStep === CrtTutorialSteps.UNREAD_POPOVER;
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const {total = 0, total_unread_threads: totalUnread} = useSelector(getThreadCountsInCurrentTeam) ?? {};
const [isLoading, setLoading] = React.useState<boolean>(false);
const [hasLoaded, setHasLoaded] = React.useState<boolean>(false);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Ensure that arrow keys navigation is not triggered if the textbox is focused
const target = e.target as HTMLElement;
const tagName = target?.tagName?.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return;
}
const comboKeyPressed = e.altKey || e.metaKey || e.shiftKey || e.ctrlKey;
if (comboKeyPressed || (!Utils.isKeyPressed(e, Constants.KeyCodes.DOWN) && !Utils.isKeyPressed(e, Constants.KeyCodes.UP))) {
return;
}
// Don't switch threads if a modal or popup is open, since the focus is inside the modal/popup.
const noModalsAreOpen = document.getElementsByClassName(A11yClassNames.MODAL).length === 0;
const noPopupsDropdownsAreOpen = document.getElementsByClassName(A11yClassNames.POPUP).length === 0;
if (!noModalsAreOpen || !noPopupsDropdownsAreOpen) {
return;
}
let threadIdToSelect = 0;
if (selectedThreadId) {
const selectedThreadIndex = data.indexOf(selectedThreadId);
if (Utils.isKeyPressed(e, Constants.KeyCodes.DOWN)) {
if (selectedThreadIndex < data.length - 1) {
threadIdToSelect = selectedThreadIndex + 1;
}
if (selectedThreadIndex === data.length - 1) {
return;
}
}
if (Utils.isKeyPressed(e, Constants.KeyCodes.UP)) {
if (selectedThreadIndex > 0) {
threadIdToSelect = selectedThreadIndex - 1;
} else {
return;
}
}
}
select(data[threadIdToSelect]);
// hacky way to ensure the thread item loses focus.
ref.current?.focus();
}, [selectedThreadId, data]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const handleRead = useCallback(() => {
setFilter(ThreadFilter.none);
}, [setFilter]);
const handleUnread = useCallback(() => {
trackEvent('crt', 'filter_threads_by_unread');
setFilter(ThreadFilter.unread);
}, [setFilter]);
const handleLoadMoreItems = useCallback(async (startIndex) => {
setLoading(true);
let before = data[startIndex - 1];
if (before === selectedThreadId) {
before = data[startIndex - 2];
}
await dispatch(getThreads(currentUserId, currentTeamId, {unread, perPage: Constants.THREADS_PAGE_SIZE, before}));
setLoading(false);
setHasLoaded(true);
return {data: true};
}, [currentTeamId, data, unread, selectedThreadId]);
const handleAllMarkedRead = useCallback(() => {
trackEvent('crt', 'mark_all_threads_read');
dispatch(markAllThreadsInTeamRead(currentUserId, currentTeamId));
if (currentFilter === ThreadFilter.unread) {
clear();
}
}, [currentTeamId, currentUserId, currentFilter]);
const handleOpenMarkAllAsReadModal = useCallback(() => {
const handleCloseMarkAllAsReadModal = () => {
dispatch(closeModal(ModalIdentifiers.MARK_ALL_THREADS_AS_READ));
};
const handleConfirm = () => {
handleAllMarkedRead();
handleCloseMarkAllAsReadModal();
};
const modalProp: MarkAllThreadsAsReadModalProps = {
onConfirm: handleConfirm,
onCancel: handleCloseMarkAllAsReadModal,
};
dispatch(openModal({
modalId: ModalIdentifiers.MARK_ALL_THREADS_AS_READ,
dialogType: MarkAllThreadsAsReadModal,
dialogProps: modalProp,
}));
}, [handleAllMarkedRead]);
return (
<div
tabIndex={0}
ref={ref}
className={'ThreadList'}
id={'threads-list-container'}
>
<Header
id={'tutorial-threads-mobile-header'}
heading={(
<>
<div className={'tab-button-wrapper'}>
<Button
className={'Button___large Margined'}
isActive={currentFilter === ThreadFilter.none}
onClick={handleRead}
>
<FormattedMessage
id='threading.filters.allThreads'
defaultMessage='All your threads'
/>
</Button>
</div>
<div
id={'threads-list-unread-button'}
className={'tab-button-wrapper'}
>
<Button
className={'Button___large Margined'}
isActive={currentFilter === ThreadFilter.unread}
hasDot={someUnread}
onClick={handleUnread}
>
<FormattedMessage
id='threading.filters.unreads'
defaultMessage='Unreads'
/>
</Button>
{showUnreadTutorialTip && <CRTUnreadTutorialTip/>}
</div>
</>
)}
right={(
<div className='right-anchor'>
<SimpleTooltip
id='threadListMarkRead'
content={formatMessage({
id: 'threading.threadList.markRead',
defaultMessage: 'Mark all as read',
})}
>
<Button
id={'threads-list__mark-all-as-read'}
disabled={!someUnread}
className={'Button___large Button___icon'}
onClick={handleOpenMarkAllAsReadModal}
marginTop={true}
>
<span className='icon'>
<PlaylistCheckIcon size={18}/>
</span>
</Button>
</SimpleTooltip>
</div>
)}
/>
<div
className='threads'
data-testid={'threads_list'}
>
<VirtualizedThreadList
key={`threads_list_${currentFilter}`}
loadMoreItems={handleLoadMoreItems}
ids={data}
selectedThreadId={selectedThreadId}
total={unread ? totalUnread : total}
isLoading={isLoading}
addNoMoreResultsItem={hasLoaded && !unread}
/>
{showListTutorialTip && !isMobileView && <CRTListTutorialTip/>}
{unread && !someUnread && isEmpty(unreadIds) ? (
<NoResultsIndicator
expanded={true}
iconGraphic={BalloonIllustration}
title={formatMessage({
id: 'globalThreads.threadList.noUnreadThreads',
defaultMessage: 'No unread threads',
})}
/>
) : null}
</div>
</div>
);
};
export default memo(ThreadList);

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

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import VirtualizedThreadList from './virtualized_thread_list';
describe('components/threading/global_threads/thread_list/virtualized_thread_list', () => {
let props: ComponentProps<typeof VirtualizedThreadList>;
let loadMoreItems: (startIndex: number, stopIndex: number) => Promise<any>;
beforeEach(() => {
loadMoreItems = () => Promise.resolve();
props = {
ids: ['1', '2', '3'],
loadMoreItems,
selectedThreadId: '1',
total: 3,
isLoading: false,
addNoMoreResultsItem: false,
};
});
test('should match snapshot', () => {
const wrapper = shallow(<VirtualizedThreadList {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

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

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useEffect, useMemo} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import InfiniteLoader from 'react-window-infinite-loader';
import {FixedSizeList} from 'react-window';
import {UserThread} from '@mattermost/types/threads';
import {Constants} from 'utils/constants';
import Row from './virtualized_thread_list_row';
type Props = {
ids: Array<UserThread['id']>;
loadMoreItems: (startIndex: number, stopIndex: number) => Promise<any>;
selectedThreadId?: UserThread['id'];
total: number;
isLoading?: boolean;
addNoMoreResultsItem?: boolean;
};
const style = {
willChange: 'auto',
};
function VirtualizedThreadList({
ids,
selectedThreadId,
loadMoreItems,
total,
isLoading,
addNoMoreResultsItem,
}: Props) {
const infiniteLoaderRef = React.useRef<any>();
const startIndexRef = React.useRef<number>(0);
const stopIndexRef = React.useRef<number>(0);
useEffect(() => {
if (ids.length > 0 && selectedThreadId) {
const index = ids.indexOf(selectedThreadId);
if (startIndexRef.current >= index || index > stopIndexRef.current) {
// eslint-disable-next-line no-underscore-dangle
infiniteLoaderRef.current?._listRef.scrollToItem(index);
}
}
// ids should not be on the dependency list as
// it will auto scroll to selected item upon
// infinite loading
// when the selectedThreadId changes it will get
// the new ids so no issue there
}, [selectedThreadId]);
const data = useMemo(
() => (
{
ids: addNoMoreResultsItem && ids.length === total ? [...ids, Constants.THREADS_NO_RESULTS_ITEM_ID] : (isLoading && ids.length !== total && [...ids, Constants.THREADS_LOADING_INDICATOR_ITEM_ID]) || ids,
selectedThreadId,
}
),
[ids, selectedThreadId, isLoading, addNoMoreResultsItem, total],
);
const itemKey = useCallback((index, data) => data.ids[index], []);
const isItemLoaded = useCallback((index) => {
return ids.length === total || index < ids.length;
}, [ids, total]);
return (
<AutoSizer>
{({height, width}) => (
<InfiniteLoader
ref={infiniteLoaderRef}
itemCount={total}
loadMoreItems={loadMoreItems}
isItemLoaded={isItemLoaded}
minimumBatchSize={Constants.THREADS_PAGE_SIZE}
>
{({onItemsRendered, ref}) => {
return (
<FixedSizeList
onItemsRendered={({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
}) => {
onItemsRendered({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
});
startIndexRef.current = visibleStartIndex;
stopIndexRef.current = visibleStopIndex;
}}
ref={ref}
height={height}
itemCount={data.ids.length}
itemData={data}
itemKey={itemKey}
itemSize={133}
style={style}
width={width}
className='virtualized-thread-list'
>
{Row}
</FixedSizeList>
);
}
}
</InfiniteLoader>
)}
</AutoSizer>
);
}
function areEqual(prevProps: Props, nextProps: Props) {
return (
prevProps.selectedThreadId === nextProps.selectedThreadId &&
prevProps.ids.join() === nextProps.ids.join() &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.addNoMoreResultsItem === nextProps.addNoMoreResultsItem
);
}
export default memo(VirtualizedThreadList, areEqual);

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

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import {Constants} from 'utils/constants';
import Row from './virtualized_thread_list_row';
describe('components/threading/global_threads/thread_list/virtualized_thread_list_row', () => {
let props: ComponentProps<typeof Row>;
beforeEach(() => {
props = {
data: {
ids: ['1', '2', '3'],
selectedThreadId: undefined,
},
index: 1,
style: {},
};
});
test('should match snapshot', () => {
const wrapper = shallow(<Row {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should support item loading indicator', () => {
const wrapper = shallow(
<Row
{...props}
data={{ids: [...props.data.ids, Constants.THREADS_LOADING_INDICATOR_ITEM_ID], selectedThreadId: undefined}}
index={3}
/>);
expect(wrapper).toMatchSnapshot();
});
test('should support item search guidance ', () => {
const wrapper = shallow(
<Row
{...props}
data={{ids: [...props.data.ids, Constants.THREADS_NO_RESULTS_ITEM_ID], selectedThreadId: undefined}}
index={3}
/>);
expect(wrapper).toMatchSnapshot();
});
});

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

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {areEqual} from 'react-window';
import {UserThread} from '@mattermost/types/threads';
import LoadingScreen from 'components/loading_screen';
import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsLayout} from 'components/no_results_indicator/types';
import {SearchShortcut} from 'components/search_shortcut/search_shortcut';
import {ShortcutKeyVariant} from 'components/shortcut_key';
import {Constants} from 'utils/constants';
import SearchHintSVG from 'components/common/svg_images_components/search_hint_svg';
import ThreadItem from '../thread_item';
type Props = {
data: {
ids: Array<UserThread['id']>;
selectedThreadId?: UserThread['id'];
};
index: number;
style: any;
};
function Row({index, style, data}: Props) {
const itemId = data.ids[index];
const isSelected = data.selectedThreadId === itemId;
if (itemId === Constants.THREADS_LOADING_INDICATOR_ITEM_ID) {
return (
<LoadingScreen
message={<></>}
style={style}
/>
);
}
if (itemId === Constants.THREADS_NO_RESULTS_ITEM_ID) {
return (
<NoResultsIndicator
style={{...style, padding: '16px 16px 16px 24px', background: 'rgba(var(--center-channel-color-rgb), 0.04)'}}
iconGraphic={<SearchHintSVG/>}
title={
<FormattedMessage
id='globalThreads.searchGuidance.title'
defaultMessage='Thats the end of the list'
/>
}
subtitle={
<FormattedMessage
id='globalThreads.searchGuidance.subtitle'
defaultMessage='If youre looking for older conversations, try searching with {searchShortcut}'
values={{
searchShortcut: (
<SearchShortcut
className='thread-no-results-subtitle-shortcut'
variant={ShortcutKeyVariant.TutorialTip}
/>
),
}}
/>
}
titleClassName='thread-no-results-title'
subtitleClassName='thread-no-results-subtitle'
layout={NoResultsLayout.Horizontal}
/>
);
}
return (
<ThreadItem
isSelected={isSelected}
key={itemId}
style={style}
threadId={itemId}
isFirstThreadInList={index === 0}
/>
);
}
export default memo(Row, areEqual);

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

@@ -0,0 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/common/thread_menu should match snapshot 1`] = `
<MenuWrapper
animationComponent={[Function]}
className=""
stopPropagationOnToggle={true}
>
<button>
test
</button>
<Menu
ariaLabel=""
openLeft={true}
>
<MenuItemAction
extraText="You will be notified about replies"
onClick={[Function]}
show={true}
text="Follow thread"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Mark as unread"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Save"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
`;
exports[`components/threading/common/thread_menu should match snapshot after opening 1`] = `
<MenuWrapper
animationComponent={[Function]}
className=""
stopPropagationOnToggle={true}
>
<button>
test
</button>
<Menu
ariaLabel=""
openLeft={true}
>
<MenuItemAction
extraText="You will be notified about replies"
onClick={[Function]}
show={true}
text="Follow thread"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Mark as unread"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Save"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
`;

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

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

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

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ThreadMenu {
.MenuItem__help-text {
margin: 0;
}
}

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

@@ -0,0 +1,201 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {set} from 'lodash';
import {shallow} from 'enzyme';
import {setThreadFollow, updateThreadRead, markLastPostInThreadAsUnread} from 'mattermost-redux/actions/threads';
jest.mock('mattermost-redux/actions/threads');
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
jest.mock('actions/views/threads');
import ThreadMenu from '../thread_menu';
import Menu from 'components/widgets/menu/menu';
import {
flagPost as savePost,
unflagPost as unsavePost,
} from 'actions/post_actions';
jest.mock('actions/post_actions');
import {copyToClipboard} from 'utils/utils';
import {fakeDate} from 'tests/helpers/date';
import {GlobalState} from 'types/store';
jest.mock('utils/utils');
const mockRouting = {
params: {
team: 'team-name-1',
},
currentUserId: 'uid',
currentTeamId: 'tid',
goToInChannel: jest.fn(),
};
jest.mock('../../hooks', () => {
return {
useThreadRouting: () => mockRouting,
};
});
const mockDispatch = jest.fn();
let mockState: GlobalState;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useDispatch: () => mockDispatch,
}));
describe('components/threading/common/thread_menu', () => {
let props: ComponentProps<typeof ThreadMenu>;
beforeEach(() => {
props = {
threadId: '1y8hpek81byspd4enyk9mp1ncw',
unreadTimestamp: 1610486901110,
hasUnreads: false,
isFollowing: false,
children: (
<button>{'test'}</button>
),
};
mockState = {entities: {preferences: {myPreferences: {}}}} as GlobalState;
});
test('should match snapshot', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot after opening', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
wrapper.find('button').simulate('click');
expect(wrapper).toMatchSnapshot();
});
test('should allow following', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
isFollowing={false}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Follow thread'}).simulate('click');
expect(setThreadFollow).toHaveBeenCalledWith('uid', 'tid', '1y8hpek81byspd4enyk9mp1ncw', true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should allow unfollowing', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
isFollowing={true}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Unfollow thread'}).simulate('click');
expect(setThreadFollow).toHaveBeenCalledWith('uid', 'tid', '1y8hpek81byspd4enyk9mp1ncw', false);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should allow opening in channel', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Open in channel'}).simulate('click');
expect(mockRouting.goToInChannel).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw');
expect(mockDispatch).not.toHaveBeenCalled();
});
test('should allow marking as read', () => {
const resetFakeDate = fakeDate(new Date(1612582579566));
const wrapper = shallow(
<ThreadMenu
{...props}
hasUnreads={true}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Mark as read'}).simulate('click');
expect(markLastPostInThreadAsUnread).not.toHaveBeenCalled();
expect(updateThreadRead).toHaveBeenCalledWith('uid', 'tid', '1y8hpek81byspd4enyk9mp1ncw', 1612582579566);
expect(manuallyMarkThreadAsUnread).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw', 1612582579566);
expect(mockDispatch).toHaveBeenCalledTimes(2);
resetFakeDate();
});
test('should allow marking as unread', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
hasUnreads={false}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Mark as unread'}).simulate('click');
expect(updateThreadRead).not.toHaveBeenCalled();
expect(markLastPostInThreadAsUnread).toHaveBeenCalledWith('uid', 'tid', '1y8hpek81byspd4enyk9mp1ncw');
expect(manuallyMarkThreadAsUnread).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw', 1610486901110);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
test('should allow saving', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Save'}).simulate('click');
expect(savePost).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw');
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should allow unsaving', () => {
set(mockState, 'entities.preferences.myPreferences', {
'flagged_post--1y8hpek81byspd4enyk9mp1ncw': {
user_id: 'uid',
category: 'flagged_post',
name: '1y8hpek81byspd4enyk9mp1ncw',
value: 'true',
},
});
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Unsave'}).simulate('click');
expect(unsavePost).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw');
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should allow link copying', () => {
const wrapper = shallow(
<ThreadMenu
{...props}
/>,
);
wrapper.find('button').simulate('click');
wrapper.find(Menu.ItemAction).find({text: 'Copy link'}).simulate('click');
expect(copyToClipboard).toHaveBeenCalledWith('http://localhost:8065/team-name-1/pl/1y8hpek81byspd4enyk9mp1ncw');
expect(mockDispatch).not.toHaveBeenCalled();
});
});

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

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector, shallowEqual} from 'react-redux';
import {Preferences} from 'mattermost-redux/constants';
import {UserThread} from '@mattermost/types/threads';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {setThreadFollow, updateThreadRead, markLastPostInThreadAsUnread} from 'mattermost-redux/actions/threads';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import {
flagPost as savePost,
unflagPost as unsavePost,
} from 'actions/post_actions';
import {getSiteURL} from 'utils/url';
import {t} from 'utils/i18n';
import {copyToClipboard} from 'utils/utils';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {GlobalState} from 'types/store';
import {useThreadRouting} from '../../hooks';
import './thread_menu.scss';
type Props = {
threadId: UserThread['id'];
isFollowing?: boolean;
hasUnreads: boolean;
children: ReactNode;
unreadTimestamp: number;
};
function ThreadMenu({
threadId,
isFollowing = false,
unreadTimestamp,
hasUnreads,
children,
}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const {
params: {
team,
},
currentTeamId,
currentUserId,
goToInChannel,
} = useThreadRouting();
const isSaved = useSelector((state: GlobalState) => get(state, Preferences.CATEGORY_FLAGGED_POST, threadId, null) != null, shallowEqual);
const handleReadUnread = useCallback(() => {
const lastViewedAt = hasUnreads ? Date.now() : unreadTimestamp;
dispatch(manuallyMarkThreadAsUnread(threadId, lastViewedAt));
if (hasUnreads) {
dispatch(updateThreadRead(currentUserId, currentTeamId, threadId, Date.now()));
} else {
dispatch(markLastPostInThreadAsUnread(currentUserId, currentTeamId, threadId));
}
}, [
currentUserId,
currentTeamId,
threadId,
hasUnreads,
updateThreadRead,
unreadTimestamp,
]);
return (
<MenuWrapper
stopPropagationOnToggle={true}
>
{children}
<Menu
ariaLabel={''}
openLeft={true}
>
<Menu.ItemAction
{...isFollowing ? {
text: formatMessage({
id: t('threading.threadMenu.unfollow'),
defaultMessage: 'Unfollow thread',
}),
extraText: formatMessage({
id: t('threading.threadMenu.unfollowExtra'),
defaultMessage: 'You wont be notified about replies',
}),
} : {
text: formatMessage({
id: t('threading.threadMenu.follow'),
defaultMessage: 'Follow thread',
}),
extraText: formatMessage({
id: t('threading.threadMenu.followExtra'),
defaultMessage: 'You will be notified about replies',
}),
}}
onClick={useCallback(() => {
dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing));
}, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow])}
/>
<Menu.ItemAction
text={formatMessage({
id: t('threading.threadMenu.openInChannel'),
defaultMessage: 'Open in channel',
})}
onClick={useCallback(() => {
goToInChannel(threadId);
}, [threadId])}
/>
<Menu.ItemAction
text={formatMessage(hasUnreads ? {
id: t('threading.threadMenu.markRead'),
defaultMessage: 'Mark as read',
} : {
id: t('threading.threadMenu.markUnread'),
defaultMessage: 'Mark as unread',
})}
onClick={handleReadUnread}
/>
<Menu.ItemAction
text={formatMessage(isSaved ? {
id: t('threading.threadMenu.unsave'),
defaultMessage: 'Unsave',
} : {
id: t('threading.threadMenu.save'),
defaultMessage: 'Save',
})}
onClick={useCallback(() => {
dispatch(isSaved ? unsavePost(threadId) : savePost(threadId));
}, [threadId, isSaved])}
/>
<Menu.ItemAction
text={formatMessage({
id: t('threading.threadMenu.copy'),
defaultMessage: 'Copy link',
})}
onClick={useCallback(() => {
copyToClipboard(`${getSiteURL()}/${team}/pl/${threadId}`);
}, [team, threadId])}
/>
</Menu>
</MenuWrapper>
);
}
function areEqual(prevProps: Props, nextProps: Props) {
return (
prevProps.threadId === nextProps.threadId &&
prevProps.isFollowing === nextProps.isFollowing &&
prevProps.unreadTimestamp === nextProps.unreadTimestamp &&
prevProps.hasUnreads === nextProps.hasUnreads
);
}
export default memo(ThreadMenu, areEqual);

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

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_pane should match snapshot 1`] = `
<div
className="ThreadPane"
id="thread-pane-container"
>
<Header
className="ThreadPane___header"
heading={
<React.Fragment>
<Memo(Button)
className="Button___icon Button___large back"
onClick={[Function]}
>
<i
className="icon icon-arrow-back-ios"
/>
</Memo(Button)>
<h3>
<span
className="separated"
>
Thread
</span>
<Memo(Button)
allowTextOverflow={true}
className="separated"
onClick={[Function]}
>
Team name
</Memo(Button)>
</h3>
</React.Fragment>
}
right={
<React.Fragment>
<Memo(FollowButton)
disabled={false}
isFollowing={true}
onClick={[Function]}
/>
<Memo(ThreadMenu)
hasUnreads={false}
isFollowing={true}
threadId="1y8hpek81byspd4enyk9mp1ncw"
unreadTimestamp={1611786714912}
>
<SimpleTooltip
content="More Actions"
id="threadActionMenu"
>
<Memo(Button)
className="Button___icon Button___large"
>
<DotsVerticalIcon
size={18}
/>
</Memo(Button)>
</SimpleTooltip>
</Memo(ThreadMenu)>
</React.Fragment>
}
/>
</div>
`;

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

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

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

@@ -0,0 +1,77 @@
.ThreadPane {
display: grid;
overflow: hidden;
grid-template-areas:
'header'
'pane';
grid-template-rows: 56px 1fr;
.Header {
display: flex;
height: 56px;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: var(--border);
grid-area: header;
--button-separator-height: 24px;
.left {
display: flex;
}
.back {
display: none;
flex-shrink: 0;
}
.FollowButton,
h3 .Button {
height: 24px;
padding: 4px 8px;
font-size: 12px;
line-height: 16px;
}
h3 {
display: inline-grid;
align-items: center;
margin: 0 10px 0 0;
color: rgba(var(--center-channel-color-rgb), 1);
grid-template-columns: max-content 1fr;
> span {
padding-right: 8px;
padding-left: 4px;
font-size: 16px;
font-weight: 600;
line-height: 32px;
vertical-align: top;
}
.Button {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-weight: 400;
}
}
.MenuWrapper {
margin-left: 4px;
.dropdown-menu {
min-width: 250px;
}
}
}
.ThreadViewer {
overflow: hidden;
grid-area: pane;
.post-right__scroll {
padding-top: 0; // remove excess padding in relative time (no zero-height date separators)
}
}
}

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

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import {setThreadFollow} from 'mattermost-redux/actions/threads';
import TestHelper from 'packages/mattermost-redux/test/test_helper';
jest.mock('mattermost-redux/actions/threads');
import Header from 'components/widgets/header';
import FollowButton from 'components/threading/common/follow_button';
import Button from 'components/threading/common/button';
import {UserProfile} from '@mattermost/types/users';
import ThreadPane from './thread_pane';
const mockRouting = {
currentUserId: 'uid',
currentTeamId: 'tid',
goToInChannel: jest.fn(),
select: jest.fn(),
};
jest.mock('../../hooks', () => {
return {
useThreadRouting: () => mockRouting,
};
});
const mockDispatch = jest.fn();
let mockState: any;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useDispatch: () => mockDispatch,
}));
describe('components/threading/global_threads/thread_pane', () => {
let props: ComponentProps<typeof ThreadPane>;
let mockThread: typeof props['thread'];
beforeEach(() => {
mockThread = {
id: '1y8hpek81byspd4enyk9mp1ncw',
unread_replies: 0,
unread_mentions: 0,
is_following: true,
post: {
user_id: 'mt5td9mdriyapmwuh5pc84dmhr',
channel_id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
},
} as typeof props['thread'];
props = {
thread: mockThread,
};
const user1 = TestHelper.fakeUserWithId('uid');
const profiles: Record<string, UserProfile> = {};
profiles[user1.id] = user1;
mockState = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
posts: {
postsInThread: {'1y8hpek81byspd4enyk9mp1ncw': []},
posts: {
'1y8hpek81byspd4enyk9mp1ncw': {
id: '1y8hpek81byspd4enyk9mp1ncw',
user_id: 'mt5td9mdriyapmwuh5pc84dmhr',
channel_id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
create_at: 1610486901110,
edit_at: 1611786714912,
},
},
},
channels: {
channels: {
pnzsh7kwt7rmzgj8yb479sc9yw: {
id: 'pnzsh7kwt7rmzgj8yb479sc9yw',
display_name: 'Team name',
},
},
},
users: {
profiles,
currentUserId: 'uid',
},
},
};
});
test('should match snapshot', () => {
const wrapper = shallow(
<ThreadPane {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should support follow', () => {
props.thread.is_following = false;
const wrapper = shallow(
<ThreadPane {...props}/>,
);
wrapper.find(Header).shallow().find(FollowButton).shallow().simulate('click');
expect(setThreadFollow).toHaveBeenCalledWith(mockRouting.currentUserId, mockRouting.currentTeamId, mockThread.id, true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should support unfollow', () => {
props.thread.is_following = true;
const wrapper = shallow(
<ThreadPane {...props}/>,
);
wrapper.find(Header).shallow().find(FollowButton).shallow().simulate('click');
expect(setThreadFollow).toHaveBeenCalledWith(mockRouting.currentUserId, mockRouting.currentTeamId, mockThread.id, false);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
test('should support openInChannel', () => {
const wrapper = shallow(
<ThreadPane {...props}/>,
);
wrapper.find(Header).shallow().find('h3').find(Button).simulate('click');
expect(mockRouting.goToInChannel).toHaveBeenCalledWith('1y8hpek81byspd4enyk9mp1ncw');
});
test('should support go back to list', () => {
const wrapper = shallow(
<ThreadPane {...props}/>,
);
wrapper.find(Header).shallow().find(Button).find('.back').simulate('click');
expect(mockRouting.select).toHaveBeenCalledWith();
});
});

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

@@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {DotsVerticalIcon} from '@mattermost/compass-icons/components';
import {UserThread} from '@mattermost/types/threads';
import {setThreadFollow} from 'mattermost-redux/actions/threads';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getPost, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
import {t} from 'utils/i18n';
import {GlobalState} from 'types/store';
import ThreadMenu from '../thread_menu';
import Button from '../../common/button';
import FollowButton from '../../common/follow_button';
import SimpleTooltip from 'components/widgets/simple_tooltip';
import Header from 'components/widgets/header';
import {useThreadRouting} from '../../hooks';
import './thread_pane.scss';
const getChannel = makeGetChannel();
const getPostsForThread = makeGetPostsForThread();
type Props = {
thread: UserThread;
children?: ReactNode;
};
const ThreadPane = ({
thread,
children,
}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const {
currentTeamId,
currentUserId,
goToInChannel,
select,
} = useThreadRouting();
const {
id: threadId,
is_following: isFollowing,
post: {
channel_id: channelId,
},
} = thread;
const channel = useSelector((state: GlobalState) => getChannel(state, {id: channelId}));
const post = useSelector((state: GlobalState) => getPost(state, thread.id));
const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id));
const selectHandler = useCallback(() => select(), []);
let unreadTimestamp = post.edit_at || post.create_at;
// if we have the whole thread, get the posts in it, sorted from newest to oldest.
// First post is latest reply. Use that timestamp
if (postsInThread.length > 1) {
const p = postsInThread[0];
unreadTimestamp = p.edit_at || p.create_at;
}
const goToInChannelHandler = useCallback(() => {
goToInChannel(threadId);
}, [goToInChannel, threadId]);
const followHandler = useCallback(() => {
dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing));
}, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow]);
return (
<div
id={'thread-pane-container'}
className='ThreadPane'
>
<Header
className='ThreadPane___header'
heading={(
<>
<Button
className='Button___icon Button___large back'
onClick={selectHandler}
>
<i className='icon icon-arrow-back-ios'/>
</Button>
<h3>
<span className='separated'>
{formatMessage({
id: 'threading.header.heading',
defaultMessage: 'Thread',
})}
</span>
<Button
className='separated'
allowTextOverflow={true}
onClick={goToInChannelHandler}
>
{channel?.display_name}
</Button>
</h3>
</>
)}
right={(
<>
<FollowButton
isFollowing={isFollowing}
disabled={isFollowing == null}
onClick={followHandler}
/>
<ThreadMenu
threadId={threadId}
isFollowing={isFollowing}
hasUnreads={Boolean(thread.unread_replies || thread.unread_mentions)}
unreadTimestamp={unreadTimestamp}
>
<SimpleTooltip
id='threadActionMenu'
content={formatMessage({
id: t('threading.threadHeader.menu'),
defaultMessage: 'More Actions',
})}
>
<Button className='Button___icon Button___large'>
<DotsVerticalIcon size={18}/>
</Button>
</SimpleTooltip>
</ThreadMenu>
</>
)}
/>
{children}
</div>
);
};
export default memo(ThreadPane);

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

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
#SidebarContainer .SidebarGlobalThreads {
.SidebarChannel {
height: 100%;
}
.SidebarLink {
width: 100%;
.icon {
padding: 3px;
margin-right: 8px;
margin-left: 1px;
svg {
fill: rgba(var(--sidebar-text-rgb), 0.6);
vertical-align: middle;
}
}
&:hover {
padding-right: 16px;
.badge {
position: initial;
visibility: visible;
}
}
}
}
// legacy
.sidebar--left .SidebarGlobalThreads {
padding-bottom: 0;
margin-top: 13px;
.icon {
width: auto;
padding-left: 1px;
margin-top: 1px;
svg {
fill: rgba(var(--sidebar-text-rgb), 0.6);
}
}
.SidebarChannelLinkLabel_wrapper {
display: flex;
flex: 1;
}
.badge {
margin: 0 12px 0 0;
}
}

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

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {Link, useRouteMatch, useLocation, matchPath} from 'react-router-dom';
import classNames from 'classnames';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {
getThreadCountsInCurrentTeam, getThreadsInCurrentTeam,
} from 'mattermost-redux/selectors/entities/threads';
import {getInt, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getThreadCounts} from 'mattermost-redux/actions/threads';
import {t} from 'utils/i18n';
import {trackEvent} from 'actions/telemetry_actions';
import {closeRightHandSide} from 'actions/views/rhs';
import ChannelMentionBadge from 'components/sidebar/sidebar_channel/channel_mention_badge';
import {GlobalState} from 'types/store';
import {isAnyModalOpen} from 'selectors/views/modals';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import Constants, {
CrtTutorialSteps,
CrtTutorialTriggerSteps,
ModalIdentifiers,
Preferences,
RHSStates,
} from 'utils/constants';
import CollapsedReplyThreadsModal
from 'components/tours/crt_tour/collapsed_reply_threads_modal/collapsed_reply_threads_modal';
import {openModal} from 'actions/views/modals';
import {PulsatingDot} from '@mattermost/components';
import CRTWelcomeTutorialTip
from '../../tours/crt_tour/crt_welcome_tutorial_tip';
import {useThreadRouting} from '../hooks';
import ThreadsIcon from './threads_icon';
import './global_threads_link.scss';
const GlobalThreadsLink = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const isFeatureEnabled = useSelector(isCollapsedThreadsEnabled);
const {url} = useRouteMatch();
const {pathname} = useLocation();
const inGlobalThreads = matchPath(pathname, {path: '/:team/threads/:threadIdentifier?'}) != null;
const {currentTeamId, currentUserId} = useThreadRouting();
const counts = useSelector(getThreadCountsInCurrentTeam);
const someUnreadThreads = counts?.total_unread_threads;
const appHaveOpenModal = useSelector(isAnyModalOpen);
const tipStep = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_STEP, currentUserId, CrtTutorialSteps.WELCOME_POPOVER));
const crtTutorialTrigger = useSelector((state: GlobalState) => getInt(state, Preferences.CRT_TUTORIAL_TRIGGERED, currentUserId, Constants.CrtTutorialTriggerSteps.START));
const threads = useSelector(getThreadsInCurrentTeam);
const showTutorialTip = crtTutorialTrigger === CrtTutorialTriggerSteps.STARTED && tipStep === CrtTutorialSteps.WELCOME_POPOVER && threads.length >= 1;
const threadsCount = useSelector(getThreadCountsInCurrentTeam);
const rhsOpen = useSelector(getIsRhsOpen);
const rhsState = useSelector(getRhsState);
const showTutorialTrigger = isFeatureEnabled && crtTutorialTrigger === Constants.CrtTutorialTriggerSteps.START && !appHaveOpenModal && Boolean(threadsCount) && threadsCount.total >= 1;
const openThreads = useCallback((e) => {
e.stopPropagation();
trackEvent('crt', 'go_to_global_threads');
if (showTutorialTrigger) {
dispatch(openModal({modalId: ModalIdentifiers.COLLAPSED_REPLY_THREADS_MODAL, dialogType: CollapsedReplyThreadsModal, dialogProps: {}}));
}
if (rhsOpen && rhsState === RHSStates.EDIT_HISTORY) {
dispatch(closeRightHandSide());
}
}, [showTutorialTrigger, threadsCount, threads, rhsOpen, rhsState]);
useEffect(() => {
// load counts if necessary
if (isFeatureEnabled) {
dispatch(getThreadCounts(currentUserId, currentTeamId));
}
}, [currentUserId, currentTeamId, isFeatureEnabled]);
if (!isFeatureEnabled) {
// hide link if feature disabled
return null;
}
return (
<ul className='SidebarGlobalThreads NavGroupContent nav nav-pills__container'>
<li
id={'sidebar-threads-button'}
className={classNames('SidebarChannel', {
active: inGlobalThreads,
unread: someUnreadThreads,
})}
tabIndex={-1}
>
<Link
onClick={openThreads}
to={`${url}/threads`}
id='sidebarItem_threads'
draggable='false'
className={classNames('SidebarLink sidebar-item', {
'unread-title': Boolean(someUnreadThreads),
})}
tabIndex={0}
>
<span className='icon'>
<ThreadsIcon/>
</span>
<div className='SidebarChannelLinkLabel_wrapper'>
<span className='SidebarChannelLinkLabel sidebar-item__name'>
{formatMessage({id: t('globalThreads.sidebarLink'), defaultMessage: 'Threads'})}
</span>
</div>
{counts?.total_unread_mentions > 0 && (
<ChannelMentionBadge
unreadMentions={counts.total_unread_mentions}
hasUrgent={Boolean(counts?.total_unread_urgent_mentions)}
/>
)}
{showTutorialTrigger && <PulsatingDot/>}
</Link>
{showTutorialTip && <CRTWelcomeTutorialTip/>}
</li>
</ul>
);
};
export default GlobalThreadsLink;

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

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

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

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {HTMLAttributes} from 'react';
const ThreadsIcon = (attrs: HTMLAttributes<SVGElement>) => {
return (
<svg
width='14'
height='13'
viewBox='0 0 14 13'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...attrs}
>
<path
d='M11.7952 0.00524884C12.1312 0.00524884 12.4144 0.125249 12.6448 0.365248C12.8848 0.595648 13.0048 0.878848 13.0048 1.21485V8.41485C13.0048 8.75085 12.8848 9.03405 12.6448 9.26445C12.4144 9.49485 12.1312 9.61005 11.7952 9.61005H3.4L0.9952 12.0148V1.21485C0.9952 0.878848 1.1104 0.595648 1.3408 0.365248C1.5808 0.125249 1.8688 0.00524884 2.2048 0.00524884H11.7952ZM2.2048 1.21485V9.10605L2.896 8.41485H11.7952V1.21485H2.2048ZM3.4 3.01485H10.6V4.21005H3.4V3.01485ZM3.4 5.40525H8.8V6.61485H3.4V5.40525Z'
/>
</svg>
);
};
export default ThreadsIcon;

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

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo, useCallback} from 'react';
import {useParams, useHistory} from 'react-router-dom';
import {useSelector, shallowEqual} from 'react-redux';
import {UserThread} from '@mattermost/types/threads';
import {Team} from '@mattermost/types/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
/**
* GlobalThreads-specific hook for nav/routing, selection, and common data needed for actions.
*/
export function useThreadRouting() {
const matchParams = useParams<{team: string; threadIdentifier?: UserThread['id']}>();
const params = useMemo(() => matchParams, [matchParams.threadIdentifier, matchParams.team]);
const history = useHistory();
const currentTeamId = useSelector(getCurrentTeamId, shallowEqual);
const currentUserId = useSelector(getCurrentUserId, shallowEqual);
const select = useCallback((threadId?: UserThread['id']) => {
return history.push(`/${params.team}/threads${threadId ? '/' + threadId : ''}`);
}, [params.team]);
const clear = useCallback(() => history.replace(`/${params.team}/threads`), [params.team]);
const goToInChannel = useCallback((threadId?: UserThread['id'], teamName: Team['name'] = params.team) => {
return history.push(`/${teamName}/pl/${threadId ?? params.threadIdentifier}`);
}, [params.threadIdentifier, params.team]);
return {
params,
history,
currentTeamId,
currentUserId,
clear,
select,
goToInChannel,
};
}

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

@@ -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>
</>
);
}
}

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

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, forwardRef, useMemo} from 'react';
import {useSelector} from 'react-redux';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/posts';
import {UserProfile} from '@mattermost/types/users';
import {Post} from '@mattermost/types/posts';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import Constants from 'utils/constants';
import {Posts} from 'mattermost-redux/constants';
import {GlobalState} from 'types/store';
import AdvancedCreateComment from 'components/advanced_create_comment';
import BasicSeparator from 'components/widgets/separator/basic-separator';
type Props = {
focusOnMount: boolean;
onHeightChange: (height: number, maxHeight: number) => void;
teammate?: UserProfile;
threadId: string;
latestPostId: Post['id'];
isThreadView?: boolean;
};
const CreateComment = forwardRef<HTMLDivElement, Props>(({
focusOnMount,
onHeightChange,
teammate,
threadId,
latestPostId,
isThreadView,
}: Props, ref) => {
const getChannel = useMemo(makeGetChannel, []);
const rootPost = useSelector((state: GlobalState) => getPost(state, threadId));
const threadIsLimited = useSelector(getLimitedViews).threads[threadId];
const channel = useSelector((state: GlobalState) => {
if (threadIsLimited) {
return null;
}
return getChannel(state, {id: rootPost.channel_id});
});
if (!channel || threadIsLimited) {
return null;
}
const rootDeleted = (rootPost as Post).state === Posts.POST_DELETED;
const isFakeDeletedPost = rootPost.type === Constants.PostTypes.FAKE_PARENT_DELETED;
const channelType = channel.type;
const channelIsArchived = channel.delete_at !== 0;
if (channelType === Constants.DM_CHANNEL && teammate?.delete_at) {
return (
<div
className='post-create-message'
>
<FormattedMarkdownMessage
id='create_post.deactivated'
defaultMessage='You are viewing an archived channel with a **deactivated user**. New messages cannot be posted.'
/>
</div>
);
}
if (isFakeDeletedPost) {
return null;
}
if (channelIsArchived) {
return (
<div className='channel-archived-warning__container'>
<BasicSeparator/>
<div className='channel-archived-warning__content'>
<ArchiveOutlineIcon
size={20}
color={'rgba(var(--center-channel-color-rgb), 0.56)'}
/>
<FormattedMarkdownMessage
id='threadFromArchivedChannelMessage'
defaultMessage='You are viewing a thread from an **archived channel**. New messages cannot be posted.'
/>
</div>
</div>
);
}
return (
<div
className='post-create__container'
ref={ref}
>
<AdvancedCreateComment
focusOnMount={focusOnMount}
channelId={channel.id}
latestPostId={latestPostId}
onHeightChange={onHeightChange}
rootDeleted={rootDeleted}
rootId={threadId}
isThreadView={isThreadView}
/>
</div>
);
});
export default memo(CreateComment);

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

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getDirectTeammate} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {Channel} from '@mattermost/types/channels';
import {Post} from '@mattermost/types/posts';
import {FakePost} from 'types/store/rhs';
import {makePrepareReplyIdsForThreadViewer, makeGetThreadLastViewedAt} from 'selectors/views/threads';
import {GlobalState} from 'types/store';
import ThreadViewerVirtualized from './virtualized_thread_viewer';
type OwnProps = {
channel: Channel;
postIds: Array<Post['id'] | FakePost['id']>;
selected: Post | FakePost;
useRelativeTimestamp: boolean;
onCardClick: (post: Post) => void;
}
function makeMapStateToProps() {
const getRepliesListWithSeparators = makePrepareReplyIdsForThreadViewer();
const getThreadLastViewedAt = makeGetThreadLastViewedAt();
return (state: GlobalState, ownProps: OwnProps) => {
const {postIds, useRelativeTimestamp, selected, channel} = ownProps;
const collapsedThreads = isCollapsedThreadsEnabled(state);
const currentUserId = getCurrentUserId(state);
const lastViewedAt = getThreadLastViewedAt(state, selected.id);
const directTeammate = getDirectTeammate(state, channel.id);
const lastPost = getPost(state, postIds[0]);
const replyListIds = getRepliesListWithSeparators(state, {
postIds,
showDate: !useRelativeTimestamp,
lastViewedAt: collapsedThreads ? lastViewedAt : undefined,
});
return {
currentUserId,
directTeammate,
lastPost,
replyListIds,
teamId: channel.team_id,
};
};
}
export default connect(makeMapStateToProps)(ThreadViewerVirtualized);

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

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {get, getBool} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'utils/constants';
import {GlobalState} from 'types/store';
import Reply from './reply';
type OwnProps = {
id: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const previewCollapsed = get(
state,
Preferences.CATEGORY_DISPLAY_SETTINGS,
Preferences.COLLAPSE_DISPLAY,
Preferences.COLLAPSE_DISPLAY_DEFAULT,
);
const previewEnabled = getBool(
state,
Preferences.CATEGORY_DISPLAY_SETTINGS,
Preferences.LINK_PREVIEW_DISPLAY,
Preferences.LINK_PREVIEW_DISPLAY_DEFAULT === 'true',
);
return {
post: getPost(state, ownProps.id),
previewEnabled,
previewCollapsed,
};
}
export default connect(mapStateToProps)(Reply);

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

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {Post} from '@mattermost/types/posts';
import PostComponent from 'components/post';
import {Props as TimestampProps} from 'components/timestamp/timestamp';
import {Locations} from 'utils/constants';
type Props = {
a11yIndex: number;
currentUserId: string;
isLastPost: boolean;
onCardClick: (post: Post) => void;
post: Post;
previousPostId: string;
teamId: string;
timestampProps?: Partial<TimestampProps>;
id?: Post['id'];
}
function Reply({
a11yIndex,
isLastPost,
onCardClick,
post,
previousPostId,
teamId,
timestampProps,
}: Props) {
return (
<PostComponent
a11yIndex={a11yIndex}
handleCardClick={onCardClick}
isLastPost={isLastPost}
post={post}
previousPostId={previousPostId}
teamId={teamId}
timestampProps={timestampProps}
location={Locations.RHS_COMMENT}
/>
);
}
export default memo(Reply);

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

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import * as PostListUtils from 'mattermost-redux/utils/post_list';
import {Post} from '@mattermost/types/posts';
import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post';
import DateSeparator from 'components/post_view/date_separator';
import NewMessageSeparator from 'components/post_view/new_message_separator/new_message_separator';
import {Props as TimestampProps} from 'components/timestamp/timestamp';
import PostComponent from 'components/post';
import {Locations} from 'utils/constants';
import Reply from './reply';
type Props = {
a11yIndex: number;
currentUserId: string;
isRootPost: boolean;
isLastPost: boolean;
listId: string;
onCardClick: (post: Post) => void;
previousPostId: string;
teamId: string;
timestampProps?: Partial<TimestampProps>;
lastPost: Post;
};
function noop() {}
function ThreadViewerRow({
a11yIndex,
currentUserId,
isRootPost,
isLastPost,
listId,
onCardClick,
previousPostId,
teamId,
timestampProps,
}: Props) {
switch (true) {
case PostListUtils.isDateLine(listId): {
const date = PostListUtils.getDateForDateLine(listId);
return (
<DateSeparator
key={date}
date={date}
/>
);
}
case PostListUtils.isStartOfNewMessages(listId):
return <NewMessageSeparator separatorId={listId}/>;
case isRootPost:
return (
<PostComponent
postId={listId}
isLastPost={isLastPost}
handleCardClick={onCardClick}
teamId={teamId}
timestampProps={timestampProps}
location={Locations.RHS_ROOT}
/>
);
case PostListUtils.isCombinedUserActivityPost(listId): {
return (
<CombinedUserActivityPost
location={Locations.CENTER}
combinedId={listId}
previousPostId={previousPostId}
isLastPost={isLastPost}
shouldHighlight={false}
togglePostMenu={noop}
/>
);
}
default:
return (
<Reply
a11yIndex={a11yIndex}
currentUserId={currentUserId}
id={listId}
isLastPost={isLastPost}
onCardClick={onCardClick}
previousPostId={previousPostId}
teamId={teamId}
timestampProps={timestampProps}
/>
);
}
}
export default memo(ThreadViewerRow);

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

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Post} from '@mattermost/types/posts';
import {TestHelper} from 'utils/test_helper';
import {Channel} from '@mattermost/types/channels';
import {UserProfile} from '@mattermost/types/users';
import VirtualizedThreadViewer from './virtualized_thread_viewer';
describe('components/threading/VirtualizedThreadViewer', () => {
const post: Post = TestHelper.getPostMock({
channel_id: 'channel_id',
create_at: 1502715365009,
update_at: 1502715372443,
is_following: true,
reply_count: 3,
});
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(),
getPostThread: jest.fn(),
getThread: jest.fn(),
updateThreadRead: jest.fn(),
updateThreadLastOpened: jest.fn(),
fetchRHSAppsBindings: jest.fn(),
};
const directTeammate: UserProfile = TestHelper.getUserMock();
const baseProps = {
selected: post,
channel,
currentUserId: 'user_id',
currentTeamId: 'team_id',
previewCollapsed: 'false',
previewEnabled: true,
socketConnectionStatus: true,
actions,
directTeammate,
isCollapsedThreadsEnabled: false,
posts: [post],
lastPost: post,
onCardClick: () => {},
onCardClickPost: () => {},
replyListIds: [],
teamId: '',
useRelativeTimestamp: true,
isThreadView: true,
};
test('should scroll to the bottom when the current user makes a new post in the thread', () => {
const scrollToBottom = jest.fn();
const wrapper = shallow(
<VirtualizedThreadViewer {...baseProps}/>,
);
const instance = wrapper.instance() as VirtualizedThreadViewer;
instance.scrollToBottom = scrollToBottom;
expect(scrollToBottom).not.toHaveBeenCalled();
wrapper.setProps({
lastPost:
{
id: 'newpost',
root_id: post.id,
user_id: 'user_id',
},
});
expect(scrollToBottom).toHaveBeenCalled();
});
test('should not scroll to the bottom when another user makes a new post in the thread', () => {
const scrollToBottom = jest.fn();
const wrapper = shallow(
<VirtualizedThreadViewer {...baseProps}/>,
);
const instance = wrapper.instance() as VirtualizedThreadViewer;
instance.scrollToBottom = scrollToBottom;
expect(scrollToBottom).not.toHaveBeenCalled();
wrapper.setProps({
lastPost:
{
id: 'newpost',
root_id: post.id,
user_id: 'other_user_id',
},
});
expect(scrollToBottom).not.toHaveBeenCalled();
});
test('should not scroll to the bottom when there is a highlighted reply', () => {
const scrollToBottom = jest.fn();
const wrapper = shallow(
<VirtualizedThreadViewer
{...baseProps}
/>,
);
const instance = wrapper.instance() as VirtualizedThreadViewer;
instance.scrollToBottom = scrollToBottom;
wrapper.setProps({
lastPost:
{
id: 'newpost',
root_id: post.id,
user_id: 'user_id',
},
highlightedPostId: '42',
});
expect(scrollToBottom).not.toHaveBeenCalled();
});
});

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

@@ -0,0 +1,502 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent, RefObject} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import {DynamicSizeList, OnScrollArgs, OnItemsRenderedArgs} from 'dynamic-virtualized-list';
import {Channel} from '@mattermost/types/channels';
import {Post} from '@mattermost/types/posts';
import {UserProfile} from '@mattermost/types/users';
import {isDateLine, isStartOfNewMessages, isCreateComment} from 'mattermost-redux/utils/post_list';
import DelayedAction from 'utils/delayed_action';
import * as Utils from 'utils/utils';
import Constants from 'utils/constants';
import {FakePost} from 'types/store/rhs';
import {getNewMessageIndex, getPreviousPostId, getLatestPostId} from 'utils/post_utils';
import NewRepliesBanner from 'components/new_replies_banner';
import FloatingTimestamp from 'components/post_view/floating_timestamp';
import {THREADING_TIME as BASE_THREADING_TIME} from 'components/threading/common/options';
import CreateComment from './create_comment';
import Row from './thread_viewer_row';
type Props = {
channel: Channel;
currentUserId: string;
directTeammate: UserProfile | undefined;
highlightedPostId?: Post['id'];
selectedPostFocusedAt?: number;
lastPost: Post;
onCardClick: (post: Post) => void;
replyListIds: string[];
selected: Post | FakePost;
teamId: string;
useRelativeTimestamp: boolean;
isThreadView: boolean;
}
type State = {
createCommentHeight: number;
isMobile: boolean;
isScrolling: boolean;
topRhsPostId?: string;
userScrolled: boolean;
userScrolledToBottom: boolean;
lastViewedBottom: number;
visibleStartIndex?: number;
visibleStopIndex?: number;
overscanStartIndex?: number;
overscanStopIndex?: number;
}
const virtListStyles = {
position: 'absolute',
top: '0',
height: '100%',
willChange: 'auto',
};
const innerStyles = {
paddingTop: '28px',
};
const CREATE_COMMENT_BUTTON_HEIGHT = 81;
const THREADING_TIME: typeof BASE_THREADING_TIME = {
...BASE_THREADING_TIME,
units: [
'now',
'minute',
'hour',
'day',
'week',
'month',
'year',
],
};
const OFFSET_TO_SHOW_TOAST = -50;
const OVERSCAN_COUNT_FORWARD = 80;
const OVERSCAN_COUNT_BACKWARD = 80;
class ThreadViewerVirtualized extends PureComponent<Props, State> {
private mounted = false;
private scrollStopAction: DelayedAction;
private scrollShortCircuit = 0;
postCreateContainerRef: RefObject<HTMLDivElement>;
listRef: RefObject<DynamicSizeList>;
innerRef: RefObject<HTMLDivElement>;
initRangeToRender: number[];
constructor(props: Props) {
super(props);
const postIndex = this.getInitialPostIndex();
const isMobile = Utils.isMobile();
this.initRangeToRender = [
Math.max(postIndex - 30, 0),
Math.max(postIndex + 30, Math.min(props.replyListIds.length - 1, 50)),
];
this.listRef = React.createRef();
this.innerRef = React.createRef();
this.postCreateContainerRef = React.createRef();
this.scrollStopAction = new DelayedAction(this.handleScrollStop);
this.state = {
createCommentHeight: 0,
isMobile,
isScrolling: false,
userScrolled: false,
userScrolledToBottom: false,
topRhsPostId: undefined,
lastViewedBottom: Date.now(),
visibleStartIndex: undefined,
visibleStopIndex: undefined,
overscanStartIndex: undefined,
overscanStopIndex: undefined,
};
}
componentDidMount() {
this.mounted = true;
window.addEventListener('resize', this.handleWindowResize);
}
componentWillUnmount() {
this.mounted = false;
window.removeEventListener('resize', this.handleWindowResize);
}
componentDidUpdate(prevProps: Props) {
const {highlightedPostId, selectedPostFocusedAt, lastPost, currentUserId, directTeammate} = this.props;
// In case the user is being deactivated, we need to trigger a re-render
if (directTeammate?.delete_at !== prevProps.directTeammate?.delete_at) {
this.scrollToBottom();
}
if ((highlightedPostId && prevProps.highlightedPostId !== highlightedPostId) ||
prevProps.selectedPostFocusedAt !== selectedPostFocusedAt) {
this.scrollToHighlightedPost();
} else if (
prevProps.lastPost.id !== lastPost.id &&
(lastPost.user_id === currentUserId || this.state.userScrolledToBottom)
) {
this.scrollToBottom();
}
}
canLoadMorePosts() {
return Promise.resolve();
}
handleWindowResize = () => {
const isMobile = Utils.isMobile();
if (isMobile !== this.state.isMobile) {
this.setState({
isMobile,
});
}
}
initScrollToIndex = (): {index: number; position: string; offset?: number} => {
const {highlightedPostId, replyListIds} = this.props;
if (highlightedPostId) {
const index = replyListIds.indexOf(highlightedPostId);
return {
index,
position: 'center',
};
}
const newMessagesSeparatorIndex = getNewMessageIndex(replyListIds);
if (newMessagesSeparatorIndex > 0) {
return {
index: newMessagesSeparatorIndex,
position: 'start',
offset: OFFSET_TO_SHOW_TOAST,
};
}
return {
index: 0,
position: 'end',
};
}
handleScroll = ({scrollHeight, scrollUpdateWasRequested, scrollOffset, clientHeight}: OnScrollArgs) => {
if (scrollHeight <= 0) {
return;
}
const {createCommentHeight} = this.state;
const updatedState: Partial<State> = {};
const userScrolledToBottom = scrollHeight - scrollOffset - createCommentHeight <= clientHeight;
if (!scrollUpdateWasRequested) {
this.scrollShortCircuit = 0;
updatedState.userScrolled = true;
updatedState.userScrolledToBottom = userScrolledToBottom;
if (this.state.isMobile) {
if (!this.state.isScrolling) {
updatedState.isScrolling = true;
}
if (this.scrollStopAction) {
this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY);
}
}
}
if (userScrolledToBottom) {
updatedState.lastViewedBottom = Date.now();
}
this.setState(updatedState as State);
}
updateFloatingTimestamp = (visibleTopItem: number) => {
if (!this.props.replyListIds) {
return;
}
this.setState({
topRhsPostId: getLatestPostId(this.props.replyListIds.slice(visibleTopItem)),
});
}
onItemsRendered = ({
visibleStartIndex,
visibleStopIndex,
overscanStartIndex,
overscanStopIndex,
}: OnItemsRenderedArgs) => {
if (this.state.isMobile) {
this.updateFloatingTimestamp(visibleStartIndex);
}
this.setState({
visibleStartIndex,
visibleStopIndex,
overscanStartIndex,
overscanStopIndex,
});
}
getInitialPostIndex = (): number => {
let postIndex = 0;
if (this.props.highlightedPostId) {
postIndex = this.props.replyListIds.findIndex((postId) => postId === this.props.highlightedPostId);
} else {
postIndex = getNewMessageIndex(this.props.replyListIds);
}
return postIndex === -1 ? 0 : postIndex;
}
handleScrollToFailed = (index: number) => {
if (index < 0 || index >= this.props.replyListIds.length) {
return;
}
const {overscanStopIndex, overscanStartIndex} = this.state;
if (overscanStartIndex != null && index < overscanStartIndex) {
this.scrollToItemCorrection(index, Math.max(overscanStartIndex + 1, 0));
}
if (overscanStopIndex != null && index > overscanStopIndex) {
this.scrollToItemCorrection(index, Math.min(overscanStopIndex - 1, this.props.replyListIds.length - 1));
}
}
scrollToItemCorrection = (index: number, nearIndex: number) => {
// stop after 10 times so we won't end up in an infinite loop
if (this.scrollShortCircuit > 10) {
return;
}
this.scrollShortCircuit++;
// this should not trigger a failure to scroll
// it should always be an index in between rendered items (overscanStartIndex < nearIndex < overscanStopIndex)
this.scrollToItem(nearIndex, 'start');
window.requestAnimationFrame(() => {
this.scrollToItem(index, 'start');
});
}
scrollToItem = (index: number, position: string, offset?: number) => {
if (this.listRef.current) {
this.listRef.current.scrollToItem(index, position, offset);
}
}
scrollToBottom = () => {
this.scrollToItem(0, 'end');
}
handleToastDismiss = () => {
this.setState({lastViewedBottom: Date.now()});
}
handleToastClick = () => {
const index = getNewMessageIndex(this.props.replyListIds);
if (index >= 0) {
this.scrollToItem(index, 'start', OFFSET_TO_SHOW_TOAST);
} else {
this.scrollToBottom();
}
}
scrollToHighlightedPost = () => {
const {highlightedPostId, replyListIds} = this.props;
if (highlightedPostId) {
this.setState({userScrolledToBottom: false}, () => {
this.scrollToItem(replyListIds.indexOf(highlightedPostId), 'center');
});
}
}
handleScrollStop = () => {
if (this.mounted) {
this.setState({isScrolling: false});
}
}
handleCreateCommentHeightChange = (height: number, maxHeight: number) => {
let createCommentHeight = height > maxHeight ? maxHeight : height;
createCommentHeight += CREATE_COMMENT_BUTTON_HEIGHT;
if (createCommentHeight !== this.state.createCommentHeight) {
this.setState({createCommentHeight});
if (this.state.userScrolledToBottom) {
this.scrollToBottom();
}
}
}
renderRow = ({data, itemId, style}: {data: any; itemId: any; style: any}) => {
const index = data.indexOf(itemId);
let className = '';
let a11yIndex = 0;
const basePaddingClass = 'post-row__padding';
const previousItemId = (index !== -1 && index < data.length - 1) ? data[index + 1] : '';
const nextItemId = (index > 0 && index < data.length) ? data[index - 1] : '';
if (isDateLine(nextItemId) || isStartOfNewMessages(nextItemId)) {
className += basePaddingClass + ' bottom';
}
if (isDateLine(previousItemId) || isStartOfNewMessages(previousItemId)) {
if (className.includes(basePaddingClass)) {
className += ' top';
} else {
className += basePaddingClass + ' top';
}
}
const isLastPost = itemId === this.props.lastPost.id;
const isRootPost = itemId === this.props.selected.id;
if (!isDateLine(itemId) && !isStartOfNewMessages(itemId) && !isCreateComment(itemId) && !isRootPost) {
a11yIndex++;
}
if (isCreateComment(itemId)) {
return (
<CreateComment
focusOnMount={!this.props.isThreadView && (this.state.userScrolledToBottom || (!this.state.userScrolled && this.getInitialPostIndex() === 0))}
isThreadView={this.props.isThreadView}
latestPostId={this.props.lastPost.id}
onHeightChange={this.handleCreateCommentHeightChange}
ref={this.postCreateContainerRef}
teammate={this.props.directTeammate}
threadId={this.props.selected.id}
/>
);
}
return (
<div
style={style}
className={className}
>
<Row
a11yIndex={a11yIndex}
currentUserId={this.props.currentUserId}
isRootPost={isRootPost}
isLastPost={isLastPost}
listId={itemId}
onCardClick={this.props.onCardClick}
previousPostId={getPreviousPostId(data, index)}
teamId={this.props.teamId}
timestampProps={this.props.useRelativeTimestamp ? THREADING_TIME : undefined}
lastPost={this.props.lastPost}
/>
</div>
);
};
getInnerStyles = (): React.CSSProperties|undefined => {
if (!this.props.useRelativeTimestamp) {
return innerStyles;
}
return undefined;
}
isNewMessagesVisible = (): boolean => {
const {visibleStopIndex} = this.state;
const newMessagesSeparatorIndex = getNewMessageIndex(this.props.replyListIds);
if (visibleStopIndex != null) {
return visibleStopIndex < newMessagesSeparatorIndex;
}
return false;
}
renderToast = (width: number) => {
const {visibleStopIndex, lastViewedBottom, userScrolledToBottom} = this.state;
const canShow =
visibleStopIndex !== 0 &&
!this.isNewMessagesVisible() &&
!userScrolledToBottom;
return (
<NewRepliesBanner
threadId={this.props.selected.id}
lastViewedBottom={lastViewedBottom}
canShow={canShow}
onDismiss={this.handleToastDismiss}
width={width}
onClick={this.handleToastClick}
/>
);
}
render() {
const {isMobile, topRhsPostId} = this.state;
return (
<>
{isMobile && topRhsPostId && !this.props.useRelativeTimestamp && (
<FloatingTimestamp
isRhsPost={true}
isScrolling={this.state.isScrolling}
postId={topRhsPostId}
/>
)}
<div
role='application'
aria-label={Utils.localizeMessage('accessibility.sections.rhsContent', 'message details complimentary region')}
className='post-right__content a11y__region'
style={{height: '100%'}}
data-a11y-sort-order='3'
data-a11y-focus-child={true}
data-a11y-order-reversed={true}
>
<AutoSizer>
{({width, height}) => (
<>
<DynamicSizeList
canLoadMorePosts={this.canLoadMorePosts}
height={height}
initRangeToRender={this.initRangeToRender}
initScrollToIndex={this.initScrollToIndex}
innerListStyle={this.getInnerStyles()}
innerRef={this.innerRef}
itemData={this.props.replyListIds}
scrollToFailed={this.handleScrollToFailed}
onItemsRendered={this.onItemsRendered}
onScroll={this.handleScroll}
overscanCountBackward={OVERSCAN_COUNT_BACKWARD}
overscanCountForward={OVERSCAN_COUNT_FORWARD}
ref={this.listRef}
style={virtListStyles}
width={width}
className={'post-list__dynamic--RHS'}
>
{this.renderRow}
</DynamicSizeList>
{this.renderToast(width)}
</>
)}
</AutoSizer>
</div>
</>
);
}
}
export default ThreadViewerVirtualized;