Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-events-25kg8c/deployments/1620
commit
ea4f7a7332
@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"account": {
|
||||
"id": "ABDSjI3Q0R8aDaz1U0"
|
||||
},
|
||||
"content": "quoast",
|
||||
"id": "AJsajx9hY4Q7IKQXEe",
|
||||
"pleroma": {
|
||||
"quote": {
|
||||
"content": "<p>10</p>",
|
||||
"id": "AJmoVikzI3SkyITyim"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
@ -0,0 +1,150 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { StatusListRecord } from 'soapbox/reducers/status-lists';
|
||||
|
||||
import { fetchStatusQuotes, expandStatusQuotes } from '../status-quotes';
|
||||
|
||||
const status = {
|
||||
account: {
|
||||
id: 'ABDSjI3Q0R8aDaz1U0',
|
||||
},
|
||||
content: 'quoast',
|
||||
id: 'AJsajx9hY4Q7IKQXEe',
|
||||
pleroma: {
|
||||
quote: {
|
||||
content: '<p>10</p>',
|
||||
id: 'AJmoVikzI3SkyITyim',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const statusId = 'AJmoVikzI3SkyITyim';
|
||||
|
||||
describe('fetchStatusQuotes()', () => {
|
||||
let store: ReturnType<typeof mockStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootState.set('me', '1234');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
const quotes = require('soapbox/__fixtures__/status-quotes.json');
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, {
|
||||
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch quotes from the API', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
|
||||
{ type: 'POLLS_IMPORT', polls: [] },
|
||||
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
|
||||
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
|
||||
{ type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null },
|
||||
];
|
||||
await store.dispatch(fetchStatusQuotes(statusId));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
|
||||
{ type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') },
|
||||
];
|
||||
await store.dispatch(fetchStatusQuotes(statusId));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandStatusQuotes()', () => {
|
||||
let store: ReturnType<typeof mockStore>;
|
||||
|
||||
describe('without a url', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootState
|
||||
.set('me', '1234')
|
||||
.set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }));
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('should do nothing', async() => {
|
||||
await store.dispatch(expandStatusQuotes(statusId));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a url', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootState.set('me', '1234')
|
||||
.set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }));
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
const quotes = require('soapbox/__fixtures__/status-quotes.json');
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet('example').reply(200, quotes, {
|
||||
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch quotes from the API', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
|
||||
{ type: 'POLLS_IMPORT', polls: [] },
|
||||
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
|
||||
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
|
||||
{ type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null },
|
||||
];
|
||||
await store.dispatch(expandStatusQuotes(statusId));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('example').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
|
||||
{ type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') },
|
||||
];
|
||||
await store.dispatch(expandStatusQuotes(statusId));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,75 @@
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
export const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST';
|
||||
export const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS';
|
||||
export const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL';
|
||||
|
||||
export const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST';
|
||||
export const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS';
|
||||
export const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL';
|
||||
|
||||
const noOp = () => new Promise(f => f(null));
|
||||
|
||||
export const fetchStatusQuotes = (statusId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
statusId,
|
||||
type: STATUS_QUOTES_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
return dispatch({
|
||||
type: STATUS_QUOTES_FETCH_SUCCESS,
|
||||
statusId,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({
|
||||
type: STATUS_QUOTES_FETCH_FAIL,
|
||||
statusId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const expandStatusQuotes = (statusId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().status_lists.getIn([`quotes:${statusId}`, 'next'], null) as string | null;
|
||||
|
||||
if (url === null || getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: STATUS_QUOTES_EXPAND_REQUEST,
|
||||
statusId,
|
||||
});
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch({
|
||||
type: STATUS_QUOTES_EXPAND_SUCCESS,
|
||||
statusId,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({
|
||||
type: STATUS_QUOTES_EXPAND_FAIL,
|
||||
statusId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
@ -1,129 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
// import classNames from 'clsx';
|
||||
// import { injectIntl, defineMessages } from 'react-intl';
|
||||
// import Icon from 'soapbox/components/icon';
|
||||
import SubNavigation from 'soapbox/components/sub-navigation';
|
||||
|
||||
// const messages = defineMessages({
|
||||
// show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
// hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
// });
|
||||
|
||||
class ColumnHeader extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
// intl: PropTypes.object.isRequired,
|
||||
title: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
extraButton: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
animating: false,
|
||||
};
|
||||
|
||||
historyBack = () => {
|
||||
if (window.history?.length === 1) {
|
||||
this.props.history.push('/');
|
||||
} else {
|
||||
this.props.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({ collapsed: !this.state.collapsed, animating: true });
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
this.historyBack();
|
||||
}
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({ animating: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title } = this.props;
|
||||
|
||||
return <SubNavigation message={title} />;
|
||||
}
|
||||
|
||||
// render() {
|
||||
// const { title, icon, active, children, extraButton, intl: { formatMessage } } = this.props;
|
||||
// const { collapsed, animating } = this.state;
|
||||
//
|
||||
// const wrapperClassName = classNames('column-header__wrapper', {
|
||||
// 'active': active,
|
||||
// });
|
||||
//
|
||||
// const buttonClassName = classNames('column-header', {
|
||||
// 'active': active,
|
||||
// });
|
||||
//
|
||||
// const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
// 'collapsed': collapsed,
|
||||
// 'animating': animating,
|
||||
// });
|
||||
//
|
||||
// const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
// 'active': !collapsed,
|
||||
// });
|
||||
//
|
||||
// let extraContent, collapseButton;
|
||||
//
|
||||
// if (children) {
|
||||
// extraContent = (
|
||||
// <div key='extra-content' className='column-header__collapsible__extra'>
|
||||
// {children}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// const collapsedContent = [
|
||||
// extraContent,
|
||||
// ];
|
||||
//
|
||||
// if (children) {
|
||||
// collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='cog' /></button>;
|
||||
// }
|
||||
//
|
||||
// const hasTitle = icon && title;
|
||||
//
|
||||
// return (
|
||||
// <div className={wrapperClassName}>
|
||||
// <h1 className={buttonClassName}>
|
||||
// {hasTitle && (
|
||||
// <button>
|
||||
// <Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
// {title}
|
||||
// </button>
|
||||
// )}
|
||||
//
|
||||
// <div className='column-header__buttons'>
|
||||
// {extraButton}
|
||||
// {collapseButton}
|
||||
// </div>
|
||||
// </h1>
|
||||
//
|
||||
// <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
||||
// <div className='column-header__collapsible-inner'>
|
||||
// {(!collapsed || animating) && collapsedContent}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(ColumnHeader);
|
@ -1,41 +0,0 @@
|
||||
// import throttle from 'lodash/throttle';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
// import { connect } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { CardHeader, CardTitle } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
interface ISubNavigation {
|
||||
message: React.ReactNode,
|
||||
/** @deprecated Unused. */
|
||||
settings?: React.ComponentType,
|
||||
}
|
||||
|
||||
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
history.push('/');
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardHeader
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
onBackClick={handleBackClick}
|
||||
>
|
||||
<CardTitle title={message} />
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubNavigation;
|
@ -0,0 +1,55 @@
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { expandStatusQuotes, fetchStatusQuotes } from 'soapbox/actions/status-quotes';
|
||||
import StatusList from 'soapbox/components/status-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((statusId: string, dispatch: React.Dispatch<any>) =>
|
||||
dispatch(expandStatusQuotes(statusId)), 300, { leading: true });
|
||||
|
||||
const Quotes: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const { statusId } = useParams<{ statusId: string }>();
|
||||
|
||||
const statusIds = useAppSelector((state) => state.status_lists.getIn([`quotes:${statusId}`, 'items'], ImmutableOrderedSet<string>()));
|
||||
const isLoading = useAppSelector((state) => state.status_lists.getIn([`quotes:${statusId}`, 'isLoading'], true));
|
||||
const hasMore = useAppSelector((state) => !!state.status_lists.getIn([`quotes:${statusId}`, 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchStatusQuotes(statusId));
|
||||
}, [statusId]);
|
||||
|
||||
const handleRefresh = async() => {
|
||||
await dispatch(fetchStatusQuotes(statusId));
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<StatusList
|
||||
statusIds={statusIds as ImmutableOrderedSet<string>}
|
||||
scrollKey={`quotes:${statusId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={() => handleLoadMore(statusId, dispatch)}
|
||||
onRefresh={handleRefresh}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType='space'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Quotes;
|
@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
|
||||
import ColumnHeader from './column-header';
|
||||
|
||||
// Yes, there are 3 types of columns at this point, but this one is better, I swear
|
||||
export default class BetterColumn extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
heading: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
active: PropTypes.bool,
|
||||
menu: PropTypes.array,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { heading, icon, children, active, menu, ...rest } = this.props;
|
||||
const columnHeaderId = heading && heading.replace(/ /g, '-');
|
||||
|
||||
return (
|
||||
<Column aria-labelledby={columnHeaderId} className='column--better' {...rest}>
|
||||
<div className='column__top'>
|
||||
{heading && <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />}
|
||||
{menu && (
|
||||
<div className='column__menu'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-v' size={18} direction='right' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// import classNames from 'clsx';
|
||||
// import Icon from 'soapbox/components/icon';
|
||||
import SubNavigation from 'soapbox/components/sub-navigation';
|
||||
|
||||
interface IColumnHeader {
|
||||
icon?: string,
|
||||
type: string
|
||||
active?: boolean,
|
||||
columnHeaderId?: string,
|
||||
}
|
||||
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ type }) => {
|
||||
return <SubNavigation message={type} />;
|
||||
};
|
||||
|
||||
export default ColumnHeader;
|
||||
|
||||
// export default class ColumnHeader extends React.PureComponent {
|
||||
|
||||
// static propTypes = {
|
||||
// icon: PropTypes.string,
|
||||
// type: PropTypes.string,
|
||||
// active: PropTypes.bool,
|
||||
// onClick: PropTypes.func,
|
||||
// columnHeaderId: PropTypes.string,
|
||||
// };
|
||||
|
||||
// handleClick = () => {
|
||||
// this.props.onClick();
|
||||
// }
|
||||
|
||||
// render() {
|
||||
// const { icon, type, active, columnHeaderId } = this.props;
|
||||
|
||||
// return (
|
||||
// <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
|
||||
// <button onClick={this.handleClick}>
|
||||
// {icon && <Icon id={icon} fixedWidth className='column-header__icon' />}
|
||||
// {type}
|
||||
// </button>
|
||||
// </h1>
|
||||
// );
|
||||
// }
|
||||
|
||||
// }
|
@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import Pullable from 'soapbox/components/pullable';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
|
||||
import ColumnHeader from './column-header';
|
||||
|
||||
import type { IColumn } from 'soapbox/components/ui/column/column';
|
||||
|
||||
interface IUIColumn extends IColumn {
|
||||
heading?: string,
|
||||
icon?: string,
|
||||
active?: boolean,
|
||||
}
|
||||
|
||||
const UIColumn: React.FC<IUIColumn> = ({
|
||||
heading,
|
||||
icon,
|
||||
children,
|
||||
active,
|
||||
...rest
|
||||
}) => {
|
||||
const columnHeaderId = heading && heading.replace(/ /g, '-');
|
||||
|
||||
return (
|
||||
<Column aria-labelledby={columnHeaderId} {...rest}>
|
||||
{heading && <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />}
|
||||
<Pullable>
|
||||
{children}
|
||||
</Pullable>
|
||||
</Column>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default UIColumn;
|
Loading…
Reference in new issue