From 443b960067d32715f0ba345079854d214f9734ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 29 Nov 2022 23:32:21 +0000 Subject: [PATCH 1/3] Show quoted statuses list --- app/soapbox/__fixtures__/status-quotes.json | 15 ++ .../actions/__tests__/status-quotes.test.ts | 150 ++++++++++++++++++ app/soapbox/actions/status-quotes.ts | 75 +++++++++ app/soapbox/features/quotes/index.tsx | 55 +++++++ .../components/status-interaction-bar.tsx | 26 +++ app/soapbox/features/ui/index.tsx | 2 + .../features/ui/util/async-components.ts | 4 + app/soapbox/normalizers/status.ts | 3 + app/soapbox/reducers/status-lists.ts | 21 ++- 9 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 app/soapbox/__fixtures__/status-quotes.json create mode 100644 app/soapbox/actions/__tests__/status-quotes.test.ts create mode 100644 app/soapbox/actions/status-quotes.ts create mode 100644 app/soapbox/features/quotes/index.tsx diff --git a/app/soapbox/__fixtures__/status-quotes.json b/app/soapbox/__fixtures__/status-quotes.json new file mode 100644 index 000000000..d74a149c9 --- /dev/null +++ b/app/soapbox/__fixtures__/status-quotes.json @@ -0,0 +1,15 @@ +[ + { + "account": { + "id": "ABDSjI3Q0R8aDaz1U0" + }, + "content": "quoast", + "id": "AJsajx9hY4Q7IKQXEe", + "pleroma": { + "quote": { + "content": "

10

", + "id": "AJmoVikzI3SkyITyim" + } + } + } +] diff --git a/app/soapbox/actions/__tests__/status-quotes.test.ts b/app/soapbox/actions/__tests__/status-quotes.test.ts new file mode 100644 index 000000000..1e68dc882 --- /dev/null +++ b/app/soapbox/actions/__tests__/status-quotes.test.ts @@ -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: '

10

', + id: 'AJmoVikzI3SkyITyim', + }, + }, +}; + +const statusId = 'AJmoVikzI3SkyITyim'; + +describe('fetchStatusQuotes()', () => { + let store: ReturnType; + + 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: `; 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; + + 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: `; 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); + }); + }); + }); +}); diff --git a/app/soapbox/actions/status-quotes.ts b/app/soapbox/actions/status-quotes.ts new file mode 100644 index 000000000..9dab8df46 --- /dev/null +++ b/app/soapbox/actions/status-quotes.ts @@ -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, + }); + }); + }; diff --git a/app/soapbox/features/quotes/index.tsx b/app/soapbox/features/quotes/index.tsx new file mode 100644 index 000000000..a93fc8317 --- /dev/null +++ b/app/soapbox/features/quotes/index.tsx @@ -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) => + 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())); + 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 = ; + + return ( + + } + scrollKey={`quotes:${statusId}`} + hasMore={hasMore} + isLoading={typeof isLoading === 'boolean' ? isLoading : true} + onLoadMore={() => handleLoadMore(statusId, dispatch)} + onRefresh={handleRefresh} + emptyMessage={emptyMessage} + divideType='space' + /> + + ); +}; + +export default Quotes; diff --git a/app/soapbox/features/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx index f888cb059..ef22ba14b 100644 --- a/app/soapbox/features/status/components/status-interaction-bar.tsx +++ b/app/soapbox/features/status/components/status-interaction-bar.tsx @@ -3,6 +3,7 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; import { FormattedMessage, FormattedNumber } from 'react-intl'; import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; import { HStack, Text, Emoji } from 'soapbox/components/ui'; @@ -16,6 +17,8 @@ interface IStatusInteractionBar { } const StatusInteractionBar: React.FC = ({ status }): JSX.Element | null => { + const history = useHistory(); + const me = useAppSelector(({ me }) => me); const { allowedEmoji } = useSoapboxConfig(); const dispatch = useDispatch(); @@ -81,6 +84,28 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. return null; }; + const navigateToQuotes: React.EventHandler = (e) => { + e.preventDefault(); + + history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}/quotes`); + }; + + const getQuotes = () => { + if (status.quotes_count) { + return ( + + + + ); + } + + return null; + }; + const handleOpenFavouritesModal: React.EventHandler> = (e) => { e.preventDefault(); @@ -142,6 +167,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. return ( {getReposts()} + {getQuotes()} {features.emojiReacts ? getEmojiReacts() : getFavourites()} ); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index c280aafb1..6e6b7427b 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -109,6 +109,7 @@ import { TestTimeline, LogoutPage, AuthTokenList, + Quotes, ServiceWorkerInfo, } from './util/async-components'; import { WrappedRoute } from './util/react-router-helpers'; @@ -265,6 +266,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { + diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 23f221cea..c7d0e2946 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -505,3 +505,7 @@ export function FamiliarFollowersModal() { export function AnnouncementsPanel() { return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel'); } + +export function Quotes() { + return import(/*webpackChunkName: "features/quotes" */'../../quotes'); +} diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 41ebacfc7..120a4b62b 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -45,6 +45,7 @@ export const StatusRecord = ImmutableRecord({ pleroma: ImmutableMap(), poll: null as EmbeddedEntity, quote: null as EmbeddedEntity, + quotes_count: 0, reblog: null as EmbeddedEntity, reblogged: false, reblogs_count: 0, @@ -142,6 +143,8 @@ const fixQuote = (status: ImmutableMap) => { return status.withMutations(status => { status.update('quote', quote => quote || status.getIn(['pleroma', 'quote']) || null); status.deleteIn(['pleroma', 'quote']); + status.update('quotes_count', quotes_count => quotes_count || status.getIn(['pleroma', 'quotes_count'], 0)); + status.deleteIn(['pleroma', 'quotes_count']); }); }; diff --git a/app/soapbox/reducers/status-lists.ts b/app/soapbox/reducers/status-lists.ts index 3a19c5eea..64373f0e2 100644 --- a/app/soapbox/reducers/status-lists.ts +++ b/app/soapbox/reducers/status-lists.ts @@ -4,6 +4,15 @@ import { Record as ImmutableRecord, } from 'immutable'; +import { + STATUS_QUOTES_EXPAND_FAIL, + STATUS_QUOTES_EXPAND_REQUEST, + STATUS_QUOTES_EXPAND_SUCCESS, + STATUS_QUOTES_FETCH_FAIL, + STATUS_QUOTES_FETCH_REQUEST, + STATUS_QUOTES_FETCH_SUCCESS, +} from 'soapbox/actions/status-quotes'; + import { BOOKMARKED_STATUSES_FETCH_REQUEST, BOOKMARKED_STATUSES_FETCH_SUCCESS, @@ -51,7 +60,7 @@ import { import type { AnyAction } from 'redux'; import type { Status as StatusEntity } from 'soapbox/types/entities'; -const StatusListRecord = ImmutableRecord({ +export const StatusListRecord = ImmutableRecord({ next: null as string | null, loaded: false, isLoading: null as boolean | null, @@ -168,6 +177,16 @@ export default function statusLists(state = initialState, action: AnyAction) { case SCHEDULED_STATUS_CANCEL_REQUEST: case SCHEDULED_STATUS_CANCEL_SUCCESS: return removeOneFromList(state, 'scheduled_statuses', action.id || action.status.id); + case STATUS_QUOTES_FETCH_REQUEST: + case STATUS_QUOTES_EXPAND_REQUEST: + return setLoading(state, `quotes:${action.statusId}`, true); + case STATUS_QUOTES_FETCH_FAIL: + case STATUS_QUOTES_EXPAND_FAIL: + return setLoading(state, `quotes:${action.statusId}`, false); + case STATUS_QUOTES_FETCH_SUCCESS: + return normalizeList(state, `quotes:${action.statusId}`, action.statuses, action.next); + case STATUS_QUOTES_EXPAND_SUCCESS: + return appendToList(state, `quotes:${action.statusId}`, action.statuses, action.next); default: return state; } From 7675860429d80cfd50b8a8c5f8b4abd09a694bc5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Nov 2022 11:19:16 -0600 Subject: [PATCH 2/3] Refactor columns --- app/soapbox/components/column-header.js | 129 ------------------ app/soapbox/components/sub-navigation.tsx | 41 ------ app/soapbox/components/ui/card/card.tsx | 5 +- .../ui/column/__tests__/column.test.tsx | 2 +- app/soapbox/components/ui/column/column.tsx | 77 ++++++----- app/soapbox/components/ui/index.ts | 2 +- app/soapbox/features/admin/moderation-log.tsx | 5 +- app/soapbox/features/admin/user-index.js | 2 +- app/soapbox/features/backups/index.tsx | 9 +- app/soapbox/features/bookmarks/index.tsx | 6 +- .../features/community-timeline/index.tsx | 7 +- .../features/developers/apps/create.tsx | 3 +- .../developers/developers-challenge.tsx | 4 +- .../features/developers/developers-menu.tsx | 4 +- app/soapbox/features/domain-blocks/index.tsx | 6 +- app/soapbox/features/export-data/index.tsx | 5 +- .../features/favourited-statuses/index.tsx | 3 +- .../federation-restrictions/index.tsx | 6 +- .../features/follow-recommendations/index.tsx | 3 +- .../features/follow-requests/index.tsx | 6 +- app/soapbox/features/followers/index.tsx | 4 +- app/soapbox/features/following/index.tsx | 4 +- .../features/hashtag-timeline/index.tsx | 31 +++-- app/soapbox/features/import-data/index.tsx | 5 +- app/soapbox/features/list-timeline/index.tsx | 46 +------ .../features/pinned-statuses/index.tsx | 5 +- .../features/public-timeline/index.tsx | 7 +- .../features/remote-timeline/index.tsx | 42 +++--- .../features/scheduled-statuses/index.tsx | 5 +- app/soapbox/features/status/index.tsx | 7 +- app/soapbox/features/test-timeline/index.tsx | 4 +- .../features/ui/components/better-column.js | 39 ------ .../ui/components/column-forbidden.tsx | 2 +- .../features/ui/components/column-header.tsx | 47 ------- app/soapbox/features/ui/components/column.tsx | 36 ----- app/soapbox/features/ui/index.tsx | 2 +- 36 files changed, 123 insertions(+), 488 deletions(-) delete mode 100644 app/soapbox/components/column-header.js delete mode 100644 app/soapbox/components/sub-navigation.tsx delete mode 100644 app/soapbox/features/ui/components/better-column.js delete mode 100644 app/soapbox/features/ui/components/column-header.tsx delete mode 100644 app/soapbox/features/ui/components/column.tsx diff --git a/app/soapbox/components/column-header.js b/app/soapbox/components/column-header.js deleted file mode 100644 index 2d915c9f8..000000000 --- a/app/soapbox/components/column-header.js +++ /dev/null @@ -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 ; - } - - // 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 = ( - //
- // {children} - //
- // ); - // } - // - // const collapsedContent = [ - // extraContent, - // ]; - // - // if (children) { - // collapseButton = ; - // } - // - // const hasTitle = icon && title; - // - // return ( - //
- //

- // {hasTitle && ( - // - // )} - // - //
- // {extraButton} - // {collapseButton} - //
- //

- // - //
- //
- // {(!collapsed || animating) && collapsedContent} - //
- //
- //
- // ); - // } - -} - -export default withRouter(ColumnHeader); \ No newline at end of file diff --git a/app/soapbox/components/sub-navigation.tsx b/app/soapbox/components/sub-navigation.tsx deleted file mode 100644 index 1e6afb85a..000000000 --- a/app/soapbox/components/sub-navigation.tsx +++ /dev/null @@ -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 = ({ message }) => { - const intl = useIntl(); - const history = useHistory(); - - const handleBackClick = () => { - if (window.history && window.history.length === 1) { - history.push('/'); - } else { - history.goBack(); - } - }; - - return ( - - - - ); -}; - -export default SubNavigation; diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 675a9d15b..fd01e5a94 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -44,13 +44,14 @@ const Card = React.forwardRef(({ children, variant = 'def interface ICardHeader { backHref?: string, onBackClick?: (event: React.MouseEvent) => void + className?: string } /** * Card header container with back button. * Typically holds a CardTitle. */ -const CardHeader: React.FC = ({ children, backHref, onBackClick }): JSX.Element => { +const CardHeader: React.FC = ({ className, children, backHref, onBackClick }): JSX.Element => { const intl = useIntl(); const renderBackButton = () => { @@ -70,7 +71,7 @@ const CardHeader: React.FC = ({ children, backHref, onBackClick }): }; return ( - + {renderBackButton()} {children} diff --git a/app/soapbox/components/ui/column/__tests__/column.test.tsx b/app/soapbox/components/ui/column/__tests__/column.test.tsx index 6241773fa..f4796dfaf 100644 --- a/app/soapbox/components/ui/column/__tests__/column.test.tsx +++ b/app/soapbox/components/ui/column/__tests__/column.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '../../../../jest/test-helpers'; -import Column from '../column'; +import { Column } from '../column'; describe('', () => { it('renders correctly with minimal props', () => { diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index e0e86a398..f68bad0e6 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -1,3 +1,4 @@ +import classNames from 'clsx'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -6,6 +7,38 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; +interface IColumnHeader extends Pick { + children?: React.ReactNode +} + +/** Contains the column title with optional back button. */ +const ColumnHeader: React.FC = ({ label, backHref, transparent, children }) => { + const history = useHistory(); + + const handleBackClick = () => { + if (backHref) { + history.push(backHref); + return; + } + + if (history.length === 1) { + history.push('/'); + } else { + history.goBack(); + } + }; + + return ( + + + {children} + + ); +}; + export interface IColumn { /** Route the back button goes to. */ backHref?: string, @@ -24,37 +57,8 @@ export interface IColumn { /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { const { backHref, children, label, transparent = false, withHeader = true, className } = props; - - const history = useHistory(); const soapboxConfig = useSoapboxConfig(); - const handleBackClick = () => { - if (backHref) { - history.push(backHref); - return; - } - - if (history.length === 1) { - history.push('/'); - } else { - history.goBack(); - } - }; - - const renderChildren = () => ( - - {withHeader ? ( - - - - ) : null} - - - {children} - - - ); - return (
@@ -69,9 +73,20 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR )} - {renderChildren()} + + {withHeader && ( + + )} + + + {children} + +
); }); -export default Column; +export { + Column, + ColumnHeader, +}; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 4953ed9eb..490392742 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -4,7 +4,7 @@ export { default as Banner } from './banner/banner'; export { default as Button } from './button/button'; export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Checkbox } from './checkbox/checkbox'; -export { default as Column } from './column/column'; +export { Column, ColumnHeader } from './column/column'; export { default as Counter } from './counter/counter'; export { default as Datepicker } from './datepicker/datepicker'; export { default as Divider } from './divider/divider'; diff --git a/app/soapbox/features/admin/moderation-log.tsx b/app/soapbox/features/admin/moderation-log.tsx index 063e3092a..baf002eb4 100644 --- a/app/soapbox/features/admin/moderation-log.tsx +++ b/app/soapbox/features/admin/moderation-log.tsx @@ -3,10 +3,9 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl'; import { fetchModerationLog } from 'soapbox/actions/admin'; import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import Column from '../ui/components/column'; - const messages = defineMessages({ heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' }, emptyMessage: { id: 'admin.moderation_log.empty_message', defaultMessage: 'You have not performed any moderation actions yet. When you do, a history will be shown here.' }, @@ -47,7 +46,7 @@ const ModerationLog = () => { }; return ( - + { ); return ( - + { const emptyMessage = ; return ( - -
- -
+ { }, [onlyMedia]); return ( - -
- -
- + { const emptyMessage = ; return ( - + handleLoadMore(dispatch)} diff --git a/app/soapbox/features/export-data/index.tsx b/app/soapbox/features/export-data/index.tsx index 5883f5ac2..27273c339 100644 --- a/app/soapbox/features/export-data/index.tsx +++ b/app/soapbox/features/export-data/index.tsx @@ -6,8 +6,7 @@ import { exportBlocks, exportMutes, } from 'soapbox/actions/export-data'; - -import Column from '../ui/components/column'; +import { Column } from 'soapbox/components/ui'; import CSVExporter from './components/csv-exporter'; @@ -38,7 +37,7 @@ const ExportData = () => { const intl = useIntl(); return ( - + diff --git a/app/soapbox/features/favourited-statuses/index.tsx b/app/soapbox/features/favourited-statuses/index.tsx index fe284fce1..1e381d37e 100644 --- a/app/soapbox/features/favourited-statuses/index.tsx +++ b/app/soapbox/features/favourited-statuses/index.tsx @@ -7,11 +7,10 @@ import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; import MissingIndicator from 'soapbox/components/missing-indicator'; import StatusList from 'soapbox/components/status-list'; +import { Column } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { findAccountByUsername } from 'soapbox/selectors'; -import Column from '../ui/components/column'; - const messages = defineMessages({ heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, }); diff --git a/app/soapbox/features/federation-restrictions/index.tsx b/app/soapbox/features/federation-restrictions/index.tsx index f0b1a3ae2..47f5a218b 100644 --- a/app/soapbox/features/federation-restrictions/index.tsx +++ b/app/soapbox/features/federation-restrictions/index.tsx @@ -2,13 +2,11 @@ import React, { useState, useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Accordion } from 'soapbox/components/ui'; +import { Column, Accordion } from 'soapbox/components/ui'; import { useAppSelector, useInstance } from 'soapbox/hooks'; import { makeGetHosts } from 'soapbox/selectors'; import { federationRestrictionsDisclosed } from 'soapbox/utils/state'; -import Column from '../ui/components/column'; - import RestrictedInstance from './components/restricted-instance'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; @@ -39,7 +37,7 @@ const FederationRestrictions = () => { const emptyMessage = disclosed ? messages.emptyMessage : messages.notDisclosed; return ( - + { const emptyMessage = ; return ( - + handleLoadMore(dispatch)} diff --git a/app/soapbox/features/followers/index.tsx b/app/soapbox/features/followers/index.tsx index d9025499b..c01dae0bb 100644 --- a/app/soapbox/features/followers/index.tsx +++ b/app/soapbox/features/followers/index.tsx @@ -11,13 +11,11 @@ import { } from 'soapbox/actions/accounts'; import MissingIndicator from 'soapbox/components/missing-indicator'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Spinner } from 'soapbox/components/ui'; +import { Column, Spinner } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { findAccountByUsername } from 'soapbox/selectors'; -import Column from '../ui/components/column'; - const messages = defineMessages({ heading: { id: 'column.followers', defaultMessage: 'Followers' }, }); diff --git a/app/soapbox/features/following/index.tsx b/app/soapbox/features/following/index.tsx index a189fddf1..2392edd8d 100644 --- a/app/soapbox/features/following/index.tsx +++ b/app/soapbox/features/following/index.tsx @@ -11,13 +11,11 @@ import { } from 'soapbox/actions/accounts'; import MissingIndicator from 'soapbox/components/missing-indicator'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Spinner } from 'soapbox/components/ui'; +import { Column, Spinner } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { findAccountByUsername } from 'soapbox/selectors'; -import Column from '../ui/components/column'; - const messages = defineMessages({ heading: { id: 'column.following', defaultMessage: 'Following' }, }); diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 2282bc1c7..133a96a5f 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useRef } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; -import SubNavigation from 'soapbox/components/sub-navigation'; import { Column } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; import { useAppDispatch } from 'soapbox/hooks'; @@ -15,6 +14,13 @@ type Mode = 'any' | 'all' | 'none'; type Tag = { value: string }; type Tags = { [k in Mode]: Tag[] }; +const messages = defineMessages({ + any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, + all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, + none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, + empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, +}); + interface IHashtagTimeline { params?: { id?: string, @@ -23,6 +29,7 @@ interface IHashtagTimeline { } export const HashtagTimeline: React.FC = ({ params }) => { + const intl = useIntl(); const id = params?.id || ''; const tags = params?.tags || { any: [], all: [], none: [] }; @@ -31,22 +38,22 @@ export const HashtagTimeline: React.FC = ({ params }) => { // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 - const title = () => { - const title: React.ReactNode[] = [`#${id}`]; + const title = (): string => { + const title: string[] = [`#${id}`]; if (additionalFor('any')) { - title.push(' ', ); + title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('any') })); } if (additionalFor('all')) { - title.push(' ', ); + title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('all') })); } if (additionalFor('none')) { - title.push(' ', ); + title.push(' ', intl.formatMessage(messages.any, { additional: additionalFor('none') })); } - return title; + return title.join(''); }; const additionalFor = (mode: Mode) => { @@ -98,16 +105,12 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - -
- -
- + } + emptyMessage={intl.formatMessage(messages.empty)} divideType='space' /> diff --git a/app/soapbox/features/import-data/index.tsx b/app/soapbox/features/import-data/index.tsx index 9955ec6c3..812fb20fb 100644 --- a/app/soapbox/features/import-data/index.tsx +++ b/app/soapbox/features/import-data/index.tsx @@ -6,8 +6,7 @@ import { importBlocks, importMutes, } from 'soapbox/actions/import-data'; - -import Column from '../ui/components/column'; +import { Column } from 'soapbox/components/ui'; import CSVImporter from './components/csv-importer'; @@ -38,7 +37,7 @@ const ImportData = () => { const intl = useIntl(); return ( - + diff --git a/app/soapbox/features/list-timeline/index.tsx b/app/soapbox/features/list-timeline/index.tsx index c8acc7a4c..9751f2440 100644 --- a/app/soapbox/features/list-timeline/index.tsx +++ b/app/soapbox/features/list-timeline/index.tsx @@ -7,26 +7,16 @@ import { openModal } from 'soapbox/actions/modals'; import { connectListStream } from 'soapbox/actions/streaming'; import { expandListTimeline } from 'soapbox/actions/timelines'; import MissingIndicator from 'soapbox/components/missing-indicator'; -import { Button, Spinner } from 'soapbox/components/ui'; -import Column from 'soapbox/features/ui/components/column'; +import { Column, Button, Spinner } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; -// const messages = defineMessages({ -// deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' }, -// deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, -// deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, -// }); - const ListTimeline: React.FC = () => { const dispatch = useAppDispatch(); const { id } = useParams<{ id: string }>(); - // const intl = useIntl(); - // const history = useHistory(); const list = useAppSelector((state) => state.lists.get(id)); - // const hasUnread = useAppSelector((state) => state.timelines.get(`list:${props.params.id}`)?.unread > 0); useEffect(() => { dispatch(fetchList(id)); @@ -47,19 +37,6 @@ const ListTimeline: React.FC = () => { dispatch(openModal('LIST_EDITOR', { listId: id })); }; - // const handleDeleteClick = () => { - // dispatch(openModal('CONFIRM', { - // icon: require('@tabler/icons/trash.svg'), - // heading: intl.formatMessage(messages.deleteHeading), - // message: intl.formatMessage(messages.deleteMessage), - // confirm: intl.formatMessage(messages.deleteConfirm), - // onConfirm: () => { - // dispatch(deleteList(id)); - // history.push('/lists'); - // }, - // })); - // }; - const title = list ? list.title : id; if (typeof list === 'undefined') { @@ -85,26 +62,7 @@ const ListTimeline: React.FC = () => { ); return ( - - {/* -
- - - - -
- - - - - -
-
*/} - + { } return ( - + { }, [onlyMedia]); return ( - -
- -
- + {showExplanationBox &&
diff --git a/app/soapbox/features/remote-timeline/index.tsx b/app/soapbox/features/remote-timeline/index.tsx index a6963df48..67d8c31d5 100644 --- a/app/soapbox/features/remote-timeline/index.tsx +++ b/app/soapbox/features/remote-timeline/index.tsx @@ -1,11 +1,10 @@ import React, { useEffect, useRef } from 'react'; -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { connectRemoteStream } from 'soapbox/actions/streaming'; import { expandRemoteTimeline } from 'soapbox/actions/timelines'; import IconButton from 'soapbox/components/icon-button'; -import SubNavigation from 'soapbox/components/sub-navigation'; import { Column, HStack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; @@ -13,10 +12,6 @@ import Timeline from '../ui/components/timeline'; import PinnedHostsPicker from './components/pinned-hosts-picker'; -const messages = defineMessages({ - heading: { id: 'column.remote', defaultMessage: 'Federated timeline' }, -}); - interface IRemoteTimeline { params?: { instance?: string, @@ -25,7 +20,6 @@ interface IRemoteTimeline { /** View statuses from a remote instance. */ const RemoteTimeline: React.FC = ({ params }) => { - const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); @@ -65,25 +59,21 @@ const RemoteTimeline: React.FC = ({ params }) => { }, [onlyMedia]); return ( - -
- - - {instance && } - - {!pinned && ( - - - - - - - )} -
+ + {instance && } + + {!pinned && ( + + + + + + + )} { const emptyMessage = ; return ( - + = (props) => { } return ( - -
- -
- +
diff --git a/app/soapbox/features/test-timeline/index.tsx b/app/soapbox/features/test-timeline/index.tsx index 7102d9602..1c64ecad5 100644 --- a/app/soapbox/features/test-timeline/index.tsx +++ b/app/soapbox/features/test-timeline/index.tsx @@ -4,7 +4,6 @@ import { useDispatch } from 'react-redux'; import { importFetchedStatuses } from 'soapbox/actions/importer'; import { expandTimelineSuccess } from 'soapbox/actions/timelines'; -import SubNavigation from 'soapbox/components/sub-navigation'; import { Column } from '../../components/ui'; import Timeline from '../ui/components/timeline'; @@ -40,8 +39,7 @@ const TestTimeline: React.FC = () => { }, []); return ( - - + -
- {heading && } - {menu && ( -
- -
- )} -
- {children} -
- ); - } - -} diff --git a/app/soapbox/features/ui/components/column-forbidden.tsx b/app/soapbox/features/ui/components/column-forbidden.tsx index 001c82417..86d4e7ab1 100644 --- a/app/soapbox/features/ui/components/column-forbidden.tsx +++ b/app/soapbox/features/ui/components/column-forbidden.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import Column from './column'; +import { Column } from 'soapbox/components/ui'; const messages = defineMessages({ title: { id: 'column_forbidden.title', defaultMessage: 'Forbidden' }, diff --git a/app/soapbox/features/ui/components/column-header.tsx b/app/soapbox/features/ui/components/column-header.tsx deleted file mode 100644 index 2c61be235..000000000 --- a/app/soapbox/features/ui/components/column-header.tsx +++ /dev/null @@ -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 = ({ type }) => { - return ; -}; - -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 ( -//

-// -//

-// ); -// } - -// } diff --git a/app/soapbox/features/ui/components/column.tsx b/app/soapbox/features/ui/components/column.tsx deleted file mode 100644 index 9604049b4..000000000 --- a/app/soapbox/features/ui/components/column.tsx +++ /dev/null @@ -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 = ({ - heading, - icon, - children, - active, - ...rest -}) => { - const columnHeaderId = heading && heading.replace(/ /g, '-'); - - return ( - - {heading && } - - {children} - - - ); - -}; - -export default UIColumn; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 6e6b7427b..9c1b5c545 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -240,7 +240,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { {features.lists && } - {features.lists && } + {features.lists && } {features.bookmarks && } From f93f4c9a8bb8750e2d3beb486f2a05cc2ba673a1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Nov 2022 11:38:03 -0600 Subject: [PATCH 3/3] ColumnHeader: remove unused `children` prop --- app/soapbox/components/ui/column/column.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index f68bad0e6..62aede9b2 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -7,12 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; -interface IColumnHeader extends Pick { - children?: React.ReactNode -} +type IColumnHeader = Pick; /** Contains the column title with optional back button. */ -const ColumnHeader: React.FC = ({ label, backHref, transparent, children }) => { +const ColumnHeader: React.FC = ({ label, backHref, transparent }) => { const history = useHistory(); const handleBackClick = () => { @@ -34,7 +32,6 @@ const ColumnHeader: React.FC = ({ label, backHref, transparent, c onBackClick={handleBackClick} > - {children} ); };