Merge remote-tracking branch 'soapbox/develop' into edit-posts
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
f6f8ef99d9
195 changed files with 15778 additions and 5659 deletions
BIN
.eslintrc.js
BIN
.eslintrc.js
Binary file not shown.
14
app/soapbox/__fixtures__/rules.json
Normal file
14
app/soapbox/__fixtures__/rules.json
Normal 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"
|
||||
}
|
||||
]
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
26
app/soapbox/actions/__tests__/rules.test.ts
Normal file
26
app/soapbox/actions/__tests__/rules.test.ts
Normal 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.
|
@ -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.
|
@ -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.
31
app/soapbox/actions/rules.ts
Normal file
31
app/soapbox/actions/rules.ts
Normal 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.
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Binary file not shown.
|
@ -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;
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
interface ICounter {
|
||||
/** Number this counter should display. */
|
||||
count: number,
|
||||
}
|
||||
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()}`, []);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' >
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
|
|
13
app/soapbox/components/ui/progress-bar/progress-bar.tsx
Normal file
13
app/soapbox/components/ui/progress-bar/progress-bar.tsx
Normal 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;
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react';
|
||||
|
||||
/** Multiple-select dropdown. */
|
||||
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
|
||||
const { children, ...filteredProps } = props;
|
||||
|
||||
|
|
|
@ -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 }}>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
94
app/soapbox/components/ui/streamfield/streamfield.tsx
Normal file
94
app/soapbox/components/ui/streamfield/streamfield.tsx
Normal 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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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} />
|
||||
|
||||
|
|
|
@ -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,
|
||||
}];
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
|||
}
|
||||
|
||||
const handleAction = () => {
|
||||
history.push('/admin/users');
|
||||
history.push('/soapbox/admin/users');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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' />
|
||||
|
|
Binary file not shown.
|
@ -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;
|
||||
|
|
Binary file not shown.
|
@ -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;
|
Binary file not shown.
485
app/soapbox/features/edit_profile/index.tsx
Normal file
485
app/soapbox/features/edit_profile/index.tsx
Normal 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;
|
Binary file not shown.
Binary file not shown.
|
@ -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} />
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
@ -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.'));
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -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;
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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;
|
Binary file not shown.
Binary file not shown.
|
@ -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;
|
Binary file not shown.
|
@ -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;
|
Binary file not shown.
|
@ -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,
|
||||
|
|
Binary file not shown.
|
@ -38,6 +38,7 @@ const languages = {
|
|||
hy: 'Հայերեն',
|
||||
id: 'Bahasa Indonesia',
|
||||
io: 'Ido',
|
||||
is: 'íslenska',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
ka: 'ქართული',
|
||||
|
|
|
@ -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)}
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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 }}
|
||||
/>
|
||||
|
|
Binary file not shown.
|
@ -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 => {
|
||||
|
|
Binary file not shown.
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
92
app/soapbox/features/ui/components/profile_fields_panel.tsx
Normal file
92
app/soapbox/features/ui/components/profile_fields_panel.tsx
Normal 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;
|
Binary file not shown.
230
app/soapbox/features/ui/components/profile_info_panel.tsx
Normal file
230
app/soapbox/features/ui/components/profile_info_panel.tsx
Normal 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
Loading…
Reference in a new issue