Truth Social
|
@ -134,7 +134,7 @@ module.exports = {
|
|||
'react/jsx-equals-spacing': 'error',
|
||||
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
|
||||
'react/jsx-indent': ['error', 2],
|
||||
'react/jsx-no-bind': 'error',
|
||||
// 'react/jsx-no-bind': ['error'],
|
||||
'react/jsx-no-duplicate-props': 'error',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }],
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-selectors": null,
|
||||
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/"]}]
|
||||
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/"]}],
|
||||
"no-invalid-position-at-import-rule": [true, { "ignoreAtRules": ["/tailwind/"]}]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import loadPolyfills from './soapbox/load_polyfills';
|
|||
require.context('./images/', true);
|
||||
|
||||
// Load stylesheet
|
||||
require('react-datepicker/dist/react-datepicker.css');
|
||||
require('./styles/application.scss');
|
||||
|
||||
loadPolyfills().then(() => {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 14v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6M19 9a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 267 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 781 B |
|
@ -0,0 +1 @@
|
|||
<svg fill="white" stroke="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M8.21 4.175V5.86h1.685a.842.842 0 0 1 0 1.684H8.21v1.684a.842.842 0 1 1-1.685 0V7.544H4.842a.842.842 0 1 1 0-1.684h1.684V4.175a.842.842 0 1 1 1.685 0Zm12.87 3.523a.814.814 0 0 1 0 1.18l-1.43 1.6-3.2-3.2 1.515-1.517a.814.814 0 0 1 1.179 0l1.937 1.937ZM6.573 18.364a5 5 0 0 1 1.392-2.686l7.559-7.559 3.116 3.2-7.47 7.544a5 5 0 0 1-2.704 1.409l-2.29.395.397-2.303Z"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 487 B |
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M22 3H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1ZM1 15h22M1 21h22" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 264 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m3 8 7.89 5.26a2 2 0 0 0 2.22 0L21 8M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2z"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 284 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zm-4 7a7 7 0 0 0-7 7h14a7 7 0 0 0-7-7z"/></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 247 B |
|
@ -0,0 +1 @@
|
|||
<svg width="1754" height="1336" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter x="-18.1%" y="-15.3%" width="136.3%" height="130.7%" filterUnits="objectBoundingBox" id="c"><feGaussianBlur stdDeviation="50" in="SourceGraphic"/></filter><filter x="-16.5%" y="-11.7%" width="133%" height="123.3%" filterUnits="objectBoundingBox" id="d"><feGaussianBlur stdDeviation="50" in="SourceGraphic"/></filter><path id="a" d="M0 0h1754v1336H0z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path d="M1257.79 335.852C1262 527.117 897.55 530.28 792.32 977.19 600.48 981.41 435.29 545.31 431.08 354.046 426.871 162.782 578.976 4.31 770.815.088c191.844-4.222 482.764 144.5 486.974 335.764Z" fill="#E7F5FF" fill-rule="nonzero" filter="url(#c)" transform="translate(309.54 -367.538)"/><path d="M71.127 1126.654c206.164 179.412 502.452 211.232 661.777 71.072 159.325-140.163 295.165-510.155 8.23-504.412-320.079 6.405-381.35-817.422-540.675-677.258-31 368-335.497 931.182-129.332 1110.598Z" fill="#5448EE" fill-rule="nonzero" filter="url(#d)" transform="translate(309.54 -141.056)" opacity=".1"/></g></g></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 1.2 KiB |
|
@ -5,15 +5,45 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<link href="/manifest.json" rel="manifest">
|
||||
<!--server-generated-meta-->
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link href='/icons/icon-57x57.png' rel='apple-touch-icon' sizes='57x57'>
|
||||
<link href='/icons/icon-64x64.png' rel='apple-touch-icon' sizes='64x64'>
|
||||
<link href='/icons/icon-72x72.png' rel='apple-touch-icon' sizes='72x72'>
|
||||
<link href='/icons/icon-114x114.png' rel='apple-touch-icon' sizes='114x114'>
|
||||
<link href='/icons/icon-120x120.png' rel='apple-touch-icon' sizes='120x120'>
|
||||
<link href='/icons/icon-180x180.png' rel='apple-touch-icon' sizes='180x180'>
|
||||
<link href='/icons/icon-192x192.png' rel='apple-touch-icon' sizes='192x192'>
|
||||
<link href='/icons/icon-512x512.png' rel='apple-touch-icon' sizes='512x512'>
|
||||
<script>
|
||||
if(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.didBeginLoadingSoapbox) {
|
||||
window.webkit.messageHandlers.didBeginLoadingSoapbox.postMessage("started");
|
||||
}
|
||||
</script>
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u="//trk.bonsa.net/";
|
||||
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||
_paq.push(['setSiteId', '23231245']);
|
||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="app-body app-body--loading theme-mode-light no-reduce-motion">
|
||||
<div class="app-holder" id="soapbox">
|
||||
<div class="loading-indicator">
|
||||
<div class="loading-indicator__container">
|
||||
<div class="loading-indicator__figure"></div>
|
||||
<body class="theme-mode-light no-reduce-motion">
|
||||
<div id="soapbox">
|
||||
<div class="loading-indicator-wrapper">
|
||||
<div class="loading-indicator">
|
||||
<div class="loading-indicator__container">
|
||||
<div class="loading-indicator__figure"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
"column.home": "Home",
|
||||
"column.lists": "Lists",
|
||||
"column.mutes": "Muted users",
|
||||
"column.notifications": "Notifications",
|
||||
"column.notifications": "Alerts",
|
||||
"column.preferences": "Preferences",
|
||||
"column.public": "Federated timeline",
|
||||
"column.security": "Security",
|
||||
|
@ -445,7 +445,7 @@
|
|||
"tabs_bar.apps": "Apps",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.news": "News",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"tabs_bar.notifications": "Alerts",
|
||||
"tabs_bar.post": "Post",
|
||||
"tabs_bar.search": "Search",
|
||||
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
|
||||
|
@ -547,7 +547,7 @@
|
|||
"column.home": "Home",
|
||||
"column.lists": "Lists",
|
||||
"column.mutes": "Muted users",
|
||||
"column.notifications": "Notifications",
|
||||
"column.notifications": "Alerts",
|
||||
"column.preferences": "Preferences",
|
||||
"column.public": "Federated timeline",
|
||||
"column.security": "Security",
|
||||
|
@ -924,7 +924,7 @@
|
|||
"tabs_bar.apps": "Apps",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.news": "News",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"tabs_bar.notifications": "Alerts",
|
||||
"tabs_bar.post": "Post",
|
||||
"tabs_bar.search": "Search",
|
||||
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
|
||||
|
|
|
@ -160,8 +160,18 @@ export function verifyCredentials(token, accountUrl) {
|
|||
if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account));
|
||||
return account;
|
||||
}).catch(error => {
|
||||
if (getState().get('me') === null) dispatch(fetchMeFail(error));
|
||||
dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true });
|
||||
if (error?.response?.status === 403 && error?.response?.data?.id) {
|
||||
// The user is waitlisted
|
||||
const account = error.response.data;
|
||||
dispatch(importFetchedAccount(account));
|
||||
dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account });
|
||||
if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account));
|
||||
return account;
|
||||
} else {
|
||||
if (getState().get('me') === null) dispatch(fetchMeFail(error));
|
||||
dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true });
|
||||
return error;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -213,6 +223,12 @@ export function logIn(intl, username, password) {
|
|||
};
|
||||
}
|
||||
|
||||
export function deleteSession() {
|
||||
return (dispatch, getState) => {
|
||||
return api(getState).delete('/api/sign_out');
|
||||
};
|
||||
}
|
||||
|
||||
export function logOut(intl) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
@ -225,7 +241,10 @@ export function logOut(intl) {
|
|||
token: state.getIn(['auth', 'users', account.get('url'), 'access_token']),
|
||||
};
|
||||
|
||||
return dispatch(revokeOAuthToken(params)).finally(() => {
|
||||
return Promise.all([
|
||||
dispatch(revokeOAuthToken(params)),
|
||||
dispatch(deleteSession()),
|
||||
]).finally(() => {
|
||||
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.loggedOut)));
|
||||
});
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { staticClient } from '../api';
|
||||
|
||||
export const FETCH_BETA_PAGE_REQUEST = 'FETCH_BETA_PAGE_REQUEST';
|
||||
export const FETCH_BETA_PAGE_SUCCESS = 'FETCH_BETA_PAGE_SUCCESS';
|
||||
export const FETCH_BETA_PAGE_FAIL = 'FETCH_BETA_PAGE_FAIL';
|
||||
|
||||
export function fetchBetaPage(slug = 'index', locale) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: FETCH_BETA_PAGE_REQUEST, slug, locale });
|
||||
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
|
||||
return staticClient.get(`/instance/beta/${filename}`).then(({ data: html }) => {
|
||||
dispatch({ type: FETCH_BETA_PAGE_SUCCESS, slug, locale, html });
|
||||
return html;
|
||||
}).catch(error => {
|
||||
dispatch({ type: FETCH_BETA_PAGE_FAIL, slug, locale, error });
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { CancelToken, isCancel } from 'axios';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { throttle } from 'lodash';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
|
@ -219,7 +220,7 @@ export function submitCompose(routerHistory, force = false) {
|
|||
|
||||
const status = state.getIn(['compose', 'text'], '');
|
||||
const media = state.getIn(['compose', 'media_attachments']);
|
||||
let to = state.getIn(['compose', 'to']);
|
||||
let to = state.getIn(['compose', 'to'], ImmutableOrderedSet());
|
||||
|
||||
if (!validateSchedule(state)) {
|
||||
dispatch(snackbar.error(messages.scheduleError));
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { staticClient } from '../api';
|
||||
|
||||
export const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST';
|
||||
export const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS';
|
||||
export const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL';
|
||||
|
||||
export function fetchMobilePage(slug = 'index', locale) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale });
|
||||
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
|
||||
return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => {
|
||||
dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html });
|
||||
return html;
|
||||
}).catch(error => {
|
||||
dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error });
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
|
@ -23,6 +23,10 @@ export const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST';
|
|||
export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS';
|
||||
export const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL';
|
||||
|
||||
export const RESET_PASSWORD_CONFIRM_REQUEST = 'RESET_PASSWORD_CONFIRM_REQUEST';
|
||||
export const RESET_PASSWORD_CONFIRM_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS';
|
||||
export const RESET_PASSWORD_CONFIRM_FAIL = 'RESET_PASSWORD_CONFIRM_FAIL';
|
||||
|
||||
export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST';
|
||||
export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS';
|
||||
export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL';
|
||||
|
@ -78,14 +82,14 @@ export function changePassword(oldPassword, newPassword, confirmation) {
|
|||
};
|
||||
}
|
||||
|
||||
export function resetPassword(nickNameOrEmail) {
|
||||
export function resetPassword(usernameOrEmail) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: RESET_PASSWORD_REQUEST });
|
||||
const params =
|
||||
nickNameOrEmail.includes('@')
|
||||
? { email: nickNameOrEmail }
|
||||
: { nickname: nickNameOrEmail };
|
||||
return api(getState).post('/auth/password', params).then(() => {
|
||||
usernameOrEmail.includes('@')
|
||||
? { email: usernameOrEmail }
|
||||
: { username: usernameOrEmail };
|
||||
return api(getState).post('/api/v1/truth/password_reset/request', params).then(() => {
|
||||
dispatch({ type: RESET_PASSWORD_SUCCESS });
|
||||
}).catch(error => {
|
||||
dispatch({ type: RESET_PASSWORD_FAIL, error });
|
||||
|
@ -94,6 +98,20 @@ export function resetPassword(nickNameOrEmail) {
|
|||
};
|
||||
}
|
||||
|
||||
export function resetPasswordConfirm(password, token) {
|
||||
return (dispatch, getState) => {
|
||||
const params = { password, reset_password_token: token };
|
||||
dispatch({ type: RESET_PASSWORD_CONFIRM_REQUEST });
|
||||
|
||||
return api(getState).post('/api/v1/truth/password_reset/confirm', params).then(() => {
|
||||
dispatch({ type: RESET_PASSWORD_CONFIRM_SUCCESS });
|
||||
}).catch(error => {
|
||||
dispatch({ type: RESET_PASSWORD_CONFIRM_FAIL, error });
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function changeEmail(email, password) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: CHANGE_EMAIL_REQUEST, email });
|
||||
|
@ -110,6 +128,12 @@ export function changeEmail(email, password) {
|
|||
};
|
||||
}
|
||||
|
||||
export function confirmChangedEmail(token) {
|
||||
return (_dispatch, getState) => {
|
||||
return api(getState).get(`/api/v1/truth/email/confirm?confirmation_token=${token}`);
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteAccount(intl, password) {
|
||||
return (dispatch, getState) => {
|
||||
const account = getLoggedInAccount(getState());
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { debounce } from 'lodash';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
|
@ -8,6 +8,7 @@ import { isLoggedIn } from 'soapbox/utils/auth';
|
|||
import uuid from '../uuid';
|
||||
|
||||
import { showAlertForError } from './alerts';
|
||||
import snackbar from './snackbar';
|
||||
|
||||
export const SETTING_CHANGE = 'SETTING_CHANGE';
|
||||
export const SETTING_SAVE = 'SETTING_SAVE';
|
||||
|
@ -15,9 +16,12 @@ export const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
|||
|
||||
export const FE_NAME = 'soapbox_fe';
|
||||
|
||||
const messages = defineMessages({
|
||||
saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' },
|
||||
});
|
||||
|
||||
export const defaultSettings = ImmutableMap({
|
||||
onboarded: false,
|
||||
|
||||
skinTone: 1,
|
||||
reduceMotion: false,
|
||||
underlineLinks: false,
|
||||
|
@ -184,7 +188,7 @@ export function changeSettingImmediate(path, value) {
|
|||
};
|
||||
}
|
||||
|
||||
export function changeSetting(path, value) {
|
||||
export function changeSetting(path, value, intl) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: SETTING_CHANGE,
|
||||
|
@ -192,11 +196,11 @@ export function changeSetting(path, value) {
|
|||
value,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
return dispatch(saveSettings(intl));
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSettingsImmediate() {
|
||||
export function saveSettingsImmediate(intl) {
|
||||
return (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
|
||||
|
@ -211,16 +215,16 @@ export function saveSettingsImmediate() {
|
|||
},
|
||||
})).then(response => {
|
||||
dispatch({ type: SETTING_SAVE });
|
||||
|
||||
if (intl) {
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.saveSuccess)));
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(showAlertForError(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedSave = debounce((dispatch, getState) => {
|
||||
dispatch(saveSettingsImmediate());
|
||||
}, 5000, { trailing: true });
|
||||
|
||||
export function saveSettings() {
|
||||
return (dispatch, getState) => debouncedSave(dispatch, getState);
|
||||
export function saveSettings(intl) {
|
||||
return (dispatch, getState) => dispatch(saveSettingsImmediate(intl));
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { getHost } from 'soapbox/actions/instance';
|
|||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import api, { staticClient } from '../api';
|
||||
import { staticClient } from '../api';
|
||||
|
||||
export const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS';
|
||||
export const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL';
|
||||
|
@ -47,7 +47,7 @@ export const makeDefaultConfig = features => {
|
|||
}),
|
||||
extensions: ImmutableMap(),
|
||||
defaultSettings: ImmutableMap(),
|
||||
copyright: `♥${year}. Copying is an act of love. Please copy and share.`,
|
||||
copyright: `©${year} TRUTH Social`,
|
||||
navlinks: ImmutableMap({
|
||||
homeFooter: ImmutableList(),
|
||||
}),
|
||||
|
@ -60,6 +60,8 @@ export const makeDefaultConfig = features => {
|
|||
limit: 1,
|
||||
}),
|
||||
aboutPages: ImmutableMap(),
|
||||
betaPages: ImmutableMap(),
|
||||
mobilePages: ImmutableMap(),
|
||||
authenticatedProfile: true,
|
||||
singleUserMode: false,
|
||||
singleUserModeProfile: '',
|
||||
|
@ -86,17 +88,19 @@ export function rememberSoapboxConfig(host) {
|
|||
}
|
||||
|
||||
export function fetchSoapboxConfig(host) {
|
||||
return (dispatch, getState) => {
|
||||
api(getState).get('/api/pleroma/frontend_configurations').then(response => {
|
||||
if (response.data.soapbox_fe) {
|
||||
dispatch(importSoapboxConfig(response.data.soapbox_fe, host));
|
||||
} else {
|
||||
dispatch(fetchSoapboxJson(host));
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(fetchSoapboxJson(host));
|
||||
});
|
||||
};
|
||||
return fetchSoapboxJson(host);
|
||||
|
||||
// return (dispatch, getState) => {
|
||||
// api(getState).get('/api/pleroma/frontend_configurations').then(response => {
|
||||
// if (response.data.soapbox_fe) {
|
||||
// dispatch(importSoapboxConfig(response.data.soapbox_fe, host));
|
||||
// } else {
|
||||
// dispatch(fetchSoapboxJson(host));
|
||||
// }
|
||||
// }).catch(error => {
|
||||
// dispatch(fetchSoapboxJson(host));
|
||||
// });
|
||||
// };
|
||||
}
|
||||
|
||||
// Tries to remember the config from browser storage before fetching it
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import api from '../api';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const TRENDING_STATUSES_FETCH_REQUEST = 'TRENDING_STATUSES_FETCH_REQUEST';
|
||||
export const TRENDING_STATUSES_FETCH_SUCCESS = 'TRENDING_STATUSES_FETCH_SUCCESS';
|
||||
export const TRENDING_STATUSES_FETCH_FAIL = 'TRENDING_STATUSES_FETCH_FAIL';
|
||||
|
||||
export function fetchTrendingStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST });
|
||||
return api(getState).get('/api/v1/truth/trending/truths').then(({ data: statuses }) => {
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch({ type: TRENDING_STATUSES_FETCH_SUCCESS, statuses });
|
||||
return statuses;
|
||||
}).catch(error => {
|
||||
dispatch({ type: TRENDING_STATUSES_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
}
|
|
@ -0,0 +1,409 @@
|
|||
import api from '../api';
|
||||
|
||||
/**
|
||||
* LocalStorage 'soapbox:verification'
|
||||
*
|
||||
* {
|
||||
* token: String,
|
||||
* challenges: {
|
||||
* email: Number (0 = incomplete, 1 = complete),
|
||||
* sms: Number,
|
||||
* age: Number
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
const LOCAL_STORAGE_VERIFICATION_KEY = 'soapbox:verification';
|
||||
|
||||
const PEPE_FETCH_INSTANCE_SUCCESS = 'PEPE_FETCH_INSTANCE_SUCCESS';
|
||||
const FETCH_CHALLENGES_SUCCESS = 'FETCH_CHALLENGES_SUCCESS';
|
||||
const FETCH_TOKEN_SUCCESS = 'FETCH_TOKEN_SUCCESS';
|
||||
|
||||
const SET_NEXT_CHALLENGE = 'SET_NEXT_CHALLENGE';
|
||||
const SET_CHALLENGES_COMPLETE = 'SET_CHALLENGES_COMPLETE';
|
||||
const SET_LOADING = 'SET_LOADING';
|
||||
|
||||
const ChallengeTypes = {
|
||||
EMAIL: 'email',
|
||||
SMS: 'sms',
|
||||
AGE: 'age',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the state of the user's verification in local storage.
|
||||
*
|
||||
* @returns {object}
|
||||
* {
|
||||
* token: String,
|
||||
* challenges: {
|
||||
* email: Number (0 = incomplete, 1 = complete),
|
||||
* sms: Number,
|
||||
* age: Number
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
function fetchStoredVerification() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the state of the user's verification from local storage.
|
||||
*/
|
||||
function removeStoredVerification() {
|
||||
localStorage.removeItem(LOCAL_STORAGE_VERIFICATION_KEY);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch and return the Registration token for Pepe.
|
||||
* @returns {string}
|
||||
*/
|
||||
function fetchStoredToken() {
|
||||
try {
|
||||
const verification = fetchStoredVerification();
|
||||
return verification.token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and return the state of the verification challenges.
|
||||
* @returns {object}
|
||||
* {
|
||||
* challenges: {
|
||||
* email: Number (0 = incomplete, 1 = complete),
|
||||
* sms: Number,
|
||||
* age: Number
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
function fetchStoredChallenges() {
|
||||
try {
|
||||
const verification = fetchStoredVerification();
|
||||
return verification.challenges;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the verification object in local storage.
|
||||
*
|
||||
* @param {*} verification object
|
||||
*/
|
||||
function updateStorage({ ...updatedVerification }) {
|
||||
const verification = fetchStoredVerification();
|
||||
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_VERIFICATION_KEY,
|
||||
JSON.stringify({ ...verification, ...updatedVerification }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Pepe challenges and registration token
|
||||
* @returns {promise}
|
||||
*/
|
||||
function fetchVerificationConfig() {
|
||||
return async(dispatch) => {
|
||||
await dispatch(fetchPepeInstance());
|
||||
|
||||
dispatch(fetchRegistrationToken());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the challenges in localStorage.
|
||||
*
|
||||
* - If the API removes a challenge after the client has stored it, remove that
|
||||
* challenge from localStorage.
|
||||
* - If the API adds a challenge after the client has stored it, add that
|
||||
* challenge to localStorage.
|
||||
* - Don't overwrite a challenge that has already been completed.
|
||||
* - Update localStorage to the new set of challenges.
|
||||
*
|
||||
* @param {array} challenges - ['age', 'sms', 'email']
|
||||
*/
|
||||
function saveChallenges(challenges) {
|
||||
const currentChallenges = fetchStoredChallenges() || {};
|
||||
|
||||
const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge));
|
||||
challengesToRemove.forEach((challengeToRemove) => delete currentChallenges[challengeToRemove]);
|
||||
|
||||
for (let i = 0; i < challenges.length; i++) {
|
||||
const challengeName = challenges[i];
|
||||
|
||||
if (typeof currentChallenges[challengeName] !== 'number') {
|
||||
currentChallenges[challengeName] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
updateStorage({ challenges: currentChallenges });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a challenge.
|
||||
* @param {string} challenge - "sms" or "email" or "age"
|
||||
*/
|
||||
function finishChallenge(challenge) {
|
||||
const currentChallenges = fetchStoredChallenges() || {};
|
||||
// Set challenge to "complete"
|
||||
currentChallenges[challenge] = 1;
|
||||
|
||||
updateStorage({ challenges: currentChallenges });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the next challenge
|
||||
* @returns {string} challenge - "sms" or "email" or "age"
|
||||
*/
|
||||
function fetchNextChallenge() {
|
||||
const currentChallenges = fetchStoredChallenges() || {};
|
||||
return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge] === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the next challenge or set to complete if all challenges are completed.
|
||||
*/
|
||||
function dispatchNextChallenge(dispatch) {
|
||||
const nextChallenge = fetchNextChallenge();
|
||||
|
||||
if (nextChallenge) {
|
||||
dispatch({ type: SET_NEXT_CHALLENGE, challenge: nextChallenge });
|
||||
} else {
|
||||
dispatch({ type: SET_CHALLENGES_COMPLETE });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the challenges and age mininum from Pepe
|
||||
* @returns {promise}
|
||||
*/
|
||||
function fetchPepeInstance() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
return api(getState).get('/api/v1/pepe/instance').then(response => {
|
||||
const { challenges, age_minimum: ageMinimum } = response.data;
|
||||
saveChallenges(challenges);
|
||||
const currentChallenge = fetchNextChallenge();
|
||||
|
||||
dispatch({ type: PEPE_FETCH_INSTANCE_SUCCESS, instance: { isReady: true, ...response.data } });
|
||||
|
||||
dispatch({
|
||||
type: FETCH_CHALLENGES_SUCCESS,
|
||||
ageMinimum,
|
||||
currentChallenge,
|
||||
isComplete: !currentChallenge,
|
||||
});
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the regristration token from Pepe unless it's already been stored locally
|
||||
* @returns {promise}
|
||||
*/
|
||||
function fetchRegistrationToken() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
if (token) {
|
||||
dispatch({
|
||||
type: FETCH_TOKEN_SUCCESS,
|
||||
value: token,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return api(getState).post('/api/v1/pepe/registrations')
|
||||
.then(response => {
|
||||
updateStorage({ token: response.data.access_token });
|
||||
|
||||
return dispatch({
|
||||
type: FETCH_TOKEN_SUCCESS,
|
||||
value: response.data.access_token,
|
||||
});
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
function checkEmailAvailability(email) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's email to Pepe to request confirmation
|
||||
* @param {string} email
|
||||
* @returns {promise}
|
||||
*/
|
||||
function requestEmailVerification(email) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/verify_email/request', { email }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
function checkEmailVerification() {
|
||||
return (dispatch, getState) => {
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).get('/api/v1/pepe/verify_email', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's email with Pepe
|
||||
* @param {string} emailToken
|
||||
* @returns {promise}
|
||||
*/
|
||||
function confirmEmailVerification(emailToken) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(() => {
|
||||
finishChallenge(ChallengeTypes.EMAIL);
|
||||
dispatchNextChallenge(dispatch);
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
function postEmailVerification() {
|
||||
return (dispatch, getState) => {
|
||||
finishChallenge(ChallengeTypes.EMAIL);
|
||||
dispatchNextChallenge(dispatch);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's phone number to Pepe to request confirmation
|
||||
* @param {string} phone
|
||||
* @returns {promise}
|
||||
*/
|
||||
function requestPhoneVerification(phone) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/verify_sms/request', { phone }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's phone number with Pepe
|
||||
* @param {string} code
|
||||
* @returns {promise}
|
||||
*/
|
||||
function confirmPhoneVerification(code) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/verify_sms/confirm', { code }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(() => {
|
||||
finishChallenge(ChallengeTypes.SMS);
|
||||
dispatchNextChallenge(dispatch);
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's age with Pepe
|
||||
* @param {date} birthday
|
||||
* @returns {promise}
|
||||
*/
|
||||
function verifyAge(birthday) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/verify_age/confirm', { birthday }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(() => {
|
||||
finishChallenge(ChallengeTypes.AGE);
|
||||
dispatchNextChallenge(dispatch);
|
||||
})
|
||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the user's account with Pepe
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {promise}
|
||||
*/
|
||||
function createAccount(username, password) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: SET_LOADING });
|
||||
|
||||
const token = fetchStoredToken();
|
||||
|
||||
return api(getState).post('/api/v1/pepe/accounts', { username, password }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
PEPE_FETCH_INSTANCE_SUCCESS,
|
||||
FETCH_CHALLENGES_SUCCESS,
|
||||
FETCH_TOKEN_SUCCESS,
|
||||
LOCAL_STORAGE_VERIFICATION_KEY,
|
||||
SET_CHALLENGES_COMPLETE,
|
||||
SET_LOADING,
|
||||
SET_NEXT_CHALLENGE,
|
||||
checkEmailAvailability,
|
||||
confirmEmailVerification,
|
||||
confirmPhoneVerification,
|
||||
createAccount,
|
||||
fetchStoredChallenges,
|
||||
fetchVerificationConfig,
|
||||
fetchRegistrationToken,
|
||||
removeStoredVerification,
|
||||
requestEmailVerification,
|
||||
checkEmailVerification,
|
||||
postEmailVerification,
|
||||
requestPhoneVerification,
|
||||
verifyAge,
|
||||
};
|
|
@ -11,6 +11,7 @@ const {
|
|||
BACKEND_URL,
|
||||
FE_SUBDIRECTORY,
|
||||
FE_BUILD_DIR,
|
||||
FE_INSTANCE_SOURCE_DIR,
|
||||
SENTRY_DSN,
|
||||
} = process.env;
|
||||
|
||||
|
@ -39,5 +40,6 @@ module.exports = sanitize({
|
|||
BACKEND_URL: sanitizeURL(BACKEND_URL),
|
||||
FE_SUBDIRECTORY: sanitizeBasename(FE_SUBDIRECTORY),
|
||||
FE_BUILD_DIR: sanitizePath(FE_BUILD_DIR) || 'static',
|
||||
FE_INSTANCE_SOURCE_DIR: FE_INSTANCE_SOURCE_DIR || 'instance',
|
||||
SENTRY_DSN,
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
exports[`<Avatar /> Autoplay renders an animated avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar still-image"
|
||||
className="rounded-full still-image"
|
||||
style={
|
||||
Object {
|
||||
"height": "100px",
|
||||
|
@ -20,7 +20,7 @@ exports[`<Avatar /> Autoplay renders an animated avatar 1`] = `
|
|||
|
||||
exports[`<Avatar /> Still renders a still avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar still-image"
|
||||
className="rounded-full still-image"
|
||||
style={
|
||||
Object {
|
||||
"height": "100px",
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
|
||||
<button
|
||||
className="button button-secondary"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a button element 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
|
||||
<button
|
||||
className="button button--block"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<p>
|
||||
children
|
||||
</p>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the given text 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the props.text instead of children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
|
@ -1,8 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Column /> renders correctly with minimal props 1`] = `
|
||||
<div
|
||||
className="column"
|
||||
role="region"
|
||||
/>
|
||||
`;
|
|
@ -18,7 +18,7 @@ exports[`<DisplayName /> renders display name + account name 1`] = `
|
|||
className="display-name__html"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<p>Foo</p>",
|
||||
"__html": "bar",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
exports[`<TimelineQueueButtonHeader /> renders correctly 1`] = `
|
||||
<div
|
||||
className="timeline-queue-header hidden"
|
||||
className="left-1/2 -translate-x-1/2 fixed top-20 z-50 hidden"
|
||||
>
|
||||
<a
|
||||
className="timeline-queue-header__btn"
|
||||
className="flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
|
@ -25,10 +25,10 @@ exports[`<TimelineQueueButtonHeader /> renders correctly 1`] = `
|
|||
|
||||
exports[`<TimelineQueueButtonHeader /> renders correctly 2`] = `
|
||||
<div
|
||||
className="timeline-queue-header hidden"
|
||||
className="left-1/2 -translate-x-1/2 fixed top-20 z-50 hidden"
|
||||
>
|
||||
<a
|
||||
className="timeline-queue-header__btn"
|
||||
className="flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
|
@ -42,21 +42,22 @@ exports[`<TimelineQueueButtonHeader /> renders correctly 2`] = `
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="timeline-queue-header__label"
|
||||
<p
|
||||
className="text-sm text-inherit font-normal tracking-normal font-sans"
|
||||
style={null}
|
||||
>
|
||||
Click to see 1 new post
|
||||
</div>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<TimelineQueueButtonHeader /> renders correctly 3`] = `
|
||||
<div
|
||||
className="timeline-queue-header hidden"
|
||||
className="left-1/2 -translate-x-1/2 fixed top-20 z-50 hidden"
|
||||
>
|
||||
<a
|
||||
className="timeline-queue-header__btn"
|
||||
className="flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
|
@ -70,11 +71,12 @@ exports[`<TimelineQueueButtonHeader /> renders correctly 3`] = `
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="timeline-queue-header__label"
|
||||
<p
|
||||
className="text-sm text-inherit font-normal tracking-normal font-sans"
|
||||
style={null}
|
||||
>
|
||||
Click to see 9999999 new posts
|
||||
</div>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
import { fromJS } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { createComponent } from 'soapbox/test_helpers';
|
||||
|
||||
import DisplayName from '../display_name';
|
||||
|
||||
describe('<DisplayName />', () => {
|
||||
it('renders display name + account name', () => {
|
||||
const account = fromJS({
|
||||
username: 'bar',
|
||||
acct: 'bar@baz',
|
||||
display_name_html: '<p>Foo</p>',
|
||||
});
|
||||
const account = normalizeAccount({ acct: 'bar@baz' });
|
||||
const component = createComponent(<DisplayName account={account} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
|
|
|
@ -1,143 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Fragment } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import ActionButton from 'soapbox/features/ui/components/action_button';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import Icon from './icon';
|
||||
import IconButton from './icon_button';
|
||||
import Permalink from './permalink';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
me: state.get('me'),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onMuteNotifications: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
actionIcon: PropTypes.string,
|
||||
actionTitle: PropTypes.string,
|
||||
onActionClick: PropTypes.func,
|
||||
withDate: PropTypes.bool,
|
||||
withRelationship: PropTypes.bool,
|
||||
reaction: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
withDate: false,
|
||||
withRelationship: true,
|
||||
}
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
handleMuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, true);
|
||||
}
|
||||
|
||||
handleUnmuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, false);
|
||||
}
|
||||
|
||||
handleAction = () => {
|
||||
this.props.onActionClick(this.props.account);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { account, hidden, onActionClick, actionIcon, actionTitle, me, withDate, withRelationship, reaction } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
let followedBy;
|
||||
let emoji;
|
||||
|
||||
if (onActionClick && actionIcon) {
|
||||
buttons = <IconButton src={actionIcon} title={actionTitle} onClick={this.handleAction} />;
|
||||
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
buttons = <ActionButton account={account} />;
|
||||
}
|
||||
|
||||
if (reaction) {
|
||||
emoji = (
|
||||
<span
|
||||
className='emoji-react__emoji'
|
||||
dangerouslySetInnerHTML={{ __html: emojify(reaction) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const createdAt = account.get('created_at');
|
||||
|
||||
const joinedAt = createdAt ? (
|
||||
<div className='account__joined-at'>
|
||||
<Icon src={require('@tabler/icons/icons/clock.svg')} />
|
||||
<RelativeTimestamp timestamp={createdAt} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--with-relationship': withRelationship, 'account--with-date': withDate })}>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{emoji}
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
<DisplayName account={account} withDate={Boolean(withDate && withRelationship)} />
|
||||
</Permalink>
|
||||
|
||||
{withRelationship ? (<>
|
||||
{followedBy &&
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
</span>}
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</>) : withDate && joinedAt}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||
import ActionButton from 'soapbox/features/ui/components/action_button';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { getAcct } from 'soapbox/utils/accounts';
|
||||
import { displayFqn } from 'soapbox/utils/state';
|
||||
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import { Avatar, HStack, IconButton, Text } from './ui';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IProfilePopper {
|
||||
condition: boolean,
|
||||
wrapper: (children: any) => React.ReactElement<any, any>
|
||||
}
|
||||
|
||||
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }): any =>
|
||||
condition ? wrapper(children) : children;
|
||||
|
||||
interface IAccount {
|
||||
account: AccountEntity,
|
||||
action?: React.ReactElement,
|
||||
actionAlignment?: 'center' | 'top',
|
||||
actionIcon?: string,
|
||||
actionTitle?: string,
|
||||
avatarSize?: number,
|
||||
hidden?: boolean,
|
||||
hideActions?: boolean,
|
||||
onActionClick?: (account: any) => void,
|
||||
showProfileHoverCard?: boolean,
|
||||
timestamp?: string,
|
||||
timestampUrl?: string,
|
||||
withRelationship?: boolean,
|
||||
}
|
||||
|
||||
const Account = ({
|
||||
account,
|
||||
action,
|
||||
actionIcon,
|
||||
actionTitle,
|
||||
actionAlignment = 'center',
|
||||
avatarSize = 42,
|
||||
hidden = false,
|
||||
hideActions = false,
|
||||
onActionClick,
|
||||
showProfileHoverCard = true,
|
||||
timestamp,
|
||||
timestampUrl,
|
||||
withRelationship = true,
|
||||
}: IAccount) => {
|
||||
const overflowRef = React.useRef(null);
|
||||
const actionRef = React.useRef(null);
|
||||
|
||||
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
|
||||
|
||||
const handleAction = () => {
|
||||
onActionClick(account);
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
|
||||
if (hideActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onActionClick && actionIcon) {
|
||||
return (
|
||||
<IconButton
|
||||
src={actionIcon}
|
||||
title={actionTitle}
|
||||
onClick={handleAction}
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
return <ActionButton account={account} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const style: React.CSSProperties = {};
|
||||
|
||||
const actionWidth = actionRef.current?.clientWidth;
|
||||
|
||||
if (overflowRef.current) {
|
||||
style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth;
|
||||
} else {
|
||||
style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
setStyle(style);
|
||||
}, [overflowRef, actionRef]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const LinkEl = showProfileHoverCard ? Link : 'div';
|
||||
|
||||
return (
|
||||
<div className='flex-shrink-0 group block w-full overflow-hidden' ref={overflowRef}>
|
||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||
<HStack alignItems='center' space={3} grow>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>}
|
||||
>
|
||||
<LinkEl
|
||||
to={`/@${account.get('acct')}`}
|
||||
title={account.get('acct')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Avatar src={account.get('avatar')} size={avatarSize} />
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<div className='flex-grow'>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>}
|
||||
>
|
||||
<LinkEl
|
||||
to={`/@${account.get('acct')}`}
|
||||
title={account.get('acct')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='flex items-center space-x-1 flex-grow' style={style}>
|
||||
<Text
|
||||
size='sm'
|
||||
weight='semibold'
|
||||
truncate
|
||||
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
|
||||
/>
|
||||
|
||||
{account.get('verified') && <VerificationBadge />}
|
||||
</div>
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<HStack alignItems='center' space={1}>
|
||||
<Text theme='muted' size='sm'>@{username}</Text>
|
||||
|
||||
{(timestamp) ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
{timestampUrl ? (
|
||||
<Link to={timestampUrl} className='hover:underline'>
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' />
|
||||
</Link>
|
||||
) : (
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<div ref={actionRef}>
|
||||
{withRelationship ? renderAction() : null}
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
|
@ -7,7 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
|
@ -195,12 +195,22 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
inner = <AutosuggestAccount id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true,
|
||||
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion,
|
||||
})}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
@ -228,7 +238,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
|
||||
return menu.map((item, i) => (
|
||||
<a
|
||||
className={classNames('autosuggest-input__action', { selected: suggestions.size - selectedSuggestion === i })}
|
||||
className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100', { selected: suggestions.size - selectedSuggestion === i })}
|
||||
href='#'
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
|
@ -256,32 +266,42 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
<div className='relative'>
|
||||
<label className='sr-only'>{placeholder}</label>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className={classNames({
|
||||
'block w-full sm:text-sm focus:ring-indigo-500 focus:border-indigo-500': true,
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
|
||||
<div className={classNames({
|
||||
'absolute top-full w-full z-50 shadow bg-white rounded-lg py-1': true,
|
||||
hidden: !visible,
|
||||
block: visible,
|
||||
'autosuggest-textarea__suggestions--visible': visible,
|
||||
})}
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
|
||||
<div className={classNames('autosuggest-textarea__suggestions', { 'autosuggest-textarea__suggestions--visible': visible })}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
{this.renderMenu()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Portal from '@reach/portal';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -52,6 +53,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
autoFocus: PropTypes.bool,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
condensed: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -208,30 +210,57 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true,
|
||||
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion,
|
||||
})}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
setPortalPosition() {
|
||||
if (!this.textarea) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { top, height, left, width } = this.textarea.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: top + height,
|
||||
left,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, condensed, id } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
const style = { direction: 'ltr', minRows: 10 };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return [
|
||||
<div className='compose-form__autosuggest-wrapper' key='compose-form__autosuggest-wrapper'>
|
||||
<div className='autosuggest-textarea'>
|
||||
<div key='textarea'>
|
||||
<div className='relative'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
ref={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
className={classNames('px-0 border-0 text-gray-800 placeholder:text-color-400 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', {
|
||||
'min-h-[100px]': !condensed,
|
||||
})}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
|
@ -247,13 +276,22 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>,
|
||||
<div className='autosuggest-textarea__suggestions-wrapper' key='autosuggest-textarea__suggestions-wrapper'>
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
|
||||
<Portal key='portal'>
|
||||
<div
|
||||
style={this.setPortalPosition()}
|
||||
className={classNames({
|
||||
'fixed z-1000 shadow bg-white rounded-lg py-1 space-y-0': true,
|
||||
hidden: suggestionsHidden || suggestions.isEmpty(),
|
||||
block: !suggestionsHidden && !suggestions.isEmpty(),
|
||||
})}
|
||||
>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>,
|
||||
</Portal>,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -11,15 +11,11 @@ export default class Avatar extends React.PureComponent {
|
|||
account: ImmutablePropTypes.map,
|
||||
size: PropTypes.number,
|
||||
style: PropTypes.object,
|
||||
inline: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
inline: false,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, size, inline } = this.props;
|
||||
const { account, size, className } = this.props;
|
||||
if (!account) return null;
|
||||
|
||||
// : TODO : remove inline and change all avatars to be sized using css
|
||||
|
@ -30,7 +26,9 @@ export default class Avatar extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<StillImage
|
||||
className={classNames('account__avatar', { 'account__avatar-inline': inline })}
|
||||
className={classNames('rounded-full', {
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
style={style}
|
||||
src={account.get('avatar')}
|
||||
alt=''
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
export default class Button extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
text: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
to: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
icon: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 36,
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled && this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
padding: `0 ${this.props.size / 2.25}px`,
|
||||
height: `${this.props.size}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
};
|
||||
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button-secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
type={this.props.type}
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
style={style}
|
||||
>
|
||||
{this.props.icon && <Icon id={this.props.icon} />}
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (this.props.to) {
|
||||
return (
|
||||
<Link to={this.props.to} tabIndex={-1} className='button__link'>
|
||||
{btn}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default class Column extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
transparent: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
label: PropTypes.string,
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, label, children, transparent, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role='region'
|
||||
aria-label={label}
|
||||
className={classNames('column', className, { 'column--transparent': transparent })}
|
||||
{...rest}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -10,7 +10,7 @@ import Icon from 'soapbox/components/icon';
|
|||
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
|
||||
import IconButton from './icon_button';
|
||||
import { IconButton } from './ui';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
@ -233,6 +233,7 @@ export default class Dropdown extends React.PureComponent {
|
|||
|
||||
handleClick = e => {
|
||||
const { onOpen, onShiftClick, openDropdownId } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (onShiftClick && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
@ -286,12 +287,12 @@ export default class Dropdown extends React.PureComponent {
|
|||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
@ -311,31 +312,33 @@ export default class Dropdown extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { icon, src, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard, active, pressed, text } = this.props;
|
||||
const { src, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard, pressed, text } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
src={src}
|
||||
title={title}
|
||||
active={open || active}
|
||||
pressed={pressed}
|
||||
disabled={disabled}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'text-gray-600': open,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
pressed={pressed}
|
||||
size={size}
|
||||
text={text}
|
||||
ref={this.setTargetRef}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
|
||||
</Overlay>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,33 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { NODE_ENV } from 'soapbox/build_config';
|
||||
import { Text, Stack } from 'soapbox/components/ui';
|
||||
import { captureException } from 'soapbox/monitoring';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
|
||||
export default class ErrorBoundary extends React.PureComponent {
|
||||
import { getSoapboxConfig } from '../actions/soapbox';
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
|
||||
return {
|
||||
helpLink: soapboxConfig.getIn(['links', 'help']),
|
||||
supportLink: soapboxConfig.getIn(['links', 'support']),
|
||||
statusLink: soapboxConfig.getIn(['links', 'status']),
|
||||
};
|
||||
};
|
||||
|
||||
@connect(mapStateToProps)
|
||||
class ErrorBoundary extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
supportLink: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
statusLink: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -60,52 +78,112 @@ export default class ErrorBoundary extends React.PureComponent {
|
|||
|
||||
render() {
|
||||
const { browser, hasError } = this.state;
|
||||
const { children, helpLink, statusLink, supportLink } = this.props;
|
||||
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
return children;
|
||||
}
|
||||
|
||||
const isProduction = NODE_ENV === 'production';
|
||||
|
||||
const errorText = this.getErrorText();
|
||||
|
||||
return (
|
||||
<div className='error-boundary'>
|
||||
<div>
|
||||
<Icon src={require('@tabler/icons/icons/mood-sad.svg')} className='sad-face' />
|
||||
<FormattedMessage id='alert.unexpected.message' defaultMessage='An unexpected error occurred.' />
|
||||
<div className='return-home'>
|
||||
<a href='/'>
|
||||
<Icon src={require('@tabler/icons/icons/arrow-back.svg')} />
|
||||
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
||||
<div className='h-screen pt-16 pb-12 flex flex-col bg-white'>
|
||||
<main className='flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex-shrink-0 flex justify-center'>
|
||||
<a href='/' className='inline-flex'>
|
||||
<img className='h-12 w-12' src='/instance/images/app-icon.png' alt='Truth Social' />
|
||||
</a>
|
||||
</div>
|
||||
{errorText && <textarea
|
||||
ref={this.setTextareaRef}
|
||||
className='error-boundary__component-stack'
|
||||
value={errorText}
|
||||
onClick={this.handleCopy}
|
||||
readOnly
|
||||
/>}
|
||||
{browser && <p className='error-boundary__browser'>
|
||||
{browser.getBrowserName()} {browser.getBrowserVersion()}
|
||||
</p>}
|
||||
<p className='error-boundary__version'>{sourceCode.displayName} {sourceCode.version}</p>
|
||||
<p className='help-text'>
|
||||
<FormattedMessage
|
||||
id='alert.unexpected.help_text'
|
||||
defaultMessage='If the problem persists, please notify a site admin with a screenshot and information about your web browser. You may also {clear_cookies} (this will log you out).'
|
||||
values={{ clear_cookies: (
|
||||
<a href='/' onClick={this.clearCookies}>
|
||||
|
||||
<div className='py-8'>
|
||||
<div className='text-center max-w-xl mx-auto space-y-2'>
|
||||
<h1 className='text-3xl font-extrabold text-gray-900 tracking-tight sm:text-4xl'>
|
||||
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
|
||||
</h1>
|
||||
<p className='text-lg text-gray-500'>
|
||||
We're sorry for the interruption. If the problem persists, please reach out to our support team. You
|
||||
may also try to <a href='/' onClick={this.clearCookies} className='text-gray-700 hover:underline'>
|
||||
<FormattedMessage
|
||||
id='alert.unexpected.clear_cookies'
|
||||
defaultMessage='clear cookies and browser data'
|
||||
/>
|
||||
</a>
|
||||
) }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
{' ' }(this will log you out).
|
||||
</p>
|
||||
|
||||
<Text theme='muted'>
|
||||
<Text weight='medium' tag='span' theme='muted'>{sourceCode.displayName}:</Text>
|
||||
|
||||
{' '}{sourceCode.version}
|
||||
</Text>
|
||||
|
||||
<div className='mt-10'>
|
||||
<a href='/' className='text-base font-medium text-primary-600 hover:text-primary-500'>
|
||||
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
||||
<span aria-hidden='true'> →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isProduction && (
|
||||
<div className='py-16 max-w-lg mx-auto space-y-4'>
|
||||
{errorText && (
|
||||
<textarea
|
||||
ref={this.setTextareaRef}
|
||||
className='h-48 p-4 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono'
|
||||
value={errorText}
|
||||
onClick={this.handleCopy}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{browser && (
|
||||
<Stack>
|
||||
<Text weight='semibold'>Browser</Text>
|
||||
<Text theme='muted'>{browser.getBrowserName()} {browser.getBrowserVersion()}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<nav className='flex justify-center space-x-4'>
|
||||
{statusLink && (
|
||||
<>
|
||||
<a href={statusLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
Status
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{helpLink && (
|
||||
<>
|
||||
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
|
||||
<a href={helpLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
Help Center
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{supportLink && (
|
||||
<>
|
||||
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
|
||||
<a href={supportLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
Support
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
|
@ -1,24 +1,29 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
|
||||
import { shortNumberFormat } from '../utils/numbers';
|
||||
|
||||
import Permalink from './permalink';
|
||||
import { HStack, Stack, Text } from './ui';
|
||||
|
||||
const Hashtag = ({ hashtag }) => {
|
||||
const count = Number(hashtag.getIn(['history', 0, 'accounts']));
|
||||
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
||||
|
||||
return (
|
||||
<div className='trends__item'>
|
||||
<div className='trends__item__name'>
|
||||
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`}>
|
||||
#<span>{hashtag.get('name')}</span>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Stack>
|
||||
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`} className='hover:underline'>
|
||||
<Text tag='span' size='sm' weight='semibold'>#{hashtag.get('name')}</Text>
|
||||
</Permalink>
|
||||
|
||||
{hashtag.get('history') && (
|
||||
<div className='trends__item__count'>
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='trends.count_by_accounts'
|
||||
defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking'
|
||||
|
@ -27,18 +32,22 @@ const Hashtag = ({ hashtag }) => {
|
|||
count: <strong>{shortNumberFormat(count)}</strong>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{hashtag.get('history') && (
|
||||
<div className='trends__item__sparkline'>
|
||||
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
||||
<SparklinesCurve style={{ fill: 'none' }} />
|
||||
<div className='w-[40px]'>
|
||||
<Sparklines
|
||||
width={40}
|
||||
height={28}
|
||||
data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}
|
||||
>
|
||||
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
|
||||
</Sparklines>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { connect } from 'react-redux';
|
|||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
// import sourceCode from 'soapbox/utils/code';
|
||||
import FaviconService from 'soapbox/utils/favicon_service';
|
||||
|
||||
FaviconService.initFaviconService();
|
||||
|
@ -22,7 +22,7 @@ const mapStateToProps = state => {
|
|||
const settings = getSettings(state);
|
||||
|
||||
return {
|
||||
siteTitle: state.getIn(['instance', 'title'], sourceCode.displayName),
|
||||
siteTitle: state.getIn(['instance', 'title'], 'Truth Social'),
|
||||
unreadCount: getNotifTotals(state),
|
||||
demetricator: settings.get('demetricator'),
|
||||
};
|
||||
|
|
|
@ -11,41 +11,36 @@ import { isMobile } from 'soapbox/is_mobile';
|
|||
|
||||
const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
||||
dispatch(openProfileHoverCard(ref, accountId));
|
||||
}, 1200);
|
||||
|
||||
const handleMouseEnter = (dispatch, ref, accountId) => {
|
||||
return e => {
|
||||
if (!isMobile(window.innerWidth))
|
||||
showProfileHoverCard(dispatch, ref, accountId);
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseLeave = (dispatch) => {
|
||||
return e => {
|
||||
showProfileHoverCard.cancel();
|
||||
setTimeout(() => dispatch(closeProfileHoverCard()), 300);
|
||||
};
|
||||
};
|
||||
|
||||
const handleClick = (dispatch) => {
|
||||
return e => {
|
||||
showProfileHoverCard.cancel();
|
||||
dispatch(closeProfileHoverCard(true));
|
||||
};
|
||||
};
|
||||
}, 600);
|
||||
|
||||
export const HoverRefWrapper = ({ accountId, children, inline }) => {
|
||||
const dispatch = useDispatch();
|
||||
const ref = useRef();
|
||||
const Elem = inline ? 'span' : 'div';
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile(window.innerWidth)) {
|
||||
showProfileHoverCard(dispatch, ref, accountId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
showProfileHoverCard.cancel();
|
||||
setTimeout(() => dispatch(closeProfileHoverCard()), 300);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
showProfileHoverCard.cancel();
|
||||
dispatch(closeProfileHoverCard(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<Elem
|
||||
ref={ref}
|
||||
className='hover-ref-wrapper'
|
||||
onMouseEnter={handleMouseEnter(dispatch, ref, accountId)}
|
||||
onMouseLeave={handleMouseLeave(dispatch)}
|
||||
onClick={handleClick(dispatch)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</Elem>
|
||||
|
@ -62,4 +57,4 @@ HoverRefWrapper.defaultProps = {
|
|||
inline: false,
|
||||
};
|
||||
|
||||
export default HoverRefWrapper;
|
||||
export { HoverRefWrapper as default, showProfileHoverCard };
|
||||
|
|
|
@ -6,9 +6,10 @@ import { shortNumberFormat } from 'soapbox/utils/numbers';
|
|||
|
||||
const IconWithCounter = ({ icon, count, ...rest }) => {
|
||||
return (
|
||||
<div className='icon-with-counter'>
|
||||
<div className='relative'>
|
||||
<Icon id={icon} {...rest} />
|
||||
{count > 0 && <i className='icon-with-counter__counter'>
|
||||
|
||||
{count > 0 && <i className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{shortNumberFormat(count)}
|
||||
</i>}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
|
||||
const List = ({ children }) => (
|
||||
<div className='space-y-0.5'>{children}</div>
|
||||
);
|
||||
|
||||
List.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const ListItem = ({ label, hint, children, onClick }) => {
|
||||
const id = uuidv4();
|
||||
const domId = `list-group-${id}`;
|
||||
|
||||
const Comp = onClick ? 'a' : 'div';
|
||||
const LabelComp = onClick ? 'span' : 'label';
|
||||
const linkProps = onClick ? { onClick } : {};
|
||||
|
||||
const renderChildren = React.useCallback(() =>
|
||||
React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, {
|
||||
id: domId,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
, [children, domId]);
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={classNames({
|
||||
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-purple/20 to-gradient-blue/20': true,
|
||||
'cursor-pointer hover:from-gradient-purple/30 hover:to-gradient-blue/30': typeof onClick !== 'undefined',
|
||||
})}
|
||||
{...linkProps}
|
||||
>
|
||||
<div className='flex flex-col py-1.5 pr-4'>
|
||||
<LabelComp htmlFor={domId}>{label}</LabelComp>
|
||||
|
||||
{hint ? (
|
||||
<span className='text-sm text-gray-500'>{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{onClick ? (
|
||||
<div className='flex flex-row items-center text-gray-500'>
|
||||
{children}
|
||||
|
||||
<Icon src={require('@tabler/icons/icons/chevron-right.svg')} className='ml-1' />
|
||||
</div>
|
||||
) : renderChildren()}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
ListItem.propTypes = {
|
||||
label: PropTypes.node.isRequired,
|
||||
hint: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export { List as default, ListItem };
|
|
@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
|
||||
export default class LoadMore extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -17,10 +19,14 @@ export default class LoadMore extends React.PureComponent {
|
|||
render() {
|
||||
const { disabled, visible } = this.props;
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
|
||||
<Button theme='secondary' block disabled={disabled || !visible} onClick={this.props.onClick}>
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<div className='loading-indicator'>
|
||||
<div className='loading-indicator__container'>
|
||||
<div className='loading-indicator__figure' />
|
||||
</div>
|
||||
<span><FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' /></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* iOS style loading spinner.
|
||||
* It's mostly CSS, adapted from: https://loading.io/css/
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
const LoadingSpinner = ({ size = 30 }) => (
|
||||
<div className='lds-spinner' style={{ width: size, height: size }}>
|
||||
{[...Array(12).keys()].map(i => (
|
||||
<div key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
LoadingSpinner.propTypes = {
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* MaterialStatus: like a Status, but with gaps and rounded corners.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import StatusContainer from 'soapbox/containers/status_container';
|
||||
|
||||
export default class MaterialStatus extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
hidden: PropTypes.bool,
|
||||
}
|
||||
|
||||
render() {
|
||||
// Performance: when hidden, don't render the wrapper divs
|
||||
if (this.props.hidden) {
|
||||
return <StatusContainer {...this.props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='material-status' tabIndex={-1}>
|
||||
<div className='material-status__status focusable' tabIndex={0}>
|
||||
<StatusContainer {...this.props} focusable={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -300,7 +300,9 @@ class MediaGallery extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
handleOpen = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const MissingIndicator = () => (
|
||||
<div className='regeneration-indicator missing-indicator'>
|
||||
<div>
|
||||
<div className='regeneration-indicator__label'>
|
||||
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
|
||||
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MissingIndicator;
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Card, CardBody, Stack, Text } from './ui';
|
||||
|
||||
interface MissingIndicatorProps {
|
||||
nested?: boolean
|
||||
}
|
||||
|
||||
const MissingIndicator = ({ nested = false }: MissingIndicatorProps): JSX.Element => (
|
||||
<Card variant={nested ? null : 'rounded'} size='lg'>
|
||||
<CardBody>
|
||||
<Stack space={2}>
|
||||
<Text weight='medium' align='center' size='lg'>
|
||||
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
|
||||
</Text>
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default MissingIndicator;
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -189,21 +190,36 @@ class ModalRoot extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { children, type } = this.props;
|
||||
const { revealed } = this.state;
|
||||
const visible = !!children;
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
|
||||
<div className='z-50 transition-all' ref={this.setRef} style={{ opacity: 0 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
|
||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' onClick={this.handleOnClose} />
|
||||
<div role='dialog' className='modal-root__container'>{children}</div>
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className={classNames({
|
||||
'fixed top-0 left-0 z-1000 w-full h-full overflow-x-hidden overflow-y-auto': true,
|
||||
'pointer-events-none': !visible,
|
||||
})}
|
||||
style={{ opacity: revealed ? 1 : 0 }}
|
||||
>
|
||||
<div role='presentation' id='modal-overlay' className='fixed inset-0 bg-gray-600 bg-opacity-90' onClick={this.handleOnClose} />
|
||||
|
||||
<div
|
||||
role='dialog'
|
||||
className={classNames({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
})}
|
||||
style={{ minHeight: 'calc(100% - 3.5rem)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,172 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconWithCounter from 'soapbox/components/icon_with_counter';
|
||||
import { isStaff, getBaseURL } from 'soapbox/utils/accounts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
const reportsCount = state.getIn(['admin', 'openReports']).count();
|
||||
const approvalCount = state.getIn(['admin', 'awaitingApproval']).count();
|
||||
const instance = state.get('instance');
|
||||
|
||||
return {
|
||||
account,
|
||||
logo: getSoapboxConfig(state).get('logo'),
|
||||
notificationCount: state.getIn(['notifications', 'unread']),
|
||||
chatsCount: state.getIn(['chats', 'items']).reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0),
|
||||
dashboardCount: reportsCount + approvalCount,
|
||||
baseURL: getBaseURL(account),
|
||||
settings: getSettings(state),
|
||||
features: getFeatures(instance),
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
export default @withRouter @connect(mapStateToProps)
|
||||
class PrimaryNavigation extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
logo: PropTypes.string,
|
||||
account: ImmutablePropTypes.map,
|
||||
dashboardCount: PropTypes.number,
|
||||
notificationCount: PropTypes.number,
|
||||
chatsCount: PropTypes.number,
|
||||
baseURL: PropTypes.string,
|
||||
settings: PropTypes.object.isRequired,
|
||||
features: PropTypes.object.isRequired,
|
||||
location: PropTypes.object,
|
||||
instance: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, settings, features, notificationCount, chatsCount, dashboardCount, location, instance, baseURL } = this.props;
|
||||
|
||||
return (
|
||||
<div className='column-header__wrapper primary-navigation__wrapper'>
|
||||
<h1 className='column-header primary-navigation'>
|
||||
<NavLink to='/' exact className='btn grouped'>
|
||||
<Icon
|
||||
src={require('icons/home-square.svg')}
|
||||
className={classNames('primary-navigation__icon', 'svg-icon--home', { 'svg-icon--active': location.pathname === '/' })}
|
||||
/>
|
||||
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink key='search' className='btn grouped' to='/search'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/search.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/search' })}
|
||||
/>
|
||||
<FormattedMessage id='navigation.search' defaultMessage='Search' />
|
||||
</NavLink>
|
||||
|
||||
{account && (
|
||||
<NavLink key='notifications' className='btn grouped' to='/notifications' data-preview-title-id='column.notifications'>
|
||||
<IconWithCounter
|
||||
src={require('@tabler/icons/icons/bell.svg')}
|
||||
className={classNames('primary-navigation__icon', {
|
||||
'svg-icon--active': location.pathname === '/notifications',
|
||||
'svg-icon--unread': notificationCount > 0,
|
||||
})}
|
||||
count={notificationCount}
|
||||
/>
|
||||
<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{account && (
|
||||
features.chats ? (
|
||||
<NavLink key='chats' className='btn grouped' to='/chats' data-preview-title-id='column.chats'>
|
||||
<IconWithCounter
|
||||
src={require('@tabler/icons/icons/messages.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/chats' })}
|
||||
count={chatsCount}
|
||||
/>
|
||||
<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink to='/messages' className='btn grouped'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/mail.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': ['/messages', '/conversations'].includes(location.pathname) })}
|
||||
/>
|
||||
<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
|
||||
{(account && isStaff(account)) && (
|
||||
<NavLink key='dashboard' className='btn grouped' to='/admin' data-preview-title-id='tabs_bar.dashboard'>
|
||||
<IconWithCounter
|
||||
src={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
||||
className='primary-navigation__icon'
|
||||
count={dashboardCount}
|
||||
/>
|
||||
<FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{(account && instance.get('invites_enabled')) && (
|
||||
<a href={`${baseURL}/invites`} className='btn grouped'>
|
||||
<Icon src={require('@tabler/icons/icons/mailbox.svg')} className='primary-navigation__icon' />
|
||||
<FormattedMessage id='navigation.invites' defaultMessage='Invites' />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(settings.get('isDeveloper')) && (
|
||||
<NavLink key='developers' className='btn grouped' to='/developers'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/code.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname.startsWith('/developers') })}
|
||||
/>
|
||||
<FormattedMessage id='navigation.developers' defaultMessage='Developers' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
{features.federating ? (
|
||||
<NavLink to='/timeline/local' className='btn grouped'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/users.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/timeline/local' })}
|
||||
/>
|
||||
{instance.get('title')}
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink to='/timeline/local' className='btn grouped'>
|
||||
<Icon src={require('@tabler/icons/icons/world.svg')} className='primary-navigation__icon' />
|
||||
<FormattedMessage id='tabs_bar.all' defaultMessage='All' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{features.federating && (
|
||||
<NavLink to='/timeline/fediverse' className='btn grouped'>
|
||||
<Icon src={require('icons/fediverse.svg')} className='column-header__icon' />
|
||||
<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />
|
||||
</NavLink>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -2,9 +2,10 @@ import classNames from 'classnames';
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import {
|
||||
|
@ -18,6 +19,9 @@ import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
|||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { isAdmin, isModerator } from 'soapbox/utils/accounts';
|
||||
|
||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||
import { Card, CardBody, Stack, Text } from './ui';
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const getBadges = (account) => {
|
||||
|
@ -48,8 +52,9 @@ const handleMouseLeave = (dispatch) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const ProfileHoverCard = ({ visible }) => {
|
||||
export const ProfileHoverCard = ({ history, visible }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const [popperElement, setPopperElement] = useState(null);
|
||||
|
||||
|
@ -63,6 +68,17 @@ export const ProfileHoverCard = ({ visible }) => {
|
|||
if (accountId) dispatch(fetchRelationships([accountId]));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = history.listen(() => {
|
||||
showProfileHoverCard.cancel();
|
||||
dispatch(closeProfileHoverCard());
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { styles, attributes } = usePopper(targetRef, popperElement);
|
||||
|
||||
if (!account) return null;
|
||||
|
@ -70,23 +86,46 @@ export const ProfileHoverCard = ({ visible }) => {
|
|||
const followedBy = me !== account.get('id') && account.getIn(['relationship', 'followed_by']);
|
||||
|
||||
return (
|
||||
<div className={classNames('profile-hover-card', { 'profile-hover-card--visible': visible })} ref={setPopperElement} style={styles.popper} {...attributes.popper} onMouseEnter={handleMouseEnter(dispatch)} onMouseLeave={handleMouseLeave(dispatch)}>
|
||||
<div className='profile-hover-card__container'>
|
||||
{followedBy &&
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
</span>}
|
||||
<div className='profile-hover-card__action-button'><ActionButton account={account} small /></div>
|
||||
<BundleContainer fetchComponent={UserPanel}>
|
||||
{Component => <Component className='profile-hover-card__user' accountId={account.get('id')} />}
|
||||
</BundleContainer>
|
||||
{badges.length > 0 &&
|
||||
<div className='profile-hover-card__badges'>
|
||||
{badges}
|
||||
</div>}
|
||||
{account.getIn(['source', 'note'], '').length > 0 &&
|
||||
<div className='profile-hover-card__bio' dangerouslySetInnerHTML={accountBio} />}
|
||||
</div>
|
||||
<div
|
||||
className={classNames({
|
||||
'absolute transition-opacity w-[320px] z-50 top-0 left-0': true,
|
||||
'opacity-100': visible,
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
onMouseEnter={handleMouseEnter(dispatch)}
|
||||
onMouseLeave={handleMouseLeave(dispatch)}
|
||||
>
|
||||
<Card variant='rounded' className='relative'>
|
||||
<CardBody>
|
||||
<Stack space={2}>
|
||||
<BundleContainer fetchComponent={UserPanel}>
|
||||
{Component => (
|
||||
<Component
|
||||
accountId={account.get('id')}
|
||||
action={<ActionButton account={account} small />}
|
||||
badges={badges}
|
||||
/>
|
||||
)}
|
||||
</BundleContainer>
|
||||
|
||||
{account.getIn(['source', 'note'], '').length > 0 && (
|
||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{followedBy && (
|
||||
<div className='absolute top-2 left-2'>
|
||||
<Badge
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.follows_you', defaultMessage: 'Follows you' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -95,11 +134,11 @@ ProfileHoverCard.propTypes = {
|
|||
visible: PropTypes.bool,
|
||||
accountId: PropTypes.string,
|
||||
account: ImmutablePropTypes.map,
|
||||
intl: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
ProfileHoverCard.defaultProps = {
|
||||
visible: true,
|
||||
};
|
||||
|
||||
export default injectIntl(ProfileHoverCard);
|
||||
export default withRouter(ProfileHoverCard);
|
||||
|
|
|
@ -25,14 +25,14 @@ export default class ProgressCircle extends React.PureComponent {
|
|||
const dashoffset = circumference * (1 - Math.min(progress, 1));
|
||||
|
||||
return (
|
||||
<div className={classNames('progress-circle', { 'progress-circle--over': progress > 1 })} title={title}>
|
||||
<div title={title}>
|
||||
<svg
|
||||
width={actualRadius * 2}
|
||||
height={actualRadius * 2}
|
||||
viewBox={`0 0 ${actualRadius * 2} ${actualRadius * 2}`}
|
||||
>
|
||||
<circle
|
||||
className='progress-circle__circle'
|
||||
className='stroke-gray-400'
|
||||
cx={actualRadius}
|
||||
cy={actualRadius}
|
||||
r={radius}
|
||||
|
@ -40,7 +40,9 @@ export default class ProgressCircle extends React.PureComponent {
|
|||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
className={classNames('progress-circle__progress')}
|
||||
className={classNames('stroke-primary-800', {
|
||||
'stroke-danger-600': progress > 1,
|
||||
})}
|
||||
style={{
|
||||
strokeDashoffset: dashoffset,
|
||||
strokeDasharray: circumference,
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import PTRComponent from 'react-simple-pull-to-refresh';
|
||||
|
||||
import { Spinner } from 'soapbox/components/ui';
|
||||
|
||||
interface IPullToRefresh {
|
||||
children: JSX.Element & React.ReactNode,
|
||||
onRefresh?: () => Promise<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* PullToRefresh:
|
||||
* Wrapper around a third-party PTR component with Soapbox defaults.
|
||||
*/
|
||||
const PullToRefresh = ({ children, onRefresh, ...rest }: IPullToRefresh) => {
|
||||
const handleRefresh = () => {
|
||||
if (onRefresh) {
|
||||
return onRefresh();
|
||||
} else {
|
||||
// If not provided, do nothing
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PTRComponent
|
||||
onRefresh={handleRefresh}
|
||||
pullingContent={null}
|
||||
// `undefined` will fallback to the default, while `null` will render nothing
|
||||
refreshingContent={onRefresh ? <Spinner size={30} withText={false} /> : null}
|
||||
pullDownThreshold={67}
|
||||
maxPullDownDistance={95}
|
||||
resistance={2}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</PTRComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
|
@ -1,46 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import PTRComponent from 'react-simple-pull-to-refresh';
|
||||
|
||||
/**
|
||||
* PullToRefresh:
|
||||
* Wrapper around a third-party PTR component with Soapbox defaults.
|
||||
*/
|
||||
export default class PullToRefresh extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
onRefresh: PropTypes.func,
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
const { onRefresh } = this.props;
|
||||
|
||||
if (onRefresh) {
|
||||
return onRefresh();
|
||||
} else {
|
||||
// If not provided, do nothing
|
||||
return new Promise(resolve => resolve());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, onRefresh, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<PTRComponent
|
||||
onRefresh={this.handleRefresh}
|
||||
pullingContent={null}
|
||||
// `undefined` will fallback to the default, while `null` will render nothing
|
||||
refreshingContent={onRefresh ? undefined : null}
|
||||
pullDownThreshold={67}
|
||||
maxPullDownDistance={95}
|
||||
resistance={2}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</PTRComponent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import PullToRefresh from './pull_to_refresh';
|
||||
import PullToRefresh from './pull-to-refresh';
|
||||
|
||||
/**
|
||||
* Pullable:
|
||||
|
|
|
@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Text } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
|
||||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
|
||||
|
@ -171,15 +173,15 @@ class RelativeTimestamp extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { timestamp, intl, year, futureDate } = this.props;
|
||||
const { timestamp, intl, year, futureDate, ...textProps } = this.props;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
<Text {...textProps} color='inherit' tag='time' title={intl.formatDate(date, dateFormatOptions)}>
|
||||
{relativeTime}
|
||||
</time>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,14 +6,14 @@ import React, { PureComponent } from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import PullToRefresh from 'soapbox/components/pull_to_refresh';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
|
||||
|
||||
import LoadMore from './load_more';
|
||||
import LoadingIndicator from './loading_indicator';
|
||||
import MoreFollows from './more_follows';
|
||||
import { Spinner, Text } from './ui';
|
||||
|
||||
const MOUSE_IDLE_DELAY = 300;
|
||||
|
||||
|
@ -249,12 +249,10 @@ class ScrollableList extends PureComponent {
|
|||
|
||||
if (Placeholder && placeholderCount > 0) {
|
||||
return (
|
||||
<div className={classNames('slist slist--flex', className)}>
|
||||
<div role='feed' className='item-list'>
|
||||
{Array(placeholderCount).fill().map((_, i) => (
|
||||
<Placeholder key={i} />
|
||||
))}
|
||||
</div>
|
||||
<div role='feed' className={className}>
|
||||
{Array(placeholderCount).fill().map((_, i) => (
|
||||
<Placeholder key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -266,7 +264,7 @@ class ScrollableList extends PureComponent {
|
|||
</div>
|
||||
|
||||
<div className='slist__append'>
|
||||
<LoadingIndicator />
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -276,11 +274,11 @@ class ScrollableList extends PureComponent {
|
|||
const { className, prepend, alwaysPrepend, emptyMessage } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames('slist slist--flex', className)} ref={this.setRef}>
|
||||
<div className={classNames('mt-2', className)} ref={this.setRef}>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<div className='empty-column-indicator'>
|
||||
<div>{emptyMessage}</div>
|
||||
<div className='bg-primary-50 mt-2 rounded-lg text-center p-8'>
|
||||
<Text>{emptyMessage}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -293,8 +291,8 @@ class ScrollableList extends PureComponent {
|
|||
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
|
||||
|
||||
const feed = (
|
||||
<div className={classNames('slist', className)} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<div role='feed' className='item-list'>
|
||||
<div ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<div role='feed' className={className}>
|
||||
{prepend}
|
||||
|
||||
{React.Children.map(children, (child, index) => (
|
||||
|
@ -315,11 +313,7 @@ class ScrollableList extends PureComponent {
|
|||
</IntersectionObserverArticleContainer>
|
||||
))}
|
||||
{(isLoading && Placeholder) && (
|
||||
<div className='slist__placeholder'>
|
||||
{Array(3).fill().map((_, i) => (
|
||||
<Placeholder key={i} />
|
||||
))}
|
||||
</div>
|
||||
<Placeholder />
|
||||
)}
|
||||
{this.getMoreFollows()}
|
||||
{loadMore}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon, Text } from './ui';
|
||||
|
||||
interface ISidebarNavigationLink {
|
||||
count?: number,
|
||||
icon: string,
|
||||
text: string | React.ReactElement,
|
||||
to: string,
|
||||
}
|
||||
|
||||
const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink) => {
|
||||
const isActive = location.pathname === to;
|
||||
const withCounter = typeof count !== 'undefined';
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
exact
|
||||
to={to}
|
||||
className={classNames({
|
||||
'flex items-center py-2 text-sm font-semibold space-x-4': true,
|
||||
'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200': !isActive,
|
||||
'text-gray-900 dark:text-white': isActive,
|
||||
})}
|
||||
>
|
||||
<span className={classNames({
|
||||
'relative rounded-lg inline-flex p-3': true,
|
||||
'bg-primary-50 dark:bg-slate-700': !isActive,
|
||||
'bg-primary-600': isActive,
|
||||
})}
|
||||
>
|
||||
{withCounter && count > 0 ? (
|
||||
<span className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Icon
|
||||
src={icon}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-primary-700 dark:text-white': !isActive,
|
||||
'text-white': isActive,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Text weight='semibold' theme='inherit'>{text}</Text>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarNavigationLink;
|
|
@ -0,0 +1,128 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import ComposeButton from 'soapbox/features/ui/components/compose-button';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
||||
const SidebarNavigation = () => {
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
const account = useAppSelector((state) => state.accounts.get(me));
|
||||
const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
|
||||
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
|
||||
|
||||
const baseURL = getBaseURL(ImmutableMap(account));
|
||||
const features = getFeatures(instance);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<SidebarNavigationLink
|
||||
to='/'
|
||||
icon={require('icons/feed.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.home' defaultMessage='Feed' />}
|
||||
/>
|
||||
|
||||
{account && (
|
||||
<>
|
||||
<SidebarNavigationLink
|
||||
to={`/@${account.get('acct')}`}
|
||||
icon={require('icons/user.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />}
|
||||
/>
|
||||
|
||||
<SidebarNavigationLink
|
||||
to='/notifications'
|
||||
icon={require('icons/alert.svg')}
|
||||
count={notificationCount}
|
||||
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Alerts' />}
|
||||
/>
|
||||
|
||||
<SidebarNavigationLink
|
||||
to='/settings'
|
||||
icon={require('icons/cog.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.settings' defaultMessage='Settings' />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{account && (
|
||||
features.chats ? (
|
||||
<SidebarNavigationLink
|
||||
to='/chats'
|
||||
icon={require('@tabler/icons/icons/messages.svg')}
|
||||
count={chatsCount}
|
||||
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
|
||||
/>
|
||||
) : (
|
||||
<SidebarNavigationLink
|
||||
to='/messages'
|
||||
icon={require('icons/mail.svg')}
|
||||
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* {(account && isStaff(account)) && (
|
||||
<SidebarNavigationLink
|
||||
to='/admin'
|
||||
icon={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />}
|
||||
count={dashboardCount}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{(account && instance.get('invites_enabled')) && (
|
||||
<SidebarNavigationLink
|
||||
to={`${baseURL}/invites`}
|
||||
icon={require('@tabler/icons/icons/mailbox.svg')}
|
||||
text={<FormattedMessage id='navigation.invites' defaultMessage='Invites' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(settings.get('isDeveloper')) && (
|
||||
<SidebarNavigationLink
|
||||
to='/developers'
|
||||
icon={require('@tabler/icons/icons/code.svg')}
|
||||
text={<FormattedMessage id='navigation.developers' defaultMessage='Developers' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* {features.federating ? (
|
||||
<NavLink to='/timeline/local' className='btn grouped'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/users.svg')}
|
||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/timeline/local' })}
|
||||
/>
|
||||
{instance.get('title')}
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink to='/timeline/local' className='btn grouped'>
|
||||
<Icon src={require('@tabler/icons/icons/world.svg')} className='primary-navigation__icon' />
|
||||
<FormattedMessage id='tabs_bar.all' defaultMessage='All' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{features.federating && (
|
||||
<NavLink to='/timeline/fediverse' className='btn grouped'>
|
||||
<Icon src={require('icons/fediverse.svg')} className='column-header__icon' />
|
||||
<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />
|
||||
</NavLink>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{account && (
|
||||
<ComposeButton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarNavigation;
|
|
@ -1,366 +1,270 @@
|
|||
import classNames from 'classnames';
|
||||
import { is as ImmutableIs } from 'immutable';
|
||||
import { throttle } 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 { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
import { logOut, switchAccount } from 'soapbox/actions/auth';
|
||||
import { fetchOwnAccounts } from 'soapbox/actions/auth';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import Account from 'soapbox/components/account';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import ProfileStats from 'soapbox/features/ui/components/profile_stats';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import { closeSidebar } from '../actions/sidebar';
|
||||
import ThemeToggle from '../features/ui/components/theme_toggle_container';
|
||||
import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
|
||||
import { isAdmin, getBaseURL } from '../utils/accounts';
|
||||
import { isAdmin, isStaff } from '../utils/accounts';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import Icon from './icon';
|
||||
import IconButton from './icon_button';
|
||||
import { HStack, Icon, IconButton, Text } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
||||
follows: { id: 'account.follows', defaultMessage: 'Follows' },
|
||||
profile: { id: 'account.profile', defaultMessage: 'Profile' },
|
||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
admin_settings: { id: 'navigation_bar.admin_settings', defaultMessage: 'Admin settings' },
|
||||
soapbox_config: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||
import_data: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
|
||||
account_aliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
|
||||
account_migration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
||||
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
|
||||
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
|
||||
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
profileDirectory: { id: 'column.profile_directory', defaultMessage: 'Profile directory' },
|
||||
header: { id: 'tabs_bar.header', defaultMessage: 'Account Info' },
|
||||
apps: { id: 'tabs_bar.apps', defaultMessage: 'Apps' },
|
||||
news: { id: 'tabs_bar.news', defaultMessage: 'News' },
|
||||
donate: { id: 'donate', defaultMessage: 'Donate' },
|
||||
donate_crypto: { id: 'donate_crypto', defaultMessage: 'Donate cryptocurrency' },
|
||||
info: { id: 'column.info', defaultMessage: 'Server information' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
add_account: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
const getOtherAccounts = makeGetOtherAccounts();
|
||||
const SidebarLink = ({ to, icon, text, onClick }) => (
|
||||
<NavLink className='group py-1 rounded-md' to={to} onClick={onClick}>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<div className='bg-gray-50 relative rounded inline-flex p-2'>
|
||||
<Icon src={icon} className='text-primary-600 h-5 w-5' />
|
||||
</div>
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
const instance = state.get('instance');
|
||||
<Text tag='span' weight='medium' theme='muted' className='group-hover:text-gray-800'>{text}</Text>
|
||||
</HStack>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const features = getFeatures(instance);
|
||||
const soapbox = getSoapboxConfig(state);
|
||||
|
||||
return {
|
||||
account: getAccount(state, me),
|
||||
sidebarOpen: state.get('sidebar').sidebarOpen,
|
||||
donateUrl: state.getIn(['patron', 'instance', 'url']),
|
||||
hasCrypto: typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string',
|
||||
otherAccounts: getOtherAccounts(state),
|
||||
features,
|
||||
instance,
|
||||
settings: getSettings(state),
|
||||
siteTitle: instance.get('title'),
|
||||
baseURL: getBaseURL(account),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
SidebarLink.propTypes = {
|
||||
to: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onClose() {
|
||||
dispatch(closeSidebar());
|
||||
},
|
||||
onClickLogOut(e) {
|
||||
dispatch(logOut(intl));
|
||||
e.preventDefault();
|
||||
},
|
||||
fetchOwnAccounts() {
|
||||
dispatch(fetchOwnAccounts());
|
||||
},
|
||||
switchAccount(account) {
|
||||
dispatch(switchAccount(account.get('id')));
|
||||
},
|
||||
});
|
||||
const SidebarMenu = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
export default @injectIntl
|
||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||
class SidebarMenu extends ImmutablePureComponent {
|
||||
const logo = useSelector((state) => getSoapboxConfig(state).get('logo'));
|
||||
const features = useSelector((state) => getFeatures(state.get('instance')));
|
||||
const getAccount = makeGetAccount();
|
||||
const getOtherAccounts = makeGetOtherAccounts();
|
||||
const me = useSelector((state) => state.get('me'));
|
||||
const account = useSelector((state) => getAccount(state, me));
|
||||
const otherAccounts = useSelector((state) => getOtherAccounts(state));
|
||||
const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen);
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
account: ImmutablePropTypes.map,
|
||||
otherAccounts: ImmutablePropTypes.list,
|
||||
sidebarOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
features: PropTypes.object.isRequired,
|
||||
instance: ImmutablePropTypes.map.isRequired,
|
||||
baseURL: PropTypes.string,
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
const [switcher, setSwitcher] = React.useState(false);
|
||||
|
||||
const onClose = () => dispatch(closeSidebar());
|
||||
|
||||
const handleClose = () => {
|
||||
setSwitcher(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
state = {
|
||||
switcher: false,
|
||||
}
|
||||
const handleSwitchAccount = (event, account) => {
|
||||
event.preventDefault();
|
||||
switchAccount(account);
|
||||
dispatch(switchAccount(account.get('id')));
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ switcher: false });
|
||||
this.props.onClose();
|
||||
}
|
||||
const onClickLogOut = (event) => {
|
||||
event.preventDefault();
|
||||
dispatch(logOut(intl));
|
||||
};
|
||||
|
||||
handleSwitchAccount = account => {
|
||||
return e => {
|
||||
this.props.switchAccount(account);
|
||||
e.preventDefault();
|
||||
};
|
||||
}
|
||||
|
||||
handleSwitcherClick = e => {
|
||||
this.setState({ switcher: !this.state.switcher });
|
||||
const handleSwitcherClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
setSwitcher((prevState) => (!prevState));
|
||||
};
|
||||
|
||||
const renderAccount = (account) => (
|
||||
<a href='/' className='block py-2' onClick={(event) => handleSwitchAccount(event, account)} key={account.get('id')}>
|
||||
<Account account={account} showProfileHoverCard={false} />
|
||||
</a>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchOwnAccounts());
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fetchOwnAccounts = throttle(() => {
|
||||
this.props.fetchOwnAccounts();
|
||||
}, 2000);
|
||||
const acct = account.get('acct');
|
||||
const classes = classNames('sidebar-menu__root', {
|
||||
'sidebar-menu__root--visible': sidebarOpen,
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchOwnAccounts();
|
||||
}
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div
|
||||
className={classNames({
|
||||
'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true,
|
||||
'hidden': !sidebarOpen,
|
||||
})}
|
||||
role='button'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const accountChanged = !ImmutableIs(prevProps.account, this.props.account);
|
||||
const otherAccountsChanged = !ImmutableIs(prevProps.otherAccounts, this.props.otherAccounts);
|
||||
|
||||
if (accountChanged || otherAccountsChanged) {
|
||||
this.fetchOwnAccounts();
|
||||
}
|
||||
|
||||
if (this.props.sidebarOpen && !prevProps.sidebarOpen) {
|
||||
document.querySelector('.sidebar-menu__close').focus();
|
||||
}
|
||||
}
|
||||
|
||||
renderAccount = account => {
|
||||
return (
|
||||
<a href='/' className='sidebar-account' onClick={this.handleSwitchAccount(account)} key={account.get('id')}>
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sidebarOpen, intl, account, onClickLogOut, donateUrl, otherAccounts, hasCrypto, settings, features, instance, siteTitle, baseURL } = this.props;
|
||||
const { switcher } = this.state;
|
||||
if (!account) return null;
|
||||
const acct = account.get('acct');
|
||||
|
||||
const classes = classNames('sidebar-menu__root', {
|
||||
'sidebar-menu__root--visible': sidebarOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className='sidebar-menu__wrapper' role='button' onClick={this.handleClose} />
|
||||
<div className='sidebar-menu'>
|
||||
<div className='sidebar-menu__content'>
|
||||
|
||||
<div className='sidebar-menu-profile'>
|
||||
<IconButton title='close' onClick={this.handleClose} src={require('@tabler/icons/icons/x.svg')} className='sidebar-menu__close' />
|
||||
<div className='sidebar-menu-profile__avatar'>
|
||||
<Link to={`/@${acct}`} title={acct} onClick={this.handleClose}>
|
||||
<Avatar account={account} />
|
||||
<div className='sidebar-menu'>
|
||||
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
|
||||
<div className='p-4'>
|
||||
<Stack space={4}>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Link to='/' onClick={onClose}>
|
||||
<img alt='Logo' src={logo} className='h-5 w-auto min-w-[140px] cursor-pointer' />
|
||||
</Link>
|
||||
</div>
|
||||
<a href='#' className='sidebar-menu-profile__name' onClick={this.handleSwitcherClick}>
|
||||
<DisplayName account={account} />
|
||||
<Icon src={switcher ? require('@tabler/icons/icons/caret-up.svg') : require('@tabler/icons/icons/caret-down.svg')} className='sidebar-menu-profile__caret' />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{switcher && <div className='sidebar-menu__section'>
|
||||
{otherAccounts.map(account => this.renderAccount(account))}
|
||||
<IconButton
|
||||
title='close'
|
||||
onClick={handleClose}
|
||||
src={require('@tabler/icons/icons/x.svg')}
|
||||
ref={closeButtonRef}
|
||||
className='text-gray-400 hover:text-gray-600'
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<NavLink className='sidebar-menu-item' to='/auth/sign_in' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/plus.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.add_account)}</span>
|
||||
</NavLink>
|
||||
</div>}
|
||||
<Stack space={1}>
|
||||
<Link to={`/@${acct}`} onClick={onClose}>
|
||||
<Account account={account} showProfileHoverCard={false} />
|
||||
</Link>
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
<div className='sidebar-menu-item theme-toggle'>
|
||||
<ThemeToggle showLabel />
|
||||
</div>
|
||||
</div>
|
||||
{isStaff(account) && (
|
||||
<Stack>
|
||||
<button type='button' onClick={handleSwitcherClick} className='py-1'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
{features.federating ? (
|
||||
<NavLink to='/timeline/local' className='sidebar-menu-item' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/users.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{siteTitle}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink to='/timeline/local' className='sidebar-menu-item' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/world.svg')} />
|
||||
<span className='sidebar-menu-item__title'><FormattedMessage id='tabs_bar.all' defaultMessage='All' /></span>
|
||||
</NavLink>
|
||||
)}
|
||||
<Icon
|
||||
src={switcher ? require('@tabler/icons/icons/chevron-up.svg') : require('@tabler/icons/icons/chevron-down.svg')} className='sidebar-menu-profile__caret'
|
||||
/>
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
{features.federating && <NavLink to='/timeline/fediverse' className='sidebar-menu-item' onClick={this.handleClose}>
|
||||
<Icon src={require('icons/fediverse.svg')} />
|
||||
<span className='sidebar-menu-item__title'><FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' /></span>
|
||||
</NavLink>}
|
||||
</div>
|
||||
{switcher && (
|
||||
<div className='border-t border-solid border-gray-200'>
|
||||
{otherAccounts.map(account => renderAccount(account))}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
<NavLink className='sidebar-menu-item' to={`/@${acct}`} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/user.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.profile)}</span>
|
||||
</NavLink>
|
||||
{instance.get('invites_enabled') && <a className='sidebar-menu-item' href={`${baseURL}/invites`} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/mailbox.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.invites)}</span>
|
||||
</a>}
|
||||
{donateUrl && <a className='sidebar-menu-item' href={donateUrl} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/coin.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.donate)}</span>
|
||||
</a>}
|
||||
{hasCrypto && <NavLink className='sidebar-menu-item' to='/donate/crypto' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/currency-bitcoin.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.donate_crypto)}</span>
|
||||
</NavLink>}
|
||||
{features.lists && <NavLink className='sidebar-menu-item' to='/lists' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/list.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.lists)}</span>
|
||||
</NavLink>}
|
||||
{features.bookmarks && <NavLink className='sidebar-menu-item' to='/bookmarks' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/bookmarks.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.bookmarks)}</span>
|
||||
</NavLink>}
|
||||
{features.profileDirectory && <NavLink className='sidebar-menu-item' to='/directory' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/friends.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.profileDirectory)}</span>
|
||||
</NavLink>}
|
||||
</div>
|
||||
<ProfileStats
|
||||
account={account}
|
||||
onClickHandler={handleClose}
|
||||
/>
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
<NavLink className='sidebar-menu-item' to='/follow_requests' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/user-plus.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.follow_requests)}</span>
|
||||
</NavLink>
|
||||
<NavLink className='sidebar-menu-item' to='/blocks' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/ban.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.blocks)}</span>
|
||||
</NavLink>
|
||||
{features.federating && <NavLink className='sidebar-menu-item' to='/domain_blocks' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/ban.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.domain_blocks)}</span>
|
||||
</NavLink>}
|
||||
<NavLink className='sidebar-menu-item' to='/mutes' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/circle-x.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
|
||||
</NavLink>
|
||||
<NavLink className='sidebar-menu-item' to='/filters' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/filter.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
|
||||
</NavLink>
|
||||
{isAdmin(account) && <a className='sidebar-menu-item' href='/pleroma/admin' target='_blank' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/shield.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.admin_settings)}</span>
|
||||
</a>}
|
||||
{isAdmin(account) && <NavLink className='sidebar-menu-item' to='/soapbox/config' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/settings.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.soapbox_config)}</span>
|
||||
</NavLink>}
|
||||
{features.settingsStore ? (
|
||||
<NavLink className='sidebar-menu-item' to='/settings/preferences' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/settings.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.preferences)}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<a className='sidebar-menu-item' href={`${baseURL}/settings/preferences`} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/settings.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.preferences)}</span>
|
||||
</a>
|
||||
)}
|
||||
{features.importAPI ? (
|
||||
<NavLink className='sidebar-menu-item' to='/settings/import' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/cloud-upload.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<a className='sidebar-menu-item' href={`${baseURL}/settings/import`} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/cloud-upload.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span>
|
||||
</a>
|
||||
)}
|
||||
{(features.federating && features.accountMoving) && <NavLink className='sidebar-menu-item' to='/settings/migration' onClick={this.handleClose}>
|
||||
<Icon src={require('feather-icons/dist/icons/briefcase.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.account_migration)}</span>
|
||||
</NavLink>}
|
||||
{features.securityAPI ? (
|
||||
<NavLink className='sidebar-menu-item' to='/auth/edit' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/shield-lock.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.security)}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<a className='sidebar-menu-item' href={`${baseURL}/auth/edit`} onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/shield-lock.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.security)}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Stack space={2}>
|
||||
<hr />
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
<Link className='sidebar-menu-item' to='/info' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/info-circle.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.info)}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to={`/@${acct}`}
|
||||
icon={require('@tabler/icons/icons/user.svg')}
|
||||
text={intl.formatMessage(messages.profile)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{(settings.get('isDeveloper')) && (
|
||||
<Link className='sidebar-menu-item' to='/developers' onClick={this.handleClose}>
|
||||
<Icon src={require('@tabler/icons/icons/code.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.developers)}</span>
|
||||
</Link>
|
||||
)}
|
||||
<hr />
|
||||
|
||||
<div className='sidebar-menu__section'>
|
||||
<Link className='sidebar-menu-item' to='/auth/sign_out' onClick={onClickLogOut}>
|
||||
<Icon src={require('@tabler/icons/icons/logout.svg')} />
|
||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.logout)}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to='/blocks'
|
||||
icon={require('@tabler/icons/icons/ban.svg')}
|
||||
text={intl.formatMessage(messages.blocks)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<SidebarLink
|
||||
to='/mutes'
|
||||
icon={require('@tabler/icons/icons/circle-x.svg')}
|
||||
text={intl.formatMessage(messages.mutes)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<SidebarLink
|
||||
to='/settings/preferences'
|
||||
icon={require('@tabler/icons/icons/settings.svg')}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{features.federating && (
|
||||
<SidebarLink
|
||||
to='/domain_blocks'
|
||||
icon={require('@tabler/icons/icons/ban.svg')}
|
||||
text={intl.formatMessage(messages.domain_blocks)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.filters && (
|
||||
<SidebarLink
|
||||
to='/filters'
|
||||
icon={require('@tabler/icons/icons/filter.svg')}
|
||||
text={intl.formatMessage(messages.filters)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdmin(account) && (
|
||||
<SidebarLink
|
||||
to='/soapbox/config'
|
||||
icon={require('@tabler/icons/icons/settings.svg')}
|
||||
text={intl.formatMessage(messages.soapboxConfig)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.importAPI && (
|
||||
<SidebarLink
|
||||
to='/settings/import'
|
||||
icon={require('@tabler/icons/icons/cloud-upload.svg')}
|
||||
text={intl.formatMessage(messages.importData)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(features.federating && features.accountMoving) && (
|
||||
<SidebarLink
|
||||
to='/settings/migration'
|
||||
icon={require('@tabler/icons/icons/briefcase.svg')}
|
||||
text={intl.formatMessage(messages.accountMigration)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
<SidebarLink
|
||||
to='/auth/sign_out'
|
||||
icon='logout'
|
||||
text={intl.formatMessage(messages.logout)}
|
||||
onClick={onClickLogOut}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
export default SidebarMenu;
|
||||
|
|
|
@ -5,27 +5,22 @@ import { HotKeys } from 'react-hotkeys';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
|
||||
import { getDomain } from 'soapbox/utils/accounts';
|
||||
|
||||
import AccountContainer from '../containers/account_container';
|
||||
import Card from '../features/status/components/card';
|
||||
import Bundle from '../features/ui/components/bundle';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
|
||||
import AttachmentThumbs from './attachment_thumbs';
|
||||
import Avatar from './avatar';
|
||||
import AvatarComposite from './avatar_composite';
|
||||
import AvatarOverlay from './avatar_overlay';
|
||||
import DisplayName from './display_name';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusContent from './status_content';
|
||||
import StatusReplyMentions from './status_reply_mentions';
|
||||
import { HStack, Text } from './ui';
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
|
@ -314,12 +309,18 @@ class Status extends ImmutablePureComponent {
|
|||
this.node = c;
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
if (c) {
|
||||
this.setState({ mediaWrapperWidth: c.offsetWidth });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let media = null;
|
||||
const poll = null;
|
||||
let statusAvatar, prepend, rebloggedByText, reblogContent;
|
||||
let prepend, rebloggedByText, reblogContent, reblogElement, reblogElementMobile;
|
||||
|
||||
const { intl, hidden, featured, otherAccounts, unread, group } = this.props;
|
||||
const { intl, hidden, featured, unread, group } = this.props;
|
||||
|
||||
// FIXME: why does this need to reassign status and account??
|
||||
let { status, account, ...other } = this.props; // eslint-disable-line prefer-const
|
||||
|
@ -354,38 +355,77 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (featured) {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'>
|
||||
<Icon src={require('@tabler/icons/icons/pinned.svg')} className='status__prepend-icon status__prepend-icon--pinned' />
|
||||
</div>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
<div className='pt-4 px-4'>
|
||||
<HStack alignItems='center' space={1}>
|
||||
<Icon src={require('@tabler/icons/icons/pinned.svg')} className='text-gray-600' />
|
||||
|
||||
<Text size='sm' theme='muted' weight='medium'>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
</Text>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
}
|
||||
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'>
|
||||
<Icon src={require('feather-icons/dist/icons/repeat.svg')} className='status__prepend-icon' />
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by' defaultMessage='{name} reposted' values={{
|
||||
name: <NavLink to={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'>
|
||||
<bdi>
|
||||
<strong dangerouslySetInnerHTML={display_name_html} />
|
||||
</bdi>
|
||||
</NavLink>,
|
||||
}}
|
||||
/>
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const displayNameHtml = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
|
||||
reblogElement = (
|
||||
<NavLink
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='hidden sm:flex items-center text-gray-500 text-xs font-medium space-x-1 hover:underline'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/repeat.svg')} className='text-green-600' />
|
||||
|
||||
<HStack alignItems='center'>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: <bdi className='max-w-[100px] truncate pr-1'>
|
||||
<strong className='text-gray-800' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
</bdi>,
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
reblogElementMobile = (
|
||||
<div className='pt-4 px-4 sm:hidden truncate'>
|
||||
<NavLink
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='flex items-center text-gray-500 text-xs font-medium space-x-1 hover:underline'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/repeat.svg')} className='text-green-600' />
|
||||
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: <bdi>
|
||||
<strong className='text-gray-800' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
</bdi>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, { name: status.getIn(['account', 'acct']) });
|
||||
rebloggedByText = intl.formatMessage({
|
||||
id: 'status.reblogged_by',
|
||||
defaultMessage: '{name} reposted',
|
||||
}, {
|
||||
name: status.getIn(['account', 'acct']),
|
||||
});
|
||||
|
||||
account = status.get('account');
|
||||
reblogContent = status.get('contentHtml');
|
||||
status = status.get('reblog');
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
const size = status.get('media_attachments').size;
|
||||
|
@ -402,27 +442,51 @@ class Status extends ImmutablePureComponent {
|
|||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={285}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
const external_id = (video.get('external_video_id'));
|
||||
if (external_id) {
|
||||
const { mediaWrapperWidth } = this.state;
|
||||
const height = mediaWrapperWidth / (video.getIn(['meta', 'original', 'width']) / video.getIn(['meta', 'original', 'height']));
|
||||
media = (
|
||||
<div className='status-card horizontal compact interactive status-card--video'>
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status-card__image status-card-video'
|
||||
style={height ? { height } : {}}
|
||||
>
|
||||
<iframe
|
||||
src={`https://rumble.com/embed/${external_id}/`}
|
||||
frameBorder='0'
|
||||
allowFullScreen
|
||||
webkitallowfullscreen='true'
|
||||
mozallowfullscreen='true'
|
||||
title=''
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={285}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
|
@ -492,14 +556,6 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (otherAccounts && otherAccounts.size > 1) {
|
||||
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
|
||||
} else if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={48} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
|
@ -516,70 +572,76 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`;
|
||||
const favicon = status.getIn(['account', 'pleroma', 'favicon']);
|
||||
const domain = getDomain(status.get('account'));
|
||||
// const favicon = status.getIn(['account', 'pleroma', 'favicon']);
|
||||
// const domain = getDomain(status.get('account'));
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: this.props.focusable && !this.props.muted })} tabIndex={this.props.focusable && !this.props.muted ? 0 : null} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
<div
|
||||
className='status cursor-pointer'
|
||||
tabIndex={this.props.focusable && !this.props.muted ? 0 : null}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
||||
ref={this.handleRef}
|
||||
onClick={() => this.context.router.history.push(statusUrl)}
|
||||
role='link'
|
||||
>
|
||||
{prepend}
|
||||
{reblogElementMobile}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
|
||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||
<div className='status__info'>
|
||||
<NavLink to={statusUrl} className='status__relative-time'>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</NavLink>
|
||||
|
||||
{favicon &&
|
||||
<div className='status__favicon'>
|
||||
<Link to={`/timeline/${domain}`}>
|
||||
<img src={favicon} alt='' title={domain} />
|
||||
</Link>
|
||||
</div>}
|
||||
|
||||
<div className='status__profile'>
|
||||
<div className='status__avatar'>
|
||||
<HoverRefWrapper accountId={status.getIn(['account', 'id'])}>
|
||||
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])}>
|
||||
{statusAvatar}
|
||||
</NavLink>
|
||||
</HoverRefWrapper>
|
||||
</div>
|
||||
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<DisplayName account={status.get('account')} others={otherAccounts} />
|
||||
</NavLink>
|
||||
</div>
|
||||
<div
|
||||
className={classNames({
|
||||
'status__wrapper': true,
|
||||
[`status-${status.get('visibility')}`]: true,
|
||||
'status-reply': !!status.get('in_reply_to_id'),
|
||||
muted: this.props.muted,
|
||||
read: unread === false,
|
||||
})}
|
||||
data-id={status.get('id')}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
<HStack justifyContent='between' alignItems='start'>
|
||||
<AccountContainer
|
||||
key={status.getIn(['account', 'id'])}
|
||||
id={status.getIn(['account', 'id'])}
|
||||
timestamp={status.get('created_at')}
|
||||
timestampUrl={statusUrl}
|
||||
action={reblogElement}
|
||||
hideActions={!reblogElement}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{!group && status.get('group') && (
|
||||
<div className='status__meta'>
|
||||
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
|
||||
</div>
|
||||
)}
|
||||
<div className='status__content-wrapper'>
|
||||
{!group && status.get('group') && (
|
||||
<div className='status__meta'>
|
||||
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusReplyMentions status={this._properStatus()} />
|
||||
<StatusReplyMentions status={this._properStatus()} />
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
reblogContent={reblogContent}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.get('hidden')}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
collapsable
|
||||
/>
|
||||
<StatusContent
|
||||
status={status}
|
||||
reblogContent={reblogContent}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.get('hidden')}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
collapsable
|
||||
/>
|
||||
|
||||
{media}
|
||||
{poll}
|
||||
{quote}
|
||||
{media}
|
||||
{poll}
|
||||
{quote}
|
||||
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
account={account}
|
||||
emojiSelectorFocused={this.state.emojiSelectorFocused}
|
||||
handleEmojiSelectorUnfocus={this.handleEmojiSelectorUnfocus}
|
||||
{...other}
|
||||
/>
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
account={account}
|
||||
emojiSelectorFocused={this.state.emojiSelectorFocused}
|
||||
handleEmojiSelectorUnfocus={this.handleEmojiSelectorUnfocus}
|
||||
{...other}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -8,7 +9,7 @@ import { connect } from 'react-redux';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||
import EmojiSelector from 'soapbox/components/emoji_selector';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
|
||||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
|
||||
|
@ -16,9 +17,9 @@ import { getFeatures } from 'soapbox/utils/features';
|
|||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||
|
||||
import { openModal } from '../actions/modals';
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
|
||||
import IconButton from './icon_button';
|
||||
import { IconButton, Text } from './ui';
|
||||
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -122,8 +123,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
'emojiSelectorFocused',
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
handleReplyClick = (event) => {
|
||||
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
||||
event.stopPropagation();
|
||||
|
||||
if (me) {
|
||||
onReply(status, this.context.router.history);
|
||||
} else {
|
||||
|
@ -131,7 +134,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleShareClick = () => {
|
||||
handleShareClick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
navigator.share({
|
||||
text: this.props.status.get('search_index'),
|
||||
url: this.props.status.get('url'),
|
||||
|
@ -158,6 +163,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
handleLikeButtonClick = e => {
|
||||
const { features } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍';
|
||||
|
||||
if (features.emojiReacts && isUserTouching()) {
|
||||
|
@ -192,12 +200,15 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleBookmarkClick = () => {
|
||||
handleBookmarkClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onBookmark(this.props.status);
|
||||
}
|
||||
|
||||
handleReblogClick = e => {
|
||||
const { me, onReblog, onOpenUnauthorizedModal, status } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (me) {
|
||||
onReblog(status, e);
|
||||
} else {
|
||||
|
@ -205,7 +216,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
handleQuoteClick = (e) => {
|
||||
e.stopPropagation();
|
||||
const { me, onQuote, onOpenUnauthorizedModal, status } = this.props;
|
||||
if (me) {
|
||||
onQuote(status, this.context.router.history);
|
||||
|
@ -214,39 +226,48 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
handleDeleteClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDelete(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleRedraftClick = () => {
|
||||
handleRedraftClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDelete(this.props.status, this.context.router.history, true);
|
||||
}
|
||||
|
||||
handlePinClick = () => {
|
||||
handlePinClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onPin(this.props.status);
|
||||
}
|
||||
|
||||
handleMentionClick = () => {
|
||||
handleMentionClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleDirectClick = () => {
|
||||
handleDirectClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleChatClick = () => {
|
||||
handleChatClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onChat(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleMuteClick = () => {
|
||||
handleMuteClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onMute(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
handleBlockClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onBlock(this.props.status);
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
handleOpen = (e) => {
|
||||
e.stopPropagation();
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('id')}`);
|
||||
}
|
||||
|
||||
|
@ -254,18 +275,22 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onEmbed(this.props.status);
|
||||
}
|
||||
|
||||
handleReport = () => {
|
||||
handleReport = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onReport(this.props.status);
|
||||
}
|
||||
|
||||
handleConversationMuteClick = () => {
|
||||
handleConversationMuteClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
handleCopy = (e) => {
|
||||
const url = this.props.status.get('url');
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
textarea.textContent = url;
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
|
@ -274,44 +299,54 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
handleGroupRemoveAccount = () => {
|
||||
handleGroupRemoveAccount = (e) => {
|
||||
const { status } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id']));
|
||||
}
|
||||
|
||||
handleGroupRemovePost = () => {
|
||||
handleGroupRemovePost = (e) => {
|
||||
const { status } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.get('id'));
|
||||
}
|
||||
|
||||
handleDeactivateUser = () => {
|
||||
handleDeactivateUser = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeactivateUser(this.props.status);
|
||||
}
|
||||
|
||||
handleDeleteUser = () => {
|
||||
handleDeleteUser = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeleteUser(this.props.status);
|
||||
}
|
||||
|
||||
handleDeleteStatus = () => {
|
||||
handleDeleteStatus = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onDeleteStatus(this.props.status);
|
||||
}
|
||||
|
||||
handleToggleStatusSensitivity = () => {
|
||||
handleToggleStatusSensitivity = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onToggleStatusSensitivity(this.props.status);
|
||||
}
|
||||
|
||||
handleOpenReblogsModal = () => {
|
||||
handleOpenReblogsModal = (event) => {
|
||||
const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
if (!me) onOpenUnauthorizedModal();
|
||||
else onOpenReblogsModal(status.getIn(['account', 'acct']), status.get('id'));
|
||||
}
|
||||
|
@ -398,22 +433,22 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }),
|
||||
action: this.handleMentionClick,
|
||||
icon: require('feather-icons/dist/icons/at-sign.svg'),
|
||||
icon: require('@tabler/icons/icons/at.svg'),
|
||||
});
|
||||
|
||||
if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }),
|
||||
action: this.handleChatClick,
|
||||
icon: require('@tabler/icons/icons/messages.svg'),
|
||||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }),
|
||||
action: this.handleDirectClick,
|
||||
icon: require('@tabler/icons/icons/mail.svg'),
|
||||
});
|
||||
}
|
||||
// if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) {
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }),
|
||||
// action: this.handleChatClick,
|
||||
// icon: require('@tabler/icons/icons/messages.svg'),
|
||||
// });
|
||||
// } else {
|
||||
// menu.push({
|
||||
// text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }),
|
||||
// action: this.handleDirectClick,
|
||||
// icon: require('@tabler/icons/icons/mail.svg'),
|
||||
// });
|
||||
// }
|
||||
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
|
@ -441,11 +476,13 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }),
|
||||
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
|
||||
icon: require('@tabler/icons/icons/gavel.svg'),
|
||||
action: (event) => event.stopPropagation(),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.admin_status),
|
||||
href: `/pleroma/admin/#/statuses/${status.get('id')}/`,
|
||||
icon: require('@tabler/icons/icons/pencil.svg'),
|
||||
action: (event) => event.stopPropagation(),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -507,8 +544,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props;
|
||||
const { emojiSelectorVisible } = this.state;
|
||||
const { status, intl, allowedEmoji, features, me } = this.props;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
||||
|
@ -532,7 +568,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}[meEmojiReact] || messages.favourite);
|
||||
|
||||
const menu = this._makeMenu(publicStatus);
|
||||
let reblogIcon = require('feather-icons/dist/icons/repeat.svg');
|
||||
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
|
||||
let replyTitle;
|
||||
|
||||
if (status.get('visibility') === 'direct') {
|
||||
|
@ -572,10 +608,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
} else {
|
||||
reblogButton = (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
disabled={!publicStatus}
|
||||
active={status.get('reblogged')}
|
||||
pressed={status.get('reblogged')}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': !status.get('reblogged'),
|
||||
'text-success-600 hover:text-success-600': status.get('reblogged'),
|
||||
})}
|
||||
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
|
||||
src={reblogIcon}
|
||||
onClick={this.handleReblogClick}
|
||||
|
@ -592,57 +629,78 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
const canShare = ('share' in navigator) && status.get('visibility') === 'public';
|
||||
|
||||
const shareButton = canShare && (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={intl.formatMessage(messages.share)}
|
||||
src={require('feather-icons/dist/icons/share.svg')}
|
||||
onClick={this.handleShareClick}
|
||||
/>
|
||||
<div className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.share)}
|
||||
src={require('@tabler/icons/icons/upload.svg')}
|
||||
onClick={this.handleShareClick}
|
||||
className='text-gray-400 hover:text-gray-600'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<div className='status__action-bar__counter'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} src={require('feather-icons/dist/icons/message-circle.svg')} onClick={this.handleReplyClick} />
|
||||
{replyCount !== 0 && <Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`} className='detailed-status__link'>{replyCount}</Link>}
|
||||
<div className='pt-4 flex flex-row space-x-2'>
|
||||
<div className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
||||
<IconButton
|
||||
title={replyTitle}
|
||||
src={require('@tabler/icons/icons/message-circle.svg')}
|
||||
onClick={this.handleReplyClick}
|
||||
className='text-gray-400 hover:text-gray-600'
|
||||
/>
|
||||
|
||||
{replyCount !== 0 ? (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`}>
|
||||
<Text size='xs' theme='muted'>{replyCount}</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='status__action-bar__counter status__action-bar__counter--reblog'>
|
||||
|
||||
<div className='flex items-center space-x-0.5 p-1 text-gray-400 hover:text-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
||||
{reblogButton}
|
||||
{reblogCount !== 0 && <span className='detailed-status__link' type='button' role='presentation' onClick={this.handleOpenReblogsModal}>{reblogCount}</span>}
|
||||
{reblogCount !== 0 && <Text size='xs' theme='muted' role='presentation' onClick={this.handleOpenReblogsModal}>{reblogCount}</Text>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='status__action-bar__counter status__action-bar__counter--favourite'
|
||||
onMouseEnter={this.handleLikeButtonHover}
|
||||
onMouseLeave={this.handleLikeButtonLeave}
|
||||
ref={this.setRef}
|
||||
className='flex items-center space-x-0.5 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||
// onMouseEnter={this.handleLikeButtonHover}
|
||||
// onMouseLeave={this.handleLikeButtonLeave}
|
||||
>
|
||||
<EmojiSelector
|
||||
{/* <EmojiSelector
|
||||
onReact={this.handleReactClick}
|
||||
visible={features.emojiReacts && emojiSelectorVisible}
|
||||
focused={emojiSelectorFocused}
|
||||
onUnfocus={handleEmojiSelectorUnfocus}
|
||||
/>
|
||||
/> */}
|
||||
<IconButton
|
||||
className='status__action-bar-button star-icon'
|
||||
animate
|
||||
active={Boolean(meEmojiReact)}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': !meEmojiReact,
|
||||
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
title={meEmojiTitle}
|
||||
src={require('@tabler/icons/icons/thumb-up.svg')}
|
||||
emoji={meEmojiReact}
|
||||
src={require('@tabler/icons/icons/heart.svg')}
|
||||
iconClassName={classNames({
|
||||
'fill-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
// emoji={meEmojiReact}
|
||||
onClick={this.handleLikeButtonClick}
|
||||
/>
|
||||
{emojiReactCount !== 0 && (
|
||||
(features.exposableReactions && !features.emojiReacts) ? (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/likes`} className='detailed-status__link'>{emojiReactCount}</Link>
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/likes`} className='pointer-events-none'>
|
||||
<Text size='xs' theme='muted'>{emojiReactCount}</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<span className='detailed-status__link'>{emojiReactCount}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shareButton}
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer status={status} items={menu} src={require('@tabler/icons/icons/dots.svg')} direction='right' title={intl.formatMessage(messages.more)} />
|
||||
<div className='flex items-center space-x-0.5 p-1 text-gray-400 hover:text-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'>
|
||||
<DropdownMenuContainer items={menu} title={intl.formatMessage(messages.more)} status={status} src={require('@tabler/icons/icons/dots.svg')} direction='right' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,8 +5,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import MaterialStatus from 'soapbox/components/material_status';
|
||||
import PlaceholderMaterialStatus from 'soapbox/features/placeholder/components/placeholder_material_status';
|
||||
import StatusContainer from 'soapbox/containers/status_container';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
|
||||
import PendingStatus from 'soapbox/features/ui/components/pending_status';
|
||||
|
||||
import LoadGap from './load_gap';
|
||||
|
@ -38,8 +38,13 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
withGroupAdmin: PropTypes.bool,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
divideType: PropTypes.oneOf(['space', 'border']),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
divideType: 'border',
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.handleDequeueTimeline();
|
||||
}
|
||||
|
@ -112,7 +117,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
const { timelineId, withGroupAdmin, group } = this.props;
|
||||
|
||||
return (
|
||||
<MaterialStatus
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
|
@ -150,7 +155,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
if (!featuredStatusIds) return null;
|
||||
|
||||
return featuredStatusIds.map(statusId => (
|
||||
<MaterialStatus
|
||||
<StatusContainer
|
||||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
|
@ -191,7 +196,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
|
||||
const { statusIds, divideType, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
|
||||
|
||||
if (isPartial) {
|
||||
return (
|
||||
|
@ -218,9 +223,10 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
isLoading={isLoading}
|
||||
showLoading={isLoading && statusIds.size === 0}
|
||||
onLoadMore={onLoadMore && this.handleLoadOlder}
|
||||
placeholderComponent={PlaceholderMaterialStatus}
|
||||
placeholderComponent={() => <PlaceholderStatus />}
|
||||
placeholderCount={20}
|
||||
ref={this.setRef}
|
||||
className={divideType === 'border' ? 'divide-y divide-solid divide-gray-200' : 'sm:space-y-3 divide-y divide-solid divide-gray-200 sm:divide-none'}
|
||||
{...other}
|
||||
>
|
||||
{this.renderScrollableContent()}
|
||||
|
|
|
@ -27,9 +27,11 @@ class StatusReplyMentions extends ImmutablePureComponent {
|
|||
onOpenMentionsModal: PropTypes.func,
|
||||
}
|
||||
|
||||
handleOpenMentionsModal = () => {
|
||||
handleOpenMentionsModal = (e) => {
|
||||
const { status, onOpenMentionsModal } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
onOpenMentionsModal(status.getIn(['account', 'acct']), status.get('id'));
|
||||
}
|
||||
|
||||
|
@ -69,7 +71,7 @@ class StatusReplyMentions extends ImmutablePureComponent {
|
|||
{' '}
|
||||
</>)),
|
||||
more: to.size > 2 && (
|
||||
<span type='button' role='presentation' onClick={this.handleOpenMentionsModal}>
|
||||
<span className='hover:underline cursor-pointer' role='presentation' onClick={this.handleOpenMentionsModal}>
|
||||
<FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />
|
||||
</span>
|
||||
),
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import classNames from 'classnames';
|
||||
import { throttle } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -6,9 +5,8 @@ import { injectIntl, defineMessages } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
|
||||
import { CardHeader, CardTitle } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
|
@ -93,39 +91,15 @@ class SubNavigation extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { intl, message, settings: Settings } = this.props;
|
||||
const { scrolled } = this.state;
|
||||
const { intl, message } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames('sub-navigation', { 'sub-navigation--scrolled': scrolled })} ref={this.setRef}>
|
||||
<div className='sub-navigation__content'>
|
||||
<button
|
||||
className='sub-navigation__back'
|
||||
onClick={this.handleBackClick}
|
||||
onKeyUp={this.handleBackKeyUp}
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
title={intl.formatMessage(messages.back)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/arrow-left.svg')} />
|
||||
{intl.formatMessage(messages.back)}
|
||||
</button>
|
||||
{message && (
|
||||
<div className='sub-navigation__message'>
|
||||
<Helmet><title>{message}</title></Helmet>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
{Settings && (
|
||||
<div className='sub-navigation__cog'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/icons/settings.svg')}
|
||||
onClick={this.handleOpenSettings}
|
||||
title={intl.formatMessage(messages.settings)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
onBackClick={this.handleBackClick}
|
||||
>
|
||||
<CardTitle title={message} />
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,9 +7,8 @@ import { connect } from 'react-redux';
|
|||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconWithCounter from 'soapbox/components/icon_with_counter';
|
||||
import { isStaff } from 'soapbox/utils/accounts';
|
||||
import { Icon, Text } from 'soapbox/components/ui';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
|
@ -43,43 +42,55 @@ class ThumbNavigation extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { account, notificationCount, chatsCount, dashboardCount, location, features } = this.props;
|
||||
const { account, notificationCount, chatsCount, location, features } = this.props;
|
||||
|
||||
return (
|
||||
<div className='thumb-navigation'>
|
||||
<NavLink to='/' exact className='thumb-navigation__link'>
|
||||
<Icon
|
||||
src={require('icons/home-square.svg')}
|
||||
className={classNames('svg-icon--home', { 'svg-icon--active': location.pathname === '/' })}
|
||||
src={require('icons/feed.svg')}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600': location.pathname !== '/',
|
||||
'text-primary-600': location.pathname === '/',
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
|
||||
<Text tag='span' size='xs'>
|
||||
<FormattedMessage id='navigation.home' defaultMessage='Home' />
|
||||
</span>
|
||||
</Text>
|
||||
</NavLink>
|
||||
|
||||
<NavLink to='/search' className='thumb-navigation__link'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/search.svg')}
|
||||
className={classNames({ 'svg-icon--active': location.pathname === '/search' })}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600': location.pathname !== '/search',
|
||||
'text-primary-600': location.pathname === '/search',
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
|
||||
<Text tag='span' size='xs'>
|
||||
<FormattedMessage id='navigation.search' defaultMessage='Search' />
|
||||
</span>
|
||||
</Text>
|
||||
</NavLink>
|
||||
|
||||
{account && (
|
||||
<NavLink to='/notifications' className='thumb-navigation__link'>
|
||||
<IconWithCounter
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/bell.svg')}
|
||||
className={classNames({
|
||||
'svg-icon--active': location.pathname === '/notifications',
|
||||
'svg-icon--unread': notificationCount > 0,
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600': location.pathname !== '/notifications',
|
||||
'text-primary-600': location.pathname === '/notifications',
|
||||
})}
|
||||
count={notificationCount}
|
||||
/>
|
||||
<span>
|
||||
<FormattedMessage id='navigation.notifications' defaultMessage='Notifications' />
|
||||
</span>
|
||||
|
||||
<Text tag='span' size='xs'>
|
||||
<FormattedMessage id='navigation.notifications' defaultMessage='Alerts' />
|
||||
</Text>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
|
@ -99,26 +110,37 @@ class ThumbNavigation extends React.PureComponent {
|
|||
<NavLink to='/messages' className='thumb-navigation__link'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/mail.svg')}
|
||||
className={classNames({ 'svg-icon--active': ['/messages', '/conversations'].includes(location.pathname) })}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600': !['/messages', '/conversations'].includes(location.pathname),
|
||||
'text-primary-600': ['/messages', '/conversations'].includes(location.pathname),
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
|
||||
<Text tag='span' size='xs'>
|
||||
<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />
|
||||
</span>
|
||||
</Text>
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
|
||||
{(account && isStaff(account)) && (
|
||||
<NavLink key='dashboard' to='/admin' className='thumb-navigation__link'>
|
||||
<IconWithCounter
|
||||
src={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
||||
{/* (account && isStaff(account)) && (
|
||||
<NavLink to='/admin' className='thumb-navigation__link'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/icons/dashboard.svg')}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600': !location.pathname.startsWith('/admin'),
|
||||
'text-primary-600': location.pathname.startsWith('/admin'),
|
||||
})}
|
||||
count={dashboardCount}
|
||||
/>
|
||||
<span>
|
||||
|
||||
<Text tag='span' size='xs'>
|
||||
<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />
|
||||
</span>
|
||||
</Text>
|
||||
</NavLink>
|
||||
)}
|
||||
) */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const settings = getSettings(state);
|
||||
|
@ -96,18 +97,19 @@ class TimelineQueueButtonHeader extends React.PureComponent {
|
|||
|
||||
const visible = count > 0 && scrolled;
|
||||
|
||||
const classes = classNames('timeline-queue-header', {
|
||||
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
|
||||
'hidden': !visible,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<a className='timeline-queue-header__btn' onClick={this.handleClick}>
|
||||
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer' onClick={this.handleClick}>
|
||||
<Icon src={require('@tabler/icons/icons/arrow-bar-to-up.svg')} />
|
||||
|
||||
{(count > 0) && (
|
||||
<div className='timeline-queue-header__label'>
|
||||
<Text theme='inherit' size='sm'>
|
||||
{intl.formatMessage(message, { count })}
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still_image';
|
||||
|
||||
const AVATAR_SIZE = 42;
|
||||
|
||||
interface IAvatar {
|
||||
src: string,
|
||||
size?: number,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const Avatar = (props: IAvatar) => {
|
||||
const { src, size = AVATAR_SIZE, className } = props;
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(() => ({
|
||||
width: size,
|
||||
height: size,
|
||||
}), [size]);
|
||||
|
||||
return (
|
||||
<StillImage
|
||||
className={classNames('rounded-full', {
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
style={style}
|
||||
src={src}
|
||||
alt='Avatar'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Avatar as default, AVATAR_SIZE };
|
|
@ -0,0 +1,72 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.theme="secondary" given 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a button element 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all select-none disabled:opacity-50 disabled:cursor-default border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm flex w-full justify-center"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the children 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<p>
|
||||
children
|
||||
</p>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the given text 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the props.text instead of children 1`] = `
|
||||
<button
|
||||
className="inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2 px-4 py-2 text-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
|
@ -67,8 +67,8 @@ describe('<Button />', () => {
|
|||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('adds class "button-secondary" if props.secondary given', () => {
|
||||
const component = renderer.create(<Button secondary />);
|
||||
it('adds class "button-secondary" if props.theme="secondary" given', () => {
|
||||
const component = renderer.create(<Button theme='secondary' />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
|
@ -0,0 +1,84 @@
|
|||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import { useButtonStyles } from './useButtonStyles';
|
||||
|
||||
import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
|
||||
|
||||
interface IButton {
|
||||
block?: boolean,
|
||||
children?: React.ReactNode,
|
||||
classNames?: string,
|
||||
disabled?: boolean,
|
||||
icon?: string,
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||
size?: ButtonSizes,
|
||||
style?: React.CSSProperties,
|
||||
text?: React.ReactNode,
|
||||
to?: string,
|
||||
theme?: ButtonThemes,
|
||||
type?: 'button' | 'submit',
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
|
||||
const {
|
||||
block = false,
|
||||
children,
|
||||
disabled = false,
|
||||
icon,
|
||||
onClick,
|
||||
size = 'md',
|
||||
text,
|
||||
theme = 'accent',
|
||||
to,
|
||||
type = 'button',
|
||||
} = props;
|
||||
|
||||
const themeClass = useButtonStyles({
|
||||
theme,
|
||||
block,
|
||||
disabled,
|
||||
size,
|
||||
});
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Icon src={icon} className='mr-2' />;
|
||||
};
|
||||
|
||||
const handleClick = React.useCallback((event) => {
|
||||
if (onClick && !disabled) {
|
||||
onClick(event);
|
||||
}
|
||||
}, [onClick, disabled]);
|
||||
|
||||
const renderButton = () => (
|
||||
<button
|
||||
className={themeClass}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
type={type}
|
||||
>
|
||||
{renderIcon()}
|
||||
{text || children}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} tabIndex={-1} className='inline-flex'>
|
||||
{renderButton()}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return renderButton();
|
||||
});
|
||||
|
||||
export default Button;
|
|
@ -0,0 +1,47 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger'
|
||||
type ButtonSizes = 'sm' | 'md' | 'lg'
|
||||
|
||||
type IButtonStyles = {
|
||||
theme: ButtonThemes,
|
||||
block: boolean,
|
||||
disabled: boolean,
|
||||
size: ButtonSizes
|
||||
}
|
||||
|
||||
const useButtonStyles = ({
|
||||
theme,
|
||||
block,
|
||||
disabled,
|
||||
size,
|
||||
}: IButtonStyles) => {
|
||||
const themes = {
|
||||
primary:
|
||||
'border-transparent text-white bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 focus:ring-2 focus:ring-offset-2',
|
||||
secondary:
|
||||
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500 focus:ring-2 focus:ring-offset-2',
|
||||
ghost: 'shadow-none border-gray-200 text-gray-700 bg-white focus:ring-primary-500 focus:ring-2 focus:ring-offset-2',
|
||||
accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2',
|
||||
danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
xs: 'px-3 py-1 text-xs',
|
||||
sm: 'px-3 py-1.5 text-xs leading-4',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const buttonStyle = classNames({
|
||||
'inline-flex items-center border font-medium rounded-full focus:outline-none appearance-none transition-all': true,
|
||||
'select-none disabled:opacity-50 disabled:cursor-default': disabled,
|
||||
[`${themes[theme]}`]: true,
|
||||
[`${sizes[size]}`]: true,
|
||||
'flex w-full justify-center': block,
|
||||
});
|
||||
|
||||
return buttonStyle;
|
||||
};
|
||||
|
||||
export { useButtonStyles, ButtonSizes, ButtonThemes };
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
|
||||
import { createShallowComponent } from 'soapbox/test_helpers';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card';
|
||||
|
||||
describe('<Card />', () => {
|
||||
it('renders the CardTitle and CardBody', () => {
|
||||
const component = createShallowComponent(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle title='Card Title' />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
Card Body
|
||||
</CardBody>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('Card Title');
|
||||
expect(component.text()).toContain('Card Body');
|
||||
expect(component.text()).not.toContain('Back');
|
||||
});
|
||||
|
||||
it('renders the Back Button', () => {
|
||||
const component = createShallowComponent(
|
||||
<Card>
|
||||
<CardHeader backHref='/'>
|
||||
<CardTitle title='Card Title' />
|
||||
</CardHeader>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('Back');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const sizes = {
|
||||
md: 'p-4 sm:rounded-xl',
|
||||
lg: 'p-4 sm:p-6 sm:rounded-xl',
|
||||
xl: 'p-4 sm:p-10 sm:rounded-3xl',
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'card.back.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
interface ICard {
|
||||
variant?: 'rounded',
|
||||
size?: 'md' | 'lg' | 'xl',
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const Card: React.FC<ICard> = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...filteredProps}
|
||||
className={classNames({
|
||||
'space-y-4': true,
|
||||
'bg-white sm:shadow-lg overflow-hidden': variant === 'rounded',
|
||||
[sizes[size]]: true,
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
interface ICardHeader {
|
||||
backHref?: string,
|
||||
onBackClick?: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
|
||||
const renderBackButton = () => {
|
||||
if (!backHref && !onBackClick) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Comp: React.ElementType = backHref ? Link : 'button';
|
||||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||
|
||||
return (
|
||||
<Comp {...backAttributes} className='mr-2' aria-label={intl.formatMessage(messages.back)}>
|
||||
<InlineSVG src={require('@tabler/icons/icons/arrow-left.svg')} className='h-6 w-6' />
|
||||
<span className='sr-only'>Back</span>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mb-4 flex flex-row items-center'>
|
||||
{renderBackButton()}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ICardTitle {
|
||||
title: string | React.ReactNode
|
||||
}
|
||||
|
||||
const CardTitle = ({ title }: ICardTitle): JSX.Element => (
|
||||
<h1 className='text-xl font-bold'>{title}</h1>
|
||||
);
|
||||
|
||||
const CardBody: React.FC = ({ children }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardBody };
|
|
@ -0,0 +1,21 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Column /> renders correctly with minimal props 1`] = `
|
||||
<div
|
||||
column-type="filled"
|
||||
role="region"
|
||||
>
|
||||
<div
|
||||
className="space-y-4 bg-white sm:shadow-lg overflow-hidden p-4 sm:rounded-xl"
|
||||
>
|
||||
<div
|
||||
className="mb-4 flex flex-row items-center"
|
||||
>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -1,11 +1,12 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { createComponent } from 'soapbox/test_helpers';
|
||||
|
||||
import Column from '../column';
|
||||
|
||||
describe('<Column />', () => {
|
||||
it('renders correctly with minimal props', () => {
|
||||
const component = renderer.create(<Column />);
|
||||
const component = createComponent(<Column />);
|
||||
const tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||
|
||||
interface IColumn {
|
||||
backHref?: string,
|
||||
label?: string,
|
||||
transparent?: boolean,
|
||||
withHeader?: boolean,
|
||||
}
|
||||
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true } = props;
|
||||
|
||||
const renderChildren = () => {
|
||||
if (transparent) {
|
||||
return <div className='bg-white sm:bg-transparent'>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant='rounded'>
|
||||
{withHeader ? (
|
||||
<CardHeader backHref={backHref}>
|
||||
<CardTitle title={label} />
|
||||
</CardHeader>
|
||||
) : null}
|
||||
|
||||
<CardBody>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div role='region' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
||||
<Helmet><title>{label}</title></Helmet>
|
||||
|
||||
{renderChildren()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Column;
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
const FormActions: React.FC = ({ children }) => (
|
||||
<div className='flex justify-end space-x-2'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default FormActions;
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
|
||||
import { createShallowComponent } from 'soapbox/test_helpers';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
...jest.requireActual('uuid'),
|
||||
}));
|
||||
|
||||
import FormGroup from '../form-group';
|
||||
|
||||
describe('<FormGroup />', () => {
|
||||
it('connects the label and input', () => {
|
||||
const component = createShallowComponent(
|
||||
<FormGroup labelText='My label'>
|
||||
<input type='text' />
|
||||
</FormGroup>,
|
||||
);
|
||||
const otherComponent = createShallowComponent(
|
||||
<FormGroup labelText='My other label'>
|
||||
<input type='text' />
|
||||
</FormGroup>,
|
||||
);
|
||||
|
||||
const inputId = component.find('input').at(0).prop('id');
|
||||
const labelId = component.find('label').at(0).prop('htmlFor');
|
||||
expect(inputId).toBe(labelId);
|
||||
|
||||
const otherInputId = otherComponent.find('input').at(0).prop('id');
|
||||
expect(otherInputId).not.toBe(inputId);
|
||||
});
|
||||
|
||||
it('renders errors', () => {
|
||||
const component = createShallowComponent(
|
||||
<FormGroup labelText='My label' errors={['is invalid', 'is required']}>
|
||||
<input type='text' />
|
||||
</FormGroup>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('is invalid, is required');
|
||||
});
|
||||
|
||||
it('renders label', () => {
|
||||
const component = createShallowComponent(
|
||||
<FormGroup labelText='My label'>
|
||||
<input type='text' />
|
||||
</FormGroup>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('My label');
|
||||
});
|
||||
|
||||
it('renders hint', () => {
|
||||
const component = createShallowComponent(
|
||||
<FormGroup labelText='My label' hintText='My hint'>
|
||||
<input type='text' />
|
||||
</FormGroup>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('My hint');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface IFormGroup {
|
||||
hintText?: string | React.ReactNode,
|
||||
labelText: string,
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||
const { children, errors = [], labelText, hintText } = props;
|
||||
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
|
||||
const inputChildren = React.Children.toArray(children);
|
||||
|
||||
let firstChild;
|
||||
if (React.isValidElement(inputChildren[0])) {
|
||||
firstChild = React.cloneElement(
|
||||
inputChildren[0],
|
||||
{ id: formFieldId },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={formFieldId}
|
||||
className='block text-sm font-medium text-gray-700'
|
||||
>
|
||||
{labelText}
|
||||
</label>
|
||||
|
||||
<div className='mt-1'>
|
||||
{firstChild}
|
||||
{inputChildren.filter((_, i) => i !== 0)}
|
||||
|
||||
{errors?.length > 0 && (
|
||||
<p className='mt-0.5 text-xs text-danger-900 bg-danger-200 rounded-md inline-block px-2 py-1 relative form-error'>
|
||||
{errors.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hintText ? (
|
||||
<p className='mt-0.5 text-xs text-gray-400'>
|
||||
{hintText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormGroup;
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
|
||||
import { createShallowComponent } from 'soapbox/test_helpers';
|
||||
|
||||
import Form from '../form';
|
||||
|
||||
describe('<Form />', () => {
|
||||
it('renders children', () => {
|
||||
const onSubmitMock = jest.fn();
|
||||
const component = createShallowComponent(
|
||||
<Form onSubmit={onSubmitMock}>children</Form>,
|
||||
);
|
||||
|
||||
expect(component.text()).toContain('children');
|
||||
});
|
||||
|
||||
it('handles onSubmit prop', () => {
|
||||
const onSubmitMock = jest.fn();
|
||||
const component = createShallowComponent(
|
||||
<Form onSubmit={onSubmitMock}>children</Form>,
|
||||
);
|
||||
|
||||
component.find('form').at(0).simulate('submit', {
|
||||
preventDefault: () => {},
|
||||
});
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles disabled prop', () => {
|
||||
const onSubmitMock = jest.fn();
|
||||
const component = createShallowComponent(
|
||||
<Form onSubmit={onSubmitMock} disabled>
|
||||
<button type='submit'>Submit</button>
|
||||
</Form>,
|
||||
);
|
||||
|
||||
component.find('button').at(0).simulate('click');
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react';
|
||||
|
||||
interface IForm {
|
||||
disabled?: boolean,
|
||||
onSubmit?: (event: React.FormEvent) => void,
|
||||
}
|
||||
|
||||
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className='space-y-4' {...filteredProps}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
|
@ -0,0 +1,51 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
const justifyContentOptions = {
|
||||
between: 'justify-between',
|
||||
center: 'justify-center',
|
||||
};
|
||||
|
||||
const alignItemsOptions = {
|
||||
top: 'items-start',
|
||||
bottom: 'items-end',
|
||||
center: 'items-center',
|
||||
start: 'items-start',
|
||||
};
|
||||
|
||||
const spaces = {
|
||||
'0.5': 'space-x-0.5',
|
||||
1: 'space-x-1',
|
||||
1.5: 'space-x-1.5',
|
||||
2: 'space-x-2',
|
||||
3: 'space-x-3',
|
||||
4: 'space-x-4',
|
||||
6: 'space-x-6',
|
||||
};
|
||||
|
||||
interface IHStack {
|
||||
alignItems?: 'top' | 'bottom' | 'center' | 'start',
|
||||
className?: string,
|
||||
justifyContent?: 'between' | 'center',
|
||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
|
||||
grow?: boolean,
|
||||
}
|
||||
|
||||
const HStack: React.FC<IHStack> = (props) => {
|
||||
const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...filteredProps}
|
||||
className={classNames('flex', {
|
||||
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
||||
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
|
||||
[spaces[space]]: typeof space !== 'undefined',
|
||||
[className]: typeof className !== 'undefined',
|
||||
'flex-grow': grow,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HStack;
|
|
@ -0,0 +1,43 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
import Text from '../text/text';
|
||||
|
||||
interface IIconButton {
|
||||
alt?: string,
|
||||
className?: string,
|
||||
iconClassName?: string,
|
||||
disabled?: boolean,
|
||||
src: string,
|
||||
onClick?: () => void,
|
||||
text?: string,
|
||||
title?: string,
|
||||
transparent?: boolean
|
||||
}
|
||||
|
||||
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
||||
const { src, className, iconClassName, text, transparent = false, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type='button'
|
||||
className={classNames('flex items-center space-x-2 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500', {
|
||||
'bg-white': !transparent,
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
{...filteredProps}
|
||||
>
|
||||
<InlineSVG src={src} className={iconClassName} />
|
||||
|
||||
{text ? (
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{text}
|
||||
</Text>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default IconButton;
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
interface IIcon {
|
||||
className?: string,
|
||||
count?: number,
|
||||
alt?: string,
|
||||
src: string,
|
||||
}
|
||||
|
||||
const Icon = ({ src, alt, count, ...filteredProps }: IIcon): JSX.Element => {
|
||||
return (
|
||||
<div className='relative'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 -right-3 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<InlineSVG src={src} title={alt} {...filteredProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
|
@ -0,0 +1,29 @@
|
|||
export { default as Avatar } from './avatar/avatar';
|
||||
export { default as Button } from './button/button';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Column } from './column/column';
|
||||
export { default as Form } from './form/form';
|
||||
export { default as FormActions } from './form-actions/form-actions';
|
||||
export { default as FormGroup } from './form-group/form-group';
|
||||
export { default as HStack } from './hstack/hstack';
|
||||
export { default as Icon } from './icon/icon';
|
||||
export { default as IconButton } from './icon-button/icon-button';
|
||||
export { default as Input } from './input/input';
|
||||
export { default as Layout } from './layout/layout';
|
||||
export {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
MenuLink,
|
||||
MenuList,
|
||||
} from './menu/menu';
|
||||
export { default as Modal } from './modal/modal';
|
||||
export { default as Select } from './select/select';
|
||||
export { default as Spinner } from './spinner/spinner';
|
||||
export { default as Stack } from './stack/stack';
|
||||
export { default as Tabs } from './tabs/tabs';
|
||||
export { default as Text } from './text/text';
|
||||
export { default as Textarea } from './textarea/textarea';
|
||||
export { default as Tooltip } from './tooltip/tooltip';
|
|
@ -0,0 +1,89 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Icon from '../../icon';
|
||||
import Tooltip from '../tooltip/tooltip';
|
||||
|
||||
const messages = defineMessages({
|
||||
showPassword: { id: 'input.password.show_password', defaultMessage: 'Show password' },
|
||||
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
|
||||
});
|
||||
|
||||
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'type'> {
|
||||
autoFocus?: boolean,
|
||||
defaultValue?: string,
|
||||
className?: string,
|
||||
icon?: string,
|
||||
name?: string,
|
||||
placeholder?: string,
|
||||
value?: string,
|
||||
onChange?: () => void,
|
||||
type: 'text' | 'email' | 'tel' | 'password'
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||
(props, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { type = 'text', icon, className, ...filteredProps } = props;
|
||||
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
|
||||
const isPassword = type === 'password';
|
||||
|
||||
const togglePassword = React.useCallback(() => {
|
||||
setRevealed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='mt-1 relative rounded-md shadow-sm'>
|
||||
{icon ? (
|
||||
<div className='absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none'>
|
||||
<Icon src={icon} className='h-4 w-4 text-gray-400' aria-hidden='true' />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
{...filteredProps}
|
||||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'block w-full sm:text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||
true,
|
||||
'pr-7': isPassword,
|
||||
'pl-8': typeof icon !== 'undefined',
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
/>
|
||||
|
||||
{isPassword ? (
|
||||
<Tooltip
|
||||
text={
|
||||
revealed ?
|
||||
intl.formatMessage(messages.hidePassword) :
|
||||
intl.formatMessage(messages.showPassword)
|
||||
}
|
||||
>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={togglePassword}
|
||||
tabIndex={-1}
|
||||
className='text-gray-400 hover:text-gray-500 h-full px-2 focus:ring-primary-500 focus:ring-2'
|
||||
>
|
||||
<InlineSVG
|
||||
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
|
||||
className='h-4 w-4'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Input;
|
|
@ -0,0 +1,62 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
const Layout = ({ children }) => (
|
||||
<div className='sm:py-4 relative pb-36'>
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
const Sidebar = ({ children }) => (
|
||||
<div className='hidden lg:block lg:col-span-3'>
|
||||
<div className='sticky top-20'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Main = ({ children, className }) => (
|
||||
<main
|
||||
className={classNames({
|
||||
'md:col-span-12 lg:col-span-9 xl:col-span-6 sm:space-y-4': true,
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
|
||||
const Aside = ({ children }) => (
|
||||
<aside className='hidden xl:block xl:col-span-3'>
|
||||
<div className='sticky top-20 space-y-6'>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
Layout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
Sidebar.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Main.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Aside.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Layout.Sidebar = Sidebar;
|
||||
Layout.Main = Main;
|
||||
Layout.Aside = Aside;
|
||||
|
||||
export default Layout;
|
|
@ -0,0 +1,35 @@
|
|||
[data-reach-menu-popover] {
|
||||
@apply origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none;
|
||||
|
||||
z-index: 1003;
|
||||
}
|
||||
|
||||
div:focus[data-reach-menu-list] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-reach-menu-item][data-selected] {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
[data-reach-menu-list] {
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
[data-reach-menu-item],
|
||||
[data-reach-menu-link] {
|
||||
@apply block px-4 py-2.5 text-sm text-gray-700 cursor-pointer;
|
||||
}
|
||||
|
||||
[data-reach-menu-link] {
|
||||
@apply hover:bg-gray-100;
|
||||
}
|
||||
|
||||
[data-reach-menu-item][data-disabled],
|
||||
[data-reach-menu-link][data-disabled] {
|
||||
@apply opacity-25 cursor-default;
|
||||
}
|
||||
|
||||
[data-reach-menu-popover] hr {
|
||||
@apply my-1 mx-2 border-t border-gray-100;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
MenuPopover,
|
||||
MenuLink,
|
||||
MenuPopoverProps,
|
||||
} from '@reach/menu-button';
|
||||
import { positionDefault, positionRight } from '@reach/popover';
|
||||
import React from 'react';
|
||||
|
||||
import './menu.css';
|
||||
|
||||
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
|
||||
position?: 'left' | 'right'
|
||||
}
|
||||
|
||||
const MenuList = (props: IMenuList) => (
|
||||
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
|
||||
<MenuItems
|
||||
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
className='py-1 bg-white rounded-lg shadow-menu'
|
||||
{...props}
|
||||
/>
|
||||
</MenuPopover>
|
||||
);
|
||||
|
||||
const MenuDivider = () => <hr />;
|
||||
|
||||
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };
|
|
@ -0,0 +1,112 @@
|
|||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Button from '../button/button';
|
||||
import IconButton from '../icon-button/icon-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
});
|
||||
|
||||
interface IModal {
|
||||
cancelAction?: () => void,
|
||||
cancelText?: string,
|
||||
confirmationAction?: () => void,
|
||||
confirmationDisabled?: boolean,
|
||||
confirmationText?: string,
|
||||
confirmationTheme?: 'danger',
|
||||
onClose: () => void,
|
||||
secondaryAction?: () => void,
|
||||
secondaryText?: string,
|
||||
title: string | React.ReactNode,
|
||||
}
|
||||
|
||||
const Modal: React.FC<IModal> = ({
|
||||
cancelAction,
|
||||
cancelText,
|
||||
children,
|
||||
confirmationAction,
|
||||
confirmationDisabled,
|
||||
confirmationText,
|
||||
confirmationTheme,
|
||||
onClose,
|
||||
secondaryAction,
|
||||
secondaryText,
|
||||
title,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (buttonRef?.current) {
|
||||
buttonRef.current.focus();
|
||||
}
|
||||
}, [buttonRef]);
|
||||
|
||||
return (
|
||||
<div className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl pointer-events-auto'>
|
||||
<div className='sm:flex sm:items-start w-full justify-between'>
|
||||
<div className='w-full'>
|
||||
<div className='w-full flex flex-row justify-between items-center'>
|
||||
<h3 className='text-lg leading-6 font-medium text-gray-900'>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{onClose && (
|
||||
<IconButton
|
||||
src={require('@tabler/icons/icons/x.svg')}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
onClick={onClose}
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classNames('mt-2 w-full')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmationAction && (
|
||||
<div className='mt-5 flex flex-row justify-between'>
|
||||
<div className='flex-grow'>
|
||||
{cancelAction && (
|
||||
<Button
|
||||
theme='ghost'
|
||||
onClick={cancelAction}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className='flex flex-row space-x-2'>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={secondaryAction}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
theme={confirmationTheme || 'primary'}
|
||||
onClick={confirmationAction}
|
||||
disabled={confirmationDisabled}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{confirmationText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
|
@ -0,0 +1,22 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
|
||||
const Select = React.forwardRef((props, ref) => {
|
||||
const { children, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className='pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md'
|
||||
{...filteredProps}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
|
||||
Select.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Select;
|
|
@ -0,0 +1,93 @@
|
|||
|
||||
/**
|
||||
* iOS style loading spinner.
|
||||
* Adapted from: https://loading.io/css/
|
||||
* With some help scaling it: https://signalvnoise.com/posts/2577-loading-spinner-animation-using-css-and-webkit
|
||||
*/
|
||||
.spinner {
|
||||
@apply inline-block relative w-20 h-20;
|
||||
}
|
||||
|
||||
.spinner > div {
|
||||
@apply absolute origin-[50%_50%] w-full h-full;
|
||||
animation: spinner 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.spinner > div::after {
|
||||
@apply block absolute rounded-full bg-gray-600;
|
||||
content: ' ';
|
||||
top: 3.75%;
|
||||
left: 46.25%;
|
||||
width: 7.5%;
|
||||
height: 22.5%;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(1) {
|
||||
transform: rotate(0deg);
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(2) {
|
||||
transform: rotate(30deg);
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(3) {
|
||||
transform: rotate(60deg);
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(4) {
|
||||
transform: rotate(90deg);
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(5) {
|
||||
transform: rotate(120deg);
|
||||
animation-delay: -0.7s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(6) {
|
||||
transform: rotate(150deg);
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(7) {
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(8) {
|
||||
transform: rotate(210deg);
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(9) {
|
||||
transform: rotate(240deg);
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(10) {
|
||||
transform: rotate(270deg);
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(11) {
|
||||
transform: rotate(300deg);
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
|
||||
.spinner > div:nth-child(12) {
|
||||
transform: rotate(330deg);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Stack from '../stack/stack';
|
||||
import Text from '../text/text';
|
||||
|
||||
import './spinner.css';
|
||||
|
||||
interface ILoadingIndicator {
|
||||
size?: number,
|
||||
withText?: boolean
|
||||
}
|
||||
|
||||
const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
|
||||
<Stack space={2} justifyContent='center' alignItems='center'>
|
||||
<div className='spinner' style={{ width: size, height: size }}>
|
||||
{Array.from(Array(12).keys()).map(i => (
|
||||
<div key={i}> </div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{withText && (
|
||||
<Text theme='muted' tracking='wide'>
|
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
|
@ -0,0 +1,47 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5
|
||||
|
||||
const spaces = {
|
||||
'0.5': 'space-y-0.5',
|
||||
1: 'space-y-1',
|
||||
'1.5': 'space-y-1.5',
|
||||
2: 'space-y-2',
|
||||
3: 'space-y-3',
|
||||
4: 'space-y-4',
|
||||
5: 'space-y-5',
|
||||
};
|
||||
|
||||
const justifyContentOptions = {
|
||||
center: 'justify-center',
|
||||
};
|
||||
|
||||
const alignItemsOptions = {
|
||||
center: 'items-center',
|
||||
};
|
||||
|
||||
interface IStack {
|
||||
space?: SIZES,
|
||||
alignItems?: 'center',
|
||||
justifyContent?: 'center',
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const Stack: React.FC<IStack> = (props) => {
|
||||
const { space, alignItems, justifyContent, className, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...filteredProps}
|
||||
className={classNames('flex flex-col', {
|
||||
[spaces[space]]: typeof space !== 'undefined',
|
||||
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
||||
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stack;
|
|
@ -0,0 +1,20 @@
|
|||
:root {
|
||||
--reach-tabs: 1;
|
||||
}
|
||||
|
||||
[data-reach-tabs] {
|
||||
@apply relative pb-[3px];
|
||||
}
|
||||
|
||||
[data-reach-tab-list] {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
[data-reach-tab] {
|
||||
@apply flex-1 flex justify-center py-4 px-1 text-center font-medium text-sm
|
||||
text-gray-500 hover:text-gray-700;
|
||||
}
|
||||
|
||||
[data-reach-tab][data-selected] {
|
||||
@apply text-gray-900;
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import { useRect } from '@reach/rect';
|
||||
import {
|
||||
Tabs as ReachTabs,
|
||||
TabList as ReachTabList,
|
||||
Tab as ReachTab,
|
||||
useTabsContext,
|
||||
} from '@reach/tabs';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
|
||||
import './tabs.css';
|
||||
|
||||
const HORIZONTAL_PADDING = 8;
|
||||
const AnimatedContext = React.createContext(null);
|
||||
|
||||
interface IAnimatedInterface {
|
||||
onChange(index: number): void,
|
||||
defaultIndex: number
|
||||
}
|
||||
|
||||
const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
|
||||
const [activeRect, setActiveRect] = React.useState(null);
|
||||
const ref = React.useRef();
|
||||
const rect = useRect(ref);
|
||||
|
||||
const top: number = (activeRect && activeRect.bottom) - (rect && rect.top);
|
||||
const width: number = activeRect && activeRect.width - HORIZONTAL_PADDING * 2;
|
||||
const left: number = (activeRect && activeRect.left) - (rect && rect.left) + HORIZONTAL_PADDING;
|
||||
|
||||
return (
|
||||
<AnimatedContext.Provider value={setActiveRect}>
|
||||
<ReachTabs {...rest} ref={ref}>
|
||||
<div
|
||||
className='w-full h-[3px] bg-primary-200 absolute'
|
||||
style={{ top }}
|
||||
/>
|
||||
<div
|
||||
className={classNames('absolute h-[3px] bg-primary-600 transition-all duration-200', {
|
||||
'hidden': top <= 0,
|
||||
})}
|
||||
style={{ left, top, width }}
|
||||
/>
|
||||
{children}
|
||||
</ReachTabs>
|
||||
</AnimatedContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAnimatedTab {
|
||||
role: 'button',
|
||||
as: 'a' | 'button',
|
||||
href?: string,
|
||||
title: string,
|
||||
index: number
|
||||
}
|
||||
|
||||
const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
|
||||
// get the currently selected index from useTabsContext
|
||||
const { selectedIndex } = useTabsContext();
|
||||
const isSelected: boolean = selectedIndex === index;
|
||||
|
||||
// measure the size of our element, only listen to rect if active
|
||||
const ref = React.useRef();
|
||||
const rect = useRect(ref, { observe: isSelected });
|
||||
|
||||
// get the style changing function from context
|
||||
const setActiveRect = React.useContext(AnimatedContext);
|
||||
|
||||
// callup to set styles whenever we're active
|
||||
React.useLayoutEffect(() => {
|
||||
if (isSelected) {
|
||||
setActiveRect(rect);
|
||||
}
|
||||
}, [isSelected, rect, setActiveRect]);
|
||||
|
||||
return (
|
||||
<ReachTab ref={ref} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
type Item = {
|
||||
text: string,
|
||||
title?: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
action?: () => void,
|
||||
name: string
|
||||
}
|
||||
interface ITabs extends RouteComponentProps<any> {
|
||||
items: Item[],
|
||||
activeItem: string,
|
||||
}
|
||||
|
||||
const Tabs = ({ items, activeItem, history }: ITabs) => {
|
||||
const defaultIndex = items.findIndex(({ name }) => name === activeItem);
|
||||
|
||||
const onChange = (selectedIndex: number) => {
|
||||
const item = items[selectedIndex];
|
||||
|
||||
if (typeof item.action === 'function') {
|
||||
item.action();
|
||||
} else if (item.to) {
|
||||
history.push(item.to);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = (item: Item, idx: number) => {
|
||||
const { name, text, title } = item;
|
||||
|
||||
return (
|
||||
<AnimatedTab
|
||||
key={name}
|
||||
as='button'
|
||||
role='button'
|
||||
title={title}
|
||||
index={idx}
|
||||
>
|
||||
{text}
|
||||
</AnimatedTab>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedTabs onChange={onChange} defaultIndex={defaultIndex}>
|
||||
<ReachTabList>
|
||||
{items.map((item, i) => renderItem(item, i))}
|
||||
</ReachTabList>
|
||||
</AnimatedTabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(Tabs);
|
|
@ -0,0 +1,107 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
type Themes = 'default' | 'danger' | 'primary' | 'muted' | 'subtle' | 'success' | 'inherit' | 'white'
|
||||
type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
|
||||
type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
type Alignments = 'left' | 'center' | 'right'
|
||||
type TrackingSizes = 'normal' | 'wide'
|
||||
type Families = 'sans' | 'mono'
|
||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
|
||||
const themes = {
|
||||
default: 'text-gray-900',
|
||||
danger: 'text-danger-600',
|
||||
primary: 'text-primary-600',
|
||||
muted: 'text-gray-500',
|
||||
subtle: 'text-gray-400',
|
||||
success: 'text-success-600',
|
||||
inherit: 'text-inherit',
|
||||
white: 'text-white',
|
||||
};
|
||||
|
||||
const weights = {
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-md',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
'3xl': 'text-3xl',
|
||||
};
|
||||
|
||||
const alignments = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
};
|
||||
|
||||
const trackingSizes = {
|
||||
normal: 'tracking-normal',
|
||||
wide: 'tracking-wide',
|
||||
};
|
||||
|
||||
const families = {
|
||||
sans: 'font-sans',
|
||||
mono: 'font-mono',
|
||||
};
|
||||
|
||||
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
||||
align?: Alignments,
|
||||
className?: string,
|
||||
dateTime?: string,
|
||||
family?: Families,
|
||||
size?: Sizes,
|
||||
tag?: Tags,
|
||||
theme?: Themes,
|
||||
tracking?: TrackingSizes,
|
||||
truncate?: boolean,
|
||||
weight?: Weights
|
||||
}
|
||||
|
||||
const Text: React.FC<IText> = React.forwardRef(
|
||||
(props: IText, ref: React.LegacyRef<any>) => {
|
||||
const {
|
||||
align,
|
||||
className,
|
||||
family = 'sans',
|
||||
size = 'md',
|
||||
tag = 'p',
|
||||
theme = 'default',
|
||||
tracking = 'normal',
|
||||
truncate = false,
|
||||
weight = 'normal',
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const Comp: React.ElementType = tag;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
{...filteredProps}
|
||||
ref={ref}
|
||||
style={tag === 'abbr' ? { textDecoration: 'underline dotted' } : null}
|
||||
className={classNames({
|
||||
'cursor-default': tag === 'abbr',
|
||||
truncate: truncate,
|
||||
[sizes[size]]: true,
|
||||
[themes[theme]]: true,
|
||||
[weights[weight]]: true,
|
||||
[trackingSizes[tracking]]: true,
|
||||
[families[family]]: true,
|
||||
[alignments[align]]: typeof align !== 'undefined',
|
||||
[className]: typeof className !== 'undefined',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Text;
|