+ menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
-
-
-
- {unread && }
-
+ const names = accounts.map(a => (
+
+
+
+
+
+ )).reduce((prev, cur) => [prev, ', ', cur]);
-
- {names} }} />
-
+ const handlers = {
+ reply: handleReply,
+ open: handleClick,
+ moveUp: handleHotkeyMoveUp,
+ moveDown: handleHotkeyMoveDown,
+ toggleHidden: handleShowMore,
+ };
+
+ return (
+
+
+
+
+
+
+
+ {unread && }
-
+ {names} }} />
+
+
+
+
+
+ {lastStatus.get('media_attachments').size > 0 && (
+
+ )}
- {lastStatus.get('media_attachments').size > 0 && (
-
+
+
+
-
- );
- }
+
+
+ );
+};
-}
-
-export default withRouter(injectIntl(Conversation));
+Conversation.propTypes = {
+ conversation: ImmutablePropTypes.map.isRequired,
+ scrollKey: PropTypes.string,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+};
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
index 8c12ea9e5f..c9fc098a52 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx
@@ -1,77 +1,72 @@
import PropTypes from 'prop-types';
+import { useRef, useMemo, useCallback } from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { useSelector, useDispatch } from 'react-redux';
import { debounce } from 'lodash';
-import ScrollableList from '../../../components/scrollable_list';
-import ConversationContainer from '../containers/conversation_container';
+import { expandConversations } from 'mastodon/actions/conversations';
+import ScrollableList from 'mastodon/components/scrollable_list';
-export default class ConversationsList extends ImmutablePureComponent {
+import { Conversation } from './conversation';
- static propTypes = {
- conversations: ImmutablePropTypes.list.isRequired,
- scrollKey: PropTypes.string.isRequired,
- hasMore: PropTypes.bool,
- isLoading: PropTypes.bool,
- onLoadMore: PropTypes.func,
- };
+const focusChild = (node, index, alignTop) => {
+ const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
- getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
-
- handleMoveUp = id => {
- const elementIndex = this.getCurrentIndex(id) - 1;
- this._selectChild(elementIndex, true);
- };
-
- handleMoveDown = id => {
- const elementIndex = this.getCurrentIndex(id) + 1;
- this._selectChild(elementIndex, false);
- };
-
- _selectChild (index, align_top) {
- const container = this.node.node;
- const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
-
- if (element) {
- if (align_top && container.scrollTop > element.offsetTop) {
- element.scrollIntoView(true);
- } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
- element.scrollIntoView(false);
- }
- element.focus();
+ if (element) {
+ if (alignTop && node.scrollTop > element.offsetTop) {
+ element.scrollIntoView(true);
+ } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
+ element.scrollIntoView(false);
}
+
+ element.focus();
}
+};
- setRef = c => {
- this.node = c;
- };
+export const ConversationsList = ({ scrollKey, ...other }) => {
+ const listRef = useRef();
+ const conversations = useSelector(state => state.getIn(['conversations', 'items']));
+ const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
+ const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
+ const dispatch = useDispatch();
+ const lastStatusId = conversations.last()?.get('last_status');
- handleLoadOlder = debounce(() => {
- const last = this.props.conversations.last();
+ const handleMoveUp = useCallback(id => {
+ const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
+ focusChild(listRef.current.node, elementIndex, true);
+ }, [listRef, conversations]);
- if (last && last.get('last_status')) {
- this.props.onLoadMore(last.get('last_status'));
+ const handleMoveDown = useCallback(id => {
+ const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
+ focusChild(listRef.current.node, elementIndex, false);
+ }, [listRef, conversations]);
+
+ const debouncedLoadMore = useMemo(() => debounce(id => {
+ dispatch(expandConversations({ maxId: id }));
+ }, 300, { leading: true }), [dispatch]);
+
+ const handleLoadMore = useCallback(() => {
+ if (lastStatusId) {
+ debouncedLoadMore(lastStatusId);
}
- }, 300, { leading: true });
+ }, [debouncedLoadMore, lastStatusId]);
- render () {
- const { conversations, isLoading, onLoadMore, ...other } = this.props;
+ return (
+
+ {conversations.map(item => (
+
+ ))}
+
+ );
+};
- return (
-
- {conversations.map(item => (
-
- ))}
-
- );
- }
-
-}
+ConversationsList.propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+};
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
deleted file mode 100644
index 456fc7d7cc..0000000000
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import { defineMessages, injectIntl } from 'react-intl';
-
-import { connect } from 'react-redux';
-
-import { replyCompose } from 'mastodon/actions/compose';
-import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
-import { openModal } from 'mastodon/actions/modal';
-import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
-import { makeGetStatus } from 'mastodon/selectors';
-
-import Conversation from '../components/conversation';
-
-const messages = defineMessages({
- replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
- replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
-});
-
-const mapStateToProps = () => {
- const getStatus = makeGetStatus();
-
- return (state, { conversationId }) => {
- const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
- const lastStatusId = conversation.get('last_status', null);
-
- return {
- accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
- unread: conversation.get('unread'),
- lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
- };
- };
-};
-
-const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
-
- markRead () {
- dispatch(markConversationRead(conversationId));
- },
-
- reply (status, router) {
- dispatch((_, getState) => {
- let state = getState();
-
- if (state.getIn(['compose', 'text']).trim().length !== 0) {
- dispatch(openModal({
- modalType: 'CONFIRM',
- modalProps: {
- message: intl.formatMessage(messages.replyMessage),
- confirm: intl.formatMessage(messages.replyConfirm),
- onConfirm: () => dispatch(replyCompose(status, router)),
- },
- }));
- } else {
- dispatch(replyCompose(status, router));
- }
- });
- },
-
- delete () {
- dispatch(deleteConversation(conversationId));
- },
-
- onMute (status) {
- if (status.get('muted')) {
- dispatch(unmuteStatus(status.get('id')));
- } else {
- dispatch(muteStatus(status.get('id')));
- }
- },
-
- onToggleHidden (status) {
- if (status.get('hidden')) {
- dispatch(revealStatus(status.get('id')));
- } else {
- dispatch(hideStatus(status.get('id')));
- }
- },
-
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
deleted file mode 100644
index 1dcd3ec1bd..0000000000
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { connect } from 'react-redux';
-
-import { expandConversations } from '../../../actions/conversations';
-import ConversationsList from '../components/conversations_list';
-
-const mapStateToProps = state => ({
- conversations: state.getIn(['conversations', 'items']),
- isLoading: state.getIn(['conversations', 'isLoading'], true),
- hasMore: state.getIn(['conversations', 'hasMore'], false),
-});
-
-const mapDispatchToProps = dispatch => ({
- onLoadMore: maxId => dispatch(expandConversations({ maxId })),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
diff --git a/app/javascript/mastodon/features/direct_timeline/index.jsx b/app/javascript/mastodon/features/direct_timeline/index.jsx
index af29d7a5b8..7aee83ec10 100644
--- a/app/javascript/mastodon/features/direct_timeline/index.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/index.jsx
@@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
+import { useRef, useCallback, useEffect } from 'react';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
-import { connect } from 'react-redux';
+import { useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
@@ -14,103 +14,79 @@ import { connectDirectStream } from 'mastodon/actions/streaming';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
-import ConversationsListContainer from './containers/conversations_list_container';
+import { ConversationsList } from './components/conversations_list';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' },
});
-class DirectTimeline extends PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- columnId: PropTypes.string,
- intl: PropTypes.object.isRequired,
- hasUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
+const DirectTimeline = ({ columnId, multiColumn }) => {
+ const columnRef = useRef();
+ const intl = useIntl();
+ const dispatch = useDispatch();
+ const pinned = !!columnId;
+ const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
- };
+ }, [dispatch, columnId]);
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
+ const handleMove = useCallback((dir) => {
dispatch(moveColumn(columnId, dir));
- };
+ }, [dispatch, columnId]);
- handleHeaderClick = () => {
- this.column.scrollTop();
- };
-
- componentDidMount () {
- const { dispatch } = this.props;
+ const handleHeaderClick = useCallback(() => {
+ columnRef.current.scrollTop();
+ }, [columnRef]);
+ useEffect(() => {
dispatch(mountConversations());
dispatch(expandConversations());
- this.disconnect = dispatch(connectDirectStream());
- }
- componentWillUnmount () {
- this.props.dispatch(unmountConversations());
+ const disconnect = dispatch(connectDirectStream());
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
+ return () => {
+ dispatch(unmountConversations());
+ disconnect();
+ };
+ }, [dispatch]);
- setRef = c => {
- this.column = c;
- };
+ return (
+
+
- handleLoadMore = maxId => {
- this.props.dispatch(expandConversations({ maxId }));
- };
+ }
+ bindToDocument={!multiColumn}
+ prepend={}
+ alwaysPrepend
+ />
- render () {
- const { intl, hasUnread, columnId, multiColumn } = this.props;
- const pinned = !!columnId;
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ );
+};
- return (
-
-
+DirectTimeline.propTypes = {
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+};
-
-
- );
- }
-
-}
-
-export default connect()(injectIntl(DirectTimeline));
+export default DirectTimeline;