diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index 3b1abbf9f..b02469527 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -264,7 +264,8 @@ describe('fetchAccountByUsername()', () => { }); expect(actions[1].type).toEqual('ACCOUNTS_IMPORT'); expect(actions[2].type).toEqual('ACCOUNT_LOOKUP_SUCCESS'); - expect(actions[3].type).toEqual('ACCOUNT_FETCH_SUCCESS'); + expect(actions[3].type).toEqual('RELATIONSHIPS_FETCH_REQUEST'); + expect(actions[4].type).toEqual('ACCOUNT_FETCH_SUCCESS'); }); }); diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index 0fbb5c1a5..5cc0008a4 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -176,6 +176,7 @@ export function fetchAccountByUsername(username, history) { }); } else if (features.accountLookup) { return dispatch(accountLookup(username)).then(account => { + dispatch(fetchRelationships([account.id])); dispatch(fetchAccountSuccess(account)); }).catch(error => { dispatch(fetchAccountFail(null, error)); diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index 9ad58e114..cb4305901 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -69,10 +69,21 @@ export function importFetchedStatus(status, idempotencyKey) { dispatch(importFetchedStatus(status.quote)); } + // Pleroma quotes if (status.pleroma?.quote?.id) { dispatch(importFetchedStatus(status.pleroma.quote)); } + // Fedibird quote from reblog + if (status.reblog?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.quote)); + } + + // Pleroma quote from reblog + if (status.reblog?.pleroma?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.pleroma.quote)); + } + if (status.poll?.id) { dispatch(importFetchedPoll(status.poll)); } diff --git a/app/soapbox/components/__tests__/validation-checkmark.test.tsx b/app/soapbox/components/__tests__/validation-checkmark.test.tsx new file mode 100644 index 000000000..c9e204a6e --- /dev/null +++ b/app/soapbox/components/__tests__/validation-checkmark.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { render, screen } from '../../jest/test-helpers'; +import ValidationCheckmark from '../validation-checkmark'; + +describe('', () => { + it('renders text', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('validation-checkmark')).toHaveTextContent(text); + }); + + it('uses a green check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-success-500'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-gray-400'); + }); + + it('uses a gray check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-gray-400'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-success-500'); + }); +}); diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index e0fe7ff14..d6d076889 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; import RelativeTimestamp from './relative_timestamp'; -import { Avatar, HStack, Icon, IconButton, Text } from './ui'; +import { Avatar, Emoji, HStack, Icon, IconButton, Text } from './ui'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -60,6 +60,7 @@ interface IAccount { withDate?: boolean, withRelationship?: boolean, showEdit?: boolean, + emoji?: string, } const Account = ({ @@ -80,6 +81,7 @@ const Account = ({ withDate = false, withRelationship = true, showEdit = false, + emoji, }: IAccount) => { const overflowRef = React.useRef(null); const actionRef = React.useRef(null); @@ -160,7 +162,7 @@ const Account = ({ {children}} + wrapper={(children) => {children}} > event.stopPropagation()} > + {emoji && ( + + )} diff --git a/app/soapbox/components/hover_ref_wrapper.tsx b/app/soapbox/components/hover_ref_wrapper.tsx index f1da2e65d..2ef2d8372 100644 --- a/app/soapbox/components/hover_ref_wrapper.tsx +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import { debounce } from 'lodash'; import React, { useRef } from 'react'; import { useDispatch } from 'react-redux'; @@ -15,10 +16,11 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => { interface IHoverRefWrapper { accountId: string, inline: boolean, + className?: string, } /** Makes a profile hover card appear when the wrapped element is hovered. */ -export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false }) => { +export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false, className }) => { const dispatch = useDispatch(); const ref = useRef(null); const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; @@ -42,7 +44,7 @@ export const HoverRefWrapper: React.FC = ({ accountId, childre return ( = ({ index, ...props }) => { }; /** Structure to represent a tab. */ -type Item = { +export type Item = { /** Tab text. */ text: React.ReactNode, /** Tab tooltip text. */ diff --git a/app/soapbox/components/validation-checkmark.tsx b/app/soapbox/components/validation-checkmark.tsx new file mode 100644 index 000000000..111066ccf --- /dev/null +++ b/app/soapbox/components/validation-checkmark.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { HStack, Icon, Text } from 'soapbox/components/ui'; + +interface IValidationCheckmark { + isValid: boolean + text: string +} + +const ValidationCheckmark = ({ isValid, text }: IValidationCheckmark) => { + return ( + + + + {text} + + ); +}; + +export default ValidationCheckmark; diff --git a/app/soapbox/features/auth_login/components/password_reset_confirm.tsx b/app/soapbox/features/auth_login/components/password_reset_confirm.tsx index 2f30e700f..a7709834f 100644 --- a/app/soapbox/features/auth_login/components/password_reset_confirm.tsx +++ b/app/soapbox/features/auth_login/components/password_reset_confirm.tsx @@ -4,7 +4,8 @@ import { Redirect } from 'react-router-dom'; import { resetPasswordConfirm } from 'soapbox/actions/security'; import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppDispatch } from 'soapbox/hooks'; +import PasswordIndicator from 'soapbox/features/verification/components/password-indicator'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; const token = new URLSearchParams(window.location.search).get('reset_password_token'); @@ -22,9 +23,11 @@ const Statuses = { const PasswordResetConfirm = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const { passwordRequirements } = useFeatures(); const [password, setPassword] = React.useState(''); const [status, setStatus] = React.useState(Statuses.IDLE); + const [hasValidPassword, setHasValidPassword] = React.useState(passwordRequirements ? false : true); const isLoading = status === Statuses.LOADING; @@ -71,10 +74,14 @@ const PasswordResetConfirm = () => { onChange={onChange} required /> + + {passwordRequirements && ( + + )} - diff --git a/app/soapbox/features/compose/components/polls/duration-selector.tsx b/app/soapbox/features/compose/components/polls/duration-selector.tsx index 491530d22..10027824e 100644 --- a/app/soapbox/features/compose/components/polls/duration-selector.tsx +++ b/app/soapbox/features/compose/components/polls/duration-selector.tsx @@ -27,7 +27,7 @@ const DurationSelector = ({ onDurationChange }: IDurationSelector) => { now.setMinutes(now.getMinutes() + minutes); now.setHours(now.getHours() + hours); - return (now - future) / 1000; + return Math.round((now - future) / 1000); }, [days, hours, minutes]); useEffect(() => { diff --git a/app/soapbox/features/edit_password/index.tsx b/app/soapbox/features/edit_password/index.tsx index e95e6dec4..5cebc3c98 100644 --- a/app/soapbox/features/edit_password/index.tsx +++ b/app/soapbox/features/edit_password/index.tsx @@ -4,7 +4,9 @@ import { defineMessages, useIntl } from 'react-intl'; import { changePassword } from 'soapbox/actions/security'; import snackbar from 'soapbox/actions/snackbar'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; + +import PasswordIndicator from '../verification/components/password-indicator'; const messages = defineMessages({ updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' }, @@ -22,9 +24,11 @@ const initialState = { currentPassword: '', newPassword: '', newPasswordConfirma const EditPassword = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const { passwordRequirements } = useFeatures(); const [state, setState] = React.useState(initialState); const [isLoading, setLoading] = React.useState(false); + const [hasValidPassword, setHasValidPassword] = React.useState(passwordRequirements ? false : true); const { currentPassword, newPassword, newPasswordConfirmation } = state; @@ -75,6 +79,10 @@ const EditPassword = () => { onChange={handleInputChange} value={newPassword} /> + + {passwordRequirements && ( + + )} @@ -91,7 +99,7 @@ const EditPassword = () => { {intl.formatMessage(messages.cancel)} - diff --git a/app/soapbox/features/ui/components/reactions_modal.tsx b/app/soapbox/features/ui/components/reactions_modal.tsx index 4832f943d..f343dc5c4 100644 --- a/app/soapbox/features/ui/components/reactions_modal.tsx +++ b/app/soapbox/features/ui/components/reactions_modal.tsx @@ -3,12 +3,13 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions'; -import FilterBar from 'soapbox/components/filter_bar'; import ScrollableList from 'soapbox/components/scrollable_list'; -import { Modal, Spinner } from 'soapbox/components/ui'; +import { Emoji, Modal, Spinner, Tabs } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import type { Item } from 'soapbox/components/ui/tabs/tabs'; + const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, all: { id: 'reactions.all', defaultMessage: 'All' }, @@ -24,14 +25,14 @@ const ReactionsModal: React.FC = ({ onClose, statusId, reaction const dispatch = useAppDispatch(); const intl = useIntl(); const [reaction, setReaction] = useState(initialReaction); - const reactions = useAppSelector, count: number, name: string, }>>((state) => { const favourites = state.user_lists.getIn(['favourited_by', statusId]); const reactions = state.user_lists.getIn(['reactions', statusId]); - return favourites && reactions && ImmutableList(favourites ? [{ accounts: favourites, count: favourites.size, name: '👍' }] : []).concat(reactions || []); + return favourites && reactions && ImmutableList(favourites.size ? [{ accounts: favourites, count: favourites.size, name: '👍' }] : []).concat(reactions || []); }); const fetchData = () => { @@ -44,7 +45,7 @@ const ReactionsModal: React.FC = ({ onClose, statusId, reaction }; const renderFilterBar = () => { - const items = [ + const items: Array = [ { text: intl.formatMessage(messages.all), action: () => setReaction(''), @@ -54,13 +55,16 @@ const ReactionsModal: React.FC = ({ onClose, statusId, reaction reactions.forEach(reaction => items.push( { - text: `${reaction.name} ${reaction.count}`, + text:
+ + {reaction.count} +
, action: () => setReaction(reaction.name), name: reaction.name, }, )); - return ; + return ; }; useEffect(() => { @@ -69,7 +73,7 @@ const ReactionsModal: React.FC = ({ onClose, statusId, reaction const accounts = reactions && (reaction ? reactions.find(({ name }) => name === reaction)?.accounts.map(account => ({ id: account, reaction: reaction })) - : reactions.map(({ accounts, name }) => accounts.map(account => ({ id: account, reaction: name }))).flat()); + : reactions.map(({ accounts, name }) => accounts.map(account => ({ id: account, reaction: name }))).flatten()) as Array<{ id: string, reaction: string }>; let body; @@ -79,14 +83,15 @@ const ReactionsModal: React.FC = ({ onClose, statusId, reaction const emptyMessage = ; body = (<> - {reactions.length > 0 && renderFilterBar()} + {reactions.size > 0 && renderFilterBar()} {accounts.map((account) => - , + , )} ); diff --git a/app/soapbox/features/verification/__tests__/registration.test.tsx b/app/soapbox/features/verification/__tests__/registration.test.tsx index ff5da7e97..f82c0dab4 100644 --- a/app/soapbox/features/verification/__tests__/registration.test.tsx +++ b/app/soapbox/features/verification/__tests__/registration.test.tsx @@ -64,4 +64,21 @@ describe('', () => { expect(screen.getByTestId('toast')).toHaveTextContent(/failed to register your account/i); }); }); + + describe('validations', () => { + it('should undisable button with valid password', async() => { + render(); + + expect(screen.getByTestId('button')).toBeDisabled(); + fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Password' } }); + expect(screen.getByTestId('button')).not.toBeDisabled(); + }); + + it('should disable button with invalid password', async() => { + render(); + + fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Passwor' } }); + expect(screen.getByTestId('button')).toBeDisabled(); + }); + }); }); diff --git a/app/soapbox/features/verification/components/password-indicator.tsx b/app/soapbox/features/verification/components/password-indicator.tsx new file mode 100644 index 000000000..7b804d3d6 --- /dev/null +++ b/app/soapbox/features/verification/components/password-indicator.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useMemo } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Stack } from 'soapbox/components/ui'; +import ValidationCheckmark from 'soapbox/components/validation-checkmark'; + +const messages = defineMessages({ + minimumCharacters: { + id: 'registration.validation.minimum_characters', + defaultMessage: '8 characters', + }, + capitalLetter: { + id: 'registration.validation.capital_letter', + defaultMessage: '1 capital letter', + }, + lowercaseLetter: { + id: 'registration.validation.lowercase_letter', + defaultMessage: '1 lowercase letter', + }, +}); + +const hasUppercaseCharacter = (string: string) => { + for (let i = 0; i < string.length; i++) { + if (string.charAt(i) === string.charAt(i).toUpperCase() && string.charAt(i).match(/[a-z]/i)) { + return true; + } + } + return false; +}; + +const hasLowercaseCharacter = (string: string) => { + return string.toUpperCase() !== string; +}; + +interface IPasswordIndicator { + onChange(isValid: boolean): void + password: string +} + +const PasswordIndicator = ({ onChange, password }: IPasswordIndicator) => { + const intl = useIntl(); + + const meetsLengthRequirements = useMemo(() => password.length >= 8, [password]); + const meetsCapitalLetterRequirements = useMemo(() => hasUppercaseCharacter(password), [password]); + const meetsLowercaseLetterRequirements = useMemo(() => hasLowercaseCharacter(password), [password]); + const hasValidPassword = meetsLengthRequirements && meetsCapitalLetterRequirements && meetsLowercaseLetterRequirements; + + useEffect(() => { + onChange(hasValidPassword); + }, [hasValidPassword]); + + return ( + + + + + + + + ); +}; + +export default PasswordIndicator; diff --git a/app/soapbox/features/verification/registration.tsx b/app/soapbox/features/verification/registration.tsx index 3c527b828..a04b4f0cd 100644 --- a/app/soapbox/features/verification/registration.tsx +++ b/app/soapbox/features/verification/registration.tsx @@ -12,6 +12,8 @@ import { Button, Form, FormGroup, Input } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { getRedirectUrl } from 'soapbox/utils/redirect'; +import PasswordIndicator from './components/password-indicator'; + import type { AxiosError } from 'axios'; const messages = defineMessages({ @@ -43,12 +45,12 @@ const Registration = () => { const [state, setState] = React.useState(initialState); const [shouldRedirect, setShouldRedirect] = React.useState(false); + const [hasValidPassword, setHasValidPassword] = React.useState(false); const { username, password } = state; const handleSubmit = React.useCallback((event) => { event.preventDefault(); - // TODO: handle validation errors from Pepe dispatch(createAccount(username, password)) .then(() => dispatch(logIn(intl, username, password))) .then(({ access_token }: any) => dispatch(verifyCredentials(access_token))) @@ -119,11 +121,21 @@ const Registration = () => { value={password} onChange={handleInputChange} required + data-testid='password-input' /> + +
- +
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 9f3894ac5..d6808069a 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -833,6 +833,9 @@ "registration.sign_up": "Sign up", "registration.tos": "Terms of Service", "registration.username_unavailable": "Username is already taken.", + "registration.validation.minimum_characters": "8 characters", + "registration.validation.capital_letter": "1 capital letter", + "registration.validation.lowercase_letter": "1 lowercase letter", "relative_time.days": "{number}d", "relative_time.hours": "{number}h", "relative_time.just_now": "now", diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 891cdd621..8f8a65cc1 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -380,6 +380,14 @@ const getInstanceFeatures = (instance: Instance) => { */ paginatedContext: v.software === TRUTHSOCIAL, + /** + * Require minimum password requirements. + * - 8 characters + * - 1 uppercase + * - 1 lowercase + */ + passwordRequirements: v.software === TRUTHSOCIAL, + /** * Displays a form to follow a user when logged out. * @see POST /main/ostatus