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
|
@ -25,6 +25,7 @@ module.exports = {
|
||||||
'import',
|
'import',
|
||||||
'promise',
|
'promise',
|
||||||
'react-hooks',
|
'react-hooks',
|
||||||
|
'@typescript-eslint',
|
||||||
],
|
],
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
@ -104,7 +105,8 @@ module.exports = {
|
||||||
'no-undef': 'error',
|
'no-undef': 'error',
|
||||||
'no-unreachable': 'error',
|
'no-unreachable': 'error',
|
||||||
'no-unused-expressions': 'error',
|
'no-unused-expressions': 'error',
|
||||||
'no-unused-vars': [
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
vars: 'all',
|
vars: 'all',
|
||||||
|
|
|
@ -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 { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers';
|
||||||
import { createTestStore, rootState } from 'soapbox/jest/test-helpers';
|
import rootReducer from 'soapbox/reducers';
|
||||||
|
|
||||||
import { ONBOARDING_VERSION, endOnboarding } from '../onboarding';
|
import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding';
|
||||||
|
|
||||||
|
describe('checkOnboarding()', () => {
|
||||||
|
let mockGetItem: any;
|
||||||
|
|
||||||
|
mockWindowProperty('localStorage', {
|
||||||
|
getItem: (key: string) => mockGetItem(key),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetItem = jest.fn().mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if localStorage item is not set', async() => {
|
||||||
|
mockGetItem = jest.fn().mockReturnValue(null);
|
||||||
|
|
||||||
|
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()', () => {
|
describe('endOnboarding()', () => {
|
||||||
it('updates the onboardingVersion setting', async() => {
|
let mockRemoveItem: any;
|
||||||
const store = createTestStore(rootState);
|
|
||||||
|
|
||||||
// Sanity check:
|
mockWindowProperty('localStorage', {
|
||||||
// `onboardingVersion` should be `0` by default
|
removeItem: (key: string) => mockRemoveItem(key),
|
||||||
const initialVersion = getSettings(store.getState()).get('onboardingVersion');
|
});
|
||||||
expect(initialVersion).toBe(0);
|
|
||||||
|
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());
|
await store.dispatch(endOnboarding());
|
||||||
|
const actions = store.getActions();
|
||||||
|
|
||||||
// After dispatching, `onboardingVersion` is updated
|
expect(actions).toEqual([{ type: 'ONBOARDING_END' }]);
|
||||||
const updatedVersion = getSettings(store.getState()).get('onboardingVersion');
|
expect(mockRemoveItem.mock.calls.length).toBe(1);
|
||||||
expect(updatedVersion).toBe(ONBOARDING_VERSION);
|
|
||||||
|
|
||||||
// Sanity check: `updatedVersion` is greater than `initialVersion`
|
|
||||||
expect(updatedVersion > initialVersion).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -13,6 +13,7 @@ import { createAccount } from 'soapbox/actions/accounts';
|
||||||
import { createApp } from 'soapbox/actions/apps';
|
import { createApp } from 'soapbox/actions/apps';
|
||||||
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
|
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
|
||||||
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
|
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
|
||||||
|
import { startOnboarding } from 'soapbox/actions/onboarding';
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
import { custom } from 'soapbox/custom';
|
import { custom } from 'soapbox/custom';
|
||||||
import KVStore from 'soapbox/storage/kv_store';
|
import KVStore from 'soapbox/storage/kv_store';
|
||||||
|
@ -292,7 +293,10 @@ export function register(params) {
|
||||||
|
|
||||||
return dispatch(createAppAndToken())
|
return dispatch(createAppAndToken())
|
||||||
.then(() => dispatch(createAccount(params)))
|
.then(() => dispatch(createAccount(params)))
|
||||||
.then(({ token }) => dispatch(authLoggedIn(token)));
|
.then(({ token }) => {
|
||||||
|
dispatch(startOnboarding());
|
||||||
|
return dispatch(authLoggedIn(token));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,12 +39,16 @@ const needsNodeinfo = (instance: Record<string, any>): boolean => {
|
||||||
|
|
||||||
export const fetchInstance = createAsyncThunk<void, void, { state: RootState }>(
|
export const fetchInstance = createAsyncThunk<void, void, { state: RootState }>(
|
||||||
'instance/fetch',
|
'instance/fetch',
|
||||||
async(_arg, { dispatch, getState }) => {
|
async(_arg, { dispatch, getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
const { data: instance } = await api(getState).get('/api/v1/instance');
|
const { data: instance } = await api(getState).get('/api/v1/instance');
|
||||||
if (needsNodeinfo(instance)) {
|
if (needsNodeinfo(instance)) {
|
||||||
dispatch(fetchNodeinfo());
|
dispatch(fetchNodeinfo());
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
|
} catch(e) {
|
||||||
|
return rejectWithValue(e);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import KVStore from 'soapbox/storage/kv_store';
|
||||||
import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth';
|
import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
@ -46,12 +47,26 @@ export function fetchMe() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the auth account in IndexedDB for Mastodon, etc. */
|
||||||
|
const persistAuthAccount = (account, params) => {
|
||||||
|
if (account && account.url) {
|
||||||
|
if (!account.pleroma) account.pleroma = {};
|
||||||
|
|
||||||
|
if (!account.pleroma.settings_store) {
|
||||||
|
account.pleroma.settings_store = params.pleroma_settings_store || {};
|
||||||
|
}
|
||||||
|
KVStore.setItem(`authAccount:${account.url}`, account).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function patchMe(params) {
|
export function patchMe(params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(patchMeRequest());
|
dispatch(patchMeRequest());
|
||||||
|
|
||||||
return api(getState)
|
return api(getState)
|
||||||
.patch('/api/v1/accounts/update_credentials', params)
|
.patch('/api/v1/accounts/update_credentials', params)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
persistAuthAccount(response.data, params);
|
||||||
dispatch(patchMeSuccess(response.data));
|
dispatch(patchMeSuccess(response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(patchMeFail(error));
|
dispatch(patchMeFail(error));
|
||||||
|
|
|
@ -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 */
|
const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding';
|
||||||
export const ONBOARDING_VERSION = 1;
|
|
||||||
|
|
||||||
/** Finish onboarding and store the setting */
|
type OnboardingStartAction = {
|
||||||
const endOnboarding = () => (dispatch: React.Dispatch<any>) => {
|
type: typeof ONBOARDING_START
|
||||||
dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION));
|
}
|
||||||
|
|
||||||
|
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 {
|
export {
|
||||||
|
ONBOARDING_END,
|
||||||
|
ONBOARDING_START,
|
||||||
|
checkOnboardingStatus,
|
||||||
endOnboarding,
|
endOnboarding,
|
||||||
|
startOnboarding,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
import { openModal, closeModal } from './modals';
|
import { openModal } from './modals';
|
||||||
|
|
||||||
export const REPORT_INIT = 'REPORT_INIT';
|
export const REPORT_INIT = 'REPORT_INIT';
|
||||||
export const REPORT_CANCEL = 'REPORT_CANCEL';
|
export const REPORT_CANCEL = 'REPORT_CANCEL';
|
||||||
|
@ -14,6 +14,8 @@ export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
|
||||||
export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
|
export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
|
||||||
export const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
export const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
||||||
|
|
||||||
|
export const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
|
||||||
|
|
||||||
export function initReport(account, status) {
|
export function initReport(account, status) {
|
||||||
return dispatch => {
|
return dispatch => {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -54,16 +56,15 @@ export function toggleStatusReport(statusId, checked) {
|
||||||
export function submitReport() {
|
export function submitReport() {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(submitReportRequest());
|
dispatch(submitReportRequest());
|
||||||
|
const { reports } = getState();
|
||||||
|
|
||||||
api(getState).post('/api/v1/reports', {
|
return api(getState).post('/api/v1/reports', {
|
||||||
account_id: getState().getIn(['reports', 'new', 'account_id']),
|
account_id: reports.getIn(['new', 'account_id']),
|
||||||
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
|
status_ids: reports.getIn(['new', 'status_ids']),
|
||||||
comment: getState().getIn(['reports', 'new', 'comment']),
|
rule_ids: reports.getIn(['new', 'rule_ids']),
|
||||||
forward: getState().getIn(['reports', 'new', 'forward']),
|
comment: reports.getIn(['new', 'comment']),
|
||||||
}).then(response => {
|
forward: reports.getIn(['new', 'forward']),
|
||||||
dispatch(closeModal());
|
});
|
||||||
dispatch(submitReportSuccess(response.data));
|
|
||||||
}).catch(error => dispatch(submitReportFail(error)));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,10 +74,9 @@ export function submitReportRequest() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitReportSuccess(report) {
|
export function submitReportSuccess() {
|
||||||
return {
|
return {
|
||||||
type: REPORT_SUBMIT_SUCCESS,
|
type: REPORT_SUBMIT_SUCCESS,
|
||||||
report,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,3 +107,10 @@ export function changeReportBlock(block) {
|
||||||
block,
|
block,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function changeReportRule(ruleId) {
|
||||||
|
return {
|
||||||
|
type: REPORT_RULE_CHANGE,
|
||||||
|
rule_id: ruleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -20,7 +20,7 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const defaultSettings = ImmutableMap({
|
export const defaultSettings = ImmutableMap({
|
||||||
onboardingVersion: 0,
|
onboarded: false,
|
||||||
skinTone: 1,
|
skinTone: 1,
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
underlineLinks: false,
|
underlineLinks: false,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { useDispatch } from 'react-redux';
|
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 status = useAppSelector(state => state.statuses.get(statusId));
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
|
const timeout = useRef<NodeJS.Timeout>();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
// const [focused, setFocused] = 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;
|
if (!status) return null;
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
|
if (timeout.current) {
|
||||||
|
clearTimeout(timeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isUserTouching()) {
|
if (!isUserTouching()) {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
|
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);
|
setVisible(false);
|
||||||
|
} else {
|
||||||
|
timeout.current = setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReact = (emoji: string): void => {
|
const handleReact = (emoji: string): void => {
|
||||||
|
@ -107,7 +132,7 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
{React.cloneElement(children, {
|
{React.cloneElement(children, {
|
||||||
onClick: handleClick,
|
onClick: handleClick,
|
||||||
ref: setReferenceElement,
|
ref: setReferenceElement,
|
||||||
|
|
|
@ -20,7 +20,7 @@ interface IHoverRefWrapper {
|
||||||
/** Makes a profile hover card appear when the wrapped element is hovered. */
|
/** Makes a profile hover card appear when the wrapped element is hovered. */
|
||||||
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
|
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const ref = useRef<HTMLElement>();
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
|
@ -41,7 +41,6 @@ export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, childre
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Elem
|
<Elem
|
||||||
// @ts-ignore: not sure how to fix :\
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className='hover-ref-wrapper'
|
className='hover-ref-wrapper'
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
|
|
|
@ -15,7 +15,7 @@ const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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' />
|
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||||
|
@ -16,14 +13,18 @@ import Badge from 'soapbox/components/badge';
|
||||||
import ActionButton from 'soapbox/features/ui/components/action_button';
|
import ActionButton from 'soapbox/features/ui/components/action_button';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||||
import { Card, CardBody, Stack, Text } from './ui';
|
import { Card, CardBody, Stack, Text } from './ui';
|
||||||
|
|
||||||
|
import type { AppDispatch } from 'soapbox/store';
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
const getBadges = (account) => {
|
const getBadges = (account: Account): JSX.Element[] => {
|
||||||
const badges = [];
|
const badges = [];
|
||||||
|
|
||||||
if (account.admin) {
|
if (account.admin) {
|
||||||
|
@ -43,29 +44,34 @@ const getBadges = (account) => {
|
||||||
return badges;
|
return badges;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseEnter = (dispatch) => {
|
const handleMouseEnter = (dispatch: AppDispatch): React.MouseEventHandler => {
|
||||||
return e => {
|
return () => {
|
||||||
dispatch(updateProfileHoverCard());
|
dispatch(updateProfileHoverCard());
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = (dispatch) => {
|
const handleMouseLeave = (dispatch: AppDispatch): React.MouseEventHandler => {
|
||||||
return e => {
|
return () => {
|
||||||
dispatch(closeProfileHoverCard(true));
|
dispatch(closeProfileHoverCard(true));
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileHoverCard = ({ visible }) => {
|
interface IProfileHoverCard {
|
||||||
const dispatch = useDispatch();
|
visible: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup profile preview that appears when hovering avatars and display names. */
|
||||||
|
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [popperElement, setPopperElement] = useState(null);
|
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const me = useSelector(state => state.get('me'));
|
const me = useAppSelector(state => state.me);
|
||||||
const accountId = useSelector(state => state.getIn(['profile_hover_card', 'accountId']));
|
const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.get<string | undefined>('accountId', undefined));
|
||||||
const account = useSelector(state => accountId && getAccount(state, accountId));
|
const account = useAppSelector(state => accountId && getAccount(state, accountId));
|
||||||
const targetRef = useSelector(state => state.getIn(['profile_hover_card', 'ref', 'current']));
|
const targetRef = useAppSelector(state => state.profile_hover_card.getIn(['ref', 'current']) as Element | null);
|
||||||
const badges = account ? getBadges(account) : [];
|
const badges = account ? getBadges(account) : [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -86,8 +92,8 @@ export const ProfileHoverCard = ({ visible }) => {
|
||||||
const { styles, attributes } = usePopper(targetRef, popperElement);
|
const { styles, attributes } = usePopper(targetRef, popperElement);
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const accountBio = { __html: account.get('note_emojified') };
|
const accountBio = { __html: account.note_emojified };
|
||||||
const followedBy = me !== account.get('id') && account.getIn(['relationship', 'followed_by']);
|
const followedBy = me !== account.id && account.relationship.get('followed_by') === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -115,7 +121,7 @@ export const ProfileHoverCard = ({ visible }) => {
|
||||||
)}
|
)}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
||||||
{account.getIn(['source', 'note'], '').length > 0 && (
|
{account.source.get('note', '').length > 0 && (
|
||||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -134,14 +140,4 @@ export const ProfileHoverCard = ({ visible }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProfileHoverCard.propTypes = {
|
|
||||||
visible: PropTypes.bool,
|
|
||||||
accountId: PropTypes.string,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
};
|
|
||||||
|
|
||||||
ProfileHoverCard.defaultProps = {
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProfileHoverCard;
|
export default ProfileHoverCard;
|
|
@ -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 { Virtuoso, Components } from 'react-virtuoso';
|
||||||
|
|
||||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
|
import { useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import LoadMore from './load_more';
|
||||||
import { Spinner, Text } from './ui';
|
import { Spinner, Text } from './ui';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
|
@ -60,6 +62,9 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
placeholderComponent: Placeholder,
|
placeholderComponent: Placeholder,
|
||||||
placeholderCount = 0,
|
placeholderCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
|
const settings = useSettings();
|
||||||
|
const autoloadMore = settings.get('autoloadMore');
|
||||||
|
|
||||||
/** Normalized children */
|
/** Normalized children */
|
||||||
const elements = Array.from(children || []);
|
const elements = Array.from(children || []);
|
||||||
|
|
||||||
|
@ -72,9 +77,9 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
|
|
||||||
// Add a placeholder at the bottom for loading
|
// Add a placeholder at the bottom for loading
|
||||||
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
|
// (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 />);
|
data.push(<Placeholder />);
|
||||||
} else if (hasMore) {
|
} else if (hasMore && (autoloadMore || isLoading)) {
|
||||||
data.push(<Spinner />);
|
data.push(<Spinner />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,11 +110,19 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndReached = () => {
|
const handleEndReached = () => {
|
||||||
if (hasMore && onLoadMore) {
|
if (autoloadMore && hasMore && onLoadMore) {
|
||||||
onLoadMore();
|
onLoadMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (autoloadMore || !hasMore || !onLoadMore) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return <LoadMore visible={!isLoading} onClick={onLoadMore} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Render the actual Virtuoso list */
|
/** Render the actual Virtuoso list */
|
||||||
const renderFeed = (): JSX.Element => (
|
const renderFeed = (): JSX.Element => (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
@ -130,6 +143,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
EmptyPlaceholder: () => renderEmpty(),
|
EmptyPlaceholder: () => renderEmpty(),
|
||||||
List,
|
List,
|
||||||
Item,
|
Item,
|
||||||
|
Footer: loadMore,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -73,7 +73,7 @@ const SidebarNavigation = () => {
|
||||||
|
|
||||||
if (account.staff) {
|
if (account.staff) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/admin',
|
to: '/soapbox/admin',
|
||||||
icon: require('@tabler/icons/icons/dashboard.svg'),
|
icon: require('@tabler/icons/icons/dashboard.svg'),
|
||||||
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
||||||
count: dashboardCount,
|
count: dashboardCount,
|
||||||
|
@ -106,6 +106,32 @@ const SidebarNavigation = () => {
|
||||||
|
|
||||||
const menu = makeMenu();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='flex flex-col space-y-2'>
|
<div className='flex flex-col space-y-2'>
|
||||||
|
@ -138,22 +164,7 @@ const SidebarNavigation = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{account && (
|
{account && renderMessagesLink()}
|
||||||
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' />}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{menu.length > 0 && (
|
{menu.length > 0 && (
|
||||||
<DropdownMenu items={menu}>
|
<DropdownMenu items={menu}>
|
||||||
|
|
|
@ -56,7 +56,6 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
|
||||||
<Icon
|
<Icon
|
||||||
src={icon}
|
src={icon}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'rounded-full',
|
|
||||||
{
|
{
|
||||||
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||||
},
|
},
|
||||||
|
|
|
@ -131,7 +131,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
||||||
'emojiSelectorFocused',
|
'emojiSelectorFocused',
|
||||||
]
|
]
|
||||||
|
|
||||||
handleReplyClick = () => {
|
handleReplyClick: React.MouseEventHandler = (e) => {
|
||||||
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
|
@ -139,6 +139,8 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
||||||
} else {
|
} else {
|
||||||
onOpenUnauthorizedModal('REPLY');
|
onOpenUnauthorizedModal('REPLY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShareClick = () => {
|
handleShareClick = () => {
|
||||||
|
|
|
@ -12,6 +12,34 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
||||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||||
const features = getFeatures(useAppSelector((state) => state.instance));
|
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 (
|
return (
|
||||||
<div className='thumb-navigation'>
|
<div className='thumb-navigation'>
|
||||||
<ThumbNavigationLink
|
<ThumbNavigationLink
|
||||||
|
@ -38,30 +66,13 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{account && (
|
{account && renderMessagesLink()}
|
||||||
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 && account.staff) && (
|
{(account && account.staff) && (
|
||||||
<ThumbNavigationLink
|
<ThumbNavigationLink
|
||||||
src={require('@tabler/icons/icons/dashboard.svg')}
|
src={require('@tabler/icons/icons/dashboard.svg')}
|
||||||
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
|
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
|
||||||
to='/admin'
|
to='/soapbox/admin'
|
||||||
count={dashboardCount}
|
count={dashboardCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -6,11 +6,15 @@ import StillImage from 'soapbox/components/still_image';
|
||||||
const AVATAR_SIZE = 42;
|
const AVATAR_SIZE = 42;
|
||||||
|
|
||||||
interface IAvatar {
|
interface IAvatar {
|
||||||
|
/** URL to the avatar image. */
|
||||||
src: string,
|
src: string,
|
||||||
|
/** Width and height of the avatar in pixels. */
|
||||||
size?: number,
|
size?: number,
|
||||||
|
/** Extra class names for the div surrounding the avatar image. */
|
||||||
className?: string,
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Round profile avatar for accounts. */
|
||||||
const Avatar = (props: IAvatar) => {
|
const Avatar = (props: IAvatar) => {
|
||||||
const { src, size = AVATAR_SIZE, className } = props;
|
const { src, size = AVATAR_SIZE, className } = props;
|
||||||
|
|
||||||
|
|
|
@ -8,20 +8,33 @@ import { useButtonStyles } from './useButtonStyles';
|
||||||
import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
|
import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
|
||||||
|
|
||||||
interface IButton {
|
interface IButton {
|
||||||
|
/** Whether this button expands the width of its container. */
|
||||||
block?: boolean,
|
block?: boolean,
|
||||||
|
/** Elements inside the <button> */
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode,
|
||||||
|
/** @deprecated unused */
|
||||||
classNames?: string,
|
classNames?: string,
|
||||||
|
/** Prevent the button from being clicked. */
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|
/** URL to an SVG icon to render inside the button. */
|
||||||
icon?: string,
|
icon?: string,
|
||||||
|
/** Action when the button is clicked. */
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||||
|
/** A predefined button size. */
|
||||||
size?: ButtonSizes,
|
size?: ButtonSizes,
|
||||||
|
/** @deprecated unused */
|
||||||
style?: React.CSSProperties,
|
style?: React.CSSProperties,
|
||||||
|
/** Text inside the button. Takes precedence over `children`. */
|
||||||
text?: React.ReactNode,
|
text?: React.ReactNode,
|
||||||
|
/** Makes the button into a navlink, if provided. */
|
||||||
to?: string,
|
to?: string,
|
||||||
|
/** Styles the button visually with a predefined theme. */
|
||||||
theme?: ButtonThemes,
|
theme?: ButtonThemes,
|
||||||
|
/** Whether this button should submit a form by default. */
|
||||||
type?: 'button' | 'submit',
|
type?: 'button' | 'submit',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Customizable button element with various themes. */
|
||||||
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
|
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
block = false,
|
block = false,
|
||||||
|
|
|
@ -17,12 +17,17 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ICard {
|
interface ICard {
|
||||||
|
/** The type of card. */
|
||||||
variant?: 'rounded',
|
variant?: 'rounded',
|
||||||
|
/** Card size preset. */
|
||||||
size?: 'md' | 'lg' | 'xl',
|
size?: 'md' | 'lg' | 'xl',
|
||||||
|
/** Extra classnames for the <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Elements inside the card. */
|
||||||
children: React.ReactNode,
|
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 => (
|
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
@ -42,6 +47,7 @@ interface ICardHeader {
|
||||||
onBackClick?: (event: React.MouseEvent) => void
|
onBackClick?: (event: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Typically holds a CardTitle. */
|
||||||
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
|
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
@ -74,10 +80,12 @@ interface ICardTitle {
|
||||||
title: string | React.ReactNode
|
title: string | React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const CardTitle = ({ title }: ICardTitle): JSX.Element => (
|
/** A card's title. */
|
||||||
<Text size='xl' weight='bold' tag='h1' data-testid='card-title'>{title}</Text>
|
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 => (
|
const CardBody: React.FC = ({ children }): JSX.Element => (
|
||||||
<div data-testid='card-body'>{children}</div>
|
<div data-testid='card-body'>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,21 +3,29 @@ import React from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import Helmet from 'soapbox/components/helmet';
|
import Helmet from 'soapbox/components/helmet';
|
||||||
|
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||||
|
|
||||||
interface IColumn {
|
interface IColumn {
|
||||||
|
/** Route the back button goes to. */
|
||||||
backHref?: string,
|
backHref?: string,
|
||||||
|
/** Column title text. */
|
||||||
label?: string,
|
label?: string,
|
||||||
|
/** Whether this column should have a transparent background. */
|
||||||
transparent?: boolean,
|
transparent?: boolean,
|
||||||
|
/** Whether this column should have a title and back button. */
|
||||||
withHeader?: boolean,
|
withHeader?: boolean,
|
||||||
|
/** Extra class name for top <div> element. */
|
||||||
className?: string,
|
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 Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||||
const { backHref, children, label, transparent = false, withHeader = true, className } = props;
|
const { backHref, children, label, transparent = false, withHeader = true, className } = props;
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
if (backHref) {
|
if (backHref) {
|
||||||
|
@ -54,7 +62,17 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
<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()}
|
{renderChildren()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
interface ICounter {
|
interface ICounter {
|
||||||
|
/** Number this counter should display. */
|
||||||
count: number,
|
count: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,17 @@ import React from 'react';
|
||||||
import { Emoji, HStack } from 'soapbox/components/ui';
|
import { Emoji, HStack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
interface IEmojiButton {
|
interface IEmojiButton {
|
||||||
|
/** Unicode emoji character. */
|
||||||
emoji: string,
|
emoji: string,
|
||||||
|
/** Event handler when the emoji is clicked. */
|
||||||
onClick: React.EventHandler<React.MouseEvent>,
|
onClick: React.EventHandler<React.MouseEvent>,
|
||||||
|
/** Extra class name on the <button> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Tab order of the button. */
|
||||||
tabIndex?: number,
|
tabIndex?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clickable emoji button that scales when hovered. */
|
||||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
|
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
|
||||||
|
@ -19,12 +24,17 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IEmojiSelector {
|
interface IEmojiSelector {
|
||||||
|
/** List of Unicode emoji characters. */
|
||||||
emojis: Iterable<string>,
|
emojis: Iterable<string>,
|
||||||
|
/** Event handler when an emoji is clicked. */
|
||||||
onReact: (emoji: string) => void,
|
onReact: (emoji: string) => void,
|
||||||
|
/** Whether the selector should be visible. */
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
/** Whether the selector should be focused. */
|
||||||
focused?: boolean,
|
focused?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Panel with a row of emoji buttons. */
|
||||||
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
||||||
|
|
||||||
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
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';
|
import { joinPublicPath } from 'soapbox/utils/static';
|
||||||
|
|
||||||
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
|
/** Unicode emoji character. */
|
||||||
emoji: string,
|
emoji: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single emoji image. */
|
||||||
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
||||||
const { emoji, alt, ...rest } = props;
|
const { emoji, alt, ...rest } = props;
|
||||||
const codepoints = toCodePoints(removeVS16s(emoji));
|
const codepoints = toCodePoints(removeVS16s(emoji));
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
/** Container element to house form actions. */
|
||||||
const FormActions: React.FC = ({ children }) => (
|
const FormActions: React.FC = ({ children }) => (
|
||||||
<div className='flex justify-end space-x-2'>
|
<div className='flex justify-end space-x-2'>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -2,11 +2,15 @@ import React, { useMemo } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
interface IFormGroup {
|
interface IFormGroup {
|
||||||
hintText?: string | React.ReactNode,
|
/** Input label message. */
|
||||||
labelText: string,
|
labelText: React.ReactNode,
|
||||||
|
/** Input hint message. */
|
||||||
|
hintText?: React.ReactNode,
|
||||||
|
/** Input errors. */
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input element with label and hint. */
|
||||||
const FormGroup: React.FC<IFormGroup> = (props) => {
|
const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||||
const { children, errors = [], labelText, hintText } = props;
|
const { children, errors = [], labelText, hintText } = props;
|
||||||
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
|
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
interface IForm {
|
interface IForm {
|
||||||
|
/** Form submission event handler. */
|
||||||
onSubmit?: (event: React.FormEvent) => void,
|
onSubmit?: (event: React.FormEvent) => void,
|
||||||
|
/** Class name override for the <form> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Form element with custom styles. */
|
||||||
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
|
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
|
||||||
const handleSubmit = React.useCallback((event) => {
|
const handleSubmit = React.useCallback((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
@ -24,14 +24,21 @@ const spaces = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IHStack {
|
interface IHStack {
|
||||||
|
/** Vertical alignment of children. */
|
||||||
alignItems?: 'top' | 'bottom' | 'center' | 'start',
|
alignItems?: 'top' | 'bottom' | 'center' | 'start',
|
||||||
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Horizontal alignment of children. */
|
||||||
justifyContent?: 'between' | 'center',
|
justifyContent?: 'between' | 'center',
|
||||||
|
/** Size of the gap between elements. */
|
||||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
|
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
|
||||||
|
/** Whether to let the flexbox grow. */
|
||||||
grow?: boolean,
|
grow?: boolean,
|
||||||
|
/** Extra CSS styles for the <div> */
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Horizontal row of child elements. */
|
||||||
const HStack: React.FC<IHStack> = (props) => {
|
const HStack: React.FC<IHStack> = (props) => {
|
||||||
const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props;
|
const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props;
|
||||||
|
|
||||||
|
|
|
@ -5,12 +5,17 @@ import SvgIcon from '../icon/svg-icon';
|
||||||
import Text from '../text/text';
|
import Text from '../text/text';
|
||||||
|
|
||||||
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
/** Class name for the <svg> icon. */
|
||||||
iconClassName?: string,
|
iconClassName?: string,
|
||||||
|
/** URL to the svg icon. */
|
||||||
src: string,
|
src: string,
|
||||||
|
/** Text to display next ot the button. */
|
||||||
text?: string,
|
text?: string,
|
||||||
|
/** Don't render a background behind the icon. */
|
||||||
transparent?: boolean
|
transparent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A clickable icon. */
|
||||||
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
||||||
const { src, className, iconClassName, text, transparent = false, ...filteredProps } = props;
|
const { src, className, iconClassName, text, transparent = false, ...filteredProps } = props;
|
||||||
|
|
||||||
|
|
|
@ -4,16 +4,21 @@ import Counter from '../counter/counter';
|
||||||
|
|
||||||
import SvgIcon from './svg-icon';
|
import SvgIcon from './svg-icon';
|
||||||
|
|
||||||
|
|
||||||
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||||
|
/** Class name for the <svg> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Number to display a counter over the icon. */
|
||||||
count?: number,
|
count?: number,
|
||||||
|
/** Tooltip text for the icon. */
|
||||||
alt?: string,
|
alt?: string,
|
||||||
|
/** URL to the svg icon. */
|
||||||
src: string,
|
src: string,
|
||||||
|
/** Width and height of the icon in pixels. */
|
||||||
size?: number,
|
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'>
|
<div className='relative' data-testid='icon'>
|
||||||
{count ? (
|
{count ? (
|
||||||
<span className='absolute -top-2 -right-3'>
|
<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
|
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
||||||
|
|
||||||
interface ISvgIcon {
|
interface ISvgIcon {
|
||||||
|
/** Class name for the <svg> */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Tooltip text for the icon. */
|
||||||
alt?: string,
|
alt?: string,
|
||||||
|
/** URL to the svg file. */
|
||||||
src: string,
|
src: string,
|
||||||
|
/** Width and height of the icon in pixels. */
|
||||||
size?: number,
|
size?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ export {
|
||||||
MenuList,
|
MenuList,
|
||||||
} from './menu/menu';
|
} from './menu/menu';
|
||||||
export { default as Modal } from './modal/modal';
|
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 Select } from './select/select';
|
||||||
export { default as Spinner } from './spinner/spinner';
|
export { default as Spinner } from './spinner/spinner';
|
||||||
export { default as Stack } from './stack/stack';
|
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'> {
|
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,
|
autoFocus?: boolean,
|
||||||
|
/** The initial text in the input. */
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
|
/** Extra class names for the <input> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Extra class names for the outer <div> element. */
|
||||||
|
outerClassName?: string,
|
||||||
|
/** URL to the svg icon. */
|
||||||
icon?: string,
|
icon?: string,
|
||||||
|
/** Internal input name. */
|
||||||
name?: string,
|
name?: string,
|
||||||
|
/** Text to display before a value is entered. */
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
/** Text in the input. */
|
||||||
value?: string,
|
value?: string,
|
||||||
|
/** Change event handler for the input. */
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||||
|
/** HTML input type. */
|
||||||
type: 'text' | 'email' | 'tel' | 'password',
|
type: 'text' | 'email' | 'tel' | 'password',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Form input element. */
|
||||||
const Input = React.forwardRef<HTMLInputElement, IInput>(
|
const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { type = 'text', icon, className, ...filteredProps } = props;
|
const { type = 'text', icon, className, outerClassName, ...filteredProps } = props;
|
||||||
|
|
||||||
const [revealed, setRevealed] = React.useState(false);
|
const [revealed, setRevealed] = React.useState(false);
|
||||||
|
|
||||||
|
@ -38,7 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-1 relative rounded-md shadow-sm'>
|
<div className={classNames('mt-1 relative rounded-md shadow-sm', outerClassName)}>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<div className='absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none'>
|
<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' />
|
<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 React from 'react';
|
||||||
import StickyBox from 'react-sticky-box';
|
import StickyBox from 'react-sticky-box';
|
||||||
|
|
||||||
interface LayoutType extends React.FC {
|
interface LayoutComponent extends React.FC {
|
||||||
Sidebar: React.FC,
|
Sidebar: React.FC,
|
||||||
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
|
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
|
||||||
Aside: React.FC,
|
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='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'>
|
<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}
|
{children}
|
||||||
|
@ -16,6 +17,7 @@ const Layout: LayoutType = ({ children }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Left sidebar container in the UI. */
|
||||||
const Sidebar: React.FC = ({ children }) => (
|
const Sidebar: React.FC = ({ children }) => (
|
||||||
<div className='hidden lg:block lg:col-span-3'>
|
<div className='hidden lg:block lg:col-span-3'>
|
||||||
<StickyBox offsetTop={80} className='pb-4'>
|
<StickyBox offsetTop={80} className='pb-4'>
|
||||||
|
@ -24,6 +26,7 @@ const Sidebar: React.FC = ({ children }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Center column container in the UI. */
|
||||||
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
|
||||||
<main
|
<main
|
||||||
className={classNames({
|
className={classNames({
|
||||||
|
@ -34,6 +37,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Right sidebar container in the UI. */
|
||||||
const Aside: React.FC = ({ children }) => (
|
const Aside: React.FC = ({ children }) => (
|
||||||
<aside className='hidden xl:block xl:col-span-3'>
|
<aside className='hidden xl:block xl:col-span-3'>
|
||||||
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
|
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
|
||||||
|
|
|
@ -13,10 +13,12 @@ import React from 'react';
|
||||||
import './menu.css';
|
import './menu.css';
|
||||||
|
|
||||||
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
|
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
|
||||||
|
/** Position of the dropdown menu. */
|
||||||
position?: 'left' | 'right'
|
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}>
|
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
|
||||||
<MenuItems
|
<MenuItems
|
||||||
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
|
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||||
|
@ -26,6 +28,7 @@ const MenuList = (props: IMenuList) => (
|
||||||
</MenuPopover>
|
</MenuPopover>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Divides menu items. */
|
||||||
const MenuDivider = () => <hr />;
|
const MenuDivider = () => <hr />;
|
||||||
|
|
||||||
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };
|
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };
|
||||||
|
|
|
@ -11,18 +11,31 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IModal {
|
interface IModal {
|
||||||
|
/** Callback when the modal is cancelled. */
|
||||||
cancelAction?: () => void,
|
cancelAction?: () => void,
|
||||||
|
/** Cancel button text. */
|
||||||
cancelText?: string,
|
cancelText?: string,
|
||||||
|
/** Callback when the modal is confirmed. */
|
||||||
confirmationAction?: () => void,
|
confirmationAction?: () => void,
|
||||||
|
/** Whether the confirmation button is disabled. */
|
||||||
confirmationDisabled?: boolean,
|
confirmationDisabled?: boolean,
|
||||||
|
/** Confirmation button text. */
|
||||||
confirmationText?: string,
|
confirmationText?: string,
|
||||||
|
/** Confirmation button theme. */
|
||||||
confirmationTheme?: 'danger',
|
confirmationTheme?: 'danger',
|
||||||
|
/** Callback when the modal is closed. */
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
|
/** Callback when the secondary action is chosen. */
|
||||||
secondaryAction?: () => void,
|
secondaryAction?: () => void,
|
||||||
|
/** Secondary button text. */
|
||||||
secondaryText?: string,
|
secondaryText?: string,
|
||||||
|
/** Don't focus the "confirm" button on mount. */
|
||||||
|
skipFocus?: boolean,
|
||||||
|
/** Title text for the modal. */
|
||||||
title: string | React.ReactNode,
|
title: string | React.ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Displays a modal dialog box. */
|
||||||
const Modal: React.FC<IModal> = ({
|
const Modal: React.FC<IModal> = ({
|
||||||
cancelAction,
|
cancelAction,
|
||||||
cancelText,
|
cancelText,
|
||||||
|
@ -34,16 +47,17 @@ const Modal: React.FC<IModal> = ({
|
||||||
onClose,
|
onClose,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
|
skipFocus = false,
|
||||||
title,
|
title,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (buttonRef?.current) {
|
if (buttonRef?.current && !skipFocus) {
|
||||||
buttonRef.current.focus();
|
buttonRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [buttonRef]);
|
}, [skipFocus, buttonRef]);
|
||||||
|
|
||||||
return (
|
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'>
|
<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'
|
theme='ghost'
|
||||||
onClick={cancelAction}
|
onClick={cancelAction}
|
||||||
>
|
>
|
||||||
{cancelText}
|
{cancelText || 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/** Multiple-select dropdown. */
|
||||||
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
|
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
|
||||||
const { children, ...filteredProps } = props;
|
const { children, ...filteredProps } = props;
|
||||||
|
|
||||||
|
|
|
@ -7,10 +7,13 @@ import Text from '../text/text';
|
||||||
import './spinner.css';
|
import './spinner.css';
|
||||||
|
|
||||||
interface ILoadingIndicator {
|
interface ILoadingIndicator {
|
||||||
|
/** Width and height of the spinner in pixels. */
|
||||||
size?: number,
|
size?: number,
|
||||||
|
/** Whether to display "Loading..." beneath the spinner. */
|
||||||
withText?: boolean
|
withText?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spinning loading placeholder. */
|
||||||
const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
|
const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
|
||||||
<Stack space={2} justifyContent='center' alignItems='center'>
|
<Stack space={2} justifyContent='center' alignItems='center'>
|
||||||
<div className='spinner' style={{ width: size, height: size }}>
|
<div className='spinner' style={{ width: size, height: size }}>
|
||||||
|
|
|
@ -23,12 +23,17 @@ const alignItemsOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Size of the gap between elements. */
|
||||||
space?: SIZES,
|
space?: SIZES,
|
||||||
|
/** Horizontal alignment of children. */
|
||||||
alignItems?: 'center',
|
alignItems?: 'center',
|
||||||
|
/** Vertical alignment of children. */
|
||||||
justifyContent?: 'center',
|
justifyContent?: 'center',
|
||||||
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vertical stack of child elements. */
|
||||||
const Stack: React.FC<IStack> = (props) => {
|
const Stack: React.FC<IStack> = (props) => {
|
||||||
const { space, alignItems, justifyContent, className, ...filteredProps } = props;
|
const { space, alignItems, justifyContent, className, ...filteredProps } = props;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
const AnimatedContext = React.createContext(null);
|
||||||
|
|
||||||
interface IAnimatedInterface {
|
interface IAnimatedInterface {
|
||||||
|
/** Callback when a tab is chosen. */
|
||||||
onChange(index: number): void,
|
onChange(index: number): void,
|
||||||
|
/** Default tab index. */
|
||||||
defaultIndex: number
|
defaultIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tabs with a sliding active state. */
|
||||||
const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
|
const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
|
||||||
const [activeRect, setActiveRect] = React.useState(null);
|
const [activeRect, setActiveRect] = React.useState(null);
|
||||||
const ref = React.useRef();
|
const ref = React.useRef();
|
||||||
|
@ -58,13 +61,19 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IAnimatedTab {
|
interface IAnimatedTab {
|
||||||
|
/** ARIA role. */
|
||||||
role: 'button',
|
role: 'button',
|
||||||
|
/** Element to represent the tab. */
|
||||||
as: 'a' | 'button',
|
as: 'a' | 'button',
|
||||||
|
/** Route to visit when the tab is chosen. */
|
||||||
href?: string,
|
href?: string,
|
||||||
|
/** Tab title text. */
|
||||||
title: string,
|
title: string,
|
||||||
|
/** Index value of the tab. */
|
||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single animated tab. */
|
||||||
const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
|
const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
|
||||||
// get the currently selected index from useTabsContext
|
// get the currently selected index from useTabsContext
|
||||||
const { selectedIndex } = useTabsContext();
|
const { selectedIndex } = useTabsContext();
|
||||||
|
@ -91,20 +100,32 @@ const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Structure to represent a tab. */
|
||||||
type Item = {
|
type Item = {
|
||||||
text: string,
|
/** Tab text. */
|
||||||
|
text: React.ReactNode,
|
||||||
|
/** Tab tooltip text. */
|
||||||
title?: string,
|
title?: string,
|
||||||
|
/** URL to visit when the tab is selected. */
|
||||||
href?: string,
|
href?: string,
|
||||||
|
/** Route to visit when the tab is selected. */
|
||||||
to?: string,
|
to?: string,
|
||||||
|
/** Callback when the tab is selected. */
|
||||||
action?: () => void,
|
action?: () => void,
|
||||||
|
/** Display a counter over the tab. */
|
||||||
count?: number,
|
count?: number,
|
||||||
|
/** Unique name for this tab. */
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ITabs {
|
interface ITabs {
|
||||||
|
/** Array of structured tab items. */
|
||||||
items: Item[],
|
items: Item[],
|
||||||
|
/** Name of the active tab item. */
|
||||||
activeItem: string,
|
activeItem: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Animated tabs component. */
|
||||||
const Tabs = ({ items, activeItem }: ITabs) => {
|
const Tabs = ({ items, activeItem }: ITabs) => {
|
||||||
const defaultIndex = items.findIndex(({ name }) => name === activeItem);
|
const defaultIndex = items.findIndex(({ name }) => name === activeItem);
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ type Alignments = 'left' | 'center' | 'right'
|
||||||
type TrackingSizes = 'normal' | 'wide'
|
type TrackingSizes = 'normal' | 'wide'
|
||||||
type TransformProperties = 'uppercase' | 'normal'
|
type TransformProperties = 'uppercase' | 'normal'
|
||||||
type Families = 'sans' | 'mono'
|
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 = {
|
const themes = {
|
||||||
default: 'text-gray-900 dark:text-gray-100',
|
default: 'text-gray-900 dark:text-gray-100',
|
||||||
|
@ -60,19 +60,31 @@ const families = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
||||||
|
/** How to align the text. */
|
||||||
align?: Alignments,
|
align?: Alignments,
|
||||||
|
/** Extra class names for the outer element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
dateTime?: string,
|
/** Typeface of the text. */
|
||||||
family?: Families,
|
family?: Families,
|
||||||
|
/** The "for" attribute specifies which form element a label is bound to. */
|
||||||
|
htmlFor?: string,
|
||||||
|
/** Font size of the text. */
|
||||||
size?: Sizes,
|
size?: Sizes,
|
||||||
|
/** HTML element name of the outer element. */
|
||||||
tag?: Tags,
|
tag?: Tags,
|
||||||
|
/** Theme for the text. */
|
||||||
theme?: Themes,
|
theme?: Themes,
|
||||||
|
/** Letter-spacing of the text. */
|
||||||
tracking?: TrackingSizes,
|
tracking?: TrackingSizes,
|
||||||
|
/** Transform (eg uppercase) for the text. */
|
||||||
transform?: TransformProperties,
|
transform?: TransformProperties,
|
||||||
|
/** Whether to truncate the text if its container is too small. */
|
||||||
truncate?: boolean,
|
truncate?: boolean,
|
||||||
|
/** Font weight of the text. */
|
||||||
weight?: Weights
|
weight?: Weights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UI-friendly text container with dark mode support. */
|
||||||
const Text: React.FC<IText> = React.forwardRef(
|
const Text: React.FC<IText> = React.forwardRef(
|
||||||
(props: IText, ref: React.LegacyRef<any>) => {
|
(props: IText, ref: React.LegacyRef<any>) => {
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
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,
|
autoFocus?: boolean,
|
||||||
|
/** The initial text in the input. */
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
|
/** Internal input name. */
|
||||||
name?: string,
|
name?: string,
|
||||||
|
/** Renders the textarea as a code editor. */
|
||||||
isCodeEditor?: boolean,
|
isCodeEditor?: boolean,
|
||||||
|
/** Text to display before a value is entered. */
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
/** Text in the textarea. */
|
||||||
value?: string,
|
value?: string,
|
||||||
|
/** Whether the device should autocomplete text in this textarea. */
|
||||||
|
autoComplete?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Textarea with custom styles. */
|
||||||
const Textarea = React.forwardRef(
|
const Textarea = React.forwardRef(
|
||||||
({ isCodeEditor = false, ...props }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
({ isCodeEditor = false, ...props }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -4,9 +4,11 @@ import React from 'react';
|
||||||
import './tooltip.css';
|
import './tooltip.css';
|
||||||
|
|
||||||
interface ITooltip {
|
interface ITooltip {
|
||||||
|
/** Text to display in the tooltip. */
|
||||||
text: string,
|
text: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hoverable tooltip element. */
|
||||||
const Tooltip: React.FC<ITooltip> = ({
|
const Tooltip: React.FC<ITooltip> = ({
|
||||||
children,
|
children,
|
||||||
text,
|
text,
|
||||||
|
|
|
@ -5,24 +5,32 @@ import HStack from 'soapbox/components/ui/hstack/hstack';
|
||||||
import Stack from 'soapbox/components/ui/stack/stack';
|
import Stack from 'soapbox/components/ui/stack/stack';
|
||||||
|
|
||||||
interface IWidgetTitle {
|
interface IWidgetTitle {
|
||||||
title: string | React.ReactNode,
|
/** Title text for the widget. */
|
||||||
|
title: React.ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Title of a widget. */
|
||||||
const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => (
|
const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => (
|
||||||
<Text size='xl' weight='bold' tag='h1'>{title}</Text>
|
<Text size='xl' weight='bold' tag='h1'>{title}</Text>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Body of a widget. */
|
||||||
const WidgetBody: React.FC = ({ children }): JSX.Element => (
|
const WidgetBody: React.FC = ({ children }): JSX.Element => (
|
||||||
<Stack space={3}>{children}</Stack>
|
<Stack space={3}>{children}</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface IWidget {
|
interface IWidget {
|
||||||
title: string | React.ReactNode,
|
/** Widget title text. */
|
||||||
|
title: React.ReactNode,
|
||||||
|
/** Callback when the widget action is clicked. */
|
||||||
onActionClick?: () => void,
|
onActionClick?: () => void,
|
||||||
|
/** URL to the svg icon for the widget action. */
|
||||||
actionIcon?: string,
|
actionIcon?: string,
|
||||||
|
/** Text for the action. */
|
||||||
actionTitle?: string,
|
actionTitle?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sidebar widget. */
|
||||||
const Widget: React.FC<IWidget> = ({
|
const Widget: React.FC<IWidget> = ({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
|
|
|
@ -20,12 +20,12 @@ import PublicLayout from 'soapbox/features/public_layout';
|
||||||
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
|
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
|
||||||
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
|
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
|
||||||
import { createGlobals } from 'soapbox/globals';
|
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 MESSAGES from 'soapbox/locales/messages';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||||
|
|
||||||
import { ONBOARDING_VERSION } from '../actions/onboarding';
|
import { checkOnboardingStatus } from '../actions/onboarding';
|
||||||
import { preload } from '../actions/preload';
|
import { preload } from '../actions/preload';
|
||||||
import ErrorBoundary from '../components/error_boundary';
|
import ErrorBoundary from '../components/error_boundary';
|
||||||
import UI from '../features/ui';
|
import UI from '../features/ui';
|
||||||
|
@ -40,6 +40,9 @@ createGlobals(store);
|
||||||
// Preload happens synchronously
|
// Preload happens synchronously
|
||||||
store.dispatch(preload() as any);
|
store.dispatch(preload() as any);
|
||||||
|
|
||||||
|
// This happens synchronously
|
||||||
|
store.dispatch(checkOnboardingStatus() as any);
|
||||||
|
|
||||||
/** Load initial data from the backend */
|
/** Load initial data from the backend */
|
||||||
const loadInitial = () => {
|
const loadInitial = () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -68,13 +71,15 @@ const SoapboxMount = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const me = useAppSelector(state => state.me);
|
const me = useAppSelector(state => state.me);
|
||||||
|
const instance = useAppSelector(state => state.instance);
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en';
|
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 singleUserMode = soapboxConfig.singleUserMode && soapboxConfig.singleUserModeProfile;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Record<string, string>>({});
|
const [messages, setMessages] = useState<Record<string, string>>({});
|
||||||
|
@ -146,10 +151,6 @@ const SoapboxMount = () => {
|
||||||
<body className={bodyClass} />
|
<body className={bodyClass} />
|
||||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||||
|
|
||||||
{soapboxConfig.appleAppId && (
|
|
||||||
<meta name='apple-itunes-app' content={`app-id=${soapboxConfig.appleAppId}`} />
|
|
||||||
)}
|
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
@ -157,7 +158,7 @@ const SoapboxMount = () => {
|
||||||
<>
|
<>
|
||||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
||||||
<Switch>
|
<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} />} />}
|
{waitlisted && <Route render={(props) => <WaitlistPage {...props} account={account} />} />}
|
||||||
|
|
||||||
|
@ -170,7 +171,10 @@ const SoapboxMount = () => {
|
||||||
<Route exact path='/beta/:slug?' component={PublicLayout} />
|
<Route exact path='/beta/:slug?' component={PublicLayout} />
|
||||||
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
||||||
<Route exact path='/login' component={AuthLayout} />
|
<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='/reset-password' component={AuthLayout} />
|
||||||
<Route path='/edit-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 reportsCount = useAppSelector(state => state.admin.openReports.count());
|
||||||
|
|
||||||
const tabs = [{
|
const tabs = [{
|
||||||
name: '/admin',
|
name: '/soapbox/admin',
|
||||||
text: intl.formatMessage(messages.dashboard),
|
text: intl.formatMessage(messages.dashboard),
|
||||||
to: '/admin',
|
to: '/soapbox/admin',
|
||||||
}, {
|
}, {
|
||||||
name: '/admin/reports',
|
name: '/soapbox/admin/reports',
|
||||||
text: intl.formatMessage(messages.reports),
|
text: intl.formatMessage(messages.reports),
|
||||||
to: '/admin/reports',
|
to: '/soapbox/admin/reports',
|
||||||
count: reportsCount,
|
count: reportsCount,
|
||||||
}, {
|
}, {
|
||||||
name: '/admin/approval',
|
name: '/soapbox/admin/approval',
|
||||||
text: intl.formatMessage(messages.waitlist),
|
text: intl.formatMessage(messages.waitlist),
|
||||||
to: '/admin/approval',
|
to: '/soapbox/admin/approval',
|
||||||
count: approvalCount,
|
count: approvalCount,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = () => {
|
const handleAction = () => {
|
||||||
history.push('/admin/users');
|
history.push('/soapbox/admin/users');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -26,9 +26,9 @@ const Admin: React.FC = () => {
|
||||||
<AdminTabs />
|
<AdminTabs />
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path='/admin' exact component={Dashboard} />
|
<Route path='/soapbox/admin' exact component={Dashboard} />
|
||||||
<Route path='/admin/reports' exact component={Reports} />
|
<Route path='/soapbox/admin/reports' exact component={Reports} />
|
||||||
<Route path='/admin/approval' exact component={Waitlist} />
|
<Route path='/soapbox/admin/approval' exact component={Waitlist} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -77,7 +77,7 @@ const Dashboard: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isNumber(userCount) && (
|
{isNumber(userCount) && (
|
||||||
<Link className='dashcounter' to='/admin/users'>
|
<Link className='dashcounter' to='/soapbox/admin/users'>
|
||||||
<Text align='center' size='2xl' weight='medium'>
|
<Text align='center' size='2xl' weight='medium'>
|
||||||
<FormattedNumber value={userCount} />
|
<FormattedNumber value={userCount} />
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -125,7 +125,7 @@ const Dashboard: React.FC = () => {
|
||||||
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{features.emailList && account.admin && (
|
{features.emailList && account.admin && (
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Card, CardBody } from '../../components/ui';
|
||||||
import LoginPage from '../auth_login/components/login_page';
|
import LoginPage from '../auth_login/components/login_page';
|
||||||
import PasswordReset from '../auth_login/components/password_reset';
|
import PasswordReset from '../auth_login/components/password_reset';
|
||||||
import PasswordResetConfirm from '../auth_login/components/password_reset_confirm';
|
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 Verification from '../verification';
|
||||||
import EmailPassthru from '../verification/email_passthru';
|
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' />
|
<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'>
|
<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'>
|
<Link to='/' className='cursor-pointer'>
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<img src={logo} alt={siteTitle} className='h-7' />
|
<img src={logo} alt={siteTitle} className='h-7' />
|
||||||
|
@ -37,17 +37,17 @@ const AuthLayout = () => {
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className='-mt-10 flex flex-col justify-center items-center h-full'>
|
<div className='flex flex-col justify-center items-center'>
|
||||||
<div className='sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
|
<div className='pb-10 sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
|
||||||
<Card variant='rounded' size='xl'>
|
<Card variant='rounded' size='xl'>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/auth/verify' component={Verification} />
|
<Route exact path='/verify' component={Verification} />
|
||||||
<Route exact path='/auth/verify/email/:token' component={EmailPassthru} />
|
<Route exact path='/verify/email/:token' component={EmailPassthru} />
|
||||||
<Route exact path='/login' component={LoginPage} />
|
<Route exact path='/login' component={LoginPage} />
|
||||||
|
<Route exact path='/signup' component={RegistrationForm} />
|
||||||
<Route exact path='/reset-password' component={PasswordReset} />
|
<Route exact path='/reset-password' component={PasswordReset} />
|
||||||
<Route exact path='/edit-password' component={PasswordResetConfirm} />
|
<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/new' to='/reset-password' />
|
||||||
<Redirect from='/auth/password/edit' to='/edit-password' />
|
<Redirect from='/auth/password/edit' to='/edit-password' />
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { directComposeById } from 'soapbox/actions/compose';
|
import { directComposeById } from 'soapbox/actions/compose';
|
||||||
|
import AccountSearch from 'soapbox/components/account_search';
|
||||||
|
|
||||||
import { mountConversations, unmountConversations, expandConversations } from '../../actions/conversations';
|
import { mountConversations, unmountConversations, expandConversations } from '../../actions/conversations';
|
||||||
import { connectDirectStream } from '../../actions/streaming';
|
import { connectDirectStream } from '../../actions/streaming';
|
||||||
import { Card, CardBody, Column, Stack, Text } from '../../components/ui';
|
import { Column } from '../../components/ui';
|
||||||
|
|
||||||
|
import ConversationsListContainer from './containers/conversations_list_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||||
searchPlaceholder: { id: 'direct.search_placeholder', defaultMessage: 'Send a message to…' },
|
searchPlaceholder: { id: 'direct.search_placeholder', defaultMessage: 'Send a message to…' },
|
||||||
body: { id: 'direct.body', defaultMessage: 'A new direct messaging experience will be available soon. Please stay tuned.' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @connect()
|
export default @connect()
|
||||||
|
@ -54,35 +56,20 @@ class ConversationsTimeline extends React.PureComponent {
|
||||||
const { intl } = this.props;
|
const { intl } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
<Column label={intl.formatMessage(messages.title)}>
|
||||||
<Card variant='rounded'>
|
<AccountSearch
|
||||||
<CardBody>
|
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||||
<Stack space={2}>
|
onSelected={this.handleSuggestion}
|
||||||
<Text size='lg' align='center' weight='bold'>{intl.formatMessage(messages.title)}</Text>
|
/>
|
||||||
<Text theme='muted' align='center'>{intl.formatMessage(messages.body)}</Text>
|
|
||||||
</Stack>
|
<ConversationsListContainer
|
||||||
</CardBody>
|
scrollKey='direct_timeline'
|
||||||
</Card>
|
timelineId='direct'
|
||||||
|
onLoadMore={this.handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||||
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Column label={intl.formatMessage(messages.title)}>
|
|
||||||
// <ColumnHeader icon='envelope' active={hasUnread} title={intl.formatMessage(messages.title)} />
|
|
||||||
|
|
||||||
// <AccountSearch
|
|
||||||
// placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
|
||||||
// onSelected={this.handleSuggestion}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <ConversationsListContainer
|
|
||||||
// scrollKey='direct_timeline'
|
|
||||||
// timelineId='direct'
|
|
||||||
// onLoadMore={this.handleLoadMore}
|
|
||||||
// emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
|
||||||
// />
|
|
||||||
// </Column>
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
@ -75,9 +74,4 @@ const DeleteAccount = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DeleteAccount.propTypes = {
|
|
||||||
intl: PropTypes.object,
|
|
||||||
dispatch: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeleteAccount;
|
export default DeleteAccount;
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import StillImage from 'soapbox/components/still_image';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProfilePreview = ({ account, displayFqn }) => (
|
|
||||||
<div className='card h-card'>
|
|
||||||
<Link to={`/@${account.get('acct')}`}>
|
|
||||||
<div className='card__img'>
|
|
||||||
<StillImage alt='' src={account.get('header')} />
|
|
||||||
</div>
|
|
||||||
<div className='card__bar'>
|
|
||||||
<div className='avatar'>
|
|
||||||
<StillImage alt='' className='u-photo' src={account.get('avatar')} width='48' height='48' />
|
|
||||||
</div>
|
|
||||||
<div className='display-name'>
|
|
||||||
<span style={{ display: 'none' }}>{account.get('username')}</span>
|
|
||||||
<bdi>
|
|
||||||
<strong className='emojify p-name'>
|
|
||||||
{account.get('display_name')}
|
|
||||||
{account.get('verified') && <VerificationBadge />}
|
|
||||||
</strong>
|
|
||||||
</bdi>
|
|
||||||
<span>@{getAcct(account, displayFqn)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
ProfilePreview.propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(ProfilePreview);
|
|
|
@ -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;
|
|
@ -1,436 +0,0 @@
|
||||||
import {
|
|
||||||
Map as ImmutableMap,
|
|
||||||
List as ImmutableList,
|
|
||||||
} from 'immutable';
|
|
||||||
import { unescape } from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
// import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
|
||||||
import { patchMe } from 'soapbox/actions/me';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
// import Icon from 'soapbox/components/icon';
|
|
||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
} from 'soapbox/features/forms';
|
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
import resizeImage from 'soapbox/utils/resize_image';
|
|
||||||
|
|
||||||
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea } from '../../components/ui';
|
|
||||||
|
|
||||||
import ProfilePreview from './components/profile_preview';
|
|
||||||
|
|
||||||
const hidesNetwork = account => {
|
|
||||||
const pleroma = account.get('pleroma');
|
|
||||||
if (!pleroma) return false;
|
|
||||||
|
|
||||||
const { hide_followers, hide_follows, hide_followers_count, hide_follows_count } = pleroma.toJS();
|
|
||||||
return hide_followers && hide_follows && hide_followers_count && hide_follows_count;
|
|
||||||
};
|
|
||||||
|
|
||||||
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' },
|
|
||||||
verified: { id: 'edit_profile.fields.verified_display_name', defaultMessage: 'Verified users may not update their display name' },
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const me = state.get('me');
|
|
||||||
const account = getAccount(state, me);
|
|
||||||
const soapbox = getSoapboxConfig(state);
|
|
||||||
const features = getFeatures(state.instance);
|
|
||||||
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
features,
|
|
||||||
maxFields: state.getIn(['instance', 'pleroma', 'metadata', 'fields_limits', 'max_fields'], 4),
|
|
||||||
verifiedCanEditName: soapbox.get('verifiedCanEditName'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Forces fields to be maxFields size, filling empty values
|
|
||||||
const normalizeFields = (fields, maxFields) => (
|
|
||||||
ImmutableList(fields).setSize(Math.max(fields.size, maxFields)).map(field =>
|
|
||||||
field ? field : ImmutableMap({ name: '', value: '' }),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// HTML unescape for special chars, eg <br>
|
|
||||||
const unescapeParams = (map, params) => (
|
|
||||||
params.reduce((map, param) => (
|
|
||||||
map.set(param, unescape(map.get(param)))
|
|
||||||
), map)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class EditProfile extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
maxFields: PropTypes.number,
|
|
||||||
verifiedCanEditName: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
const { account, maxFields } = this.props;
|
|
||||||
|
|
||||||
const strangerNotifications = account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']);
|
|
||||||
const acceptsEmailList = account.getIn(['pleroma', 'accepts_email_list']);
|
|
||||||
const discoverable = account.getIn(['source', 'pleroma', 'discoverable']);
|
|
||||||
|
|
||||||
const initialState = ImmutableMap(account).withMutations(map => {
|
|
||||||
map.merge(map.get('source'));
|
|
||||||
map.delete('source');
|
|
||||||
map.set('fields', normalizeFields(map.get('fields'), Math.min(maxFields, 4)));
|
|
||||||
map.set('stranger_notifications', strangerNotifications);
|
|
||||||
map.set('accepts_email_list', acceptsEmailList);
|
|
||||||
map.set('hide_network', hidesNetwork(account));
|
|
||||||
map.set('discoverable', discoverable);
|
|
||||||
unescapeParams(map, ['display_name', 'bio']);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state = initialState.toObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
makePreviewAccount = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
return account.merge(ImmutableMap({
|
|
||||||
header: this.state.header,
|
|
||||||
avatar: this.state.avatar,
|
|
||||||
display_name: this.state.display_name || account.get('username'),
|
|
||||||
website: this.state.website || account.get('website'),
|
|
||||||
location: this.state.location || account.get('location'),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
getFieldParams = () => {
|
|
||||||
let params = ImmutableMap();
|
|
||||||
this.state.fields.forEach((f, i) =>
|
|
||||||
params = params
|
|
||||||
.set(`fields_attributes[${i}][name]`, f.get('name'))
|
|
||||||
.set(`fields_attributes[${i}][value]`, f.get('value')),
|
|
||||||
);
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParams = () => {
|
|
||||||
const { state } = this;
|
|
||||||
return Object.assign({
|
|
||||||
discoverable: state.discoverable,
|
|
||||||
bot: state.bot,
|
|
||||||
display_name: state.display_name,
|
|
||||||
website: state.website,
|
|
||||||
location: state.location,
|
|
||||||
birthday: state.birthday,
|
|
||||||
note: state.note,
|
|
||||||
avatar: state.avatar_file,
|
|
||||||
header: state.header_file,
|
|
||||||
locked: state.locked,
|
|
||||||
accepts_email_list: state.accepts_email_list,
|
|
||||||
hide_followers: state.hide_network,
|
|
||||||
hide_follows: state.hide_network,
|
|
||||||
hide_followers_count: state.hide_network,
|
|
||||||
hide_follows_count: state.hide_network,
|
|
||||||
}, this.getFieldParams().toJS());
|
|
||||||
}
|
|
||||||
|
|
||||||
getFormdata = () => {
|
|
||||||
const data = this.getParams();
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const key in data) {
|
|
||||||
const hasValue = data[key] !== null && data[key] !== undefined;
|
|
||||||
// Compact the submission. This should probably be done better.
|
|
||||||
const shouldAppend = Boolean(hasValue || key.startsWith('fields_attributes'));
|
|
||||||
if (shouldAppend) formData.append(key, hasValue ? data[key] : '');
|
|
||||||
}
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit = (event) => {
|
|
||||||
const { dispatch, intl } = this.props;
|
|
||||||
|
|
||||||
const credentials = dispatch(patchMe(this.getFormdata()));
|
|
||||||
/* Bad API url, was causing errors in the promise call below blocking the success message after making edits. */
|
|
||||||
/* const notifications = dispatch(updateNotificationSettings({
|
|
||||||
block_from_strangers: this.state.stranger_notifications || false,
|
|
||||||
})); */
|
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
|
|
||||||
Promise.all([credentials /*notifications*/]).then(() => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
dispatch(snackbar.success(intl.formatMessage(messages.success)));
|
|
||||||
}).catch((error) => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
dispatch(snackbar.error(intl.formatMessage(messages.error)));
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCheckboxChange = e => {
|
|
||||||
this.setState({ [e.target.name]: e.target.checked });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTextChange = e => {
|
|
||||||
this.setState({ [e.target.name]: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFieldChange = (i, key) => {
|
|
||||||
return (e) => {
|
|
||||||
this.setState({
|
|
||||||
fields: this.state.fields.setIn([i, key], e.target.value),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFileChange = maxPixels => {
|
|
||||||
return e => {
|
|
||||||
const { name } = e.target;
|
|
||||||
const [f] = e.target.files || [];
|
|
||||||
|
|
||||||
resizeImage(f, maxPixels).then(file => {
|
|
||||||
const url = file ? URL.createObjectURL(file) : this.state[name];
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
[name]: url,
|
|
||||||
[`${name}_file`]: file,
|
|
||||||
});
|
|
||||||
}).catch(console.error);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAddField = () => {
|
|
||||||
this.setState({
|
|
||||||
fields: this.state.fields.push(ImmutableMap({ name: '', value: '' })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteField = i => {
|
|
||||||
return () => {
|
|
||||||
this.setState({
|
|
||||||
fields: normalizeFields(this.state.fields.delete(i), Math.min(this.props.maxFields, 4)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, account, verifiedCanEditName, features /* maxFields */ } = this.props;
|
|
||||||
const verified = account.get('verified');
|
|
||||||
const canEditName = verifiedCanEditName || !verified;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column label={intl.formatMessage(messages.header)}>
|
|
||||||
<Form onSubmit={this.handleSubmit}>
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.display_name_label' defaultMessage='Display name' />}
|
|
||||||
hintText={!canEditName && intl.formatMessage(messages.verified)}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='display_name'
|
|
||||||
value={this.state.display_name}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.displayNamePlaceholder)}
|
|
||||||
disabled={!canEditName}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
{features.birthdays && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='birthday'
|
|
||||||
value={this.state.birthday}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.accountLocation && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='location'
|
|
||||||
value={this.state.location}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.locationPlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.accountWebsite && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.website_label' defaultMessage='Website' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='website'
|
|
||||||
value={this.state.website}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.websitePlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
name='note'
|
|
||||||
value={this.state.note}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
autoComplete='off'
|
|
||||||
placeholder={intl.formatMessage(messages.bioPlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<ProfilePreview account={this.makePreviewAccount()} />
|
|
||||||
|
|
||||||
<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' name='header' onChange={this.handleFileChange(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' name='avatar' onChange={this.handleFileChange(400 * 400)} className='text-sm' />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*<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' />}
|
|
||||||
name='locked'
|
|
||||||
checked={this.state.locked}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<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' />}
|
|
||||||
name='hide_network'
|
|
||||||
checked={this.state.hide_network}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<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' />}
|
|
||||||
name='bot'
|
|
||||||
checked={this.state.bot}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<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' />}
|
|
||||||
name='stranger_notifications'
|
|
||||||
checked={this.state.stranger_notifications}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<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' />}
|
|
||||||
name='discoverable'
|
|
||||||
checked={this.state.discoverable}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>*/}
|
|
||||||
{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.' />}
|
|
||||||
name='accepts_email_list'
|
|
||||||
checked={this.state.accepts_email_list}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* </FieldsGroup> */}
|
|
||||||
{/*<FieldsGroup>
|
|
||||||
<div className='fields-row__column fields-group'>
|
|
||||||
<div className='input with_block_label'>
|
|
||||||
<label><FormattedMessage id='edit_profile.fields.meta_fields_label' defaultMessage='Profile metadata' /></label>
|
|
||||||
<span className='hint'>
|
|
||||||
<FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile' values={{ count: maxFields }} />
|
|
||||||
</span>
|
|
||||||
{
|
|
||||||
this.state.fields.map((field, i) => (
|
|
||||||
<div className='row' key={i}>
|
|
||||||
<TextInput
|
|
||||||
placeholder={intl.formatMessage(messages.metaFieldLabel)}
|
|
||||||
value={field.get('name')}
|
|
||||||
onChange={this.handleFieldChange(i, 'name')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
placeholder={intl.formatMessage(messages.metaFieldContent)}
|
|
||||||
value={field.get('value')}
|
|
||||||
onChange={this.handleFieldChange(i, 'value')}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
this.state.fields.size > 4 && <Icon className='delete-field' src={require('@tabler/icons/icons/circle-x.svg')} onClick={this.handleDeleteField(i)} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{
|
|
||||||
this.state.fields.size < maxFields && (
|
|
||||||
<div className='actions add-row'>
|
|
||||||
<div name='button' type='button' role='presentation' className='btn button button-secondary' onClick={this.handleAddField}>
|
|
||||||
<Icon src={require('@tabler/icons/icons/circle-plus.svg')} />
|
|
||||||
<FormattedMessage id='edit_profile.meta_fields.add' defaultMessage='Add new item' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FieldsGroup>*/}
|
|
||||||
{/* </fieldset> */}
|
|
||||||
<FormActions>
|
|
||||||
<Button to='/settings' theme='ghost'>
|
|
||||||
{intl.formatMessage(messages.cancel)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button theme='primary' type='submit' disabled={this.state.isLoading}>
|
|
||||||
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</Form>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -9,7 +9,7 @@ const emojis = {};
|
||||||
// decompress
|
// decompress
|
||||||
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
|
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
|
||||||
const [
|
const [
|
||||||
filenameData, // eslint-disable-line no-unused-vars
|
filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
searchData,
|
searchData,
|
||||||
] = shortCodesToEmojiData[shortCode];
|
] = shortCodesToEmojiData[shortCode];
|
||||||
const [
|
const [
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
const [
|
const [
|
||||||
shortCodesToEmojiData,
|
shortCodesToEmojiData,
|
||||||
skins, // eslint-disable-line no-unused-vars
|
skins, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
categories, // eslint-disable-line no-unused-vars
|
categories, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
short_names, // eslint-disable-line no-unused-vars
|
short_names, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
emojisWithoutShortCodes,
|
emojisWithoutShortCodes,
|
||||||
] = require('./emoji_compressed');
|
] = require('./emoji_compressed');
|
||||||
const { unicodeToFilename } = require('./unicode_to_filename');
|
const { unicodeToFilename } = require('./unicode_to_filename');
|
||||||
|
|
|
@ -82,8 +82,8 @@ interface ISimpleInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
||||||
const { hint, label, error, ...rest } = props;
|
const { hint, error, ...rest } = props;
|
||||||
const Input = label ? LabelInput : 'input';
|
const Input = props.label ? LabelInput : 'input';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputContainer {...props}>
|
<InputContainer {...props}>
|
||||||
|
@ -146,7 +146,14 @@ export const FieldsGroup: React.FC = ({ children }) => (
|
||||||
<div className='fields-group'>{children}</div>
|
<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} />
|
<SimpleInput type='checkbox' {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -49,7 +49,7 @@ const LandingPage = () => {
|
||||||
<Text theme='muted' align='center'>Social Media Without Discrimination</Text>
|
<Text theme='muted' align='center'>Social Media Without Discrimination</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button to='/auth/verify' theme='primary' block>Create an account</Button>
|
<Button to='/verify' theme='primary' block>Create an account</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -61,7 +61,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
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 {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
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);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
|
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
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);
|
setSelectedFile(null);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
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 {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
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);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
|
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import PlaceholderAvatar from './placeholder_avatar';
|
|
||||||
import PlaceholderDisplayName from './placeholder_display_name';
|
|
||||||
|
|
||||||
export default class PlaceholderAccount extends React.Component {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -1,7 +1,11 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
const PlaceholderAvatar = ({ size }) => {
|
interface IPlaceholderAvatar {
|
||||||
|
size: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake avatar to display while data is loading. */
|
||||||
|
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size }) => {
|
||||||
const style = React.useMemo(() => {
|
const style = React.useMemo(() => {
|
||||||
if (!size) {
|
if (!size) {
|
||||||
return {};
|
return {};
|
||||||
|
@ -17,13 +21,8 @@ const PlaceholderAvatar = ({ size }) => {
|
||||||
<div
|
<div
|
||||||
className='rounded-full bg-slate-200 dark:bg-slate-700'
|
className='rounded-full bg-slate-200 dark:bg-slate-700'
|
||||||
style={style}
|
style={style}
|
||||||
alt=''
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlaceholderAvatar.propTypes = {
|
|
||||||
size: PropTypes.number.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlaceholderAvatar;
|
export default PlaceholderAvatar;
|
|
@ -3,7 +3,8 @@ import * as React from 'react';
|
||||||
|
|
||||||
import { randomIntFromInterval, generateText } from '../utils';
|
import { randomIntFromInterval, generateText } from '../utils';
|
||||||
|
|
||||||
const PlaceholderCard = () => (
|
/** Fake link preview to display while data is loading. */
|
||||||
|
const PlaceholderCard: React.FC = () => (
|
||||||
<div className={classNames('status-card', {
|
<div className={classNames('status-card', {
|
||||||
'animate-pulse': true,
|
'animate-pulse': true,
|
||||||
})}
|
})}
|
|
@ -1,32 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { randomIntFromInterval, generateText } from '../utils';
|
|
||||||
|
|
||||||
import PlaceholderAvatar from './placeholder_avatar';
|
|
||||||
import PlaceholderDisplayName from './placeholder_display_name';
|
|
||||||
|
|
||||||
export default class PlaceholderAccount extends React.Component {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -1,9 +1,14 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { randomIntFromInterval, generateText } from '../utils';
|
import { randomIntFromInterval, generateText } from '../utils';
|
||||||
|
|
||||||
const PlaceholderDisplayName = ({ minLength, maxLength }) => {
|
interface IPlaceholderDisplayName {
|
||||||
|
maxLength: number,
|
||||||
|
minLength: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake display name to show when data is loading. */
|
||||||
|
const PlaceholderDisplayName: React.FC<IPlaceholderDisplayName> = ({ minLength, maxLength }) => {
|
||||||
const length = randomIntFromInterval(maxLength, minLength);
|
const length = randomIntFromInterval(maxLength, minLength);
|
||||||
const acctLength = randomIntFromInterval(maxLength, minLength);
|
const acctLength = randomIntFromInterval(maxLength, minLength);
|
||||||
|
|
||||||
|
@ -15,9 +20,4 @@ const PlaceholderDisplayName = ({ minLength, maxLength }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlaceholderDisplayName.propTypes = {
|
|
||||||
maxLength: PropTypes.number.isRequired,
|
|
||||||
minLength: PropTypes.number.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlaceholderDisplayName;
|
export default PlaceholderDisplayName;
|
|
@ -1,21 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { generateText, randomIntFromInterval } from '../utils';
|
|
||||||
|
|
||||||
export default class PlaceholderHashtag extends React.Component {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const length = randomIntFromInterval(15, 30);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='placeholder-hashtag'>
|
|
||||||
<div className='trends__item'>
|
|
||||||
<div className='trends__item__name'>
|
|
||||||
{generateText(length)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import PlaceholderStatus from './placeholder_status';
|
|
||||||
|
|
||||||
export default class PlaceholderMaterialStatus extends React.Component {
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className='material-status' tabIndex={-1} aria-hidden>
|
|
||||||
<div className='material-status__status' tabIndex={0}>
|
|
||||||
<PlaceholderStatus {...this.props} focusable={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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;
|
|
@ -4,6 +4,7 @@ import PlaceholderAvatar from './placeholder_avatar';
|
||||||
import PlaceholderDisplayName from './placeholder_display_name';
|
import PlaceholderDisplayName from './placeholder_display_name';
|
||||||
import PlaceholderStatusContent from './placeholder_status_content';
|
import PlaceholderStatusContent from './placeholder_status_content';
|
||||||
|
|
||||||
|
/** Fake notification to display while data is loading. */
|
||||||
const PlaceholderNotification = () => (
|
const PlaceholderNotification = () => (
|
||||||
<div className='bg-white dark:bg-slate-800 px-4 py-6 sm:p-6'>
|
<div className='bg-white dark:bg-slate-800 px-4 py-6 sm:p-6'>
|
||||||
<div className='w-full animate-pulse'>
|
<div className='w-full animate-pulse'>
|
|
@ -9,7 +9,8 @@ interface IPlaceholderStatus {
|
||||||
thread?: boolean
|
thread?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaceholderStatus = ({ thread = false }: IPlaceholderStatus) => (
|
/** Fake status to display while data is loading. */
|
||||||
|
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ thread = false }) => (
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'status-placeholder bg-white dark:bg-slate-800': true,
|
'status-placeholder bg-white dark:bg-slate-800': true,
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { randomIntFromInterval, generateText } from '../utils';
|
import { randomIntFromInterval, generateText } from '../utils';
|
||||||
|
|
||||||
const PlaceholderStatusContent = ({ minLength, maxLength }) => {
|
interface IPlaceholderStatusContent {
|
||||||
|
maxLength: number,
|
||||||
|
minLength: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake status content while data is loading. */
|
||||||
|
const PlaceholderStatusContent: React.FC<IPlaceholderStatusContent> = ({ minLength, maxLength }) => {
|
||||||
const length = randomIntFromInterval(maxLength, minLength);
|
const length = randomIntFromInterval(maxLength, minLength);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -13,9 +18,4 @@ const PlaceholderStatusContent = ({ minLength, maxLength }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlaceholderStatusContent.propTypes = {
|
|
||||||
maxLength: PropTypes.number.isRequired,
|
|
||||||
minLength: PropTypes.number.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlaceholderStatusContent;
|
export default PlaceholderStatusContent;
|
|
@ -38,6 +38,7 @@ const languages = {
|
||||||
hy: 'Հայերեն',
|
hy: 'Հայերեն',
|
||||||
id: 'Bahasa Indonesia',
|
id: 'Bahasa Indonesia',
|
||||||
io: 'Ido',
|
io: 'Ido',
|
||||||
|
is: 'íslenska',
|
||||||
it: 'Italiano',
|
it: 'Italiano',
|
||||||
ja: '日本語',
|
ja: '日本語',
|
||||||
ka: 'ქართული',
|
ka: 'ქართული',
|
||||||
|
|
|
@ -57,7 +57,7 @@ const Header = () => {
|
||||||
.catch((error: AxiosError) => {
|
.catch((error: AxiosError) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
const data = error.response?.data;
|
const data: any = error.response?.data;
|
||||||
if (data?.error === 'mfa_required') {
|
if (data?.error === 'mfa_required') {
|
||||||
setMfaToken(data.mfa_token);
|
setMfaToken(data.mfa_token);
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ const Header = () => {
|
||||||
<header>
|
<header>
|
||||||
<nav className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' aria-label='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='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'>
|
<div className='hidden sm:block absolute z-0 -top-24 -left-6'>
|
||||||
<Sonar />
|
<Sonar />
|
||||||
</div>
|
</div>
|
||||||
|
@ -96,7 +96,7 @@ const Header = () => {
|
||||||
|
|
||||||
{(isOpen || features.pepe && pepeOpen) && (
|
{(isOpen || features.pepe && pepeOpen) && (
|
||||||
<Button
|
<Button
|
||||||
to={features.pepe ? '/auth/verify' : '/signup'} // FIXME: actually route this somewhere
|
to={features.pepe ? '/verify' : '/signup'}
|
||||||
theme='primary'
|
theme='primary'
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.register)}
|
{intl.formatMessage(messages.register)}
|
||||||
|
|
|
@ -82,7 +82,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='status-check-box-toggle'>
|
<div className='status-check-box-toggle'>
|
||||||
<Toggle checked={checked} onChange={onToggle} disabled={disabled} />
|
<Toggle checked={checked} onChange={onToggle} disabled={disabled} icons={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchMfa } from 'soapbox/actions/mfa';
|
import { fetchMfa } from 'soapbox/actions/mfa';
|
||||||
|
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
import List, { ListItem } from '../../components/list';
|
import List, { ListItem } from '../../components/list';
|
||||||
import { Button, Card, CardBody, CardHeader, CardTitle, Column } from '../../components/ui';
|
import { Button, Card, CardBody, CardHeader, CardTitle, Column } from '../../components/ui';
|
||||||
|
@ -21,15 +22,14 @@ const messages = defineMessages({
|
||||||
deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' },
|
deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** User settings page. */
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const mfa = useSelector((state) => state.getIn(['security', 'mfa']));
|
const mfa = useAppSelector((state) => state.security.get('mfa'));
|
||||||
const me = useSelector((state) => state.get('me'));
|
const account = useOwnAccount();
|
||||||
const account = useSelector((state) => state.getIn(['accounts', me]));
|
|
||||||
const displayName = account.get('display_name') || account.get('username');
|
|
||||||
|
|
||||||
const navigateToChangeEmail = React.useCallback(() => history.push('/settings/email'), [history]);
|
const navigateToChangeEmail = React.useCallback(() => history.push('/settings/email'), [history]);
|
||||||
const navigateToChangePassword = React.useCallback(() => history.push('/settings/password'), [history]);
|
const navigateToChangePassword = React.useCallback(() => history.push('/settings/password'), [history]);
|
||||||
|
@ -42,6 +42,10 @@ const Settings = () => {
|
||||||
dispatch(fetchMfa());
|
dispatch(fetchMfa());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const displayName = account.display_name || account.username;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.settings)} transparent withHeader={false}>
|
<Column label={intl.formatMessage(messages.settings)} transparent withHeader={false}>
|
||||||
<Card variant='rounded'>
|
<Card variant='rounded'>
|
||||||
|
@ -73,7 +77,7 @@ const Settings = () => {
|
||||||
</List>
|
</List>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
<CardHeader className='mt-4'>
|
<CardHeader>
|
||||||
<CardTitle title={intl.formatMessage(messages.preferences)} />
|
<CardTitle title={intl.formatMessage(messages.preferences)} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
@ -129,12 +129,14 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
|
||||||
{...actions}
|
{...actions}
|
||||||
id={account.id}
|
id={account.id}
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
|
withRelationship={false}
|
||||||
showProfileHoverCard={!compose}
|
showProfileHoverCard={!compose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{this.renderReplyMentions()}
|
{this.renderReplyMentions()}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
className='break-words'
|
||||||
size='sm'
|
size='sm'
|
||||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const ColumnSubheading = ({ text }) => {
|
|
||||||
return (
|
|
||||||
<div className='column-subheading'>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ColumnSubheading.propTypes = {
|
|
||||||
text: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ColumnSubheading;
|
|
|
@ -2,11 +2,9 @@ import React, { useEffect } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { fetchPatronInstance } from 'soapbox/actions/patron';
|
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 { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
import ProgressBar from '../../../components/progress_bar';
|
|
||||||
|
|
||||||
/** Open link in a new tab. */
|
/** Open link in a new tab. */
|
||||||
// https://stackoverflow.com/a/28374344/8811886
|
// https://stackoverflow.com/a/28374344/8811886
|
||||||
const openInNewTab = (href: string): void => {
|
const openInNewTab = (href: string): void => {
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
import { Button } from 'soapbox/components/ui';
|
import { Button } from 'soapbox/components/ui';
|
||||||
import { Modal } from 'soapbox/components/ui';
|
import { Modal } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
download: { id: 'landing_page_modal.download', defaultMessage: 'Download' },
|
download: { id: 'landing_page_modal.download', defaultMessage: 'Download' },
|
||||||
|
@ -15,12 +13,19 @@ const messages = defineMessages({
|
||||||
register: { id: 'header.register.label', defaultMessage: 'Register' },
|
register: { id: 'header.register.label', defaultMessage: 'Register' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const LandingPageModal = ({ onClose }) => {
|
interface ILandingPageModal {
|
||||||
|
onClose: (type: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LandingPageModal: React.FC<ILandingPageModal> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const logo = useSelector((state) => getSoapboxConfig(state).get('logo'));
|
const { logo } = useSoapboxConfig();
|
||||||
const instance = useSelector((state) => state.get('instance'));
|
const instance = useAppSelector((state) => state.instance);
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
const isOpen = instance.get('registrations', false) === true;
|
const isOpen = instance.get('registrations', false) === true;
|
||||||
|
const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
@ -38,8 +43,8 @@ const LandingPageModal = ({ onClose }) => {
|
||||||
{intl.formatMessage(messages.login)}
|
{intl.formatMessage(messages.login)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isOpen && (
|
{(isOpen || features.pepe && pepeOpen) && (
|
||||||
<Button to='/auth/verify' theme='primary' block>
|
<Button to={features.pepe ? '/verify' : '/signup'} theme='primary' block>
|
||||||
{intl.formatMessage(messages.register)}
|
{intl.formatMessage(messages.register)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
@ -49,8 +54,4 @@ const LandingPageModal = ({ onClose }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
LandingPageModal.propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LandingPageModal;
|
export default LandingPageModal;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,271 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { initAccountNoteModal } from 'soapbox/actions/account_notes';
|
|
||||||
import Badge from 'soapbox/components/badge';
|
|
||||||
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
|
||||||
import { isLocal } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import ProfileStats from './profile_stats';
|
|
||||||
|
|
||||||
// Basically ensure the URL isn't `javascript:alert('hi')` or something like that
|
|
||||||
const isSafeUrl = text => {
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class ProfileInfoPanel extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
identity_proofs: ImmutablePropTypes.list,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
username: PropTypes.string,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
onShowNote: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
getStaffBadge = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBadges = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
const staffBadge = this.getStaffBadge();
|
|
||||||
const isPatron = account.getIn(['patron', 'is_patron']);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBirthday = () => {
|
|
||||||
const { account, intl } = this.props;
|
|
||||||
|
|
||||||
const birthday = account.get('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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowNote = e => {
|
|
||||||
const { account, onShowNote } = this.props;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
onShowNote(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, displayFqn, intl, username } = this.props;
|
|
||||||
|
|
||||||
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.get('note_emojified') };
|
|
||||||
const deactivated = !account.getIn(['pleroma', 'is_active'], true);
|
|
||||||
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.get('display_name_html') };
|
|
||||||
const memberSinceDate = intl.formatDate(account.get('created_at'), { month: 'long', year: 'numeric' });
|
|
||||||
const verified = account.get('verified');
|
|
||||||
const badges = this.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.get('locked') && (
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/lock.svg')}
|
|
||||||
title={intl.formatMessage(messages.account_locked)}
|
|
||||||
className='w-4 h-4 text-gray-600'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<ProfileStats account={account} />
|
|
||||||
|
|
||||||
{
|
|
||||||
(account.get('note').length > 0 && account.get('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) ? (
|
|
||||||
<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.get('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.get('location')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{account.get('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.get('website')) ? (
|
|
||||||
<a className='text-primary-600 dark:text-primary-400 hover:underline' href={account.get('website')} target='_blank'>{account.get('website')}</a>
|
|
||||||
) : (
|
|
||||||
account.get('website')
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{this.renderBirthday()}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { account }) => {
|
|
||||||
const identity_proofs = account ? state.getIn(['identity_proofs', account.get('id')], ImmutableList()) : ImmutableList();
|
|
||||||
return {
|
|
||||||
identity_proofs,
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
onShowNote(account) {
|
|
||||||
dispatch(initAccountNoteModal(account));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default injectIntl(
|
|
||||||
connect(mapStateToProps, mapDispatchToProps, null, {
|
|
||||||
forwardRef: true,
|
|
||||||
},
|
|
||||||
)(ProfileInfoPanel));
|
|
|
@ -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 New Issue