Merge branch 'follow-hashtags' into 'develop'

Mastodon: Add ability to follow hashtags in web UI

Closes #1192

See merge request soapbox-pub/soapbox!2200
environments/review-develop-3zknud/deployments/3361
marcin mikołajczak 1 year ago
commit 91bc40c732

@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
- Posts: Support posts filtering on recent Mastodon versions - Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions - Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters. - Compatbility: Support Mastodon v2 timeline filters.

@ -0,0 +1,201 @@
import api, { getLinks } from '../api';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchHashtagRequest());
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
dispatch(fetchHashtagSuccess(name, data));
}).catch(err => {
dispatch(fetchHashtagFail(err));
});
};
const fetchHashtagRequest = () => ({
type: HASHTAG_FETCH_REQUEST,
});
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_FETCH_SUCCESS,
name,
tag,
});
const fetchHashtagFail = (error: AxiosError) => ({
type: HASHTAG_FETCH_FAIL,
error,
});
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(followHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
dispatch(followHashtagSuccess(name, data));
}).catch(err => {
dispatch(followHashtagFail(name, err));
});
};
const followHashtagRequest = (name: string) => ({
type: HASHTAG_FOLLOW_REQUEST,
name,
});
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_FOLLOW_SUCCESS,
name,
tag,
});
const followHashtagFail = (name: string, error: AxiosError) => ({
type: HASHTAG_FOLLOW_FAIL,
name,
error,
});
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(unfollowHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
dispatch(unfollowHashtagSuccess(name, data));
}).catch(err => {
dispatch(unfollowHashtagFail(name, err));
});
};
const unfollowHashtagRequest = (name: string) => ({
type: HASHTAG_UNFOLLOW_REQUEST,
name,
});
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_UNFOLLOW_SUCCESS,
name,
tag,
});
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
type: HASHTAG_UNFOLLOW_FAIL,
name,
error,
});
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
const fetchFollowedHashtagsRequest = () => ({
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
});
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
});
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
});
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
const url = getState().followed_tags.next;
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
const expandFollowedHashtagsRequest = () => ({
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
});
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
});
const expandFollowedHashtagsFail = (error: AxiosError) => ({
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
});
export {
HASHTAG_FETCH_REQUEST,
HASHTAG_FETCH_SUCCESS,
HASHTAG_FETCH_FAIL,
HASHTAG_FOLLOW_REQUEST,
HASHTAG_FOLLOW_SUCCESS,
HASHTAG_FOLLOW_FAIL,
HASHTAG_UNFOLLOW_REQUEST,
HASHTAG_UNFOLLOW_SUCCESS,
HASHTAG_UNFOLLOW_FAIL,
FOLLOWED_HASHTAGS_FETCH_REQUEST,
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
FOLLOWED_HASHTAGS_FETCH_FAIL,
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
FOLLOWED_HASHTAGS_EXPAND_FAIL,
fetchHashtag,
fetchHashtagRequest,
fetchHashtagSuccess,
fetchHashtagFail,
followHashtag,
followHashtagRequest,
followHashtagSuccess,
followHashtagFail,
unfollowHashtag,
unfollowHashtagRequest,
unfollowHashtagSuccess,
unfollowHashtagFail,
fetchFollowedHashtags,
fetchFollowedHashtagsRequest,
fetchFollowedHashtagsSuccess,
fetchFollowedHashtagsFail,
expandFollowedHashtags,
expandFollowedHashtagsRequest,
expandFollowedHashtagsSuccess,
expandFollowedHashtagsFail,
};

