[ADD] Follow hashtags (#243)
This commit is contained in:
parent
1d4a7c0d04
commit
cd5cf427a2
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
|
@ -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' },
|
||||
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||
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. */
|
||||
|
@ -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) {
|
||||
menu.push({
|
||||
to: '/directory',
|
||||
|
|
|
@ -38,6 +38,7 @@ const messages = defineMessages({
|
|||
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
|
||||
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
|
||||
tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
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 && (
|
||||
<SidebarLink
|
||||
to='/directory'
|
||||
|
|
|
@ -85,7 +85,7 @@ interface ICardTitle {
|
|||
|
||||
/** A card's title. */
|
||||
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. */
|
||||
|
|
|
@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
/** URL to the svg icon. */
|
||||
src: string,
|
||||
/** Text to display next ot the button. */
|
||||
text?: string,
|
||||
text?: React.ReactNode,
|
||||
/** Don't render a background behind the icon. */
|
||||
transparent?: boolean,
|
||||
/** Predefined styles to display for the button. */
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 { connectUserStream } from 'soapbox/actions/streaming';
|
||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||
import { fetchTags } from 'soapbox/actions/tags';
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||
|
@ -114,6 +115,7 @@ import {
|
|||
LogoutPage,
|
||||
AuthTokenList,
|
||||
ProfileFields,
|
||||
FollowedHashtags,
|
||||
} from './util/async-components';
|
||||
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} />}
|
||||
|
||||
<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} />
|
||||
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} 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(fetchTags()), 700);
|
||||
|
||||
if (account.locked) {
|
||||
setTimeout(() => dispatch(fetchFollowRequests()), 700);
|
||||
setTimeout(() => dispatch(fetchFollowRequests()), 900);
|
||||
}
|
||||
|
||||
setTimeout(() => dispatch(fetchScheduledStatuses()), 900);
|
||||
setTimeout(() => dispatch(fetchScheduledStatuses()), 1100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -525,3 +525,7 @@ export function FamiliarFollowersModal() {
|
|||
export function AnnouncementsPanel() {
|
||||
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.directory": "Annuaire",
|
||||
"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.familiar_followers": "People you know following {name}",
|
||||
"column.favourited_statuses": "Liked posts",
|
||||
|
@ -436,7 +436,7 @@
|
|||
"edit_profile.fields.stranger_notifications_label": "Bloquer les notifications d'inconnus",
|
||||
"edit_profile.fields.website_label": "Website",
|
||||
"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.avatar": "PNG, GIF or JPG. Sera réduit à {size}",
|
||||
"edit_profile.hints.bot": "Ce compte réalise essentiellement des actions automatiques",
|
||||
|
@ -645,7 +645,7 @@
|
|||
"lists.edit": "Éditer la liste",
|
||||
"lists.edit.submit": "Changer le titre",
|
||||
"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.title_placeholder": "Titre de la nouvelle liste",
|
||||
"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 statuses from './statuses';
|
||||
import suggestions from './suggestions';
|
||||
import tags from './tags';
|
||||
import timelines from './timelines';
|
||||
import trending_statuses from './trending_statuses';
|
||||
import trends from './trends';
|
||||
|
@ -124,6 +125,7 @@ const reducers = {
|
|||
rules,
|
||||
history,
|
||||
announcements,
|
||||
tags,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
|
@ -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),
|
||||
]),
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @see GET /api/pleroma/frontend_configurations
|
||||
|
|
Loading…
Reference in New Issue