[ADD] Follow hashtags (#243)
This commit is contained in:
parent
1d4a7c0d04
commit
cd5cf427a2
15 changed files with 482 additions and 137 deletions
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
119
app/soapbox/actions/tags.ts
Normal file
119
app/soapbox/actions/tags.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||||
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
|
import api, { getNextLink } from '../api';
|
||||||
|
|
||||||
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
|
|
||||||
|
const TAG_FETCH_REQUEST = 'TAG_FETCH_REQUEST';
|
||||||
|
const TAG_FETCH_SUCCESS = 'TAG_FETCH_SUCCESS';
|
||||||
|
const TAG_FETCH_FAIL = 'TAG_FETCH_FAIL';
|
||||||
|
|
||||||
|
const TAG_FOLLOW_REQUEST = 'TAG_FOLLOW_REQUEST';
|
||||||
|
const TAG_FOLLOW_SUCCESS = 'TAG_FOLLOW_SUCCESS';
|
||||||
|
const TAG_FOLLOW_FAIL = 'TAG_FOLLOW_FAIL';
|
||||||
|
|
||||||
|
const TAG_UNFOLLOW_REQUEST = 'TAG_UNFOLLOW_REQUEST';
|
||||||
|
const TAG_UNFOLLOW_SUCCESS = 'TAG_UNFOLLOW_SUCCESS';
|
||||||
|
const TAG_UNFOLLOW_FAIL = 'TAG_UNFOLLOW_FAIL';
|
||||||
|
|
||||||
|
const fetchTags = () => async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
if (!isLoggedIn(getState)) return;
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const instance = state.instance;
|
||||||
|
const features = getFeatures(instance);
|
||||||
|
|
||||||
|
if (!features.followTags) return;
|
||||||
|
|
||||||
|
dispatch({ type: TAG_FETCH_REQUEST, skipLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let next = null;
|
||||||
|
let tags = [];
|
||||||
|
do {
|
||||||
|
const response = await api(getState).get(next || '/api/v1/followed_tags');
|
||||||
|
tags = [...tags, ...response.data];
|
||||||
|
next = getNextLink(response);
|
||||||
|
} while (next);
|
||||||
|
dispatch({
|
||||||
|
type: TAG_FETCH_SUCCESS,
|
||||||
|
tags,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({
|
||||||
|
type: TAG_FETCH_FAIL,
|
||||||
|
err,
|
||||||
|
skipLoading: true,
|
||||||
|
skipAlert: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const followTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
if (!isLoggedIn(getState)) return;
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const features = getFeatures(state.instance);
|
||||||
|
|
||||||
|
if (!features.followTags) return;
|
||||||
|
|
||||||
|
dispatch({ type: TAG_FOLLOW_REQUEST });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api(getState).post(`/api/v1/tags/${tagId}/follow`);
|
||||||
|
dispatch({
|
||||||
|
type: TAG_FOLLOW_SUCCESS,
|
||||||
|
tag: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({
|
||||||
|
type: TAG_FOLLOW_FAIL,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unfollowTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
if (!isLoggedIn(getState)) return;
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const features = getFeatures(state.instance);
|
||||||
|
|
||||||
|
if (!features.followTags) return;
|
||||||
|
|
||||||
|
dispatch({ type: TAG_UNFOLLOW_REQUEST });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api(getState).post(`/api/v1/tags/${tagId}/unfollow`);
|
||||||
|
dispatch({
|
||||||
|
type: TAG_UNFOLLOW_SUCCESS,
|
||||||
|
tag: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({
|
||||||
|
type: TAG_UNFOLLOW_FAIL,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchTags,
|
||||||
|
TAG_FETCH_FAIL,
|
||||||
|
TAG_FETCH_REQUEST,
|
||||||
|
TAG_FETCH_SUCCESS,
|
||||||
|
|
||||||
|
followTag,
|
||||||
|
TAG_FOLLOW_FAIL,
|
||||||
|
TAG_FOLLOW_REQUEST,
|
||||||
|
TAG_FOLLOW_SUCCESS,
|
||||||
|
|
||||||
|
unfollowTag,
|
||||||
|
TAG_UNFOLLOW_FAIL,
|
||||||
|
TAG_UNFOLLOW_REQUEST,
|
||||||
|
TAG_UNFOLLOW_SUCCESS,
|
||||||
|
};
|
|
@ -22,6 +22,7 @@ const messages = defineMessages({
|
||||||
settings: { id: 'tabs_bar.settings', defaultMessage: 'Settings' },
|
settings: { id: 'tabs_bar.settings', defaultMessage: 'Settings' },
|
||||||
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||||
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
|
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
|
||||||
|
tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Desktop sidebar with links to different views in the app. */
|
/** Desktop sidebar with links to different views in the app. */
|
||||||
|
@ -79,6 +80,14 @@ const SidebarNavigation = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (features.followTags) {
|
||||||
|
menu.push({
|
||||||
|
to: '/followed_hashtags',
|
||||||
|
text: intl.formatMessage(messages.tags),
|
||||||
|
icon: require('@tabler/icons/hash.svg'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (features.profileDirectory) {
|
if (features.profileDirectory) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/directory',
|
to: '/directory',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const messages = defineMessages({
|
||||||
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||||
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
|
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
|
||||||
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
|
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
|
||||||
|
tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ISidebarLink {
|
interface ISidebarLink {
|
||||||
|
@ -243,6 +244,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{features.followTags && (
|
||||||
|
<SidebarLink
|
||||||
|
to='/followed_hashtags'
|
||||||
|
icon={require('@tabler/icons/hash.svg')}
|
||||||
|
text={intl.formatMessage(messages.tags)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{features.profileDirectory && (
|
{features.profileDirectory && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to='/directory'
|
to='/directory'
|
||||||
|
|
|
@ -85,7 +85,7 @@ interface ICardTitle {
|
||||||
|
|
||||||
/** A card's title. */
|
/** A card's title. */
|
||||||
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
||||||
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
|
<Text className='grow' size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
|
||||||
);
|
);
|
||||||
|
|
||||||
/** A card's body. */
|
/** A card's body. */
|
||||||
|
|
|
@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
/** URL to the svg icon. */
|
/** URL to the svg icon. */
|
||||||
src: string,
|
src: string,
|
||||||
/** Text to display next ot the button. */
|
/** Text to display next ot the button. */
|
||||||
text?: string,
|
text?: React.ReactNode,
|
||||||
/** Don't render a background behind the icon. */
|
/** Don't render a background behind the icon. */
|
||||||
transparent?: boolean,
|
transparent?: boolean,
|
||||||
/** Predefined styles to display for the button. */
|
/** Predefined styles to display for the button. */
|
||||||
|
|
102
app/soapbox/features/followed_tags/index.tsx
Normal file
102
app/soapbox/features/followed_tags/index.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchTags, followTag, unfollowTag } from 'soapbox/actions/tags';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Button, Column, IconButton, Spinner, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.tags', defaultMessage: 'Followed hashtags' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IFollowButton {
|
||||||
|
id: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FollowButton: React.FC<IFollowButton> = ({ id }) => {
|
||||||
|
const { isFollow } = useAppSelector(state => ({ isFollow: state.tags.list.find((t) => t.name === id) }));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onClick = React.useCallback(() => {
|
||||||
|
const action = isFollow ? unfollowTag : followTag;
|
||||||
|
dispatch(action(id));
|
||||||
|
}, [isFollow, id]);
|
||||||
|
|
||||||
|
const text = React.useMemo(() => {
|
||||||
|
return isFollow ? (
|
||||||
|
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow' />
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow' />
|
||||||
|
);
|
||||||
|
}, [isFollow]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton className='mx-3' style={{ background: 'transparent' }} onClick={onClick} src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} text={text} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FollowedHashtags = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [tags, setTags] = React.useState(null);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { tags: serverTags, loading } = useAppSelector((state) => ({ tags: state.tags.list, loading: state.tags.loading }));
|
||||||
|
|
||||||
|
// we want to keep our own list to allow user to refollow instantly if unfollow was a mistake
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (loading || tags) return;
|
||||||
|
setTags(serverTags);
|
||||||
|
}, [serverTags, tags, loading]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch(fetchTags());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
|
{
|
||||||
|
!tags ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
className='flex flex-col gap-2'
|
||||||
|
scrollKey='followed_hashtags'
|
||||||
|
emptyMessage={<FormattedMessage id='column.tags.empty' defaultMessage="You don't follow any hashtag yet." />}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
tags?.map((tag) => (
|
||||||
|
<div className='p-1 bg-gray-100 dark:bg-slate-900 rounded flex justify-between items-center'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<Icon src={require('@tabler/icons/hash.svg')} />
|
||||||
|
<Text tag='span' weight='semibold'>
|
||||||
|
{ tag.name }
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<FollowButton id={tag.name} />
|
||||||
|
<span className='dark:text-slate-800 text-gray-300' >|</span>
|
||||||
|
<Button theme='link' to={`/tag/${tag.name}`}>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<FormattedMessage id='column.tags.see' defaultMessage='See' />
|
||||||
|
|
||||||
|
<Icon src={require('@tabler/icons/arrow-right.svg')} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ScrollableList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FollowedHashtags;
|
|
@ -1,130 +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(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.additionalFor('all')) {
|
|
||||||
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.additionalFor('none')) {
|
|
||||||
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Column label={`#${id}`} transparent withHeader={false}>
|
|
||||||
<div className='px-4 pt-4 sm:p-0'>
|
|
||||||
<ColumnHeader active={hasUnread} title={this.title()} />
|
|
||||||
</div>
|
|
||||||
<Timeline
|
|
||||||
scrollKey='hashtag_timeline'
|
|
||||||
timelineId={`hashtag:${id}`}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
|
||||||
divideType='space'
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
156
app/soapbox/features/hashtag_timeline/index.tsx
Normal file
156
app/soapbox/features/hashtag_timeline/index.tsx
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { unfollowTag, followTag } from 'soapbox/actions/tags';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { connectHashtagStream } from '../../actions/streaming';
|
||||||
|
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
|
||||||
|
import ColumnHeader from '../../components/column_header';
|
||||||
|
import { Button, Column, Spinner } from '../../components/ui';
|
||||||
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
|
interface IFollowButton {
|
||||||
|
id: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FollowButton: React.FC<IFollowButton> = ({ id }) => {
|
||||||
|
const { isFollow, loading } = useAppSelector(state => ({ loading: state.tags.loading, isFollow: state.tags.list.find((t) => t.name === id) }));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onClick = React.useCallback(() => {
|
||||||
|
const action = isFollow ? unfollowTag : followTag;
|
||||||
|
dispatch(action(id));
|
||||||
|
}, [isFollow, id]);
|
||||||
|
|
||||||
|
const text = React.useMemo(() => {
|
||||||
|
if (loading) return <FormattedMessage id='hashtag_timeline.loading' defaultMessage='Loading...' />;
|
||||||
|
return isFollow ? (
|
||||||
|
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow tag' />
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow this tag' />
|
||||||
|
);
|
||||||
|
}, [loading, isFollow]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button disabled={loading} theme={isFollow ? 'secondary' : 'primary'} size='sm' onClick={onClick}>
|
||||||
|
{
|
||||||
|
loading ? <Spinner withText={false} size={16} /> : <Icon src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} className='mr-1' />
|
||||||
|
}
|
||||||
|
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const HashtagTimeline: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const disconnects = React.useRef([]);
|
||||||
|
const { id, tags } = useParams<{ id: string, tags: any }>();
|
||||||
|
const prevParams = React.useRef({ id, tags });
|
||||||
|
|
||||||
|
const hasUnread = useAppSelector((state) => (state.getIn(['timelines', `hashtag:${id}`, 'unread']) as number) > 0);
|
||||||
|
|
||||||
|
// TODO: wtf is this?
|
||||||
|
// It exists in Mastodon's codebase, but undocumented
|
||||||
|
const additionalFor = React.useCallback((mode) => {
|
||||||
|
if (tags && (tags[mode] || []).length > 0) {
|
||||||
|
return tags[mode].map(tag => tag.value).join('/');
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
|
||||||
|
const title = React.useMemo(() => {
|
||||||
|
const t: React.ReactNode[] = [`#${id}`];
|
||||||
|
|
||||||
|
// TODO: wtf is all this?
|
||||||
|
// It exists in Mastodon's codebase, but undocumented
|
||||||
|
if (additionalFor('any')) {
|
||||||
|
t.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: additionalFor('any') }} defaultMessage='or {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalFor('all')) {
|
||||||
|
t.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: additionalFor('all') }} defaultMessage='and {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalFor('none')) {
|
||||||
|
t.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: additionalFor('none') }} defaultMessage='without {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}, [id, additionalFor]);
|
||||||
|
|
||||||
|
|
||||||
|
const subscribe = React.useCallback((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 => {
|
||||||
|
disconnects.current.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;
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unsubscribe = React.useCallback(() => {
|
||||||
|
disconnects.current.map((d) => d());
|
||||||
|
disconnects.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const { id: prevId, tags: prevTags } = prevParams.current;
|
||||||
|
if (id !== prevId || !isEqual(tags, prevTags)) {
|
||||||
|
dispatch(clearTimeline(`hashtag:${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(dispatch, id, tags);
|
||||||
|
dispatch(expandHashtagTimeline(id, { tags }));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [dispatch, tags, id, subscribe, unsubscribe]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleLoadMore = React.useCallback((maxId) => {
|
||||||
|
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||||
|
}, [dispatch, id, tags]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={`#${id}`} transparent withHeader={false}>
|
||||||
|
<div className='px-4 pt-4 sm:p-0'>
|
||||||
|
<ColumnHeader
|
||||||
|
active={hasUnread}
|
||||||
|
title={
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
{ title }
|
||||||
|
<FollowButton id={id} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Timeline
|
||||||
|
scrollKey='hashtag_timeline'
|
||||||
|
timelineId={`hashtag:${id}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||||
|
divideType='space'
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HashtagTimeline;
|
|
@ -21,6 +21,7 @@ import { register as registerPushNotifications } from 'soapbox/actions/push_noti
|
||||||
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
|
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
|
||||||
import { connectUserStream } from 'soapbox/actions/streaming';
|
import { connectUserStream } from 'soapbox/actions/streaming';
|
||||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||||
|
import { fetchTags } from 'soapbox/actions/tags';
|
||||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||||
|
@ -114,6 +115,7 @@ import {
|
||||||
LogoutPage,
|
LogoutPage,
|
||||||
AuthTokenList,
|
AuthTokenList,
|
||||||
ProfileFields,
|
ProfileFields,
|
||||||
|
FollowedHashtags,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { WrappedRoute } from './util/react_router_helpers';
|
import { WrappedRoute } from './util/react_router_helpers';
|
||||||
|
|
||||||
|
@ -259,6 +261,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||||
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
|
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
|
||||||
|
|
||||||
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
|
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
|
||||||
|
<WrappedRoute path='/followed_hashtags' page={DefaultPage} component={FollowedHashtags} content={children} />
|
||||||
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
|
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
|
||||||
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
|
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
|
||||||
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
|
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
|
||||||
|
@ -465,11 +468,13 @@ const UI: React.FC = ({ children }) => {
|
||||||
|
|
||||||
setTimeout(() => dispatch(fetchFilters()), 500);
|
setTimeout(() => dispatch(fetchFilters()), 500);
|
||||||
|
|
||||||
|
setTimeout(() => dispatch(fetchTags()), 700);
|
||||||
|
|
||||||
if (account.locked) {
|
if (account.locked) {
|
||||||
setTimeout(() => dispatch(fetchFollowRequests()), 700);
|
setTimeout(() => dispatch(fetchFollowRequests()), 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => dispatch(fetchScheduledStatuses()), 900);
|
setTimeout(() => dispatch(fetchScheduledStatuses()), 1100);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -525,3 +525,7 @@ export function FamiliarFollowersModal() {
|
||||||
export function AnnouncementsPanel() {
|
export function AnnouncementsPanel() {
|
||||||
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
|
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowedHashtags() {
|
||||||
|
return import('../../../features/followed_tags');
|
||||||
|
}
|
||||||
|
|
|
@ -224,7 +224,7 @@
|
||||||
"column.direct": "Messages privés",
|
"column.direct": "Messages privés",
|
||||||
"column.directory": "Annuaire",
|
"column.directory": "Annuaire",
|
||||||
"column.domain_blocks": "Domaines cachés",
|
"column.domain_blocks": "Domaines cachés",
|
||||||
"column.edit_profile": "Edit profile",
|
"column.edit_profile": "Éditer le profil",
|
||||||
"column.export_data": "Exporter vos données",
|
"column.export_data": "Exporter vos données",
|
||||||
"column.familiar_followers": "People you know following {name}",
|
"column.familiar_followers": "People you know following {name}",
|
||||||
"column.favourited_statuses": "Liked posts",
|
"column.favourited_statuses": "Liked posts",
|
||||||
|
@ -436,7 +436,7 @@
|
||||||
"edit_profile.fields.stranger_notifications_label": "Bloquer les notifications d'inconnus",
|
"edit_profile.fields.stranger_notifications_label": "Bloquer les notifications d'inconnus",
|
||||||
"edit_profile.fields.website_label": "Website",
|
"edit_profile.fields.website_label": "Website",
|
||||||
"edit_profile.fields.website_placeholder": "Display a Link",
|
"edit_profile.fields.website_placeholder": "Display a Link",
|
||||||
"edit_profile.header": "Edit Profile",
|
"edit_profile.header": "Éditer le profil",
|
||||||
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
|
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
|
||||||
"edit_profile.hints.avatar": "PNG, GIF or JPG. Sera réduit à {size}",
|
"edit_profile.hints.avatar": "PNG, GIF or JPG. Sera réduit à {size}",
|
||||||
"edit_profile.hints.bot": "Ce compte réalise essentiellement des actions automatiques",
|
"edit_profile.hints.bot": "Ce compte réalise essentiellement des actions automatiques",
|
||||||
|
@ -645,7 +645,7 @@
|
||||||
"lists.edit": "Éditer la liste",
|
"lists.edit": "Éditer la liste",
|
||||||
"lists.edit.submit": "Changer le titre",
|
"lists.edit.submit": "Changer le titre",
|
||||||
"lists.new.create": "Ajouter une liste",
|
"lists.new.create": "Ajouter une liste",
|
||||||
"lists.new.create_title": "Create",
|
"lists.new.create_title": "Créer",
|
||||||
"lists.new.save_title": "Save Title",
|
"lists.new.save_title": "Save Title",
|
||||||
"lists.new.title_placeholder": "Titre de la nouvelle liste",
|
"lists.new.title_placeholder": "Titre de la nouvelle liste",
|
||||||
"lists.search": "Rechercher parmi les gens que vous suivez",
|
"lists.search": "Rechercher parmi les gens que vous suivez",
|
||||||
|
|
|
@ -58,6 +58,7 @@ import status_hover_card from './status-hover-card';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
import statuses from './statuses';
|
import statuses from './statuses';
|
||||||
import suggestions from './suggestions';
|
import suggestions from './suggestions';
|
||||||
|
import tags from './tags';
|
||||||
import timelines from './timelines';
|
import timelines from './timelines';
|
||||||
import trending_statuses from './trending_statuses';
|
import trending_statuses from './trending_statuses';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
|
@ -124,6 +125,7 @@ const reducers = {
|
||||||
rules,
|
rules,
|
||||||
history,
|
history,
|
||||||
announcements,
|
announcements,
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
57
app/soapbox/reducers/tags.ts
Normal file
57
app/soapbox/reducers/tags.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import { TAG_FETCH_FAIL, TAG_FETCH_REQUEST, TAG_FETCH_SUCCESS, TAG_FOLLOW_FAIL, TAG_FOLLOW_REQUEST, TAG_FOLLOW_SUCCESS, TAG_UNFOLLOW_FAIL, TAG_UNFOLLOW_REQUEST, TAG_UNFOLLOW_SUCCESS } from 'soapbox/actions/tags';
|
||||||
|
import { normalizeTag } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity, Tag } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const TagRecord = ImmutableRecord({
|
||||||
|
list: ImmutableList<Tag>(),
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type State = ReturnType<typeof TagRecord>;
|
||||||
|
|
||||||
|
const importTags = (state: State, tags: APIEntity[]): State => {
|
||||||
|
return state.withMutations((s) => {
|
||||||
|
s.set('list', ImmutableList(tags.map((tag) => normalizeTag(tag))));
|
||||||
|
s.set('loading', false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = (state: State, tag: APIEntity): State => {
|
||||||
|
return state.withMutations((s) => {
|
||||||
|
s.set('list', state.list.push(normalizeTag(tag)));
|
||||||
|
s.set('loading', false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (state: State, _tag: APIEntity): State => {
|
||||||
|
const tag = normalizeTag(_tag);
|
||||||
|
return state.withMutations((s) => {
|
||||||
|
s.set('list', state.list.filter((t) => t.name !== tag.name));
|
||||||
|
s.set('loading', false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function filters(state = TagRecord(), action: AnyAction): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case TAG_FETCH_REQUEST:
|
||||||
|
case TAG_FOLLOW_REQUEST:
|
||||||
|
case TAG_UNFOLLOW_REQUEST:
|
||||||
|
return state.set('loading', true);
|
||||||
|
case TAG_FETCH_SUCCESS:
|
||||||
|
return importTags(state, action.tags);
|
||||||
|
case TAG_FOLLOW_SUCCESS:
|
||||||
|
return addTag(state, action.tag);
|
||||||
|
case TAG_UNFOLLOW_SUCCESS:
|
||||||
|
return removeTag(state, action.tag);
|
||||||
|
case TAG_FETCH_FAIL:
|
||||||
|
case TAG_FOLLOW_FAIL:
|
||||||
|
case TAG_UNFOLLOW_FAIL:
|
||||||
|
return state.set('loading', false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -341,6 +341,15 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
(v.software === PLEROMA || v.software === AKKOMA),
|
(v.software === PLEROMA || v.software === AKKOMA),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can follow hashtag and show tags in home timeline
|
||||||
|
* @see GET /api/v1/followed_tags
|
||||||
|
*/
|
||||||
|
followTags: any([
|
||||||
|
v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
|
||||||
|
v.software === AKKOMA,
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether client settings can be retrieved from the API.
|
* Whether client settings can be retrieved from the API.
|
||||||
* @see GET /api/pleroma/frontend_configurations
|
* @see GET /api/pleroma/frontend_configurations
|
||||||
|
|
Loading…
Reference in a new issue