@ -51,6 +51,8 @@ export interface IColumn {
withHeader?: boolean withHeader?: boolean
/** Extra class name for top <div> element. */ /** Extra class name for top <div> element. */
className?: string className?: string
/** Extra class name for the <CardBody> element. */
bodyClassName?: string
/** Ref forwarded to column. */ /** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<HTMLDivElement>
/** Children to display in the column. */ /** Children to display in the column. */
@ -63,7 +65,7 @@ export interface IColumn {
/** A backdrop for the main section of the UI. */ /** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => { const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props; const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
@ -109,7 +111,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
/> />
)} )}
<CardBody> <CardBody className={bodyClassName}>
{children} {children}
</CardBody> </CardBody>
</Card> </Card>

@ -0,0 +1,52 @@
import debounce from 'lodash/debounce';
import React, { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags';
import Hashtag from 'soapbox/components/hashtag';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' },
});
const handleLoadMore = debounce((dispatch) => {
dispatch(expandFollowedHashtags());
}, 300, { leading: true });
const FollowedTags = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchFollowedHashtags());
}, []);
const tags = useAppSelector((state => state.followed_tags.items));
const isLoading = useAppSelector((state => state.followed_tags.isLoading));
const hasMore = useAppSelector((state => !!state.followed_tags.next));
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage="You haven't followed any hashtag yet." />;
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
isLoading={isLoading}
hasMore={hasMore}
onLoadMore={() => handleLoadMore(dispatch)}
placeholderComponent={PlaceholderHashtag}
placeholderCount={5}
itemClassName='pb-3'
>
{tags.map(tag => <Hashtag key={tag.name} hashtag={tag} />)}
</ScrollableList>
</Column>
);
};
export default FollowedTags;

@ -1,11 +1,13 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { connectHashtagStream } from 'soapbox/actions/streaming'; import { connectHashtagStream } from 'soapbox/actions/streaming';
import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags';
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
import { Column } from 'soapbox/components/ui'; import List, { ListItem } from 'soapbox/components/list';
import { Column, Toggle } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline'; import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import type { Tag as TagEntity } from 'soapbox/types/entities'; import type { Tag as TagEntity } from 'soapbox/types/entities';
@ -32,9 +34,11 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const intl = useIntl(); const intl = useIntl();
const id = params?.id || ''; const id = params?.id || '';
const tags = params?.tags || { any: [], all: [], none: [] }; const tags = params?.tags || { any: [], all: [], none: [] };
const features = useFeatures();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const disconnects = useRef<(() => void)[]>([]); const disconnects = useRef<(() => void)[]>([]);
const tag = useAppSelector((state) => state.tags.get(id));
// Mastodon supports displaying results from multiple hashtags. // Mastodon supports displaying results from multiple hashtags.
// https://github.com/mastodon/mastodon/issues/6359 // https://github.com/mastodon/mastodon/issues/6359
@ -88,9 +92,18 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
dispatch(expandHashtagTimeline(id, { maxId, tags })); dispatch(expandHashtagTimeline(id, { maxId, tags }));
}; };
const handleFollow = () => {
if (tag?.following) {
dispatch(unfollowHashtag(id));
} else {
dispatch(followHashtag(id));
}
};
useEffect(() => { useEffect(() => {
subscribe(); subscribe();
dispatch(expandHashtagTimeline(id, { tags })); dispatch(expandHashtagTimeline(id, { tags }));
dispatch(fetchHashtag(id));
return () => { return () => {
unsubscribe(); unsubscribe();
@ -105,7 +118,19 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
}, [id]); }, [id]);
return ( return (
<Column label={title()} transparent> <Column bodyClassName='space-y-3' label={title()} transparent>
{features.followHashtags && (
<List>
<ListItem
label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />}
>
<Toggle
checked={tag?.following}
onChange={handleFollow}
/>
</ListItem>
</List>
)}
<Timeline <Timeline
scrollKey='hashtag_timeline' scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`} timelineId={`hashtag:${id}`}
@ -117,4 +142,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
); );
}; };
export default HashtagTimeline; export default HashtagTimeline;

@ -858,6 +858,7 @@
"hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}", "hashtag.column_header.tag_mode.none": "without {additional}",
"hashtag.follow": "Follow hashtag",
"header.home.label": "Home", "header.home.label": "Home",
"header.login.email.placeholder": "E-mail address", "header.login.email.placeholder": "E-mail address",
"header.login.forgot_password": "Forgot password?", "header.login.forgot_password": "Forgot password?",

@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
name: '', name: '',
url: '', url: '',
history: null as ImmutableList<History> | null, history: null as ImmutableList<History> | null,
following: false,
}); });
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => { const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {

@ -0,0 +1,47 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import {
FOLLOWED_HASHTAGS_FETCH_REQUEST,
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
FOLLOWED_HASHTAGS_FETCH_FAIL,
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
FOLLOWED_HASHTAGS_EXPAND_FAIL,
} from 'soapbox/actions/tags';
import { normalizeTag } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
import type { APIEntity, Tag } from 'soapbox/types/entities';
const ReducerRecord = ImmutableRecord({
items: ImmutableList<Tag>(),
isLoading: false,
next: null,
});
export default function followed_tags(state = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
return state.set('isLoading', true);
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
map.set('isLoading', false);
map.set('next', action.next);
});
case FOLLOWED_HASHTAGS_FETCH_FAIL:
return state.set('isLoading', false);
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
return state.set('isLoading', true);
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
return state.withMutations(map => {
map.update('items', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
map.set('isLoading', false);
map.set('next', action.next);
});
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
return state.set('isLoading', false);
default:
return state;
}
}

@ -28,6 +28,7 @@ import custom_emojis from './custom-emojis';
import domain_lists from './domain-lists'; import domain_lists from './domain-lists';
import dropdown_menu from './dropdown-menu'; import dropdown_menu from './dropdown-menu';
import filters from './filters'; import filters from './filters';
import followed_tags from './followed_tags';
import group_memberships from './group-memberships'; import group_memberships from './group-memberships';
import group_relationships from './group-relationships'; import group_relationships from './group-relationships';
import groups from './groups'; import groups from './groups';
@ -61,6 +62,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';
@ -92,6 +94,7 @@ const reducers = {
dropdown_menu, dropdown_menu,
entities, entities,
filters, filters,
followed_tags,
group_memberships, group_memberships,
group_relationships, group_relationships,
groups, groups,
@ -125,6 +128,7 @@ const reducers = {
status_lists, status_lists,
statuses, statuses,
suggestions, suggestions,
tags,
timelines, timelines,
trending_statuses, trending_statuses,
trends, trends,

@ -0,0 +1,30 @@
import { Map as ImmutableMap } from 'immutable';
import {
HASHTAG_FETCH_SUCCESS,
HASHTAG_FOLLOW_REQUEST,
HASHTAG_FOLLOW_FAIL,
HASHTAG_UNFOLLOW_REQUEST,
HASHTAG_UNFOLLOW_FAIL,
} from 'soapbox/actions/tags';
import { normalizeTag } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
import type { Tag } from 'soapbox/types/entities';
const initialState = ImmutableMap<string, Tag>();
export default function tags(state = initialState, action: AnyAction) {
switch (action.type) {
case HASHTAG_FETCH_SUCCESS:
return state.set(action.name, normalizeTag(action.tag));
case HASHTAG_FOLLOW_REQUEST:
case HASHTAG_UNFOLLOW_FAIL:
return state.setIn([action.name, 'following'], true);
case HASHTAG_FOLLOW_FAIL:
case HASHTAG_UNFOLLOW_REQUEST:
return state.setIn([action.name, 'following'], false);
default:
return state;
}
}

@ -493,6 +493,16 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
/**
* Ability to follow hashtags.
* @see POST /api/v1/tags/:name/follow
* @see POST /api/v1/tags/:name/unfollow
*/
followHashtags: any([
v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
v.software === PLEROMA && v.build === AKKOMA,
]),
/** /**
* Ability to lock accounts and manually approve followers. * Ability to lock accounts and manually approve followers.
* @see PATCH /api/v1/accounts/update_credentials * @see PATCH /api/v1/accounts/update_credentials
@ -502,6 +512,12 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/**
* Ability to list followed hashtags.
* @see GET /api/v1/followed_tags
*/
followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'),
/** /**
* 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…
Cancel
Save