mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-11 14:30:35 +00:00
Merge pull request #3031 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 0e5be63fb3
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api().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));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api().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));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api';
|
||||
import api, {
|
||||
getLinks,
|
||||
apiRequestPost,
|
||||
apiRequestGet,
|
||||
} from 'flavours/glitch/api';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
@@ -9,3 +13,15 @@ export const apiFollowTag = (tagId: string) =>
|
||||
|
||||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/followed_tags',
|
||||
});
|
||||
|
||||
return {
|
||||
tags: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface HashtagProps {
|
||||
to: string;
|
||||
uses?: number;
|
||||
withGraph?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
@@ -117,6 +118,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
||||
className,
|
||||
description,
|
||||
withGraph = true,
|
||||
children,
|
||||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
@@ -158,5 +160,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
||||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='trends__item__buttons'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,10 +36,10 @@ import {
|
||||
} from 'flavours/glitch/components/badge';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
|
||||
import AccountNoteContainer from 'flavours/glitch/features/account/containers/account_note_container';
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
} from 'flavours/glitch/initial_state';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
import type { DropdownMenu } from 'flavours/glitch/models/dropdown_menu';
|
||||
import type { Relationship } from 'flavours/glitch/models/relationship';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
@@ -183,20 +182,6 @@ const titleFromAccount = (account: Account) => {
|
||||
return `${prefix} (@${acct})`;
|
||||
};
|
||||
|
||||
const messageForFollowButton = (relationship?: Relationship) => {
|
||||
if (!relationship) return messages.follow;
|
||||
|
||||
if (relationship.get('requested')) {
|
||||
return messages.cancel_follow_request;
|
||||
} else if (relationship.get('following')) {
|
||||
return messages.unfollow;
|
||||
} else if (relationship.get('followed_by')) {
|
||||
return messages.followBack;
|
||||
} else {
|
||||
return messages.follow;
|
||||
}
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -219,20 +204,6 @@ export const AccountHeader: React.FC<{
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const handleLinkClick = useLinks();
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.following || relationship?.requested) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
@@ -369,23 +340,6 @@ export const AccountHeader: React.FC<{
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleInteractionModal = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: account.id,
|
||||
url: account.uri,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
@@ -421,10 +375,6 @@ export const AccountHeader: React.FC<{
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const handleEditProfile = useCallback(() => {
|
||||
window.open('/settings/profile', '_blank');
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
@@ -684,9 +634,12 @@ export const AccountHeader: React.FC<{
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn, bellBtn, lockedIcon, shareBtn;
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info = [];
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship?.followed_by) {
|
||||
info.push(
|
||||
@@ -763,43 +716,17 @@ export const AccountHeader: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
if (me !== account.id) {
|
||||
if (signedIn && !relationship) {
|
||||
// Wait until the relationship is loaded
|
||||
actionBtn = (
|
||||
<Button disabled>
|
||||
<LoadingIndicator />
|
||||
</Button>
|
||||
);
|
||||
} else if (!relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
disabled={relationship?.blocked_by}
|
||||
className={classNames({
|
||||
'button--destructive':
|
||||
relationship?.following || relationship?.requested,
|
||||
})}
|
||||
text={intl.formatMessage(messageForFollowButton(relationship))}
|
||||
onClick={signedIn ? handleFollow : handleInteractionModal}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.edit_profile)}
|
||||
onClick={handleEditProfile}
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = <FollowButton accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (account.moved && !relationship?.following) {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import StatusList from 'flavours/glitch/components/status_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { getStatusList } from 'flavours/glitch/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'bookmarks'),
|
||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||
});
|
||||
|
||||
class Bookmarks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Bookmarks));
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import {
|
||||
fetchBookmarkedStatuses,
|
||||
expandBookmarkedStatuses,
|
||||
} from 'flavours/glitch/actions/bookmarks';
|
||||
import {
|
||||
addColumn,
|
||||
removeColumn,
|
||||
moveColumn,
|
||||
} from 'flavours/glitch/actions/columns';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import StatusList from 'flavours/glitch/components/status_list';
|
||||
import { getStatusList } from 'flavours/glitch/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const Bookmarks: React.FC<{
|
||||
columnId: string;
|
||||
multiColumn: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'bookmarks'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.bookmarked_statuses'
|
||||
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Bookmarks;
|
||||
@@ -1,116 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import StatusList from 'flavours/glitch/components/status_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { getStatusList } from 'flavours/glitch/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'favourites'),
|
||||
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
||||
});
|
||||
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchFavouritedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFavouritedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Favourites));
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import {
|
||||
addColumn,
|
||||
removeColumn,
|
||||
moveColumn,
|
||||
} from 'flavours/glitch/actions/columns';
|
||||
import {
|
||||
fetchFavouritedStatuses,
|
||||
expandFavouritedStatuses,
|
||||
} from 'flavours/glitch/actions/favourites';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import StatusList from 'flavours/glitch/components/status_list';
|
||||
import { getStatusList } from 'flavours/glitch/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
|
||||
columnId,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'favourites'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['favourites', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.favourited_statuses'
|
||||
defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Favourites;
|
||||
@@ -1,95 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
class FollowedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hashtags: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{hashtags.map((hashtag) => (
|
||||
<Hashtag
|
||||
key={hashtag.get('name')}
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(FollowedTags));
|
||||
161
app/javascript/flavours/glitch/features/followed_tags/index.tsx
Normal file
161
app/javascript/flavours/glitch/features/followed_tags/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { unfollowHashtag } from 'flavours/glitch/actions/tags_typed';
|
||||
import { apiGetFollowedTags } from 'flavours/glitch/api/tags';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const FollowedTag: React.FC<{
|
||||
tag: ApiHashtagJSON;
|
||||
onUnfollow: (arg0: string) => void;
|
||||
}> = ({ tag, onUnfollow }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const tagId = tag.name;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
void dispatch(unfollowHashtag({ tagId })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
onUnfollow(tagId);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [dispatch, onUnfollow, tagId]);
|
||||
|
||||
const people =
|
||||
parseInt(tag.history[0].accounts) +
|
||||
parseInt(tag.history[1]?.accounts ?? '');
|
||||
|
||||
return (
|
||||
<Hashtag
|
||||
name={tag.name}
|
||||
to={`/tags/${tag.name}`}
|
||||
withGraph={false}
|
||||
people={people}
|
||||
>
|
||||
<Button onClick={handleClick}>
|
||||
<FormattedMessage id='account.unfollow' defaultMessage='Unfollow' />
|
||||
</Button>
|
||||
</Hashtag>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [next, setNext] = useState<string | undefined>();
|
||||
const hasMore = !!next;
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags()
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(next)
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setLoading(false);
|
||||
setTags((previousTags) => [...previousTags, ...tags]);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext, next]);
|
||||
|
||||
const handleUnfollow = useCallback(
|
||||
(tagId: string) => {
|
||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||
},
|
||||
[setTags],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.followed_tags'
|
||||
defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={loading}
|
||||
showLoading={loading && tags.length === 0}
|
||||
onLoadMore={handleLoadMore}
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<FollowedTag key={tag.name} tag={tag} onUnfollow={handleUnfollow} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FollowedTags;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } 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 'flavours/glitch/actions/tags';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = initialState, action) {
|
||||
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', fromJS(action.followed_tags));
|
||||
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', set => set.concat(fromJS(action.followed_tags)));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import conversations from './conversations';
|
||||
import custom_emojis from './custom_emojis';
|
||||
import { dropdownMenuReducer } from './dropdown_menu';
|
||||
import filters from './filters';
|
||||
import followed_tags from './followed_tags';
|
||||
import height_cache from './height_cache';
|
||||
import history from './history';
|
||||
import { listsReducer } from './lists';
|
||||
@@ -75,7 +74,6 @@ const reducers = {
|
||||
markers: markersReducer,
|
||||
picture_in_picture: pictureInPictureReducer,
|
||||
history,
|
||||
followed_tags,
|
||||
notificationPolicy: notificationPolicyReducer,
|
||||
notificationRequests: notificationRequestsReducer,
|
||||
};
|
||||
|
||||
@@ -96,6 +96,7 @@ const removeOneFromList = (state, listType, status) => {
|
||||
return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id')));
|
||||
};
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
export default function statusLists(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FAVOURITED_STATUSES_FETCH_REQUEST:
|
||||
|
||||
@@ -6,6 +6,7 @@ import { me } from '../initial_state';
|
||||
import { getFilters } from './filters';
|
||||
|
||||
export { makeGetAccount } from "./accounts";
|
||||
export { getStatusList } from "./statuses";
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
@@ -78,7 +79,3 @@ export const makeGetReport = () => createSelector([
|
||||
(_, base) => base,
|
||||
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
|
||||
], (base, targetAccount) => base.set('target_account', targetAccount));
|
||||
|
||||
export const getStatusList = createSelector([
|
||||
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||
], (items) => items.toList());
|
||||
|
||||
15
app/javascript/flavours/glitch/selectors/statuses.ts
Normal file
15
app/javascript/flavours/glitch/selectors/statuses.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
|
||||
export const getStatusList = createSelector(
|
||||
[
|
||||
(
|
||||
state: RootState,
|
||||
type: 'favourites' | 'bookmarks' | 'pins' | 'trending',
|
||||
) =>
|
||||
state.status_lists.getIn([type, 'items']) as ImmutableOrderedSet<string>,
|
||||
],
|
||||
(items) => items.toList(),
|
||||
);
|
||||
@@ -8524,13 +8524,9 @@ noscript {
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
gap: 15px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
gap: 8px;
|
||||
|
||||
&__name {
|
||||
flex: 1 1 auto;
|
||||
@@ -8637,7 +8633,7 @@ noscript {
|
||||
}
|
||||
|
||||
&--compact &__item {
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api().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));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api().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));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
@@ -9,3 +9,15 @@ export const apiFollowTag = (tagId: string) =>
|
||||
|
||||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/followed_tags',
|
||||
});
|
||||
|
||||
return {
|
||||
tags: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,8 +16,7 @@ const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
});
|
||||
|
||||
export const FollowButton: React.FC<{
|
||||
@@ -73,11 +72,9 @@ export const FollowButton: React.FC<{
|
||||
if (!signedIn) {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
} else if (accountId === me) {
|
||||
label = intl.formatMessage(messages.edit_profile);
|
||||
label = intl.formatMessage(messages.editProfile);
|
||||
} else if (!relationship) {
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.following && relationship.followed_by) {
|
||||
label = intl.formatMessage(messages.mutual);
|
||||
} else if (relationship.following || relationship.requested) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
} else if (relationship.followed_by) {
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface HashtagProps {
|
||||
to: string;
|
||||
uses?: number;
|
||||
withGraph?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
@@ -117,6 +118,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
||||
className,
|
||||
description,
|
||||
withGraph = true,
|
||||
children,
|
||||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
@@ -158,5 +160,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
||||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='trends__item__buttons'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,10 +37,10 @@ import {
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
@@ -51,7 +51,6 @@ import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
@@ -179,20 +178,6 @@ const titleFromAccount = (account: Account) => {
|
||||
return `${prefix} (@${acct})`;
|
||||
};
|
||||
|
||||
const messageForFollowButton = (relationship?: Relationship) => {
|
||||
if (!relationship) return messages.follow;
|
||||
|
||||
if (relationship.get('following') && relationship.get('followed_by')) {
|
||||
return messages.mutual;
|
||||
} else if (relationship.get('following') || relationship.get('requested')) {
|
||||
return messages.unfollow;
|
||||
} else if (relationship.get('followed_by')) {
|
||||
return messages.followBack;
|
||||
} else {
|
||||
return messages.follow;
|
||||
}
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -215,20 +200,6 @@ export const AccountHeader: React.FC<{
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const handleLinkClick = useLinks();
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.following || relationship?.requested) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
@@ -365,23 +336,6 @@ export const AccountHeader: React.FC<{
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleInteractionModal = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: account.id,
|
||||
url: account.uri,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
@@ -417,10 +371,6 @@ export const AccountHeader: React.FC<{
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const handleEditProfile = useCallback(() => {
|
||||
window.open('/settings/profile', '_blank');
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
@@ -680,9 +630,12 @@ export const AccountHeader: React.FC<{
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn, bellBtn, lockedIcon, shareBtn;
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info = [];
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship?.blocking) {
|
||||
info.push(
|
||||
@@ -750,43 +703,17 @@ export const AccountHeader: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
if (me !== account.id) {
|
||||
if (signedIn && !relationship) {
|
||||
// Wait until the relationship is loaded
|
||||
actionBtn = (
|
||||
<Button disabled>
|
||||
<LoadingIndicator />
|
||||
</Button>
|
||||
);
|
||||
} else if (!relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
disabled={relationship?.blocked_by}
|
||||
className={classNames({
|
||||
'button--destructive':
|
||||
relationship?.following || relationship?.requested,
|
||||
})}
|
||||
text={intl.formatMessage(messageForFollowButton(relationship))}
|
||||
onClick={signedIn ? handleFollow : handleInteractionModal}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.edit_profile)}
|
||||
onClick={handleEditProfile}
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = <FollowButton accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (account.moved && !relationship?.following) {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'bookmarks'),
|
||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||
});
|
||||
|
||||
class Bookmarks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Bookmarks));
|
||||
116
app/javascript/mastodon/features/bookmarked_statuses/index.tsx
Normal file
116
app/javascript/mastodon/features/bookmarked_statuses/index.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import {
|
||||
fetchBookmarkedStatuses,
|
||||
expandBookmarkedStatuses,
|
||||
} from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const Bookmarks: React.FC<{
|
||||
columnId: string;
|
||||
multiColumn: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'bookmarks'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.bookmarked_statuses'
|
||||
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Bookmarks;
|
||||
@@ -1,115 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'favourites'),
|
||||
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
||||
});
|
||||
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchFavouritedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFavouritedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Favourites));
|
||||
116
app/javascript/mastodon/features/favourited_statuses/index.tsx
Normal file
116
app/javascript/mastodon/features/favourited_statuses/index.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import {
|
||||
fetchFavouritedStatuses,
|
||||
expandFavouritedStatuses,
|
||||
} from 'mastodon/actions/favourites';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
|
||||
columnId,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'favourites'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['favourites', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.favourited_statuses'
|
||||
defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Favourites;
|
||||
@@ -1,95 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
class FollowedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hashtags: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{hashtags.map((hashtag) => (
|
||||
<Hashtag
|
||||
key={hashtag.get('name')}
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(FollowedTags));
|
||||
161
app/javascript/mastodon/features/followed_tags/index.tsx
Normal file
161
app/javascript/mastodon/features/followed_tags/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
|
||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const FollowedTag: React.FC<{
|
||||
tag: ApiHashtagJSON;
|
||||
onUnfollow: (arg0: string) => void;
|
||||
}> = ({ tag, onUnfollow }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const tagId = tag.name;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
void dispatch(unfollowHashtag({ tagId })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
onUnfollow(tagId);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [dispatch, onUnfollow, tagId]);
|
||||
|
||||
const people =
|
||||
parseInt(tag.history[0].accounts) +
|
||||
parseInt(tag.history[1]?.accounts ?? '');
|
||||
|
||||
return (
|
||||
<Hashtag
|
||||
name={tag.name}
|
||||
to={`/tags/${tag.name}`}
|
||||
withGraph={false}
|
||||
people={people}
|
||||
>
|
||||
<Button onClick={handleClick}>
|
||||
<FormattedMessage id='account.unfollow' defaultMessage='Unfollow' />
|
||||
</Button>
|
||||
</Hashtag>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [next, setNext] = useState<string | undefined>();
|
||||
const hasMore = !!next;
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags()
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(next)
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setLoading(false);
|
||||
setTags((previousTags) => [...previousTags, ...tags]);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext, next]);
|
||||
|
||||
const handleUnfollow = useCallback(
|
||||
(tagId: string) => {
|
||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||
},
|
||||
[setTags],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.followed_tags'
|
||||
defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={loading}
|
||||
showLoading={loading && tags.length === 0}
|
||||
onLoadMore={handleLoadMore}
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<FollowedTag key={tag.name} tag={tag} onUnfollow={handleUnfollow} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FollowedTags;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } 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 'mastodon/actions/tags';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = initialState, action) {
|
||||
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', fromJS(action.followed_tags));
|
||||
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', set => set.concat(fromJS(action.followed_tags)));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import conversations from './conversations';
|
||||
import custom_emojis from './custom_emojis';
|
||||
import { dropdownMenuReducer } from './dropdown_menu';
|
||||
import filters from './filters';
|
||||
import followed_tags from './followed_tags';
|
||||
import height_cache from './height_cache';
|
||||
import history from './history';
|
||||
import { listsReducer } from './lists';
|
||||
@@ -73,7 +72,6 @@ const reducers = {
|
||||
markers: markersReducer,
|
||||
picture_in_picture: pictureInPictureReducer,
|
||||
history,
|
||||
followed_tags,
|
||||
notificationPolicy: notificationPolicyReducer,
|
||||
notificationRequests: notificationRequestsReducer,
|
||||
};
|
||||
|
||||
@@ -96,6 +96,7 @@ const removeOneFromList = (state, listType, status) => {
|
||||
return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id')));
|
||||
};
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
export default function statusLists(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FAVOURITED_STATUSES_FETCH_REQUEST:
|
||||
|
||||
@@ -6,6 +6,7 @@ import { me } from '../initial_state';
|
||||
import { getFilters } from './filters';
|
||||
|
||||
export { makeGetAccount } from "./accounts";
|
||||
export { getStatusList } from "./statuses";
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
@@ -77,7 +78,3 @@ export const makeGetReport = () => createSelector([
|
||||
(_, base) => base,
|
||||
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
|
||||
], (base, targetAccount) => base.set('target_account', targetAccount));
|
||||
|
||||
export const getStatusList = createSelector([
|
||||
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||
], (items) => items.toList());
|
||||
|
||||
15
app/javascript/mastodon/selectors/statuses.ts
Normal file
15
app/javascript/mastodon/selectors/statuses.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
export const getStatusList = createSelector(
|
||||
[
|
||||
(
|
||||
state: RootState,
|
||||
type: 'favourites' | 'bookmarks' | 'pins' | 'trending',
|
||||
) =>
|
||||
state.status_lists.getIn([type, 'items']) as ImmutableOrderedSet<string>,
|
||||
],
|
||||
(items) => items.toList(),
|
||||
);
|
||||
@@ -8110,13 +8110,9 @@ noscript {
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
gap: 15px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
gap: 8px;
|
||||
|
||||
&__name {
|
||||
flex: 1 1 auto;
|
||||
@@ -8223,7 +8219,7 @@ noscript {
|
||||
}
|
||||
|
||||
&--compact &__item {
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user