diff --git a/.eslintrc.js b/.eslintrc.js index bfe92311d4..c140fa5245 100644 Binary files a/.eslintrc.js and b/.eslintrc.js differ diff --git a/app/soapbox/actions/__tests__/statuses-test.js b/app/soapbox/actions/__tests__/statuses-test.js index 463b87437a..b001754015 100644 Binary files a/app/soapbox/actions/__tests__/statuses-test.js and b/app/soapbox/actions/__tests__/statuses-test.js differ diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index 34ed699f84..05d08b7688 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -11,8 +11,7 @@ import { createSelector } from 'reselect'; import * as BuildConfig from 'soapbox/build_config'; import { RootState } from 'soapbox/store'; -import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth'; -import { isURL } from 'soapbox/utils/auth'; +import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/auth'; /** Parse Link headers, mostly for pagination. diff --git a/app/soapbox/components/__tests__/quoted-status.test.tsx b/app/soapbox/components/__tests__/quoted-status.test.tsx new file mode 100644 index 0000000000..208a913b2a --- /dev/null +++ b/app/soapbox/components/__tests__/quoted-status.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { render, screen, rootState } from '../../jest/test-helpers'; +import { normalizeStatus, normalizeAccount } from '../../normalizers'; +import QuotedStatus from '../quoted-status'; + +describe('', () => { + it('renders content', () => { + const account = normalizeAccount({ + id: '1', + acct: 'alex', + }); + + const status = normalizeStatus({ + id: '1', + account, + content: 'hello world', + contentHtml: 'hello world', + }); + + const state = rootState.setIn(['accounts', '1', account]); + + render(, null, state); + screen.getByText(/hello world/i); + expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i); + }); +}); diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js index 44b08a3df4..5e528d4be0 100644 Binary files a/app/soapbox/components/media_gallery.js and b/app/soapbox/components/media_gallery.js differ diff --git a/app/soapbox/features/status/components/quoted_status.tsx b/app/soapbox/components/quoted-status.tsx similarity index 85% rename from app/soapbox/features/status/components/quoted_status.tsx rename to app/soapbox/components/quoted-status.tsx index bf8069aa8d..5d6eb526e2 100644 --- a/app/soapbox/features/status/components/quoted_status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -1,11 +1,13 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl, FormattedMessage, FormattedList } from 'react-intl'; import { useHistory } from 'react-router-dom'; import StatusMedia from 'soapbox/components/status-media'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; +import { useSettings } from 'soapbox/hooks'; +import { defaultMediaVisibility } from 'soapbox/utils/status'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; @@ -27,6 +29,11 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => const intl = useIntl(); const history = useHistory(); + const settings = useSettings(); + const displayMedia = settings.get('displayMedia'); + + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const handleExpandClick = (e: React.MouseEvent) => { if (!status) return; const account = status.account as AccountEntity; @@ -44,6 +51,10 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => } }; + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; + const renderReplyMentions = () => { if (!status?.in_reply_to_id) { return null; @@ -113,6 +124,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => return ( = ({ status, onCancel, compose }) => dangerouslySetInnerHTML={{ __html: status.contentHtml }} /> - + ); }; diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 7d47baa470..c53ec0b407 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -4,8 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; import { Link, NavLink } from 'react-router-dom'; -import { logOut, switchAccount } from 'soapbox/actions/auth'; -import { fetchOwnAccounts } from 'soapbox/actions/auth'; +import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import { getSettings } from 'soapbox/actions/settings'; import { closeSidebar } from 'soapbox/actions/sidebar'; import Account from 'soapbox/components/account'; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 2b1325a60d..4389b1fc19 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -8,6 +8,7 @@ import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; +import { defaultMediaVisibility } from 'soapbox/utils/status'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; @@ -50,16 +51,6 @@ export const textForScreenReader = (intl: IntlShape, status: StatusEntity, reblo return values.join(', '); }; -export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => { - if (!status) return false; - - if (status.reblog && typeof status.reblog === 'object') { - status = status.reblog; - } - - return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); -}; - interface IStatus extends RouteComponentProps { id?: string, contextType?: string, @@ -431,7 +422,7 @@ class Status extends ImmutablePureComponent { ); } else { - quote = ; + quote = ; } } diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index f508526984..dbb78b8d74 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; +import { openModal } from 'soapbox/actions/modals'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; @@ -13,8 +14,6 @@ import { isUserTouching } from 'soapbox/is_mobile'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; import { getFeatures } from 'soapbox/utils/features'; -import { openModal } from '../actions/modals'; - import type { History } from 'history'; import type { AnyAction, Dispatch } from 'redux'; import type { Menu } from 'soapbox/components/dropdown_menu'; diff --git a/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx b/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx new file mode 100644 index 0000000000..5fe6ca9d69 --- /dev/null +++ b/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx @@ -0,0 +1,83 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { queryAllByRole, render, screen } from '../../../../jest/test-helpers'; +import Datepicker from '../datepicker'; + +describe('', () => { + it('defaults to the current date', () => { + const handler = jest.fn(); + render(); + const today = new Date(); + + expect(screen.getByTestId('datepicker-month')).toHaveValue(String(today.getMonth())); + expect(screen.getByTestId('datepicker-day')).toHaveValue(String(today.getDate())); + expect(screen.getByTestId('datepicker-year')).toHaveValue(String(today.getFullYear())); + }); + + it('changes number of days based on selected month and year', async() => { + const handler = jest.fn(); + render(); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-month'), + screen.getByRole('option', { name: 'February' }), + ); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2020' }), + ); + + let daySelect: HTMLElement; + daySelect = document.querySelector('[data-testid="datepicker-day"]'); + expect(queryAllByRole(daySelect, 'option')).toHaveLength(29); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2021' }), + ); + + daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement; + expect(queryAllByRole(daySelect, 'option')).toHaveLength(28); + }); + + it('ranges from the current year to 120 years ago', () => { + const handler = jest.fn(); + render(); + const today = new Date(); + + const yearSelect = document.querySelector('[data-testid="datepicker-year"]') as HTMLElement; + expect(queryAllByRole(yearSelect, 'option')).toHaveLength(121); + expect(queryAllByRole(yearSelect, 'option')[0]).toHaveValue(String(today.getFullYear())); + expect(queryAllByRole(yearSelect, 'option')[120]).toHaveValue(String(today.getFullYear() - 120)); + }); + + it('calls the onChange function when the inputs change', async() => { + const handler = jest.fn(); + render(); + + expect(handler.mock.calls.length).toEqual(1); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-month'), + screen.getByRole('option', { name: 'February' }), + ); + + expect(handler.mock.calls.length).toEqual(2); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2020' }), + ); + + expect(handler.mock.calls.length).toEqual(3); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-day'), + screen.getByRole('option', { name: '5' }), + ); + + expect(handler.mock.calls.length).toEqual(4); + }); +}); diff --git a/app/soapbox/components/ui/datepicker/datepicker.tsx b/app/soapbox/components/ui/datepicker/datepicker.tsx new file mode 100644 index 0000000000..3c3c9b8e25 --- /dev/null +++ b/app/soapbox/components/ui/datepicker/datepicker.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import Select from '../select/select'; +import Stack from '../stack/stack'; +import Text from '../text/text'; + +const getDaysInMonth = (month: number, year: number) => new Date(year, month + 1, 0).getDate(); +const currentYear = new Date().getFullYear(); + +interface IDatepicker { + onChange(date: Date): void +} + +/** + * Datepicker that allows a user to select month, day, and year. + */ +const Datepicker = ({ onChange }: IDatepicker) => { + const intl = useIntl(); + + const [month, setMonth] = useState(new Date().getMonth()); + const [day, setDay] = useState(new Date().getDate()); + const [year, setYear] = useState(2022); + + const numberOfDays = useMemo(() => { + return getDaysInMonth(month, year); + }, [month, year]); + + useEffect(() => { + onChange(new Date(year, month, day)); + }, [month, day, year]); + + return ( +
+
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+
+ ); +}; + +export default Datepicker; diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index 75517fc79d..ec0da73e4d 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -8,6 +8,8 @@ import Stack from '../stack/stack'; interface IFormGroup { /** Input label message. */ labelText?: React.ReactNode, + /** Input label tooltip message. */ + labelTitle?: string, /** Input hint message. */ hintText?: React.ReactNode, /** Input errors. */ @@ -16,7 +18,7 @@ interface IFormGroup { /** Input container with label. Renders the child. */ const FormGroup: React.FC = (props) => { - const { children, errors = [], labelText, hintText } = props; + const { children, errors = [], labelText, labelTitle, hintText } = props; const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []); const inputChildren = React.Children.toArray(children); const hasError = errors?.length > 0; @@ -41,6 +43,7 @@ const FormGroup: React.FC = (props) => { htmlFor={formFieldId} data-testid='form-group-label' className='-mt-0.5 block text-sm font-medium text-gray-700 dark:text-gray-400' + title={labelTitle} > {labelText} @@ -74,6 +77,7 @@ const FormGroup: React.FC = (props) => { htmlFor={formFieldId} data-testid='form-group-label' className='block text-sm font-medium text-gray-700 dark:text-gray-400' + title={labelTitle} > {labelText} diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 24324ee02a..27acc184b2 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -4,6 +4,7 @@ export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Checkbox } from './checkbox/checkbox'; export { default as Column } from './column/column'; export { default as Counter } from './counter/counter'; +export { default as Datepicker } from './datepicker/datepicker'; export { default as Emoji } from './emoji/emoji'; export { default as EmojiSelector } from './emoji-selector/emoji-selector'; export { default as FileInput } from './file-input/file-input'; diff --git a/app/soapbox/components/ui/select/select.tsx b/app/soapbox/components/ui/select/select.tsx index 485b3238c6..f79d71ddd1 100644 --- a/app/soapbox/components/ui/select/select.tsx +++ b/app/soapbox/components/ui/select/select.tsx @@ -1,13 +1,17 @@ import * as React from 'react'; +interface ISelect extends React.SelectHTMLAttributes { + children: Iterable, +} + /** Multiple-select dropdown. */ -const Select = React.forwardRef((props, ref) => { +const Select = React.forwardRef((props, ref) => { const { children, ...filteredProps } = props; return (