diff --git a/.eslintrc.js b/.eslintrc.js index bb111e2f45..bce34ca612 100644 Binary files a/.eslintrc.js and b/.eslintrc.js differ diff --git a/app/soapbox/__fixtures__/rules.json b/app/soapbox/__fixtures__/rules.json new file mode 100644 index 0000000000..c1c7a1f7d5 --- /dev/null +++ b/app/soapbox/__fixtures__/rules.json @@ -0,0 +1,14 @@ +[ + { + "id": "1", + "text": "Illegal activity and behavior", + "subtext": "Content that depicts illegal or criminal acts, threats of violence.", + "rule_type": "content" + }, + { + "id": "2", + "text": "Intellectual property infringement", + "subtext": "Impersonating another account or business, infringing on intellectual property rights.", + "rule_type": "content" + } +] diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts index 08ca76284c..cdd268ed5d 100644 --- a/app/soapbox/actions/__tests__/onboarding.test.ts +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -1,24 +1,101 @@ -import { getSettings } from 'soapbox/actions/settings'; -import { createTestStore, rootState } from 'soapbox/jest/test-helpers'; +import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; +import rootReducer from 'soapbox/reducers'; -import { ONBOARDING_VERSION, endOnboarding } from '../onboarding'; +import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; -describe('endOnboarding()', () => { - it('updates the onboardingVersion setting', async() => { - const store = createTestStore(rootState); +describe('checkOnboarding()', () => { + let mockGetItem: any; - // Sanity check: - // `onboardingVersion` should be `0` by default - const initialVersion = getSettings(store.getState()).get('onboardingVersion'); - expect(initialVersion).toBe(0); + mockWindowProperty('localStorage', { + getItem: (key: string) => mockGetItem(key), + }); - await store.dispatch(endOnboarding()); + beforeEach(() => { + mockGetItem = jest.fn().mockReturnValue(null); + }); - // After dispatching, `onboardingVersion` is updated - const updatedVersion = getSettings(store.getState()).get('onboardingVersion'); - expect(updatedVersion).toBe(ONBOARDING_VERSION); + it('does nothing if localStorage item is not set', async() => { + mockGetItem = jest.fn().mockReturnValue(null); - // Sanity check: `updatedVersion` is greater than `initialVersion` - expect(updatedVersion > initialVersion).toBe(true); + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('does nothing if localStorage item is invalid', async() => { + mockGetItem = jest.fn().mockReturnValue('invalid'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('dispatches the correct action', async() => { + mockGetItem = jest.fn().mockReturnValue('1'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); +}); + +describe('startOnboarding()', () => { + let mockSetItem: any; + + mockWindowProperty('localStorage', { + setItem: (key: string, value: string) => mockSetItem(key, value), + }); + + beforeEach(() => { + mockSetItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(startOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockSetItem.mock.calls.length).toBe(1); + }); +}); + +describe('endOnboarding()', () => { + let mockRemoveItem: any; + + mockWindowProperty('localStorage', { + removeItem: (key: string) => mockRemoveItem(key), + }); + + beforeEach(() => { + mockRemoveItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(endOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); + expect(mockRemoveItem.mock.calls.length).toBe(1); }); }); diff --git a/app/soapbox/actions/__tests__/rules.test.ts b/app/soapbox/actions/__tests__/rules.test.ts new file mode 100644 index 0000000000..4574adc2e3 --- /dev/null +++ b/app/soapbox/actions/__tests__/rules.test.ts @@ -0,0 +1,26 @@ +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; + +import { fetchRules, RULES_FETCH_REQUEST, RULES_FETCH_SUCCESS } from '../rules'; + +describe('fetchRules()', () => { + it('sets the rules', (done) => { + const rules = require('soapbox/__fixtures__/rules.json'); + + __stub((mock) => { + mock.onGet('/api/v1/instance/rules').reply(200, rules); + }); + + const store = mockStore(rootState); + + store.dispatch(fetchRules()).then((context) => { + const actions = store.getActions(); + + expect(actions[0].type).toEqual(RULES_FETCH_REQUEST); + expect(actions[1].type).toEqual(RULES_FETCH_SUCCESS); + expect(actions[1].payload[0].id).toEqual('1'); + + done(); + }).catch(console.error); + }); +}); diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 00f9c606d0..79ff9d67f9 100644 Binary files a/app/soapbox/actions/auth.js and b/app/soapbox/actions/auth.js differ diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index 0d6d6c2e40..cd99ac4702 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -39,12 +39,16 @@ const needsNodeinfo = (instance: Record): boolean => { export const fetchInstance = createAsyncThunk( 'instance/fetch', - async(_arg, { dispatch, getState }) => { - const { data: instance } = await api(getState).get('/api/v1/instance'); - if (needsNodeinfo(instance)) { - dispatch(fetchNodeinfo()); + async(_arg, { dispatch, getState, rejectWithValue }) => { + try { + const { data: instance } = await api(getState).get('/api/v1/instance'); + if (needsNodeinfo(instance)) { + dispatch(fetchNodeinfo()); + } + return instance; + } catch(e) { + return rejectWithValue(e); } - return instance; }, ); diff --git a/app/soapbox/actions/me.js b/app/soapbox/actions/me.js index e04001e4ca..96afcab7d6 100644 Binary files a/app/soapbox/actions/me.js and b/app/soapbox/actions/me.js differ diff --git a/app/soapbox/actions/onboarding.ts b/app/soapbox/actions/onboarding.ts index 13bcf0f73f..ff12bd0742 100644 --- a/app/soapbox/actions/onboarding.ts +++ b/app/soapbox/actions/onboarding.ts @@ -1,13 +1,40 @@ -import { changeSettingImmediate } from 'soapbox/actions/settings'; +const ONBOARDING_START = 'ONBOARDING_START'; +const ONBOARDING_END = 'ONBOARDING_END'; -/** Repeat the onboading process when we bump the version */ -export const ONBOARDING_VERSION = 1; +const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; -/** Finish onboarding and store the setting */ -const endOnboarding = () => (dispatch: React.Dispatch) => { - dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION)); +type OnboardingStartAction = { + type: typeof ONBOARDING_START +} + +type OnboardingEndAction = { + type: typeof ONBOARDING_END +} + +export type OnboardingActions = OnboardingStartAction | OnboardingEndAction + +const checkOnboardingStatus = () => (dispatch: React.Dispatch) => { + const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1'; + + if (needsOnboarding) { + dispatch({ type: ONBOARDING_START }); + } +}; + +const startOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1'); + dispatch({ type: ONBOARDING_START }); +}; + +const endOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY); + dispatch({ type: ONBOARDING_END }); }; export { + ONBOARDING_END, + ONBOARDING_START, + checkOnboardingStatus, endOnboarding, + startOnboarding, }; diff --git a/app/soapbox/actions/reports.js b/app/soapbox/actions/reports.js index 8b7c55644c..8e8e45595e 100644 Binary files a/app/soapbox/actions/reports.js and b/app/soapbox/actions/reports.js differ diff --git a/app/soapbox/actions/rules.ts b/app/soapbox/actions/rules.ts new file mode 100644 index 0000000000..1e2c29eea8 --- /dev/null +++ b/app/soapbox/actions/rules.ts @@ -0,0 +1,31 @@ +import api from '../api'; + +import type { Rule } from 'soapbox/reducers/rules'; + +const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; +const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; + +type RulesFetchRequestAction = { + type: typeof RULES_FETCH_REQUEST +} + +type RulesFetchRequestSuccessAction = { + type: typeof RULES_FETCH_SUCCESS + payload: Rule[] +} + +export type RulesActions = RulesFetchRequestAction | RulesFetchRequestSuccessAction + +const fetchRules = () => (dispatch: React.Dispatch, getState: any) => { + dispatch({ type: RULES_FETCH_REQUEST }); + + return api(getState) + .get('/api/v1/instance/rules') + .then((response) => dispatch({ type: RULES_FETCH_SUCCESS, payload: response.data })); +}; + +export { + fetchRules, + RULES_FETCH_REQUEST, + RULES_FETCH_SUCCESS, +}; diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 8a76a2c6cc..098cdfdb14 100644 Binary files a/app/soapbox/actions/settings.js and b/app/soapbox/actions/settings.js differ diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index 5ecc81ee29..2315258678 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { usePopper } from 'react-popper'; import { useDispatch } from 'react-redux'; @@ -22,6 +22,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children const status = useAppSelector(state => state.statuses.get(statusId)); const soapboxConfig = useSoapboxConfig(); + const timeout = useRef(); const [visible, setVisible] = useState(false); // const [focused, setFocused] = useState(false); @@ -42,16 +43,40 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children ], }); + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); + if (!status) return null; const handleMouseEnter = () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + if (!isUserTouching()) { setVisible(true); } }; const handleMouseLeave = () => { - setVisible(false); + if (timeout.current) { + clearTimeout(timeout.current); + } + + // Unless the user is touching, delay closing the emoji selector briefly + // so the user can move the mouse diagonally to make a selection. + if (isUserTouching()) { + setVisible(false); + } else { + timeout.current = setTimeout(() => { + setVisible(false); + }, 500); + } }; const handleReact = (emoji: string): void => { @@ -107,7 +132,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children ); return ( -
+
{React.cloneElement(children, { onClick: handleClick, ref: setReferenceElement, diff --git a/app/soapbox/components/hover_ref_wrapper.tsx b/app/soapbox/components/hover_ref_wrapper.tsx index 239c275800..f1da2e65d7 100644 --- a/app/soapbox/components/hover_ref_wrapper.tsx +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -20,7 +20,7 @@ interface IHoverRefWrapper { /** Makes a profile hover card appear when the wrapped element is hovered. */ export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false }) => { const dispatch = useDispatch(); - const ref = useRef(); + const ref = useRef(null); const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; const handleMouseEnter = () => { @@ -41,7 +41,6 @@ export const HoverRefWrapper: React.FC = ({ accountId, childre return ( = ({ onClick, disabled, visible = true }) => } return ( - ); diff --git a/app/soapbox/components/profile_hover_card.js b/app/soapbox/components/profile_hover_card.tsx similarity index 70% rename from app/soapbox/components/profile_hover_card.js rename to app/soapbox/components/profile_hover_card.tsx index b974623db8..4474d8d213 100644 Binary files a/app/soapbox/components/profile_hover_card.js and b/app/soapbox/components/profile_hover_card.tsx differ diff --git a/app/soapbox/components/progress_bar.tsx b/app/soapbox/components/progress_bar.tsx deleted file mode 100644 index 24e4e6790c..0000000000 --- a/app/soapbox/components/progress_bar.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -interface IProgressBar { - progress: number, -} - -const ProgressBar: React.FC = ({ progress }) => ( -
-
-
-); - -export default ProgressBar; diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index dc64307b57..672520fa52 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { Virtuoso, Components } from 'react-virtuoso'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; +import { useSettings } from 'soapbox/hooks'; +import LoadMore from './load_more'; import { Spinner, Text } from './ui'; type Context = { @@ -60,6 +62,9 @@ const ScrollableList: React.FC = ({ placeholderComponent: Placeholder, placeholderCount = 0, }) => { + const settings = useSettings(); + const autoloadMore = settings.get('autoloadMore'); + /** Normalized children */ const elements = Array.from(children || []); @@ -72,9 +77,9 @@ const ScrollableList: React.FC = ({ // Add a placeholder at the bottom for loading // (Don't use Virtuoso's `Footer` component because it doesn't preserve its height) - if (hasMore && Placeholder) { + if (hasMore && (autoloadMore || isLoading) && Placeholder) { data.push(); - } else if (hasMore) { + } else if (hasMore && (autoloadMore || isLoading)) { data.push(); } @@ -105,11 +110,19 @@ const ScrollableList: React.FC = ({ }; const handleEndReached = () => { - if (hasMore && onLoadMore) { + if (autoloadMore && hasMore && onLoadMore) { onLoadMore(); } }; + const loadMore = () => { + if (autoloadMore || !hasMore || !onLoadMore) { + return null; + } else { + return ; + } + }; + /** Render the actual Virtuoso list */ const renderFeed = (): JSX.Element => ( = ({ EmptyPlaceholder: () => renderEmpty(), List, Item, + Footer: loadMore, }} /> ); diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index 9809d99c1a..3ce3b9e289 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -73,7 +73,7 @@ const SidebarNavigation = () => { if (account.staff) { menu.push({ - to: '/admin', + to: '/soapbox/admin', icon: require('@tabler/icons/icons/dashboard.svg'), text: , count: dashboardCount, @@ -106,6 +106,32 @@ const SidebarNavigation = () => { const menu = makeMenu(); + /** Conditionally render the supported messages link */ + const renderMessagesLink = (): React.ReactNode => { + if (features.chats) { + return ( + } + /> + ); + } + + if (features.directTimeline || features.conversations) { + return ( + } + /> + ); + } + + return null; + }; + return (
@@ -138,22 +164,7 @@ const SidebarNavigation = () => { )} - {account && ( - features.chats ? ( - } - /> - ) : ( - } - /> - ) - )} + {account && renderMessagesLink()} {menu.length > 0 && ( diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index d72bbbf9eb..6bbd86fa87 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -56,7 +56,6 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re { + handleReplyClick: React.MouseEventHandler = (e) => { const { me, onReply, onOpenUnauthorizedModal, status } = this.props; if (me) { @@ -139,6 +139,8 @@ class StatusActionBar extends ImmutablePureComponent { diff --git a/app/soapbox/components/thumb_navigation.tsx b/app/soapbox/components/thumb_navigation.tsx index d9953fb05e..c1ac80faeb 100644 --- a/app/soapbox/components/thumb_navigation.tsx +++ b/app/soapbox/components/thumb_navigation.tsx @@ -12,6 +12,34 @@ const ThumbNavigation: React.FC = (): JSX.Element => { const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const features = getFeatures(useAppSelector((state) => state.instance)); + /** Conditionally render the supported messages link */ + const renderMessagesLink = (): React.ReactNode => { + if (features.chats) { + return ( + } + to='/chats' + exact + count={chatsCount} + /> + ); + } + + if (features.directTimeline || features.conversations) { + return ( + } + to='/messages' + paths={['/messages', '/conversations']} + /> + ); + } + + return null; + }; + return (
{ /> )} - {account && ( - features.chats ? ( - } - to='/chats' - exact - count={chatsCount} - /> - ) : ( - } - to='/messages' - paths={['/messages', '/conversations']} - /> - ) - )} + {account && renderMessagesLink()} {(account && account.staff) && ( } - to='/admin' + to='/soapbox/admin' count={dashboardCount} /> )} diff --git a/app/soapbox/components/ui/avatar/avatar.tsx b/app/soapbox/components/ui/avatar/avatar.tsx index a327e68407..6818509e86 100644 --- a/app/soapbox/components/ui/avatar/avatar.tsx +++ b/app/soapbox/components/ui/avatar/avatar.tsx @@ -6,11 +6,15 @@ import StillImage from 'soapbox/components/still_image'; const AVATAR_SIZE = 42; interface IAvatar { + /** URL to the avatar image. */ src: string, + /** Width and height of the avatar in pixels. */ size?: number, + /** Extra class names for the div surrounding the avatar image. */ className?: string, } +/** Round profile avatar for accounts. */ const Avatar = (props: IAvatar) => { const { src, size = AVATAR_SIZE, className } = props; diff --git a/app/soapbox/components/ui/button/button.tsx b/app/soapbox/components/ui/button/button.tsx index c88d228f57..f985351662 100644 --- a/app/soapbox/components/ui/button/button.tsx +++ b/app/soapbox/components/ui/button/button.tsx @@ -8,20 +8,33 @@ import { useButtonStyles } from './useButtonStyles'; import type { ButtonSizes, ButtonThemes } from './useButtonStyles'; interface IButton { + /** Whether this button expands the width of its container. */ block?: boolean, + /** Elements inside the )}
diff --git a/app/soapbox/components/ui/progress-bar/progress-bar.tsx b/app/soapbox/components/ui/progress-bar/progress-bar.tsx new file mode 100644 index 0000000000..165d734056 --- /dev/null +++ b/app/soapbox/components/ui/progress-bar/progress-bar.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +interface IProgressBar { + progress: number, +} + +const ProgressBar: React.FC = ({ progress }) => ( +
+
+
+); + +export default ProgressBar; diff --git a/app/soapbox/components/ui/select/select.tsx b/app/soapbox/components/ui/select/select.tsx index 0781430ea7..33a57c30e1 100644 --- a/app/soapbox/components/ui/select/select.tsx +++ b/app/soapbox/components/ui/select/select.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +/** Multiple-select dropdown. */ const Select = React.forwardRef((props, ref) => { const { children, ...filteredProps } = props; diff --git a/app/soapbox/components/ui/spinner/spinner.tsx b/app/soapbox/components/ui/spinner/spinner.tsx index d54e694399..c4b77a1209 100644 --- a/app/soapbox/components/ui/spinner/spinner.tsx +++ b/app/soapbox/components/ui/spinner/spinner.tsx @@ -7,10 +7,13 @@ import Text from '../text/text'; import './spinner.css'; interface ILoadingIndicator { + /** Width and height of the spinner in pixels. */ size?: number, + /** Whether to display "Loading..." beneath the spinner. */ withText?: boolean } +/** Spinning loading placeholder. */ const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 7e55ff5a26..3bb96d2767 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -22,13 +22,18 @@ const alignItemsOptions = { center: 'items-center', }; -interface IStack extends React.HTMLAttributes { +interface IStack extends React.HTMLAttributes { + /** Size of the gap between elements. */ space?: SIZES, + /** Horizontal alignment of children. */ alignItems?: 'center', + /** Vertical alignment of children. */ justifyContent?: 'center', + /** Extra class names on the
element. */ className?: string, } +/** Vertical stack of child elements. */ const Stack: React.FC = (props) => { const { space, alignItems, justifyContent, className, ...filteredProps } = props; diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx new file mode 100644 index 0000000000..90651fb784 --- /dev/null +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import Button from '../button/button'; +import HStack from '../hstack/hstack'; +import IconButton from '../icon-button/icon-button'; +import Stack from '../stack/stack'; +import Text from '../text/text'; + +const messages = defineMessages({ + add: { id: 'streamfield.add', defaultMessage: 'Add' }, + remove: { id: 'streamfield.remove', defaultMessage: 'Remove' }, +}); + +interface IStreamfield { + /** Array of values for the streamfield. */ + values: any[], + /** Input label message. */ + labelText?: React.ReactNode, + /** Input hint message. */ + hintText?: React.ReactNode, + /** Callback to add an item. */ + onAddItem?: () => void, + /** Callback to remove an item by index. */ + onRemoveItem?: (i: number) => void, + /** Callback when values are changed. */ + onChange: (values: any[]) => void, + /** Input to render for each value. */ + component: React.ComponentType<{ onChange: (value: any) => void, value: any }>, + /** Maximum number of allowed inputs. */ + maxItems?: number, +} + +/** List of inputs that can be added or removed. */ +const Streamfield: React.FC = ({ + values, + labelText, + hintText, + onAddItem, + onRemoveItem, + onChange, + component: Component, + maxItems = Infinity, +}) => { + const intl = useIntl(); + + const handleChange = (i: number) => { + return (value: any) => { + const newData = [...values]; + newData[i] = value; + onChange(newData); + }; + }; + + return ( + + + {labelText && {labelText}} + {hintText && {hintText}} + + + + {values.map((value, i) => ( + + + {onRemoveItem && ( + onRemoveItem(i)} + title={intl.formatMessage(messages.remove)} + /> + )} + + ))} + + + {onAddItem && ( + + )} + + ); +}; + +export default Streamfield; diff --git a/app/soapbox/components/ui/tabs/tabs.tsx b/app/soapbox/components/ui/tabs/tabs.tsx index a0dfe7abbf..ec8a9a09e9 100644 --- a/app/soapbox/components/ui/tabs/tabs.tsx +++ b/app/soapbox/components/ui/tabs/tabs.tsx @@ -17,10 +17,13 @@ const HORIZONTAL_PADDING = 8; const AnimatedContext = React.createContext(null); interface IAnimatedInterface { + /** Callback when a tab is chosen. */ onChange(index: number): void, + /** Default tab index. */ defaultIndex: number } +/** Tabs with a sliding active state. */ const AnimatedTabs: React.FC = ({ children, ...rest }) => { const [activeRect, setActiveRect] = React.useState(null); const ref = React.useRef(); @@ -58,13 +61,19 @@ const AnimatedTabs: React.FC = ({ children, ...rest }) => { }; interface IAnimatedTab { + /** ARIA role. */ role: 'button', + /** Element to represent the tab. */ as: 'a' | 'button', + /** Route to visit when the tab is chosen. */ href?: string, + /** Tab title text. */ title: string, + /** Index value of the tab. */ index: number } +/** A single animated tab. */ const AnimatedTab: React.FC = ({ index, ...props }) => { // get the currently selected index from useTabsContext const { selectedIndex } = useTabsContext(); @@ -91,20 +100,32 @@ const AnimatedTab: React.FC = ({ index, ...props }) => { ); }; +/** Structure to represent a tab. */ type Item = { - text: string, + /** Tab text. */ + text: React.ReactNode, + /** Tab tooltip text. */ title?: string, + /** URL to visit when the tab is selected. */ href?: string, + /** Route to visit when the tab is selected. */ to?: string, + /** Callback when the tab is selected. */ action?: () => void, + /** Display a counter over the tab. */ count?: number, + /** Unique name for this tab. */ name: string } + interface ITabs { + /** Array of structured tab items. */ items: Item[], + /** Name of the active tab item. */ activeItem: string, } +/** Animated tabs component. */ const Tabs = ({ items, activeItem }: ITabs) => { const defaultIndex = items.findIndex(({ name }) => name === activeItem); diff --git a/app/soapbox/components/ui/text/text.tsx b/app/soapbox/components/ui/text/text.tsx index b2db90b2c4..9c7c8ddae2 100644 --- a/app/soapbox/components/ui/text/text.tsx +++ b/app/soapbox/components/ui/text/text.tsx @@ -8,7 +8,7 @@ type Alignments = 'left' | 'center' | 'right' type TrackingSizes = 'normal' | 'wide' type TransformProperties = 'uppercase' | 'normal' type Families = 'sans' | 'mono' -type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' const themes = { default: 'text-gray-900 dark:text-gray-100', @@ -60,19 +60,31 @@ const families = { }; interface IText extends Pick, 'dangerouslySetInnerHTML'> { + /** How to align the text. */ align?: Alignments, + /** Extra class names for the outer element. */ className?: string, - dateTime?: string, + /** Typeface of the text. */ family?: Families, + /** The "for" attribute specifies which form element a label is bound to. */ + htmlFor?: string, + /** Font size of the text. */ size?: Sizes, + /** HTML element name of the outer element. */ tag?: Tags, + /** Theme for the text. */ theme?: Themes, + /** Letter-spacing of the text. */ tracking?: TrackingSizes, + /** Transform (eg uppercase) for the text. */ transform?: TransformProperties, + /** Whether to truncate the text if its container is too small. */ truncate?: boolean, + /** Font weight of the text. */ weight?: Weights } +/** UI-friendly text container with dark mode support. */ const Text: React.FC = React.forwardRef( (props: IText, ref: React.LegacyRef) => { const { diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index 81a8488b8b..fbd8ba86f0 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,15 +1,24 @@ import classNames from 'classnames'; import React from 'react'; -interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required'> { +interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required' | 'disabled'> { + /** Put the cursor into the input on mount. */ autoFocus?: boolean, + /** The initial text in the input. */ defaultValue?: string, + /** Internal input name. */ name?: string, + /** Renders the textarea as a code editor. */ isCodeEditor?: boolean, + /** Text to display before a value is entered. */ placeholder?: string, + /** Text in the textarea. */ value?: string, + /** Whether the device should autocomplete text in this textarea. */ + autoComplete?: string, } +/** Textarea with custom styles. */ const Textarea = React.forwardRef( ({ isCodeEditor = false, ...props }: ITextarea, ref: React.ForwardedRef) => { return ( diff --git a/app/soapbox/components/ui/tooltip/tooltip.tsx b/app/soapbox/components/ui/tooltip/tooltip.tsx index 8a52b6ca36..4efa4749f7 100644 --- a/app/soapbox/components/ui/tooltip/tooltip.tsx +++ b/app/soapbox/components/ui/tooltip/tooltip.tsx @@ -4,9 +4,11 @@ import React from 'react'; import './tooltip.css'; interface ITooltip { + /** Text to display in the tooltip. */ text: string, } +/** Hoverable tooltip element. */ const Tooltip: React.FC = ({ children, text, diff --git a/app/soapbox/components/ui/widget/widget.tsx b/app/soapbox/components/ui/widget/widget.tsx index 1257d9779f..5a3b887df1 100644 --- a/app/soapbox/components/ui/widget/widget.tsx +++ b/app/soapbox/components/ui/widget/widget.tsx @@ -5,24 +5,32 @@ import HStack from 'soapbox/components/ui/hstack/hstack'; import Stack from 'soapbox/components/ui/stack/stack'; interface IWidgetTitle { - title: string | React.ReactNode, + /** Title text for the widget. */ + title: React.ReactNode, } +/** Title of a widget. */ const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => ( {title} ); +/** Body of a widget. */ const WidgetBody: React.FC = ({ children }): JSX.Element => ( {children} ); interface IWidget { - title: string | React.ReactNode, + /** Widget title text. */ + title: React.ReactNode, + /** Callback when the widget action is clicked. */ onActionClick?: () => void, + /** URL to the svg icon for the widget action. */ actionIcon?: string, + /** Text for the action. */ actionTitle?: string, } +/** Sidebar widget. */ const Widget: React.FC = ({ title, children, diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 44b661cf96..e6cd733d8b 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -20,12 +20,12 @@ import PublicLayout from 'soapbox/features/public_layout'; import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import WaitlistPage from 'soapbox/features/verification/waitlist_page'; import { createGlobals } from 'soapbox/globals'; -import { useAppSelector, useAppDispatch, useOwnAccount, useSoapboxConfig, useSettings } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; import { getFeatures } from 'soapbox/utils/features'; import { generateThemeCss } from 'soapbox/utils/theme'; -import { ONBOARDING_VERSION } from '../actions/onboarding'; +import { checkOnboardingStatus } from '../actions/onboarding'; import { preload } from '../actions/preload'; import ErrorBoundary from '../components/error_boundary'; import UI from '../features/ui'; @@ -40,6 +40,9 @@ createGlobals(store); // Preload happens synchronously store.dispatch(preload() as any); +// This happens synchronously +store.dispatch(checkOnboardingStatus() as any); + /** Load initial data from the backend */ const loadInitial = () => { // @ts-ignore @@ -68,13 +71,15 @@ const SoapboxMount = () => { const dispatch = useAppDispatch(); const me = useAppSelector(state => state.me); + const instance = useAppSelector(state => state.instance); const account = useOwnAccount(); const settings = useSettings(); const soapboxConfig = useSoapboxConfig(); + const features = useFeatures(); const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en'; - const needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION; + const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding); const singleUserMode = soapboxConfig.singleUserMode && soapboxConfig.singleUserModeProfile; const [messages, setMessages] = useState>({}); @@ -146,10 +151,6 @@ const SoapboxMount = () => { {themeCss && } - - {soapboxConfig.appleAppId && ( - - )} @@ -157,7 +158,7 @@ const SoapboxMount = () => { <> - + {waitlisted && } />} @@ -170,7 +171,10 @@ const SoapboxMount = () => { - + {(features.accountCreation && instance.registrations) && ( + + )} + diff --git a/app/soapbox/features/admin/components/admin-tabs.tsx b/app/soapbox/features/admin/components/admin-tabs.tsx index 4602cd0aa5..debba46a0a 100644 --- a/app/soapbox/features/admin/components/admin-tabs.tsx +++ b/app/soapbox/features/admin/components/admin-tabs.tsx @@ -19,18 +19,18 @@ const AdminTabs: React.FC = () => { const reportsCount = useAppSelector(state => state.admin.openReports.count()); const tabs = [{ - name: '/admin', + name: '/soapbox/admin', text: intl.formatMessage(messages.dashboard), - to: '/admin', + to: '/soapbox/admin', }, { - name: '/admin/reports', + name: '/soapbox/admin/reports', text: intl.formatMessage(messages.reports), - to: '/admin/reports', + to: '/soapbox/admin/reports', count: reportsCount, }, { - name: '/admin/approval', + name: '/soapbox/admin/approval', text: intl.formatMessage(messages.waitlist), - to: '/admin/approval', + to: '/soapbox/admin/approval', count: approvalCount, }]; diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.tsx b/app/soapbox/features/admin/components/latest_accounts_panel.tsx index 26b383713f..62636c3668 100644 --- a/app/soapbox/features/admin/components/latest_accounts_panel.tsx +++ b/app/soapbox/features/admin/components/latest_accounts_panel.tsx @@ -46,7 +46,7 @@ const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { } const handleAction = () => { - history.push('/admin/users'); + history.push('/soapbox/admin/users'); }; return ( diff --git a/app/soapbox/features/admin/index.tsx b/app/soapbox/features/admin/index.tsx index dae7fc2e76..84f72eda86 100644 --- a/app/soapbox/features/admin/index.tsx +++ b/app/soapbox/features/admin/index.tsx @@ -26,9 +26,9 @@ const Admin: React.FC = () => { - - - + + + ); diff --git a/app/soapbox/features/admin/tabs/dashboard.tsx b/app/soapbox/features/admin/tabs/dashboard.tsx index 248885b21f..f6b81ea29d 100644 --- a/app/soapbox/features/admin/tabs/dashboard.tsx +++ b/app/soapbox/features/admin/tabs/dashboard.tsx @@ -77,7 +77,7 @@ const Dashboard: React.FC = () => {
)} {isNumber(userCount) && ( - + @@ -125,7 +125,7 @@ const Dashboard: React.FC = () => {

  • {sourceCode.displayName} {sourceCode.version}
  • -
  • {v.software} {v.version}
  • +
  • {v.software + (v.build ? `+${v.build}` : '')} {v.version}
{features.emailList && account.admin && ( diff --git a/app/soapbox/features/auth_layout/index.tsx b/app/soapbox/features/auth_layout/index.tsx index 6f7f51b959..7a0d39c717 100644 --- a/app/soapbox/features/auth_layout/index.tsx +++ b/app/soapbox/features/auth_layout/index.tsx @@ -10,7 +10,7 @@ import { Card, CardBody } from '../../components/ui'; import LoginPage from '../auth_login/components/login_page'; import PasswordReset from '../auth_login/components/password_reset'; import PasswordResetConfirm from '../auth_login/components/password_reset_confirm'; -// import EmailConfirmation from '../email_confirmation'; +import RegistrationForm from '../auth_login/components/registration_form'; import Verification from '../verification'; import EmailPassthru from '../verification/email_passthru'; @@ -23,7 +23,7 @@ const AuthLayout = () => {
-
+
{logo ? ( {siteTitle} @@ -37,17 +37,17 @@ const AuthLayout = () => {
-
-
+
+
- - + + + - {/* */} diff --git a/app/soapbox/features/conversations/index.js b/app/soapbox/features/conversations/index.js index c1ca68320b..6d4b2ec223 100644 Binary files a/app/soapbox/features/conversations/index.js and b/app/soapbox/features/conversations/index.js differ diff --git a/app/soapbox/features/delete_account/index.tsx b/app/soapbox/features/delete_account/index.tsx index 733b9554cf..d5927f7630 100644 --- a/app/soapbox/features/delete_account/index.tsx +++ b/app/soapbox/features/delete_account/index.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import * as React from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -75,9 +74,4 @@ const DeleteAccount = () => { ); }; -DeleteAccount.propTypes = { - intl: PropTypes.object, - dispatch: PropTypes.func, -}; - export default DeleteAccount; diff --git a/app/soapbox/features/edit_profile/components/profile_preview.js b/app/soapbox/features/edit_profile/components/profile_preview.js deleted file mode 100644 index 7fe1e8e4c9..0000000000 Binary files a/app/soapbox/features/edit_profile/components/profile_preview.js and /dev/null differ diff --git a/app/soapbox/features/edit_profile/components/profile_preview.tsx b/app/soapbox/features/edit_profile/components/profile_preview.tsx new file mode 100644 index 0000000000..e750bb2737 --- /dev/null +++ b/app/soapbox/features/edit_profile/components/profile_preview.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import StillImage from 'soapbox/components/still_image'; +import VerificationBadge from 'soapbox/components/verification_badge'; +import { useSoapboxConfig } from 'soapbox/hooks'; + +import type { Account } from 'soapbox/types/entities'; + +interface IProfilePreview { + account: Account, +} + +/** Displays a preview of the user's account, including avatar, banner, etc. */ +const ProfilePreview: React.FC = ({ account }) => { + const { displayFqn } = useSoapboxConfig(); + + return ( +
+ +
+ +
+
+
+ +
+
+ {account.username} + + + {account.display_name} + {account.verified && } + + + @{displayFqn ? account.fqn : account.acct} +
+
+ +
+ ); +}; + +export default ProfilePreview; diff --git a/app/soapbox/features/edit_profile/index.js b/app/soapbox/features/edit_profile/index.js deleted file mode 100644 index fe0c668826..0000000000 Binary files a/app/soapbox/features/edit_profile/index.js and /dev/null differ diff --git a/app/soapbox/features/edit_profile/index.tsx b/app/soapbox/features/edit_profile/index.tsx new file mode 100644 index 0000000000..9ce2f2985e --- /dev/null +++ b/app/soapbox/features/edit_profile/index.tsx @@ -0,0 +1,485 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { updateNotificationSettings } from 'soapbox/actions/accounts'; +import { patchMe } from 'soapbox/actions/me'; +import snackbar from 'soapbox/actions/snackbar'; +import { + Checkbox, +} from 'soapbox/features/forms'; +import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks'; +import { normalizeAccount } from 'soapbox/normalizers'; +import resizeImage from 'soapbox/utils/resize_image'; + +import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack } from '../../components/ui'; +import Streamfield from '../../components/ui/streamfield/streamfield'; + +import ProfilePreview from './components/profile_preview'; + +import type { Account } from 'soapbox/types/entities'; + +/** + * Whether the user is hiding their follows and/or followers. + * Pleroma's config is granular, but we simplify it into one setting. + */ +const hidesNetwork = (account: Account): boolean => { + const { hide_followers, hide_follows, hide_followers_count, hide_follows_count } = account.pleroma.toJS(); + return Boolean(hide_followers && hide_follows && hide_followers_count && hide_follows_count); +}; + +/** Converts JSON objects to FormData. */ +// https://stackoverflow.com/a/60286175/8811886 +// @ts-ignore +const toFormData = (f => f(f))(h => f => f(x => h(h)(f)(x)))(f => fd => pk => d => { + if (d instanceof Object) { + // eslint-disable-next-line consistent-return + Object.keys(d).forEach(k => { + const v = d[k]; + if (pk) k = `${pk}[${k}]`; + if (v instanceof Object && !(v instanceof Date) && !(v instanceof File)) { + return f(fd)(k)(v); + } else { + fd.append(k, v); + } + }); + } + return fd; +})(new FormData())(); + +const messages = defineMessages({ + heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' }, + header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' }, + metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' }, + metaFieldContent: { id: 'edit_profile.fields.meta_fields.content_placeholder', defaultMessage: 'Content' }, + success: { id: 'edit_profile.success', defaultMessage: 'Profile saved!' }, + error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' }, + bioPlaceholder: { id: 'edit_profile.fields.bio_placeholder', defaultMessage: 'Tell us about yourself.' }, + displayNamePlaceholder: { id: 'edit_profile.fields.display_name_placeholder', defaultMessage: 'Name' }, + websitePlaceholder: { id: 'edit_profile.fields.website_placeholder', defaultMessage: 'Display a Link' }, + locationPlaceholder: { id: 'edit_profile.fields.location_placeholder', defaultMessage: 'Location' }, + cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, +}); + +/** + * Profile metadata `name` and `value`. + * (By default, max 4 fields and 255 characters per property/value) + */ +interface AccountCredentialsField { + name: string, + value: string, +} + +/** Private information (settings) for the account. */ +interface AccountCredentialsSource { + /** Default post privacy for authored statuses. */ + privacy?: string, + /** Whether to mark authored statuses as sensitive by default. */ + sensitive?: boolean, + /** Default language to use for authored statuses. (ISO 6391) */ + language?: string, +} + +/** + * Params to submit when updating an account. + * @see PATCH /api/v1/accounts/update_credentials + */ +interface AccountCredentials { + /** Whether the account should be shown in the profile directory. */ + discoverable?: boolean, + /** Whether the account has a bot flag. */ + bot?: boolean, + /** The display name to use for the profile. */ + display_name?: string, + /** The account bio. */ + note?: string, + /** Avatar image encoded using multipart/form-data */ + avatar?: File, + /** Header image encoded using multipart/form-data */ + header?: File, + /** Whether manual approval of follow requests is required. */ + locked?: boolean, + /** Private information (settings) about the account. */ + source?: AccountCredentialsSource, + /** Custom profile fields. */ + fields_attributes?: AccountCredentialsField[], + + // Non-Mastodon fields + /** Pleroma: whether to accept notifications from people you don't follow. */ + stranger_notifications?: boolean, + /** Soapbox BE: whether the user opts-in to email communications. */ + accepts_email_list?: boolean, + /** Pleroma: whether to publicly display followers. */ + hide_followers?: boolean, + /** Pleroma: whether to publicly display follows. */ + hide_follows?: boolean, + /** Pleroma: whether to publicly display follower count. */ + hide_followers_count?: boolean, + /** Pleroma: whether to publicly display follows count. */ + hide_follows_count?: boolean, + /** User's website URL. */ + website?: string, + /** User's location. */ + location?: string, + /** User's birthday. */ + birthday?: string, +} + +/** Convert an account into an update_credentials request object. */ +const accountToCredentials = (account: Account): AccountCredentials => { + const hideNetwork = hidesNetwork(account); + + return { + discoverable: account.discoverable, + bot: account.bot, + display_name: account.display_name, + note: account.source.get('note'), + locked: account.locked, + fields_attributes: [...account.source.get>('fields', []).toJS()], + stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true, + accepts_email_list: account.getIn(['pleroma', 'accepts_email_list']) === true, + hide_followers: hideNetwork, + hide_follows: hideNetwork, + hide_followers_count: hideNetwork, + hide_follows_count: hideNetwork, + website: account.website, + location: account.location, + birthday: account.birthday, + }; +}; + +interface IProfileField { + value: AccountCredentialsField, + onChange: (field: AccountCredentialsField) => void, +} + +const ProfileField: React.FC = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: string): React.ChangeEventHandler => { + return e => { + onChange({ ...value, [key]: e.currentTarget.value }); + }; + }; + + return ( + + + + + ); +}; + +/** Edit profile page. */ +const EditProfile: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const account = useOwnAccount(); + const features = useFeatures(); + const maxFields = useAppSelector(state => state.instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number); + + const [isLoading, setLoading] = useState(false); + const [data, setData] = useState({}); + const [muteStrangers, setMuteStrangers] = useState(false); + + useEffect(() => { + if (account) { + const credentials = accountToCredentials(account); + const strangerNotifications = account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true; + setData(credentials); + setMuteStrangers(strangerNotifications); + } + }, [account?.id]); + + /** Set a single key in the request data. */ + const updateData = (key: string, value: any) => { + setData(prevData => { + return { ...prevData, [key]: value }; + }); + }; + + const handleSubmit: React.FormEventHandler = (event) => { + const promises = []; + const formData = toFormData(data); + + promises.push(dispatch(patchMe(formData))); + + if (features.muteStrangers) { + promises.push( + dispatch(updateNotificationSettings({ + block_from_strangers: muteStrangers, + })).catch(console.error), + ); + } + + setLoading(true); + + Promise.all(promises).then(() => { + setLoading(false); + dispatch(snackbar.success(intl.formatMessage(messages.success))); + }).catch(() => { + setLoading(false); + dispatch(snackbar.error(intl.formatMessage(messages.error))); + }); + + event.preventDefault(); + }; + + const handleCheckboxChange = (key: keyof AccountCredentials): React.ChangeEventHandler => { + return e => { + updateData(key, e.target.checked); + }; + }; + + const handleTextChange = (key: keyof AccountCredentials): React.ChangeEventHandler => { + return e => { + updateData(key, e.target.value); + }; + }; + + const handleHideNetworkChange: React.ChangeEventHandler = e => { + const hide = e.target.checked; + + setData(prevData => { + return { + ...prevData, + hide_followers: hide, + hide_follows: hide, + hide_followers_count: hide, + hide_follows_count: hide, + }; + }); + }; + + const handleFileChange = ( + name: keyof AccountCredentials, + maxPixels: number, + ): React.ChangeEventHandler => { + return e => { + const f = e.target.files?.item(0); + if (!f) return; + + resizeImage(f, maxPixels).then(file => { + updateData(name, file); + }).catch(console.error); + }; + }; + + const handleFieldsChange = (fields: AccountCredentialsField[]) => { + updateData('fields_attributes', fields); + }; + + const handleAddField = () => { + const oldFields = data.fields_attributes || []; + const fields = [...oldFields, { name: '', value: '' }]; + updateData('fields_attributes', fields); + }; + + const handleRemoveField = (i: number) => { + const oldFields = data.fields_attributes || []; + const fields = [...oldFields]; + fields.splice(i, 1); + updateData('fields_attributes', fields); + }; + + /** Memoized avatar preview URL. */ + const avatarUrl = useMemo(() => { + return data.avatar ? URL.createObjectURL(data.avatar) : account?.avatar; + }, [data.avatar, account?.avatar]); + + /** Memoized header preview URL. */ + const headerUrl = useMemo(() => { + return data.header ? URL.createObjectURL(data.header) : account?.header; + }, [data.header, account?.header]); + + /** Preview account data. */ + const previewAccount = useMemo(() => { + return normalizeAccount({ + ...account?.toJS(), + ...data, + avatar: avatarUrl, + header: headerUrl, + }) as Account; + }, [account?.id, data.display_name, avatarUrl, headerUrl]); + + return ( + + + } + > + + + + {features.birthdays && ( + } + > + + + )} + + {features.accountLocation && ( + } + > + + + )} + + {features.accountWebsite && ( + } + > + + + )} + + } + > +