Add ability to follow hashtags in web UI
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
cb5702db15
commit
93b09d8206
8 changed files with 218 additions and 11 deletions
120
app/soapbox/actions/tags.ts
Normal file
120
app/soapbox/actions/tags.ts
Normal 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,
|
||||
};
|
|
@ -45,6 +45,12 @@ interface ICardHeader {
|
|||
backHref?: string,
|
||||
onBackClick?: (event: React.MouseEvent) => void
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -52,7 +58,7 @@ interface ICardHeader {
|
|||
* Card header container with back button.
|
||||
* 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 renderBackButton = () => {
|
||||
|
@ -64,7 +70,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
|||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||
|
||||
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' />
|
||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||
</Comp>
|
||||
|
@ -76,6 +82,12 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
|||
{renderBackButton()}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
@ -86,7 +98,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>
|
||||
);
|
||||
|
||||
interface ICardBody {
|
||||
|
|
|
@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
|||
|
||||
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. */
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const handleBackClick = () => {
|
||||
|
@ -27,7 +27,13 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
|
|||
};
|
||||
|
||||
return (
|
||||
<CardHeader className={className} onBackClick={handleBackClick}>
|
||||
<CardHeader
|
||||
className={className}
|
||||
onBackClick={handleBackClick}
|
||||
onActionClick={onActionClick}
|
||||
actionIcon={actionIcon}
|
||||
actionTitle={actionTitle}
|
||||
>
|
||||
<CardTitle title={label} />
|
||||
</CardHeader>
|
||||
);
|
||||
|
@ -46,13 +52,19 @@ export interface IColumn {
|
|||
className?: string,
|
||||
/** Ref forwarded to column. */
|
||||
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?: React.ReactNode
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
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();
|
||||
|
||||
return (
|
||||
|
@ -75,6 +87,9 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
label={label}
|
||||
backHref={backHref}
|
||||
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||
onActionClick={onActionClick}
|
||||
actionIcon={actionIcon}
|
||||
actionTitle={actionTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
||||
import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags';
|
||||
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
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';
|
||||
|
||||
|
@ -18,6 +19,8 @@ const messages = defineMessages({
|
|||
any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' },
|
||||
all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {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.' },
|
||||
});
|
||||
|
||||
|
@ -32,9 +35,11 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
const intl = useIntl();
|
||||
const id = params?.id || '';
|
||||
const tags = params?.tags || { any: [], all: [], none: [] };
|
||||
|
||||
|
||||
const features = useFeatures();
|
||||
const dispatch = useAppDispatch();
|
||||
const disconnects = useRef<(() => void)[]>([]);
|
||||
const tag = useAppSelector((state) => state.tags.get(id));
|
||||
|
||||
// Mastodon supports displaying results from multiple hashtags.
|
||||
// https://github.com/mastodon/mastodon/issues/6359
|
||||
|
@ -88,9 +93,18 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||
};
|
||||
|
||||
const handleFollow = () => {
|
||||
if (tag?.following) {
|
||||
dispatch(unfollowHashtag(id));
|
||||
} else {
|
||||
dispatch(followHashtag(id));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
subscribe();
|
||||
dispatch(expandHashtagTimeline(id, { tags }));
|
||||
dispatch(fetchHashtag(id));
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
|
@ -105,7 +119,13 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
}, [id]);
|
||||
|
||||
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
|
||||
scrollKey='hashtag_timeline'
|
||||
timelineId={`hashtag:${id}`}
|
||||
|
@ -117,4 +137,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default HashtagTimeline;
|
||||
export default HashtagTimeline;
|
||||
|
|
|
@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
|
|||
name: '',
|
||||
url: '',
|
||||
history: null as ImmutableList<History> | null,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
|
||||
|
|
|
@ -56,6 +56,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';
|
||||
|
@ -120,6 +121,7 @@ const reducers = {
|
|||
announcements,
|
||||
compose_event,
|
||||
admin_user_index,
|
||||
tags,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
30
app/soapbox/reducers/tags.ts
Normal file
30
app/soapbox/reducers/tags.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -447,6 +447,13 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
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.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
|
Loading…
Reference in a new issue