Porównaj commity

...

3 Commity

Autor SHA1 Wiadomość Data
marcin mikołajczak 0ec5ec7129 Update CHANGELOG.md
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
2023-01-20 22:32:51 +01:00
marcin mikołajczak d891024cb5 Update en.json
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
2023-01-20 14:20:50 +01:00
marcin mikołajczak 93b09d8206 Add ability to follow hashtags in web UI
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
2023-01-19 15:06:17 +01:00
10 zmienionych plików z 221 dodań i 11 usunięć

Wyświetl plik

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Admin: redirect the homepage to any URL.
- Compatibility: added compatibility with Friendica.
- Hashtags: let users follow hashtags (Mastodon).
### Changed

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}
/>
)}

Wyświetl plik

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

Wyświetl plik

@ -695,6 +695,8 @@
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"hashtag.follow": "Follow hashtag",
"hashtag.unfollow": "Unfollow hashtag",
"header.home.label": "Home",
"header.login.forgot_password": "Forgot password?",
"header.login.label": "Log in",

Wyświetl plik

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

Wyświetl plik

@ -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`

Wyświetl plik

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

Wyświetl plik

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