Merge remote-tracking branch 'soapbox/develop' into edit-posts

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-05-03 21:29:38 +02:00
commit f6f8ef99d9
195 changed files with 15778 additions and 5659 deletions

Binary file not shown.

View file

@ -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"
}
]

View file

@ -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);
});
});

View file

@ -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);
});
});

Binary file not shown.

View file

@ -39,12 +39,16 @@ const needsNodeinfo = (instance: Record<string, any>): boolean => {
export const fetchInstance = createAsyncThunk<void, void, { state: RootState }>(
'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;
},
);

Binary file not shown.

View file

@ -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<any>) => {
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<OnboardingActions>) => {
const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1';
if (needsOnboarding) {
dispatch({ type: ONBOARDING_START });
}
};
const startOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1');
dispatch({ type: ONBOARDING_START });
};
const endOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY);
dispatch({ type: ONBOARDING_END });
};
export {
ONBOARDING_END,
ONBOARDING_START,
checkOnboardingStatus,
endOnboarding,
startOnboarding,
};

Binary file not shown.

View file

@ -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<RulesActions>, 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,
};

Binary file not shown.

View file

@ -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<IEmojiButtonWrapper> = ({ statusId, children
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();
const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
@ -42,16 +43,40 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ 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<IEmojiButtonWrapper> = ({ statusId, children
);
return (
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
onClick: handleClick,
ref: setReferenceElement,

View file

@ -20,7 +20,7 @@ interface IHoverRefWrapper {
/** Makes a profile hover card appear when the wrapped element is hovered. */
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
const dispatch = useDispatch();
const ref = useRef<HTMLElement>();
const ref = useRef<HTMLDivElement>(null);
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
const handleMouseEnter = () => {
@ -41,7 +41,6 @@ export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, childre
return (
<Elem
// @ts-ignore: not sure how to fix :\
ref={ref}
className='hover-ref-wrapper'
onMouseEnter={handleMouseEnter}

View file

@ -15,7 +15,7 @@ const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) =>
}
return (
<Button theme='secondary' block disabled={disabled || !visible} onClick={onClick}>
<Button theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button>
);

View file

@ -1,13 +0,0 @@
import React from 'react';
interface IProgressBar {
progress: number,
}
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2 w-full rounded-md bg-gray-300 dark:bg-slate-700 overflow-hidden'>
<div className='h-full bg-primary-500' style={{ width: `${Math.floor(progress*100)}%` }} />
</div>
);
export default ProgressBar;

View file

@ -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<IScrollableList> = ({
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<IScrollableList> = ({
// 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(<Placeholder />);
} else if (hasMore) {
} else if (hasMore && (autoloadMore || isLoading)) {
data.push(<Spinner />);
}
@ -105,11 +110,19 @@ const ScrollableList: React.FC<IScrollableList> = ({
};
const handleEndReached = () => {
if (hasMore && onLoadMore) {
if (autoloadMore && hasMore && onLoadMore) {
onLoadMore();
}
};
const loadMore = () => {
if (autoloadMore || !hasMore || !onLoadMore) {
return null;
} else {
return <LoadMore visible={!isLoading} onClick={onLoadMore} />;
}
};
/** Render the actual Virtuoso list */
const renderFeed = (): JSX.Element => (
<Virtuoso
@ -130,6 +143,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
EmptyPlaceholder: () => renderEmpty(),
List,
Item,
Footer: loadMore,
}}
/>
);

View file

@ -73,7 +73,7 @@ const SidebarNavigation = () => {
if (account.staff) {
menu.push({
to: '/admin',
to: '/soapbox/admin',
icon: require('@tabler/icons/icons/dashboard.svg'),
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
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 (
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/icons/messages.svg')}
count={chatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
/>
);
}
if (features.directTimeline || features.conversations) {
return (
<SidebarNavigationLink
to='/messages'
icon={require('icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
);
}
return null;
};
return (
<div>
<div className='flex flex-col space-y-2'>
@ -138,22 +164,7 @@ const SidebarNavigation = () => {
</>
)}
{account && (
features.chats ? (
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/icons/messages.svg')}
count={chatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
/>
) : (
<SidebarNavigationLink
to='/messages'
icon={require('icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
)
)}
{account && renderMessagesLink()}
{menu.length > 0 && (
<DropdownMenu items={menu}>

View file

@ -56,7 +56,6 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
<Icon
src={icon}
className={classNames(
'rounded-full',
{
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
},

View file

@ -131,7 +131,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
'emojiSelectorFocused',
]
handleReplyClick = () => {
handleReplyClick: React.MouseEventHandler = (e) => {
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
if (me) {
@ -139,6 +139,8 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
} else {
onOpenUnauthorizedModal('REPLY');
}
e.stopPropagation();
}
handleShareClick = () => {

View file

@ -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 (
<ThumbNavigationLink
src={require('@tabler/icons/icons/messages.svg')}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats'
exact
count={chatsCount}
/>
);
}
if (features.directTimeline || features.conversations) {
return (
<ThumbNavigationLink
src={require('@tabler/icons/icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
to='/messages'
paths={['/messages', '/conversations']}
/>
);
}
return null;
};
return (
<div className='thumb-navigation'>
<ThumbNavigationLink
@ -38,30 +66,13 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
/>
)}
{account && (
features.chats ? (
<ThumbNavigationLink
src={require('@tabler/icons/icons/messages.svg')}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats'
exact
count={chatsCount}
/>
) : (
<ThumbNavigationLink
src={require('@tabler/icons/icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
to='/messages'
paths={['/messages', '/conversations']}
/>
)
)}
{account && renderMessagesLink()}
{(account && account.staff) && (
<ThumbNavigationLink
src={require('@tabler/icons/icons/dashboard.svg')}
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
to='/admin'
to='/soapbox/admin'
count={dashboardCount}
/>
)}

View file

@ -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;

View file

@ -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 <button> */
children?: React.ReactNode,
/** @deprecated unused */
classNames?: string,
/** Prevent the button from being clicked. */
disabled?: boolean,
/** URL to an SVG icon to render inside the button. */
icon?: string,
/** Action when the button is clicked. */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
/** A predefined button size. */
size?: ButtonSizes,
/** @deprecated unused */
style?: React.CSSProperties,
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode,
/** Makes the button into a navlink, if provided. */
to?: string,
/** Styles the button visually with a predefined theme. */
theme?: ButtonThemes,
/** Whether this button should submit a form by default. */
type?: 'button' | 'submit',
}
/** Customizable button element with various themes. */
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const {
block = false,

View file

@ -17,12 +17,17 @@ const messages = defineMessages({
});
interface ICard {
/** The type of card. */
variant?: 'rounded',
/** Card size preset. */
size?: 'md' | 'lg' | 'xl',
/** Extra classnames for the <div> element. */
className?: string,
/** Elements inside the card. */
children: React.ReactNode,
}
/** An opaque backdrop to hold a collection of related elements. */
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
<div
ref={ref}
@ -42,6 +47,7 @@ interface ICardHeader {
onBackClick?: (event: React.MouseEvent) => void
}
/** Typically holds a CardTitle. */
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
const intl = useIntl();
@ -74,10 +80,12 @@ interface ICardTitle {
title: string | React.ReactNode
}
const CardTitle = ({ title }: ICardTitle): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title'>{title}</Text>
/** A card's title. */
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
/** A card's body. */
const CardBody: React.FC = ({ children }): JSX.Element => (
<div data-testid='card-body'>{children}</div>
);

View file

@ -3,21 +3,29 @@ import React from 'react';
import { useHistory } from 'react-router-dom';
import Helmet from 'soapbox/components/helmet';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
interface IColumn {
/** Route the back button goes to. */
backHref?: string,
/** Column title text. */
label?: string,
/** Whether this column should have a transparent background. */
transparent?: boolean,
/** Whether this column should have a title and back button. */
withHeader?: boolean,
/** Extra class name for top <div> element. */
className?: string,
}
/** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className } = props;
const history = useHistory();
const soapboxConfig = useSoapboxConfig();
const handleBackClick = () => {
if (backHref) {
@ -54,7 +62,17 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
return (
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
<Helmet><title>{label}</title></Helmet>
<Helmet>
<title>{label}</title>
{soapboxConfig.appleAppId && (
<meta
data-react-helmet='true'
name='apple-itunes-app'
content={`app-id=${soapboxConfig.appleAppId}, app-argument=${location.href}`}
/>
)}
</Helmet>
{renderChildren()}
</div>

View file

@ -3,6 +3,7 @@ import React from 'react';
import { shortNumberFormat } from 'soapbox/utils/numbers';
interface ICounter {
/** Number this counter should display. */
count: number,
}

View file

@ -4,12 +4,17 @@ import React from 'react';
import { Emoji, HStack } from 'soapbox/components/ui';
interface IEmojiButton {
/** Unicode emoji character. */
emoji: string,
/** Event handler when the emoji is clicked. */
onClick: React.EventHandler<React.MouseEvent>,
/** Extra class name on the <button> element. */
className?: string,
/** Tab order of the button. */
tabIndex?: number,
}
/** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
return (
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
@ -19,12 +24,17 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
};
interface IEmojiSelector {
/** List of Unicode emoji characters. */
emojis: Iterable<string>,
/** Event handler when an emoji is clicked. */
onReact: (emoji: string) => void,
/** Whether the selector should be visible. */
visible?: boolean,
/** Whether the selector should be focused. */
focused?: boolean,
}
/** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {

View file

@ -4,9 +4,11 @@ import { removeVS16s, toCodePoints } from 'soapbox/utils/emoji';
import { joinPublicPath } from 'soapbox/utils/static';
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** Unicode emoji character. */
emoji: string,
}
/** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji));

View file

@ -1,5 +1,6 @@
import React from 'react';
/** Container element to house form actions. */
const FormActions: React.FC = ({ children }) => (
<div className='flex justify-end space-x-2'>
{children}

View file

@ -2,11 +2,15 @@ import React, { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
interface IFormGroup {
hintText?: string | React.ReactNode,
labelText: string,
/** Input label message. */
labelText: React.ReactNode,
/** Input hint message. */
hintText?: React.ReactNode,
/** Input errors. */
errors?: string[]
}
/** Input element with label and hint. */
const FormGroup: React.FC<IFormGroup> = (props) => {
const { children, errors = [], labelText, hintText } = props;
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);

View file

@ -1,10 +1,13 @@
import * as React from 'react';
interface IForm {
/** Form submission event handler. */
onSubmit?: (event: React.FormEvent) => void,
/** Class name override for the <form> element. */
className?: string,
}
/** Form element with custom styles. */
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
const handleSubmit = React.useCallback((event) => {
event.preventDefault();

View file

@ -24,14 +24,21 @@ const spaces = {
};
interface IHStack {
/** Vertical alignment of children. */
alignItems?: 'top' | 'bottom' | 'center' | 'start',
/** Extra class names on the <div> element. */
className?: string,
/** Horizontal alignment of children. */
justifyContent?: 'between' | 'center',
/** Size of the gap between elements. */
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
/** Whether to let the flexbox grow. */
grow?: boolean,
/** Extra CSS styles for the <div> */
style?: React.CSSProperties
}
/** Horizontal row of child elements. */
const HStack: React.FC<IHStack> = (props) => {
const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props;

View file

@ -5,12 +5,17 @@ import SvgIcon from '../icon/svg-icon';
import Text from '../text/text';
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Class name for the <svg> icon. */
iconClassName?: string,
/** URL to the svg icon. */
src: string,
/** Text to display next ot the button. */
text?: string,
/** Don't render a background behind the icon. */
transparent?: boolean
}
/** A clickable icon. */
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
const { src, className, iconClassName, text, transparent = false, ...filteredProps } = props;

View file

@ -4,16 +4,21 @@ import Counter from '../counter/counter';
import SvgIcon from './svg-icon';
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
/** Class name for the <svg> element. */
className?: string,
/** Number to display a counter over the icon. */
count?: number,
/** Tooltip text for the icon. */
alt?: string,
/** URL to the svg icon. */
src: string,
/** Width and height of the icon in pixels. */
size?: number,
}
const Icon = ({ src, alt, count, size, ...filteredProps }: IIcon): JSX.Element => (
/** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, ...filteredProps }): JSX.Element => (
<div className='relative' data-testid='icon'>
{count ? (
<span className='absolute -top-2 -right-3'>

View file

@ -2,9 +2,13 @@ import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
interface ISvgIcon {
/** Class name for the <svg> */
className?: string,
/** Tooltip text for the icon. */
alt?: string,
/** URL to the svg file. */
src: string,
/** Width and height of the icon in pixels. */
size?: number,
}

View file

@ -23,6 +23,7 @@ export {
MenuList,
} from './menu/menu';
export { default as Modal } from './modal/modal';
export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as Select } from './select/select';
export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack';

View file

@ -12,22 +12,34 @@ const messages = defineMessages({
});
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** The initial text in the input. */
defaultValue?: string,
/** Extra class names for the <input> element. */
className?: string,
/** Extra class names for the outer <div> element. */
outerClassName?: string,
/** URL to the svg icon. */
icon?: string,
/** Internal input name. */
name?: string,
/** Text to display before a value is entered. */
placeholder?: string,
/** Text in the input. */
value?: string,
/** Change event handler for the input. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
/** HTML input type. */
type: 'text' | 'email' | 'tel' | 'password',
}
/** Form input element. */
const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const { type = 'text', icon, className, ...filteredProps } = props;
const { type = 'text', icon, className, outerClassName, ...filteredProps } = props;
const [revealed, setRevealed] = React.useState(false);
@ -38,7 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
}, []);
return (
<div className='mt-1 relative rounded-md shadow-sm'>
<div className={classNames('mt-1 relative rounded-md shadow-sm', outerClassName)}>
{icon ? (
<div className='absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none'>
<Icon src={icon} className='h-4 w-4 text-gray-400' aria-hidden='true' />

View file

@ -2,13 +2,14 @@ import classNames from 'classnames';
import React from 'react';
import StickyBox from 'react-sticky-box';
interface LayoutType extends React.FC {
interface LayoutComponent extends React.FC {
Sidebar: React.FC,
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
Aside: React.FC,
}
const Layout: LayoutType = ({ children }) => (
/** Layout container, to hold Sidebar, Main, and Aside. */
const Layout: LayoutComponent = ({ children }) => (
<div className='sm:pt-4 relative'>
<div className='max-w-3xl mx-auto sm:px-6 md:max-w-7xl md:px-8 md:grid md:grid-cols-12 md:gap-8'>
{children}
@ -16,6 +17,7 @@ const Layout: LayoutType = ({ children }) => (
</div>
);
/** Left sidebar container in the UI. */
const Sidebar: React.FC = ({ children }) => (
<div className='hidden lg:block lg:col-span-3'>
<StickyBox offsetTop={80} className='pb-4'>
@ -24,6 +26,7 @@ const Sidebar: React.FC = ({ children }) => (
</div>
);
/** Center column container in the UI. */
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
<main
className={classNames({
@ -34,6 +37,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
</main>
);
/** Right sidebar container in the UI. */
const Aside: React.FC = ({ children }) => (
<aside className='hidden xl:block xl:col-span-3'>
<StickyBox offsetTop={80} className='space-y-6 pb-12' >

View file

@ -13,10 +13,12 @@ import React from 'react';
import './menu.css';
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
/** Position of the dropdown menu. */
position?: 'left' | 'right'
}
const MenuList = (props: IMenuList) => (
/** Renders children as a dropdown menu. */
const MenuList: React.FC<IMenuList> = (props) => (
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
<MenuItems
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
@ -26,6 +28,7 @@ const MenuList = (props: IMenuList) => (
</MenuPopover>
);
/** Divides menu items. */
const MenuDivider = () => <hr />;
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };

View file

@ -11,18 +11,31 @@ const messages = defineMessages({
});
interface IModal {
/** Callback when the modal is cancelled. */
cancelAction?: () => void,
/** Cancel button text. */
cancelText?: string,
/** Callback when the modal is confirmed. */
confirmationAction?: () => void,
/** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean,
/** Confirmation button text. */
confirmationText?: string,
/** Confirmation button theme. */
confirmationTheme?: 'danger',
/** Callback when the modal is closed. */
onClose?: () => void,
/** Callback when the secondary action is chosen. */
secondaryAction?: () => void,
/** Secondary button text. */
secondaryText?: string,
/** Don't focus the "confirm" button on mount. */
skipFocus?: boolean,
/** Title text for the modal. */
title: string | React.ReactNode,
}
/** Displays a modal dialog box. */
const Modal: React.FC<IModal> = ({
cancelAction,
cancelText,
@ -34,16 +47,17 @@ const Modal: React.FC<IModal> = ({
onClose,
secondaryAction,
secondaryText,
skipFocus = false,
title,
}) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (buttonRef?.current) {
if (buttonRef?.current && !skipFocus) {
buttonRef.current.focus();
}
}, [buttonRef]);
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto'>
@ -78,7 +92,7 @@ const Modal: React.FC<IModal> = ({
theme='ghost'
onClick={cancelAction}
>
{cancelText}
{cancelText || 'Cancel'}
</Button>
)}
</div>

View file

@ -0,0 +1,13 @@
import React from 'react';
interface IProgressBar {
progress: number,
}
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2.5 w-full rounded-full bg-gray-100 dark:bg-slate-900/50 overflow-hidden'>
<div className='h-full bg-accent-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
</div>
);
export default ProgressBar;

View file

@ -1,5 +1,6 @@
import * as React from 'react';
/** Multiple-select dropdown. */
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
const { children, ...filteredProps } = props;

View file

@ -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) => (
<Stack space={2} justifyContent='center' alignItems='center'>
<div className='spinner' style={{ width: size, height: size }}>

View file

@ -22,13 +22,18 @@ const alignItemsOptions = {
center: 'items-center',
};
interface IStack extends React.HTMLAttributes<HTMLDivElement > {
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** 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 <div> element. */
className?: string,
}
/** Vertical stack of child elements. */
const Stack: React.FC<IStack> = (props) => {
const { space, alignItems, justifyContent, className, ...filteredProps } = props;

View file

@ -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<IStreamfield> = ({
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 (
<Stack space={4}>
<Stack>
{labelText && <Text size='sm' weight='medium'>{labelText}</Text>}
{hintText && <Text size='xs' theme='muted'>{hintText}</Text>}
</Stack>
<Stack>
{values.map((value, i) => (
<HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} />
{onRemoveItem && (
<IconButton
iconClassName='w-4 h-4'
className='bg-transparent text-gray-400 hover:text-gray-600'
src={require('@tabler/icons/icons/x.svg')}
onClick={() => onRemoveItem(i)}
title={intl.formatMessage(messages.remove)}
/>
)}
</HStack>
))}
</Stack>
{onAddItem && (
<Button
icon={require('@tabler/icons/icons/plus.svg')}
onClick={onAddItem}
disabled={values.length >= maxItems}
theme='ghost'
block
>
{intl.formatMessage(messages.add)}
</Button>
)}
</Stack>
);
};
export default Streamfield;

View file

@ -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<IAnimatedInterface> = ({ children, ...rest }) => {
const [activeRect, setActiveRect] = React.useState(null);
const ref = React.useRef();
@ -58,13 +61,19 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ 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<IAnimatedTab> = ({ index, ...props }) => {
// get the currently selected index from useTabsContext
const { selectedIndex } = useTabsContext();
@ -91,20 +100,32 @@ const AnimatedTab: React.FC<IAnimatedTab> = ({ 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);

View file

@ -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<React.HTMLAttributes<HTMLParagraphElement>, '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<IText> = React.forwardRef(
(props: IText, ref: React.LegacyRef<any>) => {
const {

View file

@ -1,15 +1,24 @@
import classNames from 'classnames';
import React from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, '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<HTMLTextAreaElement>) => {
return (

View file

@ -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<ITooltip> = ({
children,
text,

View file

@ -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 => (
<Text size='xl' weight='bold' tag='h1'>{title}</Text>
);
/** Body of a widget. */
const WidgetBody: React.FC = ({ children }): JSX.Element => (
<Stack space={3}>{children}</Stack>
);
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<IWidget> = ({
title,
children,

View file

@ -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<Record<string, string>>({});
@ -146,10 +151,6 @@ const SoapboxMount = () => {
<body className={bodyClass} />
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
<meta name='theme-color' content={soapboxConfig.brandColor} />
{soapboxConfig.appleAppId && (
<meta name='apple-itunes-app' content={`app-id=${soapboxConfig.appleAppId}`} />
)}
</Helmet>
<ErrorBoundary>
@ -157,7 +158,7 @@ const SoapboxMount = () => {
<>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<Switch>
<Redirect from='/v1/verify_email/:token' to='/auth/verify/email/:token' />
<Redirect from='/v1/verify_email/:token' to='/verify/email/:token' />
{waitlisted && <Route render={(props) => <WaitlistPage {...props} account={account} />} />}
@ -170,7 +171,10 @@ const SoapboxMount = () => {
<Route exact path='/beta/:slug?' component={PublicLayout} />
<Route exact path='/mobile/:slug?' component={PublicLayout} />
<Route exact path='/login' component={AuthLayout} />
<Route path='/auth/verify' component={AuthLayout} />
{(features.accountCreation && instance.registrations) && (
<Route exact path='/signup' component={AuthLayout} />
)}
<Route path='/verify' component={AuthLayout} />
<Route path='/reset-password' component={AuthLayout} />
<Route path='/edit-password' component={AuthLayout} />

View file

@ -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,
}];

View file

@ -46,7 +46,7 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
}
const handleAction = () => {
history.push('/admin/users');
history.push('/soapbox/admin/users');
};
return (

View file

@ -26,9 +26,9 @@ const Admin: React.FC = () => {
<AdminTabs />
<Switch>
<Route path='/admin' exact component={Dashboard} />
<Route path='/admin/reports' exact component={Reports} />
<Route path='/admin/approval' exact component={Waitlist} />
<Route path='/soapbox/admin' exact component={Dashboard} />
<Route path='/soapbox/admin/reports' exact component={Reports} />
<Route path='/soapbox/admin/approval' exact component={Waitlist} />
</Switch>
</Column>
);

View file

@ -77,7 +77,7 @@ const Dashboard: React.FC = () => {
</div>
)}
{isNumber(userCount) && (
<Link className='dashcounter' to='/admin/users'>
<Link className='dashcounter' to='/soapbox/admin/users'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={userCount} />
</Text>
@ -125,7 +125,7 @@ const Dashboard: React.FC = () => {
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul>
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
<li>{v.software + (v.build ? `+${v.build}` : '')} <span className='pull-right'>{v.version}</span></li>
</ul>
</div>
{features.emailList && account.admin && (

View file

@ -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 = () => {
<div className='fixed h-screen w-full bg-gradient-to-tr from-primary-50 dark:from-slate-700 via-white dark:via-slate-900 to-cyan-50 dark:to-cyan-900' />
<main className='relative flex flex-col h-screen'>
<header className='pt-10 flex justify-center relative'>
<header className='py-10 flex justify-center relative'>
<Link to='/' className='cursor-pointer'>
{logo ? (
<img src={logo} alt={siteTitle} className='h-7' />
@ -37,17 +37,17 @@ const AuthLayout = () => {
</Link>
</header>
<div className='-mt-10 flex flex-col justify-center items-center h-full'>
<div className='sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
<div className='flex flex-col justify-center items-center'>
<div className='pb-10 sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
<Card variant='rounded' size='xl'>
<CardBody>
<Switch>
<Route exact path='/auth/verify' component={Verification} />
<Route exact path='/auth/verify/email/:token' component={EmailPassthru} />
<Route exact path='/verify' component={Verification} />
<Route exact path='/verify/email/:token' component={EmailPassthru} />
<Route exact path='/login' component={LoginPage} />
<Route exact path='/signup' component={RegistrationForm} />
<Route exact path='/reset-password' component={PasswordReset} />
<Route exact path='/edit-password' component={PasswordResetConfirm} />
{/* <Route exact path='/auth/confirmation' component={EmailConfirmation} /> */}
<Redirect from='/auth/password/new' to='/reset-password' />
<Redirect from='/auth/password/edit' to='/edit-password' />

View file

@ -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;

View file

@ -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<IProfilePreview> = ({ account }) => {
const { displayFqn } = useSoapboxConfig();
return (
<div className='card h-card'>
<Link to={`/@${account.acct}`}>
<div className='card__img'>
<StillImage alt='' src={account.header} />
</div>
<div className='card__bar'>
<div className='avatar'>
<StillImage alt='' className='u-photo' src={account.avatar} width='48' height='48' />
</div>
<div className='display-name'>
<span style={{ display: 'none' }}>{account.username}</span>
<bdi>
<strong className='emojify p-name'>
{account.display_name}
{account.verified && <VerificationBadge />}
</strong>
</bdi>
<span>@{displayFqn ? account.fqn : account.acct}</span>
</div>
</div>
</Link>
</div>
);
};
export default ProfilePreview;

View file

@ -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<Iterable<AccountCredentialsField>>('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<IProfileField> = ({ value, onChange }) => {
const intl = useIntl();
const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
return e => {
onChange({ ...value, [key]: e.currentTarget.value });
};
};
return (
<HStack space={2} grow>
<Input
type='text'
outerClassName='w-2/5 flex-grow'
value={value.name}
onChange={handleChange('name')}
placeholder={intl.formatMessage(messages.metaFieldLabel)}
/>
<Input
type='text'
outerClassName='w-3/5 flex-grow'
value={value.value}
onChange={handleChange('value')}
placeholder={intl.formatMessage(messages.metaFieldContent)}
/>
</HStack>
);
};
/** 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<AccountCredentials>({});
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<HTMLInputElement> => {
return e => {
updateData(key, e.target.checked);
};
};
const handleTextChange = (key: keyof AccountCredentials): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
return e => {
updateData(key, e.target.value);
};
};
const handleHideNetworkChange: React.ChangeEventHandler<HTMLInputElement> = 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<HTMLInputElement> => {
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 (
<Column label={intl.formatMessage(messages.header)}>
<Form onSubmit={handleSubmit}>
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.display_name_label' defaultMessage='Display name' />}
>
<Input
type='text'
value={data.display_name}
onChange={handleTextChange('display_name')}
placeholder={intl.formatMessage(messages.displayNamePlaceholder)}
/>
</FormGroup>
{features.birthdays && (
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
>
<Input
type='text'
value={data.birthday}
onChange={handleTextChange('birthday')}
/>
</FormGroup>
)}
{features.accountLocation && (
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />}
>
<Input
type='text'
value={data.location}
onChange={handleTextChange('location')}
placeholder={intl.formatMessage(messages.locationPlaceholder)}
/>
</FormGroup>
)}
{features.accountWebsite && (
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.website_label' defaultMessage='Website' />}
>
<Input
type='text'
value={data.website}
onChange={handleTextChange('website')}
placeholder={intl.formatMessage(messages.websitePlaceholder)}
/>
</FormGroup>
)}
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
>
<Textarea
value={data.note}
onChange={handleTextChange('note')}
autoComplete='off'
placeholder={intl.formatMessage(messages.bioPlaceholder)}
/>
</FormGroup>
<div className='grid grid-cols-2 gap-4'>
<ProfilePreview account={previewAccount} />
<div className='space-y-4'>
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
>
<input type='file' onChange={handleFileChange('header', 1920 * 1080)} className='text-sm' />
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
>
<input type='file' onChange={handleFileChange('avatar', 400 * 400)} className='text-sm' />
</FormGroup>
</div>
</div>
{/* HACK: wrap these checkboxes in a .simple_form container so they get styled (for now) */}
{/* Need a either move, replace, or refactor these checkboxes. */}
<div className='simple_form'>
{features.followRequests && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.locked_label' defaultMessage='Lock account' />}
hint={<FormattedMessage id='edit_profile.hints.locked' defaultMessage='Requires you to manually approve followers' />}
checked={data.locked}
onChange={handleCheckboxChange('locked')}
/>
)}
{features.hideNetwork && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.hide_network_label' defaultMessage='Hide network' />}
hint={<FormattedMessage id='edit_profile.hints.hide_network' defaultMessage='Who you follow and who follows you will not be shown on your profile' />}
checked={account ? hidesNetwork(account): false}
onChange={handleHideNetworkChange}
/>
)}
{features.bots && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.bot_label' defaultMessage='This is a bot account' />}
hint={<FormattedMessage id='edit_profile.hints.bot' defaultMessage='This account mainly performs automated actions and might not be monitored' />}
checked={data.bot}
onChange={handleCheckboxChange('bot')}
/>
)}
{features.muteStrangers && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.stranger_notifications_label' defaultMessage='Block notifications from strangers' />}
hint={<FormattedMessage id='edit_profile.hints.stranger_notifications' defaultMessage='Only show notifications from people you follow' />}
checked={muteStrangers}
onChange={(e) => setMuteStrangers(e.target.checked)}
/>
)}
{features.profileDirectory && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.discoverable_label' defaultMessage='Allow account discovery' />}
hint={<FormattedMessage id='edit_profile.hints.discoverable' defaultMessage='Display account in profile directory and allow indexing by external services' />}
checked={data.discoverable}
onChange={handleCheckboxChange('discoverable')}
/>
)}
{features.emailList && (
<Checkbox
label={<FormattedMessage id='edit_profile.fields.accepts_email_list_label' defaultMessage='Subscribe to newsletter' />}
hint={<FormattedMessage id='edit_profile.hints.accepts_email_list' defaultMessage='Opt-in to news and marketing updates.' />}
checked={data.accepts_email_list}
onChange={handleCheckboxChange('accepts_email_list')}
/>
)}
</div>
{features.profileFields && (
<Streamfield
labelText={<FormattedMessage id='edit_profile.fields.meta_fields_label' defaultMessage='Profile fields' />}
hintText={<FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# custom field} other {# custom fields}} displayed on your profile.' values={{ count: maxFields }} />}
values={data.fields_attributes || []}
onChange={handleFieldsChange}
onAddItem={handleAddField}
onRemoveItem={handleRemoveField}
component={ProfileField}
maxItems={maxFields}
/>
)}
<FormActions>
<Button to='/settings' theme='ghost'>
{intl.formatMessage(messages.cancel)}
</Button>
<Button theme='primary' type='submit' disabled={isLoading}>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
</Column>
);
};
export default EditProfile;

View file

@ -82,8 +82,8 @@ interface ISimpleInput {
}
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
const { hint, label, error, ...rest } = props;
const Input = label ? LabelInput : 'input';
const { hint, error, ...rest } = props;
const Input = props.label ? LabelInput : 'input';
return (
<InputContainer {...props}>
@ -146,7 +146,14 @@ export const FieldsGroup: React.FC = ({ children }) => (
<div className='fields-group'>{children}</div>
);
export const Checkbox: React.FC = (props) => (
interface ICheckbox {
label?: React.ReactNode,
hint?: React.ReactNode,
checked?: boolean,
onChange?: React.ChangeEventHandler<HTMLInputElement>,
}
export const Checkbox: React.FC<ICheckbox> = (props) => (
<SimpleInput type='checkbox' {...props} />
);

View file

@ -49,7 +49,7 @@ const LandingPage = () => {
<Text theme='muted' align='center'>Social Media Without Discrimination</Text>
</Stack>
<Button to='/auth/verify' theme='primary' block>Create an account</Button>
<Button to='/verify' theme='primary' block>Create an account</Button>
</Stack>
);
};

View file

@ -61,7 +61,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}

View file

@ -33,7 +33,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}

View file

@ -62,7 +62,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}

View file

@ -40,7 +40,7 @@ const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import PlaceholderAvatar from './placeholder_avatar';
import PlaceholderDisplayName from './placeholder_display_name';
/** Fake account to display while data is loading. */
const PlaceholderAccount: React.FC = () => {
return (
<div className='account'>
<div className='account__wrapper'>
<span className='account__display-name'>
<div className='account__avatar-wrapper'>
<PlaceholderAvatar size={36} />
</div>
<PlaceholderDisplayName minLength={3} maxLength={25} />
</span>
</div>
</div>
);
};
export default PlaceholderAccount;

View file

@ -0,0 +1,31 @@
import React from 'react';
import { randomIntFromInterval, generateText } from '../utils';
import PlaceholderAvatar from './placeholder_avatar';
import PlaceholderDisplayName from './placeholder_display_name';
/** Fake chat to display while data is loading. */
const PlaceholderChat: React.FC = () => {
const messageLength = randomIntFromInterval(5, 75);
return (
<div className='chat-list-item chat-list-item--placeholder'>
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'>
<PlaceholderAvatar size={36} />
</div>
<PlaceholderDisplayName minLength={3} maxLength={25} />
<span className='chat__last-message'>
{generateText(messageLength)}
</span>
</div>
</div>
</div>
</div>
);
};
export default PlaceholderChat;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { generateText, randomIntFromInterval } from '../utils';
/** Fake hashtag to display while data is loading. */
const PlaceholderHashtag: React.FC = () => {
const length = randomIntFromInterval(15, 30);
return (
<div className='placeholder-hashtag'>
<div className='trends__item'>
<div className='trends__item__name'>
{generateText(length)}
</div>
</div>
</div>
);
};
export default PlaceholderHashtag;

View file

@ -0,0 +1,16 @@
import React from 'react';
import PlaceholderStatus from './placeholder_status';
/** Fake material status to display while data is loading. */
const PlaceholderMaterialStatus: React.FC = () => {
return (
<div className='material-status' tabIndex={-1} aria-hidden>
<div className='material-status__status' tabIndex={0}>
<PlaceholderStatus />
</div>
</div>
);
};
export default PlaceholderMaterialStatus;

View file

@ -9,7 +9,8 @@ interface IPlaceholderStatus {
thread?: boolean
}
const PlaceholderStatus = ({ thread = false }: IPlaceholderStatus) => (
/** Fake status to display while data is loading. */
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ thread = false }) => (
<div
className={classNames({
'status-placeholder bg-white dark:bg-slate-800': true,

View file

@ -38,6 +38,7 @@ const languages = {
hy: 'Հայերեն',
id: 'Bahasa Indonesia',
io: 'Ido',
is: 'íslenska',
it: 'Italiano',
ja: '日本語',
ka: 'ქართული',

View file

@ -57,7 +57,7 @@ const Header = () => {
.catch((error: AxiosError) => {
setLoading(false);
const data = error.response?.data;
const data: any = error.response?.data;
if (data?.error === 'mfa_required') {
setMfaToken(data.mfa_token);
}
@ -71,7 +71,7 @@ const Header = () => {
<header>
<nav className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' aria-label='Header'>
<div className='w-full py-6 flex items-center justify-between border-b border-indigo-500 lg:border-none'>
<div className='flex items-center justify-center relative w-36'>
<div className='flex items-center sm:justify-center relative w-36'>
<div className='hidden sm:block absolute z-0 -top-24 -left-6'>
<Sonar />
</div>
@ -96,7 +96,7 @@ const Header = () => {
{(isOpen || features.pepe && pepeOpen) && (
<Button
to={features.pepe ? '/auth/verify' : '/signup'} // FIXME: actually route this somewhere
to={features.pepe ? '/verify' : '/signup'}
theme='primary'
>
{intl.formatMessage(messages.register)}

View file

@ -129,12 +129,14 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
{...actions}
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
/>
{this.renderReplyMentions()}
<Text
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>

View file

@ -2,11 +2,9 @@ import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchPatronInstance } from 'soapbox/actions/patron';
import { Widget, Button, Text } from 'soapbox/components/ui';
import { Widget, Button, ProgressBar, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import ProgressBar from '../../../components/progress_bar';
/** Open link in a new tab. */
// https://stackoverflow.com/a/28374344/8811886
const openInNewTab = (href: string): void => {

View file

@ -0,0 +1,69 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
import React from 'react';
import { __stub } from 'soapbox/api';
import { render, screen } from '../../../../../../jest/test-helpers';
import { normalizeAccount, normalizeStatus } from '../../../../../../normalizers';
import ReportModal from '../report-modal';
describe('<ReportModal />', () => {
let store;
beforeEach(() => {
const rules = require('soapbox/__fixtures__/rules.json');
const status = require('soapbox/__fixtures__/status-unordered-mentions.json');
store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
}),
reports: ImmutableMap({
new: {
account_id: '1',
status_ids: ImmutableSet(['1']),
rule_ids: ImmutableSet(),
},
}),
statuses: ImmutableMap({
'1': normalizeStatus(status),
}),
rules: {
items: rules,
},
};
__stub(mock => {
mock.onGet('/api/v1/instance/rules').reply(200, rules);
mock.onPost('/api/v1/reports').reply(200, {});
});
});
it('successfully renders the first step', () => {
render(<ReportModal onClose={jest.fn} />, {}, store);
expect(screen.getByText('Reason for reporting')).toBeInTheDocument();
});
it('successfully moves to the second step', async() => {
const user = userEvent.setup();
render(<ReportModal onClose={jest.fn} />, {}, store);
await user.click(screen.getByTestId('rule-1'));
await user.click(screen.getByText('Next'));
expect(screen.getByText(/Further actions:/)).toBeInTheDocument();
});
it('successfully moves to the third step', async() => {
const user = userEvent.setup();
render(<ReportModal onClose={jest.fn} />, {}, store);
await user.click(screen.getByTestId('rule-1'));
await user.click(screen.getByText(/Next/));
await user.click(screen.getByText(/Submit/));
expect(screen.getByText(/Thanks for submitting your report/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,204 @@
import { AxiosError } from 'axios';
import { Set as ImmutableSet } from 'immutable';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts';
import { submitReport, cancelReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
import { expandAccountTimeline } from 'soapbox/actions/timelines';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import StatusContent from 'soapbox/components/status_content';
import { Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAccount, useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ConfirmationStep from './steps/confirmation-step';
import OtherActionsStep from './steps/other-actions-step';
import ReasonStep from './steps/reason-step';
const messages = defineMessages({
blankslate: { id: 'report.reason.blankslate', defaultMessage: 'You have removed all statuses from being selected.' },
done: { id: 'report.done', defaultMessage: 'Done' },
next: { id: 'report.next', defaultMessage: 'Next' },
close: { id: 'lightbox.close', defaultMessage: 'Close' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' },
});
enum Steps {
ONE = 'ONE',
TWO = 'TWO',
THREE = 'THREE',
}
const reportSteps = {
ONE: ReasonStep,
TWO: OtherActionsStep,
THREE: ConfirmationStep,
};
const SelectedStatus = ({ statusId }: { statusId: string }) => {
const status = useAppSelector((state) => state.statuses.get(statusId));
if (!status) {
return null;
}
return (
<Stack space={2} className='p-4 rounded-lg bg-gray-100 dark:bg-slate-700'>
<AccountContainer
id={status.get('account') as any}
showProfileHoverCard={false}
timestamp={status.get('created_at')}
hideActions
/>
<StatusContent
status={status}
expanded
collapsable
/>
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>
)}
</Stack>
);
};
interface IReportModal {
onClose: () => void
}
const ReportModal = ({ onClose }: IReportModal) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accountId = useAppSelector((state) => state.reports.getIn(['new', 'account_id']) as string);
const account = useAccount(accountId);
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet<string>);
const shouldRequireRule = rules.length > 0;
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const handleSubmit = () => {
dispatch(submitReport())
.then(() => setCurrentStep(Steps.THREE))
.catch((error: AxiosError) => dispatch(submitReportFail(error)));
if (isBlocked && account) {
dispatch(blockAccount(account.id));
}
};
const handleClose = () => {
dispatch(cancelReport());
onClose();
};
const handleNextStep = () => {
switch (currentStep) {
case Steps.ONE:
setCurrentStep(Steps.TWO);
break;
case Steps.TWO:
handleSubmit();
break;
case Steps.THREE:
dispatch(submitReportSuccess());
onClose();
break;
default:
break;
}
};
const renderSelectedStatuses = useCallback(() => {
switch (selectedStatusIds.size) {
case 0:
return (
<div className='bg-gray-100 dark:bg-slate-700 p-4 rounded-lg flex items-center justify-center w-full'>
<Text theme='muted'>{intl.formatMessage(messages.blankslate)}</Text>
</div>
);
default:
return <SelectedStatus statusId={selectedStatusIds.first()} />;
}
}, [selectedStatusIds.size]);
const confirmationText = useMemo(() => {
switch (currentStep) {
case Steps.TWO:
return intl.formatMessage(messages.submit);
case Steps.THREE:
return intl.formatMessage(messages.done);
default:
return intl.formatMessage(messages.next);
}
}, [currentStep]);
const isConfirmationButtonDisabled = useMemo(() => {
if (currentStep === Steps.THREE) {
return false;
}
return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || selectedStatusIds.size === 0;
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size]);
const calculateProgress = useCallback(() => {
switch (currentStep) {
case Steps.ONE:
return 0.33;
case Steps.TWO:
return 0.66;
case Steps.THREE:
return 1;
default:
return 0;
}
}, [currentStep]);
useEffect(() => {
if (account) {
dispatch(expandAccountTimeline(account.id, { withReplies: true, maxId: null }));
}
}, [account]);
if (!account) {
return null;
}
const StepToRender = reportSteps[currentStep];
return (
<Modal
title={<FormattedMessage id='report.target' defaultMessage='Reporting {target}' values={{ target: <strong>@{account.acct}</strong> }} />}
onClose={handleClose}
cancelAction={currentStep === Steps.THREE ? undefined : onClose}
confirmationAction={handleNextStep}
confirmationText={confirmationText}
confirmationDisabled={isConfirmationButtonDisabled}
skipFocus
>
<Stack space={4}>
<ProgressBar progress={calculateProgress()} />
{currentStep !== Steps.THREE && renderSelectedStatuses()}
<StepToRender account={account} />
</Stack>
</Modal>
);
};
export default ReportModal;

View file

@ -0,0 +1,55 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { Stack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
const messages = defineMessages({
title: { id: 'report.confirmation.title', defaultMessage: 'Thanks for submitting your report.' },
content: { id: 'report.confirmation.content', defaultMessage: 'If we find that this account is violating the {link} we will take further action on the matter.' },
});
interface IOtherActionsStep {
account: ReducerAccount
}
const termsOfServiceText = (<FormattedMessage
id='shared.tos'
defaultMessage='Terms of Service'
/>);
const renderTermsOfServiceLink = (href: string) => (
<a
href={href}
target='_blank'
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500'
>
{termsOfServiceText}
</a>
);
const ConfirmationStep = ({ account }: IOtherActionsStep) => {
const intl = useIntl();
const links = useAppSelector((state) => getSoapboxConfig(state).get('links') as any);
return (
<Stack space={1}>
<Text weight='semibold' tag='h1' size='xl'>
{intl.formatMessage(messages.title)}
</Text>
<Text>
{intl.formatMessage(messages.content, {
link: links.get('termsOfService') ?
renderTermsOfServiceLink(links.get('termsOfService')) :
termsOfServiceText,
})}
</Text>
</Stack>
);
};
export default ConfirmationStep;

View file

@ -0,0 +1,139 @@
import { OrderedSet } from 'immutable';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import Toggle from 'react-toggle';
import { changeReportBlock, changeReportForward } from 'soapbox/actions/reports';
import { fetchRules } from 'soapbox/actions/rules';
import { Button, FormGroup, HStack, Stack, Text } from 'soapbox/components/ui';
import StatusCheckBox from 'soapbox/features/report/containers/status_check_box_container';
import { useAppSelector, useFeatures } from 'soapbox/hooks';
import { isRemote, getDomain } from 'soapbox/utils/accounts';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
const messages = defineMessages({
addAdditionalStatuses: { id: 'report.otherActions.addAdditionl', defaultMessage: 'Would you like to add additional statuses to this report?' },
addMore: { id: 'report.otherActions.addMore', defaultMessage: 'Add more' },
furtherActions: { id: 'report.otherActions.furtherActions', defaultMessage: 'Further actions:' },
hideAdditonalStatuses: { id: 'report.otherActions.hideAdditional', defaultMessage: 'Hide additional statuses' },
otherStatuses: { id: 'report.otherActions.otherStatuses', defaultMessage: 'Include other statuses?' },
});
interface IOtherActionsStep {
account: ReducerAccount
}
const OtherActionsStep = ({ account }: IOtherActionsStep) => {
const dispatch = useDispatch();
const features = useFeatures();
const intl = useIntl();
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.getIn(['new', 'status_ids']) as Iterable<unknown>) as OrderedSet<string>);
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
const isForward = useAppSelector((state) => state.reports.getIn(['new', 'forward']) as boolean);
const canForward = isRemote(account as any) && features.federating;
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
const [showAdditionalStatuses, setShowAdditionalStatuses] = useState<boolean>(false);
const handleBlockChange = (event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(changeReportBlock(event.target.checked));
};
const handleForwardChange = (event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(changeReportForward(event.target.checked));
};
useEffect(() => {
dispatch(fetchRules());
}, []);
return (
<Stack space={4}>
{features.reportMultipleStatuses && (
<Stack space={2}>
<Text tag='h1' size='xl' weight='semibold'>
{intl.formatMessage(messages.otherStatuses)}
</Text>
<FormGroup labelText={intl.formatMessage(messages.addAdditionalStatuses)}>
{showAdditionalStatuses ? (
<Stack space={2}>
<div className='bg-gray-100 rounded-lg p-4'>
{statusIds.map((statusId) => <StatusCheckBox id={statusId} key={statusId} />)}
</div>
<div>
<Button
icon={require('@tabler/icons/icons/arrows-minimize.svg')}
theme='secondary'
size='sm'
onClick={() => setShowAdditionalStatuses(false)}
>
{intl.formatMessage(messages.hideAdditonalStatuses)}
</Button>
</div>
</Stack>
) : (
<Button
icon={require('@tabler/icons/icons/plus.svg')}
theme='secondary'
size='sm'
onClick={() => setShowAdditionalStatuses(true)}
>
{intl.formatMessage(messages.addMore)}
</Button>
)}
</FormGroup>
</Stack>
)}
<Stack space={2}>
<Text tag='h1' size='xl' weight='semibold'>
{intl.formatMessage(messages.furtherActions)}
</Text>
<FormGroup
labelText={<FormattedMessage id='report.block_hint' defaultMessage='Do you also want to block this account?' />}
>
<HStack space={2} alignItems='center'>
<Toggle
checked={isBlocked}
onChange={handleBlockChange}
icons={false}
id='report-block'
/>
<Text theme='muted' tag='label' size='sm' htmlFor='report-block'>
<FormattedMessage id='report.block' defaultMessage='Block {target}' values={{ target: `@${account.get('acct')}` }} />
</Text>
</HStack>
</FormGroup>
{canForward && (
<FormGroup
labelText={<FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send a copy of the report there as well?' />}
>
<HStack space={2} alignItems='center'>
<Toggle
checked={isForward}
onChange={handleForwardChange}
icons={false}
id='report-forward'
disabled={isSubmitting}
/>
<Text theme='muted' tag='label' size='sm' htmlFor='report-forward'>
<FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: getDomain(account) }} />
</Text>
</HStack>
</FormGroup>
)}
</Stack>
</Stack>
);
};
export default OtherActionsStep;

View file

@ -0,0 +1,157 @@
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { changeReportComment, changeReportRule } from 'soapbox/actions/reports';
import { fetchRules } from 'soapbox/actions/rules';
import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { Set as ImmutableSet } from 'immutable';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
const messages = defineMessages({
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
reasonForReporting: { id: 'report.reason.title', defaultMessage: 'Reason for reporting' },
});
interface IReasonStep {
account: ReducerAccount
}
const RULES_HEIGHT = 385;
const ReasonStep = (_props: IReasonStep) => {
const dispatch = useDispatch();
const intl = useIntl();
const rulesListRef = useRef(null);
const [isNearBottom, setNearBottom] = useState<boolean>(false);
const [isNearTop, setNearTop] = useState<boolean>(true);
const comment = useAppSelector((state) => state.reports.getIn(['new', 'comment']) as string);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
const shouldRequireRule = rules.length > 0;
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch(changeReportComment(event.target.value));
};
const handleRulesScrolling = () => {
if (rulesListRef.current) {
const { scrollTop, scrollHeight, clientHeight } = rulesListRef.current;
if (scrollTop + clientHeight > scrollHeight - 24) {
setNearBottom(true);
} else {
setNearBottom(false);
}
if (scrollTop < 24) {
setNearTop(true);
} else {
setNearTop(false);
}
}
};
useEffect(() => {
dispatch(fetchRules());
}, []);
useEffect(() => {
if (rules.length > 0 && rulesListRef.current) {
const { clientHeight } = rulesListRef.current;
if (clientHeight <= RULES_HEIGHT) {
setNearBottom(true);
}
}
}, [rules, rulesListRef.current]);
return (
<Stack space={4}>
{shouldRequireRule && (
<Stack space={2}>
<Text size='xl' weight='semibold' tag='h1'>
{intl.formatMessage(messages.reasonForReporting)}
</Text>
<div className='relative'>
<div
style={{ maxHeight: RULES_HEIGHT }}
className='rounded-lg -space-y-px overflow-y-auto'
onScroll={handleRulesScrolling}
ref={rulesListRef}
>
{rules.map((rule, idx) => {
const isSelected = ruleIds.includes(String(rule.id));
return (
<button
key={idx}
data-testid={`rule-${rule.id}`}
onClick={() => dispatch(changeReportRule(rule.id))}
className={classNames({
'relative border border-solid border-gray-200 dark:border-slate-900/75 hover:bg-gray-50 dark:hover:bg-slate-900/50 text-left w-full p-4 flex justify-between items-center cursor-pointer': true,
'rounded-tl-lg rounded-tr-lg': idx === 0,
'rounded-bl-lg rounded-br-lg': idx === rules.length - 1,
'bg-gray-50 dark:bg-slate-900': isSelected,
})}
>
<Stack className='mr-3'>
<Text
tag='span'
size='sm'
weight='medium'
theme={isSelected ? 'primary' : 'default'}
>
{rule.text}
</Text>
<Text tag='span' theme='muted' size='sm'>{rule.subtext}</Text>
</Stack>
<input
name='reason'
type='checkbox'
value={rule.id}
checked={isSelected}
readOnly
className='h-4 w-4 cursor-pointer text-primary-600 border-gray-300 rounded focus:ring-primary-500'
/>
</button>
);
})}
</div>
<div
className={classNames('inset-x-0 top-0 flex rounded-t-lg justify-center bg-gradient-to-b from-white pb-12 pt-8 pointer-events-none dark:from-slate-900 absolute transition-opacity duration-500', {
'opacity-0': isNearTop,
'opacity-100': !isNearTop,
})}
/>
<div
className={classNames('inset-x-0 bottom-0 flex rounded-b-lg justify-center bg-gradient-to-t from-white pt-12 pb-8 pointer-events-none dark:from-slate-900 absolute transition-opacity duration-500', {
'opacity-0': isNearBottom,
'opacity-100': !isNearBottom,
})}
/>
</div>
</Stack>
)}
<FormGroup labelText={intl.formatMessage(messages.placeholder)}>
<Textarea
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={handleCommentChange}
/>
</FormGroup>
</Stack>
);
};
export default ReasonStep;

View file

@ -0,0 +1,92 @@
import classNames from 'classnames';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage, FormatDateOptions } from 'react-intl';
import { Widget, Stack, HStack, Icon, Text } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { CryptoAddress } from 'soapbox/features/ui/util/async-components';
import type { Account, Field } from 'soapbox/types/entities';
const getTicker = (value: string): string => (value.match(/\$([a-zA-Z]*)/i) || [])[1];
const isTicker = (value: string): boolean => Boolean(getTicker(value));
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
});
const dateFormatOptions: FormatDateOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour12: false,
hour: '2-digit',
minute: '2-digit',
};
interface IProfileField {
field: Field,
}
/** Renders a single profile field. */
const ProfileField: React.FC<IProfileField> = ({ field }) => {
const intl = useIntl();
if (isTicker(field.name)) {
return (
<BundleContainer fetchComponent={CryptoAddress}>
{Component => (
<Component
ticker={getTicker(field.name).toLowerCase()}
address={field.value_plain}
/>
)}
</BundleContainer>
);
}
return (
<dl>
<dt title={field.name}>
<Text weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} />
</dt>
<dd
className={classNames({ 'text-success-500': field.verified_at })}
title={field.value_plain}
>
<HStack space={2} alignItems='center'>
{field.verified_at && (
<span className='flex-none' title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(field.verified_at, dateFormatOptions) })}>
<Icon src={require('@tabler/icons/icons/check.svg')} />
</span>
)}
<Text tag='span' dangerouslySetInnerHTML={{ __html: field.value_emojified }} />
</HStack>
</dd>
</dl>
);
};
interface IProfileFieldsPanel {
account: Account,
}
/** Custom profile fields for sidebar. */
const ProfileFieldsPanel: React.FC<IProfileFieldsPanel> = ({ account }) => {
return (
<Widget title={<FormattedMessage id='profile_fields_panel.title' defaultMessage='Profile fields' />}>
<Stack space={4}>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} />
))}
</Stack>
</Widget>
);
};
export default ProfileFieldsPanel;

View file

@ -0,0 +1,230 @@
'use strict';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Badge from 'soapbox/components/badge';
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification_badge';
import { useSoapboxConfig } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
import ProfileStats from './profile_stats';
import type { Account } from 'soapbox/types/entities';
/** Basically ensure the URL isn't `javascript:alert('hi')` or something like that */
const isSafeUrl = (text: string): boolean => {
try {
const url = new URL(text);
return ['http:', 'https:'].includes(url.protocol);
} catch (e) {
return false;
}
};
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
});
interface IProfileInfoPanel {
account: Account,
/** Username from URL params, in case the account isn't found. */
username: string,
}
/** User profile metadata, such as location, birthday, etc. */
const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) => {
const intl = useIntl();
const { displayFqn } = useSoapboxConfig();
const getStaffBadge = (): React.ReactNode => {
if (account?.admin) {
return <Badge slug='admin' title='Admin' key='staff' />;
} else if (account?.moderator) {
return <Badge slug='moderator' title='Moderator' key='staff' />;
} else {
return null;
}
};
const getBadges = (): React.ReactNode[] => {
const staffBadge = getStaffBadge();
const isPatron = account.getIn(['patron', 'is_patron']) === true;
const badges = [];
if (staffBadge) {
badges.push(staffBadge);
}
if (isPatron) {
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
}
if (account.donor) {
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
}
return badges;
};
const renderBirthday = (): React.ReactNode => {
const birthday = account.birthday;
if (!birthday) return null;
const formattedBirthday = intl.formatDate(birthday, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
const date = new Date(birthday);
const today = new Date();
const hasBirthday = date.getDate() === today.getDate() && date.getMonth() === today.getMonth();
return (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/icons/ballon.svg')}
className='w-4 h-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
{hasBirthday ? (
<FormattedMessage id='account.birthday_today' defaultMessage='Birthday is today!' />
) : (
<FormattedMessage id='account.birthday' defaultMessage='Born {date}' values={{ date: formattedBirthday }} />
)}
</Text>
</HStack>
);
};
if (!account) {
return (
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='sm' theme='muted'>
@{username}
</Text>
</HStack>
</Stack>
</Stack>
</div>
);
}
const content = { __html: account.note_emojified };
const deactivated = !account.pleroma.get('is_active', true) === true;
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.display_name_html };
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
const verified = account.verified;
const badges = getBadges();
return (
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}>
{/* Not sure if this is actual used. */}
{/* <div className='profile-info-panel-content__deactivated'>
<FormattedMessage
id='account.deactivated_description' defaultMessage='This account has been deactivated.'
/>
</div> */}
<Stack>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
{verified && <VerificationBadge />}
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
{badges.length > 0 && (
<HStack space={1} alignItems='center'>
{badges}
</HStack>
)}
</HStack>
<HStack alignItems='center' space={0.5}>
<Text size='sm' theme='muted'>
@{displayFqn ? account.fqn : account.acct}
</Text>
{account.locked && (
<Icon
src={require('@tabler/icons/icons/lock.svg')}
alt={intl.formatMessage(messages.account_locked)}
className='w-4 h-4 text-gray-600'
/>
)}
</HStack>
</Stack>
<ProfileStats account={account} />
{account.note.length > 0 && account.note !== '<p></p>' && (
<Text size='sm' dangerouslySetInnerHTML={content} />
)}
<div className='flex flex-col md:flex-row items-start md:flex-wrap md:items-center gap-2'>
{isLocal(account as any) ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/icons/calendar.svg')}
className='w-4 h-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
<FormattedMessage
id='account.member_since' defaultMessage='Joined {date}' values={{
date: memberSinceDate,
}}
/>
</Text>
</HStack>
) : null}
{account.location ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/icons/map-pin.svg')}
className='w-4 h-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
{account.location}
</Text>
</HStack>
) : null}
{account.website ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/icons/link.svg')}
className='w-4 h-4 text-gray-800 dark:text-gray-200'
/>
<div className='max-w-[300px]'>
<Text size='sm' truncate>
{isSafeUrl(account.website) ? (
<a className='text-primary-600 dark:text-primary-400 hover:underline' href={account.website} target='_blank'>{account.website}</a>
) : (
account.website
)}
</Text>
</div>
</HStack>
) : null}
{renderBirthday()}
</div>
</Stack>
</div>
);
};
export default ProfileInfoPanel;

Some files were not shown because too many files have changed in this diff Show more