Add ability to follow hashtags in web UI

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-01-19 15:06:17 +01:00
parent cb5702db15
commit 93b09d8206
8 changed files with 218 additions and 11 deletions

120
app/soapbox/actions/tags.ts Normal file
View file

@ -0,0 +1,120 @@
import api 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 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,
});
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,
fetchHashtag,
fetchHashtagRequest,
fetchHashtagSuccess,
fetchHashtagFail,
followHashtag,
followHashtagRequest,
followHashtagSuccess,
followHashtagFail,
unfollowHashtag,
unfollowHashtagRequest,
unfollowHashtagSuccess,
unfollowHashtagFail,
};

View file

@ -45,6 +45,12 @@ interface ICardHeader {
backHref?: string, backHref?: string,
onBackClick?: (event: React.MouseEvent) => void onBackClick?: (event: React.MouseEvent) => void
className?: string className?: string
/** Callback when the card action is clicked. */
onActionClick?: () => void,
/** URL to the svg icon for the card action. */
actionIcon?: string,
/** Text for the action. */
actionTitle?: string,
children?: React.ReactNode children?: React.ReactNode
} }
@ -52,7 +58,7 @@ interface ICardHeader {
* Card header container with back button. * Card header container with back button.
* Typically holds a CardTitle. * Typically holds a CardTitle.
*/ */
const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBackClick }): JSX.Element => { const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBackClick, onActionClick, actionIcon, actionTitle }): JSX.Element => {
const intl = useIntl(); const intl = useIntl();
const renderBackButton = () => { const renderBackButton = () => {
@ -64,7 +70,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return ( return (
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}> <Comp {...backAttributes} className='p-0.5 -m-0.5 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2 rounded-full' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' /> <SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span> <span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp> </Comp>
@ -76,6 +82,12 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
{renderBackButton()} {renderBackButton()}
{children} {children}
{onActionClick && actionIcon && (
<button className='p-0.5 -m-0.5 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2 rounded-full' onClick={onActionClick} title={actionTitle}>
<SvgIcon src={actionIcon} className='h-6 w-6' />
</button>
)}
</HStack> </HStack>
); );
}; };
@ -86,7 +98,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>
); );
interface ICardBody { interface ICardBody {

View file

@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>; type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'onActionClick' | 'actionIcon' | 'actionTitle'>;
/** Contains the column title with optional back button. */ /** Contains the column title with optional back button. */
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => { const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => {
const history = useHistory(); const history = useHistory();
const handleBackClick = () => { const handleBackClick = () => {
@ -27,7 +27,13 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
}; };
return ( return (
<CardHeader className={className} onBackClick={handleBackClick}> <CardHeader
className={className}
onBackClick={handleBackClick}
onActionClick={onActionClick}
actionIcon={actionIcon}
actionTitle={actionTitle}
>
<CardTitle title={label} /> <CardTitle title={label} />
</CardHeader> </CardHeader>
); );
@ -46,13 +52,19 @@ export interface IColumn {
className?: string, className?: string,
/** Ref forwarded to column. */ /** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<HTMLDivElement>
/** Callback when the column action is clicked. */
onActionClick?: () => void,
/** URL to the svg icon for the column action. */
actionIcon?: string,
/** Text for the action. */
actionTitle?: string,
/** Children to display in the column. */ /** Children to display in the column. */
children?: React.ReactNode children?: React.ReactNode
} }
/** 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 } = props; const { backHref, children, label, transparent = false, withHeader = true, onActionClick, actionIcon, actionTitle, className } = props;
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
return ( return (
@ -75,6 +87,9 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
label={label} label={label}
backHref={backHref} backHref={backHref}
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })} className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
onActionClick={onActionClick}
actionIcon={actionIcon}
actionTitle={actionTitle}
/> />
)} )}

View file

@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } 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 { Column } 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';
@ -18,6 +19,8 @@ const messages = defineMessages({
any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' },
all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' },
none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' },
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' },
}); });
@ -32,9 +35,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 +93,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 +119,13 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
}, [id]); }, [id]);
return ( return (
<Column label={title()} transparent> <Column
label={title()}
transparent
onActionClick={features.followHashtags ? handleFollow : undefined}
actionIcon={tag?.following ? require('@tabler/icons/bell-ringing.svg') : require('@tabler/icons/bell.svg')}
actionTitle={intl.formatMessage(tag?.following ? messages.unfollowHashtag : messages.followHashtag)}
>
<Timeline <Timeline
scrollKey='hashtag_timeline' scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`} timelineId={`hashtag:${id}`}
@ -117,4 +137,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
); );
}; };
export default HashtagTimeline; export default HashtagTimeline;

View file

@ -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>) => {

View file

@ -56,6 +56,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';
@ -120,6 +121,7 @@ const reducers = {
announcements, announcements,
compose_event, compose_event,
admin_user_index, admin_user_index,
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`

View file

@ -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;
}
}

View file

@ -447,6 +447,13 @@ 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: v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
/** /**
* 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