virtualized-window
Alex Gleason 2022-03-21 13:09:01 -05:00
rodzic f00cd1fd07
commit 5cd894ab02
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
418 zmienionych plików z 12849 dodań i 9336 usunięć

Wyświetl plik

@ -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',

Wyświetl plik

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

Wyświetl plik

@ -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(() => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

File diff suppressed because one or more lines are too long

Wyświetl plik

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

Wyświetl plik

@ -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",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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",

Wyświetl plik

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

Wyświetl plik

@ -1,8 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Column /> renders correctly with minimal props 1`] = `
<div
className="column"
role="region"
/>
`;

Wyświetl plik

@ -18,7 +18,7 @@ exports[`<DisplayName /> renders display name + account name 1`] = `
className="display-name__html"
dangerouslySetInnerHTML={
Object {
"__html": "<p>Foo</p>",
"__html": "bar",
}
}
/>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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'>&middot;</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;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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=''

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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'> &rarr;</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;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -300,7 +300,9 @@ class MediaGallery extends React.PureComponent {
}
}
handleOpen = () => {
handleOpen = (e) => {
e.stopPropagation();
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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>
),

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}>&nbsp;</div>
))}
</div>
{withText && (
<Text theme='muted' tracking='wide'>
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />
</Text>
)}
</Stack>
);
export default LoadingIndicator;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

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