diff --git a/app/soapbox/features/hashtag_timeline/index.js b/app/soapbox/features/hashtag_timeline/index.js
deleted file mode 100644
index 888573579..000000000
--- a/app/soapbox/features/hashtag_timeline/index.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import isEqual from 'lodash/isEqual';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { connectHashtagStream } from '../../actions/streaming';
-import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
-import ColumnHeader from '../../components/column_header';
-import { Column } from '../../components/ui';
-import Timeline from '../ui/components/timeline';
-
-const mapStateToProps = (state, props) => ({
- hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
-});
-
-export default @connect(mapStateToProps)
-class HashtagTimeline extends React.PureComponent {
-
- disconnects = [];
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- hasUnread: PropTypes.bool,
- };
-
- title = () => {
- const title = [`#${this.props.params.id}`];
-
- // TODO: wtf is all this?
- // It exists in Mastodon's codebase, but undocumented
- if (this.additionalFor('any')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('all')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('none')) {
- title.push(' ', );
- }
-
- return title;
- }
-
- // TODO: wtf is this?
- // It exists in Mastodon's codebase, but undocumented
- additionalFor = (mode) => {
- const { tags } = this.props.params;
-
- if (tags && (tags[mode] || []).length > 0) {
- return tags[mode].map(tag => tag.value).join('/');
- } else {
- return '';
- }
- }
-
- _subscribe(dispatch, id, tags = {}) {
- const any = (tags.any || []).map(tag => tag.value);
- const all = (tags.all || []).map(tag => tag.value);
- const none = (tags.none || []).map(tag => tag.value);
-
- [id, ...any].map(tag => {
- this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
- const tags = status.tags.map(tag => tag.name);
-
- return all.filter(tag => tags.includes(tag)).length === all.length &&
- none.filter(tag => tags.includes(tag)).length === 0;
- })));
- });
- }
-
- _unsubscribe() {
- this.disconnects.map(disconnect => disconnect());
- this.disconnects = [];
- }
-
- componentDidMount() {
- const { dispatch } = this.props;
- const { id, tags } = this.props.params;
-
- this._subscribe(dispatch, id, tags);
- dispatch(expandHashtagTimeline(id, { tags }));
- }
-
- componentDidUpdate(prevProps) {
- const { dispatch } = this.props;
- const { id, tags } = this.props.params;
- const { id: prevId, tags: prevTags } = prevProps.params;
-
- if (id !== prevId || !isEqual(tags, prevTags)) {
- this._unsubscribe();
- this._subscribe(dispatch, id, tags);
- dispatch(clearTimeline(`hashtag:${id}`));
- dispatch(expandHashtagTimeline(id, { tags }));
- }
- }
-
- componentWillUnmount() {
- this._unsubscribe();
- }
-
- handleLoadMore = maxId => {
- const { id, tags } = this.props.params;
- this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
- }
-
- render() {
- const { hasUnread } = this.props;
- const { id } = this.props.params;
-
- return (
-
-
- }
- divideType='space'
- />
-
- );
- }
-
-}
diff --git a/app/soapbox/features/hashtag_timeline/index.tsx b/app/soapbox/features/hashtag_timeline/index.tsx
new file mode 100644
index 000000000..a8b97cbab
--- /dev/null
+++ b/app/soapbox/features/hashtag_timeline/index.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef } from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import { connectHashtagStream } from 'soapbox/actions/streaming';
+import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
+import ColumnHeader from 'soapbox/components/column_header';
+import { Column } from 'soapbox/components/ui';
+import Timeline from 'soapbox/features/ui/components/timeline';
+import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
+
+import type { Tag as TagEntity } from 'soapbox/types/entities';
+
+type Mode = 'any' | 'all' | 'none';
+
+type Tag = { value: string };
+type Tags = { [k in Mode]: Tag[] };
+
+interface IHashtagTimeline {
+ params?: {
+ id?: string,
+ tags?: Tags,
+ },
+}
+
+export const HashtagTimeline: React.FC = ({ params }) => {
+ const id = params?.id || '';
+ const tags = params?.tags || { any: [], all: [], none: [] };
+
+ const dispatch = useAppDispatch();
+ const hasUnread = useAppSelector(state => (state.timelines.getIn([`hashtag:${id}`, 'unread']) as number) > 0);
+ const disconnects = useRef<(() => void)[]>([]);
+
+ // Mastodon supports displaying results from multiple hashtags.
+ // https://github.com/mastodon/mastodon/issues/6359
+ const title = () => {
+ const title: React.ReactNode[] = [`#${id}`];
+
+ if (additionalFor('any')) {
+ title.push(' ', );
+ }
+
+ if (additionalFor('all')) {
+ title.push(' ', );
+ }
+
+ if (additionalFor('none')) {
+ title.push(' ', );
+ }
+
+ return title;
+ };
+
+ const additionalFor = (mode: Mode) => {
+ if (tags && (tags[mode] || []).length > 0) {
+ return tags[mode].map(tag => tag.value).join('/');
+ } else {
+ return '';
+ }
+ };
+
+ const subscribe = () => {
+ const any = tags.any.map(tag => tag.value);
+ const all = tags.all.map(tag => tag.value);
+ const none = tags.none.map(tag => tag.value);
+
+ [id, ...any].map(tag => {
+ disconnects.current.push(dispatch(connectHashtagStream(id, tag, status => {
+ const tags = status.tags.map((tag: TagEntity) => tag.name);
+
+ return all.filter(tag => tags.includes(tag)).length === all.length &&
+ none.filter(tag => tags.includes(tag)).length === 0;
+ })));
+ });
+ };
+
+ const unsubscribe = () => {
+ disconnects.current.map(disconnect => disconnect());
+ disconnects.current = [];
+ };
+
+ const handleLoadMore = (maxId: string) => {
+ dispatch(expandHashtagTimeline(id, { maxId, tags }));
+ };
+
+ useEffect(() => {
+ subscribe();
+ dispatch(expandHashtagTimeline(id, { tags }));
+
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ useEffect(() => {
+ unsubscribe();
+ subscribe();
+ dispatch(clearTimeline(`hashtag:${id}`));
+ dispatch(expandHashtagTimeline(id, { tags }));
+ }, [id, tags]);
+
+ return (
+
+
+ }
+ divideType='space'
+ />
+
+ );
+};
+
+export default HashtagTimeline;
\ No newline at end of file