Merge remote-tracking branch 'origin/develop' into groups

groups
Alex Gleason 2021-08-22 10:21:20 -05:00
commit 340632c9d2
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
53 zmienionych plików z 1508 dodań i 292 usunięć

Wyświetl plik

@ -60,3 +60,16 @@ docs-deploy:
# before_script:
# - yarn
# - yarn build
pages:
stage: deploy
script:
- yarn build
- mv static public
variables:
NODE_ENV: production
artifacts:
paths:
- public
only:
- develop

Wyświetl plik

@ -0,0 +1,128 @@
import { defineMessages } from 'react-intl';
import api from '../api';
import { importFetchedAccount, importFetchedAccounts } from './importer';
import { showAlertForError } from './alerts';
import snackbar from './snackbar';
import { isLoggedIn } from 'soapbox/utils/auth';
import { ME_PATCH_SUCCESS } from './me';
export const ALIASES_SUGGESTIONS_CHANGE = 'ALIASES_SUGGESTIONS_CHANGE';
export const ALIASES_SUGGESTIONS_READY = 'ALIASES_SUGGESTIONS_READY';
export const ALIASES_SUGGESTIONS_CLEAR = 'ALIASES_SUGGESTIONS_CLEAR';
export const ALIASES_ADD_REQUEST = 'ALIASES_ADD_REQUEST';
export const ALIASES_ADD_SUCCESS = 'ALIASES_ADD_SUCCESS';
export const ALIASES_ADD_FAIL = 'ALIASES_ADD_FAIL';
export const ALIASES_REMOVE_REQUEST = 'ALIASES_REMOVE_REQUEST';
export const ALIASES_REMOVE_SUCCESS = 'ALIASES_REMOVE_SUCCESS';
export const ALIASES_REMOVE_FAIL = 'ALIASES_REMOVE_FAIL';
const messages = defineMessages({
createSuccess: { id: 'aliases.success.add', defaultMessage: 'Account alias created successfully' },
removeSuccess: { id: 'aliases.success.remove', defaultMessage: 'Account alias removed successfully' },
});
export const fetchAliasesSuggestions = q => (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
const params = {
q,
resolve: true,
limit: 4,
};
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAliasesSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchAliasesSuggestionsReady = (query, accounts) => ({
type: ALIASES_SUGGESTIONS_READY,
query,
accounts,
});
export const clearAliasesSuggestions = () => ({
type: ALIASES_SUGGESTIONS_CLEAR,
});
export const changeAliasesSuggestions = value => ({
type: ALIASES_SUGGESTIONS_CHANGE,
value,
});
export const addToAliases = (intl, apId) => (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
const alsoKnownAs = getState().getIn(['meta', 'pleroma', 'also_known_as']);
dispatch(addToAliasesRequest(apId));
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, apId] })
.then((response => {
dispatch(snackbar.success(intl.formatMessage(messages.createSuccess)));
dispatch(addToAliasesSuccess(response.data));
}))
.catch(err => dispatch(addToAliasesFail(err)));
};
export const addToAliasesRequest = (apId) => ({
type: ALIASES_ADD_REQUEST,
apId,
});
export const addToAliasesSuccess = me => dispatch => {
dispatch(importFetchedAccount(me));
dispatch({
type: ME_PATCH_SUCCESS,
me,
});
dispatch({
type: ALIASES_ADD_SUCCESS,
});
};
export const addToAliasesFail = (apId, error) => ({
type: ALIASES_ADD_FAIL,
apId,
error,
});
export const removeFromAliases = (intl, apId) => (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
const alsoKnownAs = getState().getIn(['meta', 'pleroma', 'also_known_as']);
dispatch(removeFromAliasesRequest(apId));
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter(id => id !== apId) })
.then(response => {
dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess)));
dispatch(removeFromAliasesSuccess(response.data));
})
.catch(err => dispatch(removeFromAliasesFail(apId, err)));
};
export const removeFromAliasesRequest = (apId) => ({
type: ALIASES_REMOVE_REQUEST,
apId,
});
export const removeFromAliasesSuccess = me => dispatch => {
dispatch(importFetchedAccount(me));
dispatch({
type: ME_PATCH_SUCCESS,
me,
});
dispatch({
type: ALIASES_REMOVE_SUCCESS,
});
};
export const removeFromAliasesFail = (apId, error) => ({
type: ALIASES_REMOVE_FAIL,
apId,
error,
});

Wyświetl plik

@ -0,0 +1,35 @@
import { baseClient } from '../api';
export const APP_CREATE_REQUEST = 'APP_CREATE_REQUEST';
export const APP_CREATE_SUCCESS = 'APP_CREATE_SUCCESS';
export const APP_CREATE_FAIL = 'APP_CREATE_FAIL';
export const APP_VERIFY_CREDENTIALS_REQUEST = 'APP_VERIFY_CREDENTIALS_REQUEST';
export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS';
export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL';
export function createApp(params) {
return (dispatch, getState) => {
dispatch({ type: APP_CREATE_REQUEST, params });
return baseClient().post('/api/v1/apps', params).then(({ data: app }) => {
dispatch({ type: APP_CREATE_SUCCESS, params, app });
return app;
}).catch(error => {
dispatch({ type: APP_CREATE_FAIL, params, error });
throw error;
});
};
}
export function verifyAppCredentials(token) {
return (dispatch, getState) => {
dispatch({ type: APP_VERIFY_CREDENTIALS_REQUEST, token });
return baseClient(token).get('/api/v1/apps/verify_credentials').then(({ data: app }) => {
dispatch({ type: APP_VERIFY_CREDENTIALS_SUCCESS, token, app });
return app;
}).catch(error => {
dispatch({ type: APP_VERIFY_CREDENTIALS_FAIL, token, error });
throw error;
});
};
}

Wyświetl plik

@ -4,6 +4,10 @@ import { importFetchedAccount } from './importer';
import snackbar from 'soapbox/actions/snackbar';
import { createAccount } from 'soapbox/actions/accounts';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import sourceCode from 'soapbox/utils/code';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -32,14 +36,6 @@ export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST';
export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS';
export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL';
export const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST';
export const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS';
export const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL';
export const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST';
export const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS';
export const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL';
const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
@ -49,25 +45,22 @@ const noOp = () => () => new Promise(f => f());
function createAppAndToken() {
return (dispatch, getState) => {
return dispatch(createApp()).then(() => {
return dispatch(createAuthApp()).then(() => {
return dispatch(createAppToken());
});
};
}
const appName = () => {
const timestamp = (new Date()).toISOString();
return `SoapboxFE_${timestamp}`; // TODO: Add commit hash
};
function createApp() {
function createAuthApp() {
return (dispatch, getState) => {
return api(getState, 'app').post('/api/v1/apps', {
client_name: appName(),
const params = {
client_name: sourceCode.displayName,
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: 'read write follow push admin',
}).then(response => {
return dispatch(authAppCreated(response.data));
};
return dispatch(createApp(params)).then(app => {
return dispatch({ type: AUTH_APP_CREATED, app });
});
};
}
@ -76,13 +69,15 @@ function createAppToken() {
return (dispatch, getState) => {
const app = getState().getIn(['auth', 'app']);
return api(getState, 'app').post('/oauth/token', {
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'client_credentials',
}).then(response => {
return dispatch(authAppAuthorized(response.data));
};
return dispatch(obtainOAuthToken(params)).then(token => {
return dispatch({ type: AUTH_APP_AUTHORIZED, app, token });
});
};
}
@ -90,14 +85,17 @@ function createAppToken() {
function createUserToken(username, password) {
return (dispatch, getState) => {
const app = getState().getIn(['auth', 'app']);
return api(getState, 'app').post('/oauth/token', {
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'password',
username: username,
password: password,
}).then(({ data: token }) => {
};
return dispatch(obtainOAuthToken(params)).then(token => {
dispatch(authLoggedIn(token));
return token;
});
@ -111,14 +109,16 @@ export function refreshUserToken() {
if (!refreshToken) return dispatch(noOp());
return api(getState, 'app').post('/oauth/token', {
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
refresh_token: refreshToken,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'refresh_token',
}).then(response => {
dispatch(authLoggedIn(response.data));
};
return dispatch(obtainOAuthToken(params)).then(token => {
dispatch(authLoggedIn(token));
});
};
}
@ -140,11 +140,13 @@ export function otpVerify(code, mfa_token) {
};
}
export function verifyCredentials(token) {
export function verifyCredentials(token, accountUrl) {
const baseURL = parseBaseURL(accountUrl);
return (dispatch, getState) => {
dispatch({ type: VERIFY_CREDENTIALS_REQUEST });
return baseClient(token).get('/api/v1/accounts/verify_credentials').then(({ data: account }) => {
return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then(({ data: account }) => {
dispatch(importFetchedAccount(account));
dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account });
if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account));
@ -176,21 +178,26 @@ export function logIn(intl, username, password) {
export function logOut(intl) {
return (dispatch, getState) => {
const state = getState();
const me = state.get('me');
const account = getLoggedInAccount(state);
return api(getState).post('/oauth/revoke', {
const params = {
client_id: state.getIn(['auth', 'app', 'client_id']),
client_secret: state.getIn(['auth', 'app', 'client_secret']),
token: state.getIn(['auth', 'users', me, 'access_token']),
}).finally(() => {
dispatch({ type: AUTH_LOGGED_OUT, accountId: me });
token: state.getIn(['auth', 'users', account.get('url'), 'access_token']),
};
return dispatch(revokeOAuthToken(params)).finally(() => {
dispatch({ type: AUTH_LOGGED_OUT, account });
dispatch(snackbar.success(intl.formatMessage(messages.loggedOut)));
});
};
}
export function switchAccount(accountId, background = false) {
return { type: SWITCH_ACCOUNT, accountId, background };
return (dispatch, getState) => {
const account = getState().getIn(['accounts', accountId]);
dispatch({ type: SWITCH_ACCOUNT, account, background });
};
}
export function fetchOwnAccounts() {
@ -199,7 +206,7 @@ export function fetchOwnAccounts() {
state.getIn(['auth', 'users']).forEach(user => {
const account = state.getIn(['accounts', user.get('id')]);
if (!account) {
dispatch(verifyCredentials(user.get('access_token')));
dispatch(verifyCredentials(user.get('access_token'), user.get('url')));
}
});
};
@ -258,13 +265,15 @@ export function changeEmail(email, password) {
export function deleteAccount(intl, password) {
return (dispatch, getState) => {
const account = getLoggedInAccount(getState());
dispatch({ type: DELETE_ACCOUNT_REQUEST });
return api(getState).post('/api/pleroma/delete_account', {
password,
}).then(response => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: DELETE_ACCOUNT_SUCCESS, response });
dispatch({ type: AUTH_LOGGED_OUT });
dispatch({ type: AUTH_LOGGED_OUT, account });
dispatch(snackbar.success(intl.formatMessage(messages.loggedOut)));
}).catch(error => {
dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true });
@ -290,42 +299,6 @@ export function changePassword(oldPassword, newPassword, confirmation) {
};
}
export function fetchOAuthTokens() {
return (dispatch, getState) => {
dispatch({ type: FETCH_TOKENS_REQUEST });
return api(getState).get('/api/oauth_tokens.json').then(response => {
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens: response.data });
}).catch(error => {
dispatch({ type: FETCH_TOKENS_FAIL });
});
};
}
export function revokeOAuthToken(id) {
return (dispatch, getState) => {
dispatch({ type: REVOKE_TOKEN_REQUEST, id });
return api(getState).delete(`/api/oauth_tokens/${id}`).then(response => {
dispatch({ type: REVOKE_TOKEN_SUCCESS, id });
}).catch(error => {
dispatch({ type: REVOKE_TOKEN_FAIL, id });
});
};
}
export function authAppCreated(app) {
return {
type: AUTH_APP_CREATED,
app,
};
}
export function authAppAuthorized(app) {
return {
type: AUTH_APP_AUTHORIZED,
app,
};
}
export function authLoggedIn(token) {
return {
type: AUTH_LOGGED_IN,

Wyświetl plik

@ -2,6 +2,7 @@ import api from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { favourite, unfavourite } from './interactions';
import { isLoggedIn } from 'soapbox/utils/auth';
import { List as ImmutableList } from 'immutable';
export const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST';
export const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS';
@ -19,7 +20,7 @@ const noOp = () => () => new Promise(f => f());
export const simpleEmojiReact = (status, emoji) => {
return (dispatch, getState) => {
const emojiReacts = status.getIn(['pleroma', 'emoji_reactions']);
const emojiReacts = status.getIn(['pleroma', 'emoji_reactions'], ImmutableList());
if (emoji === '👍' && status.get('favourited')) return dispatch(unfavourite(status));

Wyświetl plik

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccount } from './importer';
import { verifyCredentials } from './auth';
import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth';
export const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST';
export const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS';
@ -13,19 +14,31 @@ export const ME_PATCH_FAIL = 'ME_PATCH_FAIL';
const noOp = () => new Promise(f => f());
const getMeId = state => state.get('me') || getAuthUserId(state);
const getMeUrl = state => {
const accountId = getMeId(state);
return state.getIn(['accounts', accountId, 'url']) || getAuthUserUrl(state);
};
const getMeToken = state => {
// Fallback for upgrading IDs to URLs
const accountUrl = getMeUrl(state) || state.getIn(['auth', 'me']);
return state.getIn(['auth', 'users', accountUrl, 'access_token']);
};
export function fetchMe() {
return (dispatch, getState) => {
const state = getState();
const me = state.get('me') || state.getIn(['auth', 'me']);
const token = state.getIn(['auth', 'users', me, 'access_token']);
const token = getMeToken(state);
const accountUrl = getMeUrl(state);
if (!token) {
dispatch({ type: ME_FETCH_SKIP }); return noOp();
}
dispatch(fetchMeRequest());
return dispatch(verifyCredentials(token)).catch(error => {
return dispatch(verifyCredentials(token, accountUrl)).catch(error => {
dispatch(fetchMeFail(error));
});
};

Wyświetl plik

@ -0,0 +1,35 @@
import { baseClient } from '../api';
export const OAUTH_TOKEN_CREATE_REQUEST = 'OAUTH_TOKEN_CREATE_REQUEST';
export const OAUTH_TOKEN_CREATE_SUCCESS = 'OAUTH_TOKEN_CREATE_SUCCESS';
export const OAUTH_TOKEN_CREATE_FAIL = 'OAUTH_TOKEN_CREATE_FAIL';
export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST';
export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS';
export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL';
export function obtainOAuthToken(params) {
return (dispatch, getState) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient().post('/oauth/token', params).then(({ data: token }) => {
dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token });
return token;
}).catch(error => {
dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error });
throw error;
});
};
}
export function revokeOAuthToken(params) {
return (dispatch, getState) => {
dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params });
return baseClient().post('/oauth/revoke', params).then(({ data }) => {
dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data });
return data;
}).catch(error => {
dispatch({ type: OAUTH_TOKEN_REVOKE_FAIL, params, error });
throw error;
});
};
}

Wyświetl plik

@ -0,0 +1,24 @@
import { getSettings, changeSetting } from 'soapbox/actions/settings';
const getPinnedHosts = state => {
const settings = getSettings(state);
return settings.getIn(['remote_timeline', 'pinnedHosts']);
};
export function pinHost(host) {
return (dispatch, getState) => {
const state = getState();
const pinnedHosts = getPinnedHosts(state);
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.add(host)));
};
}
export function unpinHost(host) {
return (dispatch, getState) => {
const state = getState();
const pinnedHosts = getPinnedHosts(state);
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.delete(host)));
};
}

Wyświetl plik

@ -0,0 +1,31 @@
import api from '../api';
export const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST';
export const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS';
export const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL';
export const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST';
export const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS';
export const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL';
export function fetchOAuthTokens() {
return (dispatch, getState) => {
dispatch({ type: FETCH_TOKENS_REQUEST });
return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => {
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
}).catch(error => {
dispatch({ type: FETCH_TOKENS_FAIL });
});
};
}
export function revokeOAuthTokenById(id) {
return (dispatch, getState) => {
dispatch({ type: REVOKE_TOKEN_REQUEST, id });
return api(getState).delete(`/api/oauth_tokens/${id}`).then(() => {
dispatch({ type: REVOKE_TOKEN_SUCCESS, id });
}).catch(error => {
dispatch({ type: REVOKE_TOKEN_FAIL, id });
});
};
}

Wyświetl plik

@ -1,7 +1,7 @@
import { debounce } from 'lodash';
import { showAlertForError } from './alerts';
import { patchMe } from 'soapbox/actions/me';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { isLoggedIn } from 'soapbox/utils/auth';
import uuid from '../uuid';
import { createSelector } from 'reselect';
@ -127,6 +127,13 @@ export const defaultSettings = ImmutableMap({
}),
}),
account_timeline: ImmutableMap({
shows: ImmutableMap({
reblog: true,
pinned: true,
}),
}),
trends: ImmutableMap({
show: true,
}),
@ -136,6 +143,10 @@ export const defaultSettings = ImmutableMap({
ImmutableMap({ id: 'HOME', uuid: uuid(), params: {} }),
ImmutableMap({ id: 'NOTIFICATIONS', uuid: uuid(), params: {} }),
]),
remote_timeline: ImmutableMap({
pinnedHosts: ImmutableOrderedSet(),
}),
});
export const getSettings = createSelector([

Wyświetl plik

@ -2,7 +2,8 @@
import axios from 'axios';
import LinkHeader from 'http-link-header';
import { getAccessToken, getAppToken } from 'soapbox/utils/auth';
import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth';
import { createSelector } from 'reselect';
export const getLinks = response => {
const value = response.headers.link;
@ -16,8 +17,7 @@ export const getNext = response => {
return link ? link.uri : null;
};
const getToken = (getState, authType) => {
const state = getState();
const getToken = (state, authType) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
};
@ -29,8 +29,17 @@ const maybeParseJSON = data => {
}
};
export const baseClient = accessToken => {
const getAuthBaseURL = createSelector([
(state, me) => state.getIn(['accounts', me, 'url']),
(state, me) => state.getIn(['auth', 'me']),
], (accountUrl, authUserUrl) => {
const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl);
return baseURL !== window.location.origin ? baseURL : '';
});
export const baseClient = (accessToken, baseURL = '') => {
return axios.create({
baseURL,
headers: Object.assign(accessToken ? {
'Authorization': `Bearer ${accessToken}`,
} : {}),
@ -40,6 +49,10 @@ export const baseClient = accessToken => {
};
export default (getState, authType = 'user') => {
const accessToken = getToken(getState, authType);
return baseClient(accessToken);
const state = getState();
const accessToken = getToken(state, authType);
const me = state.get('me');
const baseURL = getAuthBaseURL(state, me);
return baseClient(accessToken, baseURL);
};

Wyświetl plik

@ -33,6 +33,7 @@ const messages = defineMessages({
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' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
@ -258,6 +259,10 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='cloud-upload' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/settings/aliases' onClick={this.handleClose}>
<Icon id='suitcase' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.account_aliases)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/auth/edit' onClick={this.handleClose}>
<Icon id='lock' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.security)}</span>

Wyświetl plik

@ -14,6 +14,7 @@ import EmojiSelector from 'soapbox/components/emoji_selector';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
import { List as ImmutableList } from 'immutable';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -93,6 +94,7 @@ class StatusActionBar extends ImmutablePureComponent {
allowedEmoji: ImmutablePropTypes.list,
emojiSelectorFocused: PropTypes.bool,
handleEmojiSelectorUnfocus: PropTypes.func.isRequired,
features: PropTypes.object.isRequired,
};
static defaultProps = {
@ -132,16 +134,26 @@ class StatusActionBar extends ImmutablePureComponent {
isMobile = () => window.matchMedia('only screen and (max-width: 895px)').matches;
handleLikeButtonHover = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: true });
const { features } = this.props;
if (features.emojiReacts && !this.isMobile()) {
this.setState({ emojiSelectorVisible: true });
}
}
handleLikeButtonLeave = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: false });
const { features } = this.props;
if (features.emojiReacts && !this.isMobile()) {
this.setState({ emojiSelectorVisible: false });
}
}
handleLikeButtonClick = e => {
const { features } = this.props;
const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍';
if (this.isMobile()) {
if (features.emojiReacts && this.isMobile()) {
if (this.state.emojiSelectorVisible) {
this.handleReactClick(meEmojiReact)();
} else {
@ -362,7 +374,7 @@ class StatusActionBar extends ImmutablePureComponent {
}
render() {
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus } = this.props;
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features } = this.props;
const { emojiSelectorVisible } = this.state;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -427,7 +439,7 @@ class StatusActionBar extends ImmutablePureComponent {
>
<EmojiSelector
onReact={this.handleReactClick}
visible={emojiSelectorVisible}
visible={features.emojiReacts && emojiSelectorVisible}
focused={emojiSelectorFocused}
onUnfocus={handleEmojiSelectorUnfocus}
/>
@ -456,11 +468,13 @@ class StatusActionBar extends ImmutablePureComponent {
const mapStateToProps = state => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
const instance = state.get('instance');
return {
me,
isStaff: account ? isStaff(account) : false,
isAdmin: account ? isAdmin(account) : false,
features: getFeatures(instance),
};
};

Wyświetl plik

@ -54,6 +54,7 @@ const mapStateToProps = (state) => {
demetricator: settings.get('demetricator'),
locale: validLocale(locale) ? locale : 'en',
themeCss: generateThemeCss(soapboxConfig.get('brandColor')),
brandColor: soapboxConfig.get('brandColor'),
themeMode: settings.get('themeMode'),
halloween: settings.get('halloween'),
customCss: soapboxConfig.get('customCss'),
@ -74,6 +75,7 @@ class SoapboxMount extends React.PureComponent {
locale: PropTypes.string.isRequired,
themeCss: PropTypes.string,
themeMode: PropTypes.string,
brandColor: PropTypes.string,
customCss: ImmutablePropTypes.list,
halloween: PropTypes.bool,
dispatch: PropTypes.func,
@ -134,6 +136,7 @@ class SoapboxMount extends React.PureComponent {
{customCss && customCss.map(css => (
<link rel='stylesheet' href={css} key={css} />
))}
<meta name='theme-color' content={this.props.brandColor} />
</Helmet>
<BrowserRouter>
<ScrollContext>

Wyświetl plik

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render() {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle
prefix='account_timeline'
settings={settings}
settingPath={['shows', 'pinned']}
onChange={onChange}
label={<FormattedMessage id='account_timeline.column_settings.show_pinned' defaultMessage='Show pinned posts' />}
/>
<SettingToggle
prefix='account_timeline'
settings={settings}
settingPath={['shows', 'reblog']}
onChange={onChange}
label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />}
/>
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { getSettings, changeSetting } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: getSettings(state).get('account_timeline'),
});
const mapDispatchToProps = (dispatch) => {
return {
onChange(key, checked) {
dispatch(changeSetting(['account_timeline', ...key], checked));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

Wyświetl plik

@ -4,9 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { fetchAccount, fetchAccountByUsername } from '../../actions/accounts';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
import Icon from 'soapbox/components/icon';
import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column';
import ColumnSettingsContainer from './containers/column_settings_container';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
@ -15,47 +17,57 @@ import MissingIndicator from 'soapbox/components/missing_indicator';
import { NavLink } from 'react-router-dom';
import { fetchPatronAccount } from '../../actions/patron';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { getSettings } from 'soapbox/actions/settings';
import { makeGetStatusIds } from 'soapbox/selectors';
import classNames from 'classnames';
const mapStateToProps = (state, { params, withReplies = false }) => {
const username = params.username || '';
const me = state.get('me');
const accounts = state.getIn(['accounts']);
const accountFetchError = (state.getIn(['accounts', -1, 'username'], '').toLowerCase() === username.toLowerCase());
const soapboxConfig = getSoapboxConfig(state);
const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();
let accountId = -1;
let accountUsername = username;
let accountApId = null;
if (accountFetchError) {
accountId = null;
} else {
const account = accounts.find(acct => username.toLowerCase() === acct.getIn(['acct'], '').toLowerCase());
accountId = account ? account.getIn(['id'], null) : -1;
accountUsername = account ? account.getIn(['acct'], '') : '';
accountApId = account ? account.get('url') : '';
}
const mapStateToProps = (state, { params, withReplies = false }) => {
const username = params.username || '';
const me = state.get('me');
const accounts = state.getIn(['accounts']);
const accountFetchError = (state.getIn(['accounts', -1, 'username'], '').toLowerCase() === username.toLowerCase());
const soapboxConfig = getSoapboxConfig(state);
const path = withReplies ? `${accountId}:with_replies` : accountId;
let accountId = -1;
let accountUsername = username;
let accountApId = null;
if (accountFetchError) {
accountId = null;
} else {
const account = accounts.find(acct => username.toLowerCase() === acct.getIn(['acct'], '').toLowerCase());
accountId = account ? account.getIn(['id'], null) : -1;
accountUsername = account ? account.getIn(['acct'], '') : '';
accountApId = account ? account.get('url') : '';
}
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : isBlocked;
const path = withReplies ? `${accountId}:with_replies` : accountId;
return {
accountId,
unavailable,
accountUsername,
accountApId,
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableOrderedSet()),
featuredStatusIds: withReplies ? ImmutableOrderedSet() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableOrderedSet()),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
me,
patronEnabled: soapboxConfig.getIn(['extensions', 'patron', 'enabled']),
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : isBlocked;
const showPins = getSettings(state).getIn(['account_timeline', 'shows', 'pinned']) && !withReplies;
return {
accountId,
unavailable,
accountUsername,
accountApId,
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }),
featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
me,
patronEnabled: soapboxConfig.getIn(['extensions', 'patron', 'enabled']),
};
};
return mapStateToProps;
};
export default @connect(mapStateToProps)
export default @connect(makeMapStateToProps)
class AccountTimeline extends ImmutablePureComponent {
static propTypes = {
@ -70,6 +82,11 @@ class AccountTimeline extends ImmutablePureComponent {
unavailable: PropTypes.bool,
};
state = {
collapsed: true,
animating: false,
}
componentDidMount() {
const { params: { username }, accountId, accountApId, withReplies, me, patronEnabled } = this.props;
@ -115,8 +132,18 @@ class AccountTimeline extends ImmutablePureComponent {
}
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true });
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render() {
const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, accountId, unavailable, accountUsername } = this.props;
const { collapsed, animating } = this.state;
if (!isAccount && accountId !== -1) {
return (
@ -156,6 +183,16 @@ class AccountTimeline extends ImmutablePureComponent {
<NavLink exact to={`/@${accountUsername}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
<div className='column-header__buttons'>
<button onClick={this.handleToggleClick}>
<Icon id='sliders' />
</button>
</div>
</div>
<div className={classNames('column-header__collapsible', { collapsed, animating })} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
<ColumnSettingsContainer />
</div>
</div>
<StatusList
scrollKey='account_timeline'

Wyświetl plik

@ -138,7 +138,7 @@ class Dashboard extends ImmutablePureComponent {
<div className='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul>
<li>Soapbox FE <span className='pull-right'>{sourceCode.version}</span></li>
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
<li>{v.software} <span className='pull-right'>{v.version}</span></li>
</ul>
</div>

Wyświetl plik

@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import { addToAliases } from '../../../actions/aliases';
const messages = defineMessages({
add: { id: 'aliases.account.add', defaultMessage: 'Create alias' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => {
const account = getAccount(state, accountId);
const apId = account.getIn(['pleroma', 'ap_id']);
return {
account,
apId,
added: typeof added === 'undefined' ? state.getIn(['meta', 'pleroma', 'also_known_as']).includes(apId) : added,
me: state.get('me'),
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onAdd: (intl, apId) => dispatch(addToAliases(intl, apId)),
});
export default @connect(makeMapStateToProps, mapDispatchToProps)
@injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
apId: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
handleOnAdd = () => this.props.onAdd(this.props.intl, this.props.apId);
render() {
const { account, accountId, intl, added, me } = this.props;
let button;
if (!added && accountId !== me) {
button = (
<div className='account__relationship'>
<IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={this.handleOnAdd} />
</div>
);
}
return (
<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>
{button}
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,83 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from '../../../actions/aliases';
import classNames from 'classnames';
import Icon from 'soapbox/components/icon';
import Button from 'soapbox/components/button';
const messages = defineMessages({
search: { id: 'aliases.search', defaultMessage: 'Search your old account' },
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' },
});
const mapStateToProps = state => ({
value: state.getIn(['aliases', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchAliasesSuggestions(value)),
onClear: () => dispatch(clearAliasesSuggestions()),
onChange: value => dispatch(changeAliasesSuggestions(value)),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class Search extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
}
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
}
handleSubmit = () => {
this.props.onSubmit(this.props.value);
}
handleClear = () => {
this.props.onClear();
}
render() {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='aliases_search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<Icon id='search' className={classNames({ active: !hasValue })} />
<Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
<Button onClick={this.handleSubmit}>{intl.formatMessage(messages.searchTitle)}</Button>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,81 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Column from '../ui/components/column';
import ColumnSubheading from '../ui/components/column_subheading';
import ScrollableList from '../../components/scrollable_list';
import Icon from 'soapbox/components/icon';
import Search from './components/search';
import Account from './components/account';
import { removeFromAliases } from '../../actions/aliases';
const messages = defineMessages({
heading: { id: 'column.aliases', defaultMessage: 'Account aliases' },
subheading_add_new: { id: 'column.aliases.subheading_add_new', defaultMessage: 'Add New Alias' },
create_error: { id: 'column.aliases.create_error', defaultMessage: 'Error creating alias' },
delete_error: { id: 'column.aliases.delete_error', defaultMessage: 'Error deleting alias' },
subheading_aliases: { id: 'column.aliases.subheading_aliases', defaultMessage: 'Current aliases' },
delete: { id: 'column.aliases.delete', defaultMessage: 'Delete' },
});
const mapStateToProps = state => ({
aliases: state.getIn(['meta', 'pleroma', 'also_known_as']),
searchAccountIds: state.getIn(['aliases', 'suggestions', 'items']),
loaded: state.getIn(['aliases', 'suggestions', 'loaded']),
});
export default @connect(mapStateToProps)
@injectIntl
class Aliases extends ImmutablePureComponent {
handleFilterDelete = e => {
const { dispatch, intl } = this.props;
dispatch(removeFromAliases(intl, e.currentTarget.dataset.value));
}
render() {
const { intl, aliases, searchAccountIds, loaded } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.aliases' defaultMessage="You haven't created any account alias yet." />;
return (
<Column className='aliases-settings-panel' icon='suitcase' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<ColumnSubheading text={intl.formatMessage(messages.subheading_add_new)} />
<Search />
{
loaded && searchAccountIds.size === 0 ? (
<div className='aliases__accounts empty-column-indicator'>
<FormattedMessage id='empty_column.aliases.suggestions' defaultMessage='There are no account suggestions available for the provided term.' />
</div>
) : (
<div className='aliases__accounts'>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)
}
<ColumnSubheading text={intl.formatMessage(messages.subheading_aliases)} />
<div className='aliases-settings-panel'>
<ScrollableList
scrollKey='aliases'
emptyMessage={emptyMessage}
>
{aliases.map((alias, i) => (
<div key={i} className='alias__container'>
<div className='alias__details'>
<span className='alias__list-label'><FormattedMessage id='aliases.account_label' defaultMessage='Old account:' /></span>
<span className='alias__list-value'>{alias}</span>
</div>
<div className='alias__delete' role='button' tabIndex='0' onClick={this.handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='alias__delete-icon' id='times' size={40} />
<span className='alias__delete-label'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></span>
</div>
</div>
))}
</ScrollableList>
</div>
</Column>
);
}
}

Wyświetl plik

@ -18,10 +18,6 @@ class ColumnSettings extends React.PureComponent {
return (
<div>
<div className='column-settings__row'>
<SettingToggle prefix='community_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='community_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>

Wyświetl plik

@ -19,9 +19,10 @@ export default class SearchResults extends ImmutablePureComponent {
submitted: PropTypes.bool,
expandSearch: PropTypes.func.isRequired,
selectedFilter: PropTypes.string.isRequired,
selectFilter: PropTypes.func.isRequired,
};
handleLoadMore = () => this.props.expandSearch(this.state.selectedFilter);
handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter);
handleSelectFilter = newActiveFilter => this.props.selectFilter(newActiveFilter);

Wyświetl plik

@ -9,6 +9,40 @@ import classNames from 'classnames';
import Icon from 'soapbox/components/icon';
import Blurhash from 'soapbox/components/blurhash';
const MIMETYPE_ICONS = {
'application/x-freearc': 'file-archive-o',
'application/x-bzip': 'file-archive-o',
'application/x-bzip2': 'file-archive-o',
'application/gzip': 'file-archive-o',
'application/vnd.rar': 'file-archive-o',
'application/x-tar': 'file-archive-o',
'application/zip': 'file-archive-o',
'application/x-7z-compressed': 'file-archive-o',
'application/x-csh': 'file-code-o',
'application/html': 'file-code-o',
'text/javascript': 'file-code-o',
'application/json': 'file-code-o',
'application/ld+json': 'file-code-o',
'application/x-httpd-php': 'file-code-o',
'application/x-sh': 'file-code-o',
'application/xhtml+xml': 'file-code-o',
'application/xml': 'file-code-o',
'application/epub+zip': 'file-epub-o',
'application/vnd.oasis.opendocument.spreadsheet': 'file-excel-o',
'application/vnd.ms-excel': 'file-excel-o',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'file-excel-o',
'application/pdf': 'file-pdf-o',
'application/vnd.oasis.opendocument.presentation': 'file-powerpoint-o',
'application/vnd.ms-powerpoint': 'file-powerpoint-o',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'file-powerpoint-o',
'text/plain': 'file-text-o',
'application/rtf': 'file-text-o',
'application/msword': 'file-word-o',
'application/x-abiword': 'file-word-o',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'file-word-o',
'application/vnd.oasis.opendocument.text': 'file-word-o',
};
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
@ -100,6 +134,11 @@ class Upload extends ImmutablePureComponent {
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const mediaType = media.get('type');
const uploadIcon = mediaType === 'unknown' && (
<Icon
id={MIMETYPE_ICONS[media.getIn(['pleroma', 'mime_type'])] || 'paperclip'}
/>
);
return (
<div className='compose-form__upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
@ -115,7 +154,7 @@ class Upload extends ImmutablePureComponent {
>
<div className={classNames('compose-form__upload__actions', { active })}>
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
<button className='icon-button' onClick={this.handleOpenModal}><Icon id='search-plus' /> <FormattedMessage id='upload_form.preview' defaultMessage='Preview' /></button>
{mediaType !== 'unknown' && <button className='icon-button' onClick={this.handleOpenModal}><Icon id='search-plus' /> <FormattedMessage id='upload_form.preview' defaultMessage='Preview' /></button>}
</div>
<div className={classNames('compose-form__upload-description', { active })}>
@ -140,6 +179,7 @@ class Upload extends ImmutablePureComponent {
<source src={media.get('preview_url')} />
</video>
)}
{uploadIcon}
</div>
</div>
)}

Wyświetl plik

@ -7,6 +7,7 @@ import Column from '../../components/column';
import ColumnSettingsContainer from './containers/column_settings_container';
import HomeColumnHeader from '../../components/home_column_header';
import Accordion from 'soapbox/features/ui/components/accordion';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import { expandPublicTimeline } from '../../actions/timelines';
import { connectPublicStream } from '../../actions/streaming';
import { Link } from 'react-router-dom';
@ -101,6 +102,7 @@ class CommunityTimeline extends React.PureComponent {
<HomeColumnHeader activeItem='fediverse' active={hasUnread} >
<ColumnSettingsContainer />
</HomeColumnHeader>
<PinnedHostsPicker />
{showExplanationBox && <div className='explanation-box'>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}

Wyświetl plik

@ -0,0 +1,51 @@
'use strict';
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { getSettings } from 'soapbox/actions/settings';
const mapStateToProps = state => {
const settings = getSettings(state);
return {
pinnedHosts: settings.getIn(['remote_timeline', 'pinnedHosts']),
};
};
class PinnedHostPicker extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
pinnedHosts: ImmutablePropTypes.orderedSet,
host: PropTypes.string,
};
render() {
const { pinnedHosts, host: activeHost } = this.props;
if (!pinnedHosts || pinnedHosts.isEmpty()) return null;
return (
<div>
<div className='pinned-hosts-picker'>
{pinnedHosts.map(host => (
<div className={classNames('pinned-host', { 'active': host === activeHost })} key={host}>
<Link to={`/timeline/${host}`}>{host}</Link>
</div>
))}
</div>
</div>
);
}
}
export default connect(mapStateToProps)(PinnedHostPicker);

Wyświetl plik

@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import HomeColumnHeader from '../../components/home_column_header';
import PinnedHostsPicker from './components/pinned_hosts_picker';
import IconButton from 'soapbox/components/icon_button';
import { expandRemoteTimeline } from '../../actions/timelines';
import { connectRemoteStream } from '../../actions/streaming';
@ -26,6 +27,7 @@ const mapStateToProps = (state, props) => {
onlyMedia,
hasUnread: state.getIn(['timelines', `${timelineId}${onlyMedia ? ':media' : ''}:${instance}`, 'unread']) > 0,
instance,
pinned: settings.getIn(['remote_timeline', 'pinnedHosts']).includes(instance),
};
};
@ -44,6 +46,7 @@ class RemoteTimeline extends React.PureComponent {
onlyMedia: PropTypes.bool,
timelineId: PropTypes.string,
instance: PropTypes.string.isRequired,
pinned: PropTypes.bool,
};
componentDidMount() {
@ -79,19 +82,20 @@ class RemoteTimeline extends React.PureComponent {
}
render() {
const { intl, hasUnread, onlyMedia, timelineId, instance } = this.props;
const { intl, hasUnread, onlyMedia, timelineId, instance, pinned } = this.props;
return (
<Column label={intl.formatMessage(messages.title)}>
<HomeColumnHeader activeItem='fediverse' active={hasUnread} />
<div className='timeline-filter-message'>
<PinnedHostsPicker host={instance} />
{!pinned && <div className='timeline-filter-message'>
<IconButton icon='close' onClick={this.handleCloseClick} />
<FormattedMessage
id='remote_timeline.filter_message'
defaultMessage='You are viewing the timeline of {instance}.'
values={{ instance }}
/>
</div>
</div>}
<StatusListContainer
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}

Wyświetl plik

@ -15,10 +15,9 @@ import {
import {
changeEmail,
changePassword,
fetchOAuthTokens,
revokeOAuthToken,
deleteAccount,
} from 'soapbox/actions/auth';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { fetchUserMfaSettings } from '../../actions/mfa';
import snackbar from 'soapbox/actions/snackbar';
import { changeSetting, getSettings } from 'soapbox/actions/settings';
@ -306,7 +305,7 @@ class AuthTokenList extends ImmutablePureComponent {
handleRevoke = id => {
return e => {
this.props.dispatch(revokeOAuthToken(id));
this.props.dispatch(revokeOAuthTokenById(id));
};
}

Wyświetl plik

@ -11,6 +11,7 @@ import { isStaff, isAdmin } from 'soapbox/utils/accounts';
import { isUserTouching } from 'soapbox/is_mobile';
import EmojiSelector from 'soapbox/components/emoji_selector';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -54,11 +55,13 @@ const messages = defineMessages({
const mapStateToProps = state => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
const instance = state.get('instance');
return {
me,
isStaff: account ? isStaff(account) : false,
isAdmin: account ? isAdmin(account) : false,
features: getFeatures(instance),
};
};
@ -103,6 +106,7 @@ class ActionBar extends React.PureComponent {
emojiSelectorFocused: PropTypes.bool,
handleEmojiSelectorExpand: PropTypes.func.isRequired,
handleEmojiSelectorUnfocus: PropTypes.func.isRequired,
features: PropTypes.object.isRequired,
};
static defaultProps = {
@ -146,16 +150,26 @@ class ActionBar extends React.PureComponent {
}
handleLikeButtonHover = e => {
if (!isUserTouching()) this.setState({ emojiSelectorVisible: true });
const { features } = this.props;
if (features.emojiReacts && !isUserTouching()) {
this.setState({ emojiSelectorVisible: true });
}
}
handleLikeButtonLeave = e => {
if (!isUserTouching()) this.setState({ emojiSelectorVisible: false });
const { features } = this.props;
if (features.emojiReacts && !isUserTouching()) {
this.setState({ emojiSelectorVisible: false });
}
}
handleLikeButtonClick = e => {
const { features } = this.props;
const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍';
if (isUserTouching()) {
if (features.emojiReacts && isUserTouching()) {
if (this.state.emojiSelectorVisible) {
this.handleReactClick(meEmojiReact)();
} else {
@ -278,7 +292,7 @@ class ActionBar extends React.PureComponent {
}
render() {
const { status, intl, me, isStaff, isAdmin, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorExpand, handleEmojiSelectorUnfocus } = this.props;
const { status, intl, me, isStaff, isAdmin, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorExpand, handleEmojiSelectorUnfocus, features } = this.props;
const { emojiSelectorVisible } = this.state;
const ownAccount = status.getIn(['account', 'id']) === me;
@ -391,7 +405,7 @@ class ActionBar extends React.PureComponent {
>
<EmojiSelector
onReact={this.handleReactClick}
visible={emojiSelectorVisible}
visible={features.emojiReacts && emojiSelectorVisible}
focused={emojiSelectorFocused}
onUnfocus={handleEmojiSelectorUnfocus}
/>

Wyświetl plik

@ -4,28 +4,27 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { makeGetRemoteInstance } from 'soapbox/selectors';
import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
import { openModal } from 'soapbox/actions/modal';
import { isAdmin } from 'soapbox/utils/accounts';
import { pinHost, unpinHost } from 'soapbox/actions/remote_timeline';
import { getSettings } from 'soapbox/actions/settings';
const getRemoteInstance = makeGetRemoteInstance();
const messages = defineMessages({
editFederation: { id: 'remote_instance.edit_federation', defaultMessage: 'Edit federation' },
pinHost: { id: 'remote_instance.pin_host', defaultMessage: 'Pin {host}' },
unpinHost: { id: 'remote_instance.unpin_host', defaultMessage: 'Unpin {host}' },
});
const mapStateToProps = (state, { host }) => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
const settings = getSettings(state);
return {
instance: state.get('instance'),
remoteInstance: getRemoteInstance(state, host),
isAdmin: isAdmin(account),
pinned: settings.getIn(['remote_timeline', 'pinnedHosts']).includes(host),
};
};
@ -38,40 +37,43 @@ class InstanceInfoPanel extends ImmutablePureComponent {
host: PropTypes.string.isRequired,
instance: ImmutablePropTypes.map,
remoteInstance: ImmutablePropTypes.map,
isAdmin: PropTypes.bool,
pinned: PropTypes.bool,
};
handleEditFederation = e => {
const { dispatch, host } = this.props;
dispatch(openModal('EDIT_FEDERATION', { host }));
handlePinHost = e => {
const { dispatch, host, pinned } = this.props;
if (!pinned) {
dispatch(pinHost(host));
} else {
dispatch(unpinHost(host));
}
}
makeMenu = () => {
const { intl } = this.props;
const { intl, host, pinned } = this.props;
return [{
text: intl.formatMessage(messages.editFederation),
action: this.handleEditFederation,
text: intl.formatMessage(pinned ? messages.unpinHost : messages.pinHost, { host }),
action: this.handlePinHost,
}];
}
render() {
const { remoteInstance, isAdmin } = this.props;
const { remoteInstance, pinned } = this.props;
const menu = this.makeMenu();
const icon = pinned ? 'thumb-tack' : 'globe-w';
return (
<div className='wtf-panel instance-federation-panel'>
<div className='wtf-panel-header'>
<i role='img' alt='gavel' className='fa fa-gavel wtf-panel-header__icon' />
<i role='img' alt={icon} className={`fa fa-${icon} wtf-panel-header__icon`} />
<span className='wtf-panel-header__label'>
<span><FormattedMessage id='remote_instance.federation_panel.heading' defaultMessage='Federation Restrictions' /></span>
<span>{remoteInstance.get('host')}</span>
</span>
{isAdmin && <div className='wtf-panel__menu'>
<div className='wtf-panel__menu'>
<DropdownMenu items={menu} icon='ellipsis-v' size={18} direction='right' />
</div>}
</div>
<div className='wtf-panel__content'>
<InstanceRestrictions remoteInstance={remoteInstance} />
</div>
</div>
</div>
);

Wyświetl plik

@ -0,0 +1,80 @@
'use strict';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { makeGetRemoteInstance } from 'soapbox/selectors';
import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
import { openModal } from 'soapbox/actions/modal';
import { isAdmin } from 'soapbox/utils/accounts';
const getRemoteInstance = makeGetRemoteInstance();
const messages = defineMessages({
editFederation: { id: 'remote_instance.edit_federation', defaultMessage: 'Edit federation' },
});
const mapStateToProps = (state, { host }) => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
return {
instance: state.get('instance'),
remoteInstance: getRemoteInstance(state, host),
isAdmin: isAdmin(account),
};
};
export default @connect(mapStateToProps, null, null, { forwardRef: true })
@injectIntl
class InstanceModerationPanel extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
host: PropTypes.string.isRequired,
instance: ImmutablePropTypes.map,
remoteInstance: ImmutablePropTypes.map,
isAdmin: PropTypes.bool,
};
handleEditFederation = e => {
const { dispatch, host } = this.props;
dispatch(openModal('EDIT_FEDERATION', { host }));
}
makeMenu = () => {
const { intl } = this.props;
return [{
text: intl.formatMessage(messages.editFederation),
action: this.handleEditFederation,
}];
}
render() {
const { remoteInstance, isAdmin } = this.props;
const menu = this.makeMenu();
return (
<div className='wtf-panel instance-federation-panel'>
<div className='wtf-panel-header'>
<i role='img' alt='gavel' className='fa fa-gavel wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>
<span><FormattedMessage id='remote_instance.federation_panel.heading' defaultMessage='Federation Restrictions' /></span>
</span>
{isAdmin && <div className='wtf-panel__menu'>
<DropdownMenu items={menu} icon='ellipsis-v' size={18} direction='right' />
</div>}
</div>
<div className='wtf-panel__content'>
<InstanceRestrictions remoteInstance={remoteInstance} />
</div>
</div>
);
}
}

Wyświetl plik

@ -38,6 +38,7 @@ const LinkFooter = ({ onOpenHotkeys, account, onClickLogOut }) => (
{isAdmin(account) && <li><a href='/pleroma/admin'><FormattedMessage id='navigation_bar.admin_settings' defaultMessage='AdminFE' /></a></li>}
{isAdmin(account) && <li><Link to='/soapbox/config'><FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' /></Link></li>}
<li><Link to='/settings/import'><FormattedMessage id='navigation_bar.import_data' defaultMessage='Import data' /></Link></li>
<li><Link to='/settings/aliases'><FormattedMessage id='navigation_bar.account_aliases' defaultMessage='Account aliases' /></Link></li>
<li><a href='#' onClick={onOpenHotkeys}><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></a></li>
</>}
<li><Link to='/about'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></Link></li>
@ -49,7 +50,7 @@ const LinkFooter = ({ onOpenHotkeys, account, onClickLogOut }) => (
id='getting_started.open_source_notice'
defaultMessage='{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).'
values={{
code_name: sourceCode.name,
code_name: sourceCode.displayName,
code_link: <a href={sourceCode.url} rel='noopener' target='_blank'>{sourceCode.repository}</a>,
code_version: sourceCode.version,
}}

Wyświetl plik

@ -15,6 +15,7 @@ import Icon from '../../../components/icon';
import ThemeToggle from '../../ui/components/theme_toggle_container';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { isStaff } from 'soapbox/utils/accounts';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
post: { id: 'tabs_bar.post', defaultMessage: 'Post' },
@ -32,6 +33,7 @@ class TabsBar extends React.PureComponent {
dashboardCount: PropTypes.number,
notificationCount: PropTypes.number,
chatsCount: PropTypes.number,
features: PropTypes.object.isRequired,
}
state = {
@ -52,7 +54,7 @@ class TabsBar extends React.PureComponent {
}
getNavLinks() {
const { intl: { formatMessage }, logo, account, dashboardCount, notificationCount, chatsCount } = this.props;
const { intl: { formatMessage }, logo, account, dashboardCount, notificationCount, chatsCount, features } = this.props;
const links = [];
if (logo) {
links.push(
@ -73,7 +75,7 @@ class TabsBar extends React.PureComponent {
<span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span>
</NavLink>);
}
if (account) {
if (features.chats && account) {
links.push(
<NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'>
<IconWithCounter icon='comment' count={chatsCount} />
@ -155,12 +157,15 @@ const mapStateToProps = state => {
const me = state.get('me');
const reportsCount = state.getIn(['admin', 'openReports']).count();
const approvalCount = state.getIn(['admin', 'awaitingApproval']).count();
const instance = state.get('instance');
return {
account: state.getIn(['accounts', me]),
logo: getSoapboxConfig(state).get('logo'),
notificationCount: state.getIn(['notifications', 'unread']),
chatsCount: state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0),
dashboardCount: reportsCount + approvalCount,
features: getFeatures(instance),
};
};

Wyświetl plik

@ -1,25 +1,10 @@
import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { createSelector } from 'reselect';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { makeGetStatusIds } from 'soapbox/selectors';
import { debounce } from 'lodash';
import { dequeueTimeline } from 'soapbox/actions/timelines';
import { scrollTopTimeline } from '../../../actions/timelines';
import { getSettings } from 'soapbox/actions/settings';
import { shouldFilter } from 'soapbox/utils/timelines';
const makeGetStatusIds = () => createSelector([
(state, { type }) => getSettings(state).get(type, ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableOrderedSet()),
(state) => state.get('statuses'),
(state) => state.get('me'),
], (columnSettings, statusIds, statuses, me) => {
return statusIds.filter(id => {
const status = statuses.get(id);
if (!status) return true;
return !shouldFilter(status, columnSettings);
});
});
const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();

Wyświetl plik

@ -41,6 +41,7 @@ import Icon from 'soapbox/components/icon';
import { isStaff, isAdmin } from 'soapbox/utils/accounts';
import ProfileHoverCard from 'soapbox/components/profile_hover_card';
import { getAccessToken } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import {
Status,
@ -97,6 +98,7 @@ import {
ScheduledStatuses,
UserIndex,
FederationRestrictions,
Aliases,
} from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -113,6 +115,7 @@ const messages = defineMessages({
const mapStateToProps = state => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
const instance = state.get('instance');
return {
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
@ -120,6 +123,7 @@ const mapStateToProps = state => {
streamingUrl: state.getIn(['instance', 'urls', 'streaming_api']),
me,
account,
features: getFeatures(instance),
};
};
@ -258,6 +262,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/settings/preferences' page={DefaultPage} component={Preferences} content={children} />
<WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} />
<WrappedRoute path='/settings/import' page={DefaultPage} component={ImportData} content={children} />
<WrappedRoute path='/settings/aliases' page={DefaultPage} component={Aliases} content={children} />
<WrappedRoute path='/backups' page={DefaultPage} component={Backups} content={children} />
<WrappedRoute path='/soapbox/config' page={DefaultPage} component={SoapboxConfig} content={children} />
@ -297,6 +302,7 @@ class UI extends React.PureComponent {
me: SoapboxPropTypes.me,
streamingUrl: PropTypes.string,
account: PropTypes.object,
features: PropTypes.object.isRequired,
};
state = {
@ -406,7 +412,7 @@ class UI extends React.PureComponent {
});
componentDidMount() {
const { account } = this.props;
const { account, features } = this.props;
if (!account) return;
window.addEventListener('resize', this.handleResize, { passive: true });
@ -427,7 +433,10 @@ class UI extends React.PureComponent {
if (account) {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
this.props.dispatch(fetchChats());
if (features.chats) {
this.props.dispatch(fetchChats());
}
if (isStaff(account)) {
this.props.dispatch(fetchReports({ state: 'open' }));
@ -572,7 +581,7 @@ class UI extends React.PureComponent {
}
render() {
const { streamingUrl } = this.props;
const { streamingUrl, features } = this.props;
const { draggingOver, mobile } = this.state;
const { intl, children, location, dropdownMenuIsOpen, me } = this.props;
@ -630,7 +639,7 @@ class UI extends React.PureComponent {
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
{me && <SidebarMenu />}
{me && !mobile && (
{me && features.chats && !mobile && (
<BundleContainer fetchComponent={ChatPanes}>
{Component => <Component />}
</BundleContainer>

Wyświetl plik

@ -249,3 +249,7 @@ export function UserIndex() {
export function FederationRestrictions() {
return import(/* webpackChunkName: "features/federation_restrictions" */'../../federation_restrictions');
}
export function Aliases() {
return import(/* webpackChunkName: "features/aliases" */'../../aliases');
}

Wyświetl plik

@ -42,7 +42,8 @@
"account.share": "Udostępnij profil @{name}",
"account.show_reblogs": "Pokazuj podbicia od @{name}",
"account.subscribe": "Subskrybuj wpisy @{name}",
"account.unblock": "Odblokuj @{name}",
"account.subscribed": "Subscribed",
"account.unblock": "Zasubskrybowano",
"account.unblock_domain": "Odblokuj domenę {domain}",
"account.unendorse": "Przestań polecać",
"account.unfollow": "Przestań śledzić",
@ -50,8 +51,6 @@
"account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}",
"account.unsubscribe": "Przestań subskrybować wpisy @{name}",
"account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.",
"auth.invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło",
"auth.logged_out": "Wylogowano.",
"admin.awaiting_approval.approved_message": "Przyjęto {acct}!",
"admin.awaiting_approval.empty_message": "Nikt nie oczekuje przyjęcia. Gdy zarejestruje się nowy użytkownik, możesz zatwierdzić go tutaj.",
"admin.awaiting_approval.rejected_message": "Odrzucono {acct}!",
@ -95,7 +94,7 @@
"admin.users.actions.promote_to_admin": "Mianuj @{name} administratorem",
"admin.users.actions.promote_to_admin_message": "Mianowano @{acct} administratorem",
"admin.users.actions.promote_to_moderator": "Mianuj @{name} moderatorem",
"admin.users.actions.promote_to_moderatorem_message": "Mianowano @{acct} moderatorem",
"admin.users.actions.promote_to_moderator_message": "Mianowano @{acct} moderatorem",
"admin.users.actions.unverify_user": "Cofnij weryfikację @{name}",
"admin.users.actions.verify_user": "Weryfikuj @{name}",
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
@ -110,13 +109,14 @@
"alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
"alert.unexpected.return_home": "Wróć na stronę główną",
"alert.unexpected.title": "O nie!",
"audio.close": "Zamknij dźwięk",
"audio.expand": "Rozwiń dźwięk",
"audio.hide": "Ukryj dźwięk",
"audio.mute": "Wycisz",
"audio.pause": "Pauzuj",
"audio.play": "Odtwórz",
"audio.unmute": "Cofnij wyciszenie",
"aliases.account.add": "Utwórz alias",
"aliases.account_label": "Stare konto:",
"aliases.aliases_list_delete": "Odłącz alias",
"aliases.search": "Szukaj swojego starego konta",
"aliases.success.add": "Pomyślnie utworzono alias konta",
"aliases.success.remove": "Pomyślnie usunięto alias konta",
"auth.invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło",
"auth.logged_out": "Wylogowano.",
"backups.actions.create": "Utwórz kopię zapasową",
"backups.empty_message": "Nie znaleziono kopii zapasaowych. {action}",
"backups.empty_message.action": "Chcesz utworzyć?",
@ -143,14 +143,20 @@
"column.admin.moderation_log": "Dziennik moderacyjny",
"column.admin.reports": "Zgłoszenia",
"column.admin.reports.menu.moderation_log": "Dziennik moderacji",
"column.aliases": "Aliasy kont",
"column.aliases.create_error": "Błąd tworzenia aliasu",
"column.aliases.delete": "Usuń",
"column.aliases.delete_error": "Błąd usuwania aliasu",
"column.aliases.subheading_add_new": "Dodaj nowy alias",
"column.aliases.subheading_aliases": "Istniejące aliasy",
"column.backups": "Kopie zapasowe",
"column.blocks": "Zablokowani użytkownicy",
"column.bookmarks": "Załadki",
"column.chats": "Rozmowy",
"column.community": "Lokalna oś czasu",
"column.crypto_donate": "Przekaż kryptowalutę",
"column.direct": "Wiadomości bezpośrednie",
"column.domain_blocks": "Ukryte domeny",
"column.crypto_donate": "Przekaż kryptowalutę",
"column.edit_profile": "Edytuj profil",
"column.federation_restrictions": "Ograniczenia federacji",
"column.filters": "Wyciszone słowa",
@ -195,6 +201,7 @@
"column_header.hide_settings": "Ukryj ustawienia",
"column_header.show_settings": "Pokaż ustawienia",
"community.column_settings.media_only": "Tylko zawartość multimedialna",
"compose.invalid_schedule": "Musisz zaplanować wpis przynajmniej 5 minut wcześniej.",
"compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.",
"compose_form.direct_message_warning_learn_more": "Dowiedz się więcej",
"compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
@ -250,10 +257,11 @@
"confirmations.reply.message": "W ten sposób utracisz wpis który obecnie tworzysz. Czy na pewno chcesz to zrobić?",
"confirmations.unfollow.confirm": "Przestań śledzić",
"confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
"crypto_donate_panel.actions.more": "Naciśnij, aby zobaczyć {count} more {count, plural, one {portfel} few {portfele} many {portfeli} other {portfeli}}",
"crypto_donate.explanation_box.message": "{siteTitle} przyjmuje darowizny w kryptowalutach. Możesz wysłać darowiznę na jeden z poniższych adresów. Dziękujemy za Wasze wsparcie!",
"crypto_donate_panel.intro.message": "{siteTitle} przyjmuje darowizny w kryptowalutach, aby utrzymać naszą usługę. Dziękujemy za Wasze wsparcie!",
"crypto_donate.explanation_box.title": "Przeaż darowiznę w kryptowalutach",
"crypto_donate_panel.actions.more": "Naciśnij, aby zobaczyć {count} more {count, plural, one {portfel} few {portfele} many {portfeli} other {portfeli}}",
"crypto_donate_panel.heading": "Przekaż kryptowalutę",
"crypto_donate_panel.intro.message": "{siteTitle} przyjmuje darowizny w kryptowalutach, aby utrzymać naszą usługę. Dziękujemy za Wasze wsparcie!",
"datepicker.hint": "Zaplanowano publikację na…",
"donate": "Przekaż darowiznę",
"donate_crypto": "Przekaż kryptowalutę",
@ -277,7 +285,7 @@
"edit_profile.fields.meta_fields_label": "Metadane profilu",
"edit_profile.fields.stranger_notifications_label": "Blokuj powiadomienia od nieznajomych",
"edit_profile.fields.verified_display_name": "Zweryfikowani użytkownicy nie mogą zmieniać nazwy wyświetlanej",
"edit_profile.hints.accepts_email_list_label": "Otrzymuj wiadomości i nowości marketingowe",
"edit_profile.hints.accepts_email_list": "Otrzymuj wiadomości i nowości marketingowe.",
"edit_profile.hints.avatar": "PNG, GIF lub JPG. Maksymalnie 2 MB. Zostanie zmniejszony do 400x400px",
"edit_profile.hints.bot": "To konto podejmuje głównie zautomatyzowane działania i może nie być nadzorowane",
"edit_profile.hints.header": "PNG, GIF lub JPG. Maksymalnie 2 MB. Zostanie zmniejszony do 1500x500px",
@ -306,6 +314,8 @@
"emoji_button.travel": "Podróże i miejsca",
"empty_column.account_timeline": "Brak wpisów tutaj!",
"empty_column.account_unavailable": "Profil niedostępny",
"empty_column.aliases": "Nie utworzyłeś(-aś) jeszcze żadnego aliasu konta.",
"empty_column.aliases.suggestions": "Brak propozycji kont dla podanej frazy.",
"empty_column.blocks": "Nie zablokowałeś(-aś) jeszcze żadnego użytkownika.",
"empty_column.bookmarks": "Nie masz jeszcze żadnej zakładki. Kiedy dodasz jakąś, pojawi się ona tutaj.",
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
@ -334,11 +344,14 @@
"federation_restriction.full_media_removal": "Pełne usunięcie mediów",
"federation_restriction.media_nsfw": "Załączniki oznaczone jako NSFW",
"federation_restriction.partial_media_removal": "Częściowe usunięcie mediów",
"federation_restrictions.empty_message": "{siteTitle} nie nakłada restrykcji na żadne instancje.",
"federation_restrictions.explanation_box.message": "Zwykle serwery w Fediwersum mogą swobodnie porozumiewać się. {siteTitle} nakłada pewne ograniczenia na następujące serwery.",
"federation_restrictions.explanation_box.title": "Zasady dla poszczególnych instancji",
"federation_restrictions.not_disclosed_message": "{siteTitle} nie udostępnia informacji o ograniczeniach federacji przez API.",
"fediverse_tab.explanation_box.dismiss": "Nie pokazuj ponownie",
"fediverse_tab.explanation_box.explanation": "{site_title} jest częścią Fediwersum, sieci społecznościowej na którą składają się tysiące niezależnie funkcjonujących stron (serwerów). Wpisy które tu widzisz pochodzą z serwerów podmiotów trzecich. Możesz do woli wchodzić z nimi w interakcje lub blokować serwery których nie lubisz. Zwracaj uwagę na pełną nazwę użytkownika po znaku @, aby wiedzieć z jakiego serwera pochodzi on. Aby widzieć tylko wpisy z {site_title}, odwiedź kartę {local}.",
"fediverse_tab.explanation_box.title": "Czym jest Fediverse?",
"filters.added": "Dodano filtr.",
"filters.context_header": "Konteksty filtru",
"filters.context_hint": "Jedno lub więcej miejsc, gdzie filtr powinien zostać zaaplikowany",
"filters.filters_list_context_label": "Konteksty filtru:",
@ -348,12 +361,12 @@
"filters.filters_list_hide": "Ukrywaj",
"filters.filters_list_phrase_label": "Słowo kluczowe lub fraza:",
"filters.filters_list_whole-word": "Całe słowo",
"filters.added": "Dodano filtr.",
"filters.deleted": "Usunięto filtr.",
"filters.removed": "Usunięto filtr.",
"follow_request.authorize": "Autoryzuj",
"follow_request.reject": "Odrzuć",
"forms.copy": "Kopiuj",
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
"group.detail.archived_group": "Zarchiwizowana grupa",
"group.members.empty": "Ta grupa nie ma żadnych członków.",
"group.removed_accounts.empty": "Ta grupa nie ma żadnych usuniętych kont.",
"groups.card.join": "Dołącz",
@ -362,13 +375,21 @@
"groups.card.roles.member": "Jesteś członkiem",
"groups.card.view": "Zobacz",
"groups.create": "Utwórz grupę",
"groups.detail.role_admin": "Jesteś administratorem",
"groups.edit": "Edytuj",
"groups.form.coverImage": "Wyślij obraz baneru (nieobowiązkowe)",
"groups.form.coverImageChange": "Wybrano obraz baneru",
"groups.form.create": "Utwórz grupę",
"groups.form.description": "Opis",
"groups.form.title": "Tytuł",
"groups.form.update": "Aktualizuj grupę",
"groups.join": "Dołącz do grupy",
"groups.leave": "Opuść grupę",
"groups.removed_accounts": "Usunięte konta",
"groups.sidebar-panel.item.no_recent_activity": "Brak ostatniej aktywności",
"groups.sidebar-panel.item.view": "nowe wpisy",
"groups.sidebar-panel.show_all": "Pokaż wszystkie",
"groups.sidebar-panel.title": "Grupy do których należysz",
"groups.tab_admin": "Zarządzaj",
"groups.tab_featured": "Wyróżnione",
"groups.tab_member": "Członek",
@ -407,9 +428,7 @@
"keyboard_shortcuts.back": "aby cofnąć się",
"keyboard_shortcuts.blocked": "aby przejść do listy zablokowanych użytkowników",
"keyboard_shortcuts.boost": "aby podbić wpis",
"keyboard_shortcuts.column": "aby przejść do wpisu z jednej z kolumn",
"keyboard_shortcuts.compose": "aby przejść do pola tworzenia wpisu",
"keyboard_shortcuts.direct": "aby otworzyć kolumnę wiadomości bezpośrednich",
"keyboard_shortcuts.down": "aby przejść na dół listy",
"keyboard_shortcuts.enter": "aby otworzyć wpis",
"keyboard_shortcuts.favourite": "aby dodać do ulubionych",
@ -428,7 +447,6 @@
"keyboard_shortcuts.reply": "aby odpowiedzieć",
"keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia",
"keyboard_shortcuts.search": "aby przejść do pola wyszukiwania",
"keyboard_shortcuts.start": "aby otworzyć kolumnę „Rozpocznij”",
"keyboard_shortcuts.toggle_hidden": "aby wyświetlić lub ukryć wpis spod CW",
"keyboard_shortcuts.toggle_sensitivity": "by pokazać/ukryć multimedia",
"keyboard_shortcuts.toot": "aby utworzyć nowy wpis",
@ -484,8 +502,10 @@
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
"navigation_bar.account_aliases": "Aliasy kont",
"navigation_bar.admin_settings": "Ustawienia administracyjne",
"navigation_bar.blocks": "Zablokowani użytkownicy",
"navigation_bar.bookmarks": "Zakładki",
"navigation_bar.compose": "Utwórz nowy wpis",
"navigation_bar.domain_blocks": "Ukryte domeny",
"navigation_bar.favourites": "Ulubione",
@ -494,7 +514,9 @@
"navigation_bar.import_data": "Importuj dane",
"navigation_bar.info": "Szczegółowe informacje",
"navigation_bar.keyboard_shortcuts": "Skróty klawiszowe",
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Wyloguj",
"navigation_bar.messages": "Messages",
"navigation_bar.mutes": "Wyciszeni użytkownicy",
"navigation_bar.pins": "Przypięte wpisy",
"navigation_bar.preferences": "Preferencje",
@ -613,6 +635,7 @@
"relative_time.minutes": "{number} min.",
"relative_time.seconds": "{number} s.",
"remote_instance.edit_federation": "Edytuj federację",
"remote_instance.federation_panel.heading": "Federation Restrictions",
"remote_instance.federation_panel.no_restrictions_message": "{siteTitle} nie nakłada ograniczeń na {host}.",
"remote_instance.federation_panel.restricted_message": "{siteTitle} blokuje wszystkie aktywności z {host}.",
"remote_instance.federation_panel.some_restrictions_message": "{siteTitle} nakłada pewne ograniczenia na {host}.",
@ -641,8 +664,6 @@
"search_results.hashtags": "Hashtagi",
"search_results.statuses": "Wpisy",
"search_results.top": "Góra",
"search_results.total": "{count, number} {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}",
"search_results.total.has_more": "{count, number} Ponad {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}",
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
"security.confirm.fail": "Nieprawidłowy kod lub hasło. Spróbuj ponownie.",
"security.delete_account.fail": "Nie udało się usunąć konta.",
@ -788,7 +809,6 @@
"upload_error.limit": "Przekroczono limit plików do wysłania.",
"upload_error.poll": "Dołączanie plików nie dozwolone z głosowaniami.",
"upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
"upload_form.focus": "Zmień podgląd",
"upload_form.preview": "Podgląd",
"upload_form.undo": "Usuń",
"upload_progress.label": "Wysyłanie…",

Wyświetl plik

@ -8,6 +8,7 @@ import FeaturesPanel from 'soapbox/features/ui/components/features_panel';
import LinkFooter from 'soapbox/features/ui/components/link_footer';
import { getFeatures } from 'soapbox/utils/features';
import InstanceInfoPanel from 'soapbox/features/ui/components/instance_info_panel';
import InstanceModerationPanel from 'soapbox/features/ui/components/instance_moderation_panel';
import { federationRestrictionsDisclosed } from 'soapbox/utils/state';
import { isAdmin } from 'soapbox/utils/accounts';
@ -38,7 +39,8 @@ class RemoteInstancePage extends ImmutablePureComponent {
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
<div className='columns-area__panels__pane__inner'>
{(disclosed || isAdmin) && <InstanceInfoPanel host={host} />}
<InstanceInfoPanel host={host} />
{(disclosed || isAdmin) && <InstanceModerationPanel host={host} />}
</div>
</div>

Wyświetl plik

@ -65,17 +65,20 @@ describe('auth reducer', () => {
describe('AUTH_LOGGED_OUT', () => {
it('deletes the user', () => {
const action = { type: AUTH_LOGGED_OUT, accountId: '1234' };
const action = {
type: AUTH_LOGGED_OUT,
account: fromJS({ url: 'https://gleasonator.com/users/alex' }),
};
const state = fromJS({
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
});
const expected = fromJS({
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
});
const result = reducer(state, action);
@ -84,16 +87,20 @@ describe('auth reducer', () => {
it('sets `me` to the next available user', () => {
const state = fromJS({
me: '1234',
me: 'https://gleasonator.com/users/alex',
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
});
const action = { type: AUTH_LOGGED_OUT, accountId: '1234' };
const action = {
type: AUTH_LOGGED_OUT,
account: fromJS({ url: 'https://gleasonator.com/users/alex' }),
};
const result = reducer(state, action);
expect(result.get('me')).toEqual('5678');
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
});
});
@ -102,11 +109,11 @@ describe('auth reducer', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234' },
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const expected = fromJS({
'1234': { id: '1234', access_token: 'ABCDEFG' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
});
const result = reducer(undefined, action);
@ -117,7 +124,7 @@ describe('auth reducer', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234' },
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
@ -125,7 +132,12 @@ describe('auth reducer', () => {
});
const expected = fromJS({
'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG', account: '1234' },
'ABCDEFG': {
token_type: 'Bearer',
access_token: 'ABCDEFG',
account: '1234',
me: 'https://gleasonator.com/users/alex',
},
});
const result = reducer(state, action);
@ -136,49 +148,82 @@ describe('auth reducer', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234' },
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const result = reducer(undefined, action);
expect(result.get('me')).toEqual('1234');
expect(result.get('me')).toEqual('https://gleasonator.com/users/alex');
});
it('leaves `me` alone if already set', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234' },
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({ me: '5678' });
const state = fromJS({ me: 'https://gleasonator.com/users/benis' });
const result = reducer(state, action);
expect(result.get('me')).toEqual('5678');
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
});
it('deletes mismatched users', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234' },
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
users: {
'4567': { id: '4567', access_token: 'ABCDEFG' },
'8901': { id: '1234', access_token: 'ABCDEFG' },
'5432': { id: '5432', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/mk': { id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' },
'https://gleasonator.com/users/curtis': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' },
'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
});
const expected = fromJS({
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5432': { id: '5432', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
});
const result = reducer(state, action);
expect(result.get('users')).toEqual(expected);
});
it('upgrades from an ID to a URL', () => {
const action = {
type: VERIFY_CREDENTIALS_SUCCESS,
token: 'ABCDEFG',
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
me: '1234',
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5432': { id: '5432', access_token: 'HIJKLMN' },
},
tokens: {
'ABCDEFG': { access_token: 'ABCDEFG', account: '1234' },
},
});
const expected = fromJS({
me: 'https://gleasonator.com/users/alex',
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'5432': { id: '5432', access_token: 'HIJKLMN' },
},
tokens: {
'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' },
},
});
const result = reducer(state, action);
expect(result).toEqual(expected);
});
});
describe('VERIFY_CREDENTIALS_FAIL', () => {
@ -207,13 +252,13 @@ describe('auth reducer', () => {
it('should delete any users associated with the failed token', () => {
const state = fromJS({
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
});
const expected = fromJS({
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
});
const action = {
@ -228,10 +273,10 @@ describe('auth reducer', () => {
it('should reassign `me` to the next in line', () => {
const state = fromJS({
me: '1234',
me: 'https://gleasonator.com/users/alex',
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5678': { id: '5678', access_token: 'HIJKLMN' },
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
});
@ -242,21 +287,25 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('me')).toEqual('5678');
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
});
});
describe('SWITCH_ACCOUNT', () => {
it('sets the value of `me`', () => {
const action = { type: SWITCH_ACCOUNT, accountId: '5678' };
const action = {
type: SWITCH_ACCOUNT,
account: fromJS({ url: 'https://gleasonator.com/users/benis' }),
};
const result = reducer(undefined, action);
expect(result.get('me')).toEqual('5678');
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
});
});
describe('ME_FETCH_SKIP', () => {
it('sets `me` to null', () => {
const state = fromJS({ me: '1234' });
const state = fromJS({ me: 'https://gleasonator.com/users/alex' });
const action = { type: ME_FETCH_SKIP };
const result = reducer(state, action);
expect(result.get('me')).toEqual(null);

Wyświetl plik

@ -0,0 +1,35 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
ALIASES_SUGGESTIONS_READY,
ALIASES_SUGGESTIONS_CLEAR,
ALIASES_SUGGESTIONS_CHANGE,
} from '../actions/aliases';
const initialState = ImmutableMap({
suggestions: ImmutableMap({
value: '',
loaded: false,
items: ImmutableList(),
}),
});
export default function aliasesReducer(state = initialState, action) {
switch(action.type) {
case ALIASES_SUGGESTIONS_CHANGE:
return state
.setIn(['suggestions', 'value'], action.value)
.setIn(['suggestions', 'loaded'], false);
case ALIASES_SUGGESTIONS_READY:
return state
.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)))
.setIn(['suggestions', 'loaded'], true);
case ALIASES_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
map.set('loaded', false);
}));
default:
return state;
}
}

Wyświetl plik

@ -9,6 +9,7 @@ import {
} from '../actions/auth';
import { ME_FETCH_SKIP } from '../actions/me';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { validId, isURL } from 'soapbox/utils/auth';
const defaultState = ImmutableMap({
app: ImmutableMap(),
@ -17,8 +18,6 @@ const defaultState = ImmutableMap({
me: null,
});
const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined';
const getSessionUser = () => {
const id = sessionStorage.getItem('soapbox:auth:me');
return validId(id) ? id : undefined;
@ -39,14 +38,24 @@ const validUser = user => {
// Finds the first valid user in the state
const firstValidUser = state => state.get('users', ImmutableMap()).find(validUser);
// For legacy purposes. IDs get upgraded to URLs further down.
const getUrlOrId = user => {
try {
const { id, url } = user.toJS();
return url || id;
} catch {
return null;
}
};
// If `me` doesn't match an existing user, attempt to shift it.
const maybeShiftMe = state => {
const users = state.get('users', ImmutableMap());
const me = state.get('me');
const user = state.getIn(['users', me]);
if (!validUser(users.get(me))) {
if (!validUser(user)) {
const nextUser = firstValidUser(state);
return state.set('me', nextUser ? nextUser.get('id') : null);
return state.set('me', getUrlOrId(nextUser));
} else {
return state;
}
@ -59,7 +68,7 @@ const setSessionUser = state => state.update('me', null, me => {
state.getIn(['users', me]),
]).find(validUser);
return user ? user.get('id') : null;
return getUrlOrId(user);
});
// Upgrade the initial state
@ -83,13 +92,22 @@ const migrateLegacy = state => {
});
};
const isUpgradingUrlId = state => {
const me = state.get('me');
const user = state.getIn(['users', me]);
return validId(me) && user && !isURL(me);
};
// Checks the state and makes it valid
const sanitizeState = state => {
// Skip sanitation during ID to URL upgrade
if (isUpgradingUrlId(state)) return state;
return state.withMutations(state => {
// Remove invalid users, ensure ID match
state.update('users', ImmutableMap(), users => (
users.filter((user, id) => (
validUser(user) && user.get('id') === id
users.filter((user, url) => (
validUser(user) && user.get('url') === url
))
));
// Remove mismatched tokens
@ -135,33 +153,49 @@ const importToken = (state, token) => {
const upgradeLegacyId = (state, account) => {
if (localState) return state;
return state.withMutations(state => {
state.update('me', null, me => me === '_legacy' ? account.id : me);
state.update('me', null, me => me === '_legacy' ? account.url : me);
state.deleteIn(['users', '_legacy']);
});
// TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage?
// By this point it's probably safe, but we'll leave it just in case.
};
// Users are now stored by their ActivityPub ID instead of their
// primary key to support auth against multiple hosts.
const upgradeNonUrlId = (state, account) => {
const me = state.get('me');
if (isURL(me)) return state;
return state.withMutations(state => {
state.update('me', null, me => me === account.id ? account.url : me);
state.deleteIn(['users', account.id]);
});
};
// Returns a predicate function for filtering a mismatched user/token
const userMismatch = (token, account) => {
return (user, id) => {
return (user, url) => {
const sameToken = user.get('access_token') === token;
const differentId = id !== account.id || user.get('id') !== account.id;
const differentUrl = url !== account.url || user.get('url') !== account.url;
const differentId = user.get('id') !== account.id;
return sameToken && differentId;
return sameToken && (differentUrl || differentId);
};
};
const importCredentials = (state, token, account) => {
return state.withMutations(state => {
state.setIn(['users', account.id], ImmutableMap({
state.setIn(['users', account.url], ImmutableMap({
id: account.id,
access_token: token,
url: account.url,
}));
state.setIn(['tokens', token, 'account'], account.id);
state.setIn(['tokens', token, 'me'], account.url);
state.update('users', ImmutableMap(), users => users.filterNot(userMismatch(token, account)));
state.update('me', null, me => me || account.id);
state.update('me', null, me => me || account.url);
upgradeLegacyId(state, account);
upgradeNonUrlId(state, account);
});
};
@ -173,10 +207,12 @@ const deleteToken = (state, token) => {
});
};
const deleteUser = (state, accountId) => {
const deleteUser = (state, account) => {
const accountUrl = account.get('url');
return state.withMutations(state => {
state.update('users', ImmutableMap(), users => users.delete(accountId));
state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('account') === accountId));
state.update('users', ImmutableMap(), users => users.delete(accountUrl));
state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('me') === accountUrl));
maybeShiftMe(state);
});
};
@ -186,17 +222,17 @@ const reducer = (state, action) => {
case AUTH_APP_CREATED:
return state.set('app', fromJS(action.app));
case AUTH_APP_AUTHORIZED:
return state.update('app', ImmutableMap(), app => app.merge(fromJS(action.app)));
return state.update('app', ImmutableMap(), app => app.merge(fromJS(action.token)));
case AUTH_LOGGED_IN:
return importToken(state, action.token);
case AUTH_LOGGED_OUT:
return deleteUser(state, action.accountId);
return deleteUser(state, action.account);
case VERIFY_CREDENTIALS_SUCCESS:
return importCredentials(state, action.token, action.account);
case VERIFY_CREDENTIALS_FAIL:
return action.error.response.status === 403 ? deleteToken(state, action.token) : state;
case SWITCH_ACCOUNT:
return state.set('me', action.accountId);
return state.set('me', action.account.get('url'));
case ME_FETCH_SKIP:
return state.set('me', null);
default:
@ -214,10 +250,14 @@ const validMe = state => {
// `me` has changed from one valid ID to another
const userSwitched = (oldState, state) => {
const stillValid = validMe(oldState) && validMe(state);
const didChange = oldState.get('me') !== state.get('me');
const me = state.get('me');
const oldMe = oldState.get('me');
return stillValid && didChange;
const stillValid = validMe(oldState) && validMe(state);
const didChange = oldMe !== me;
const userUpgradedUrl = state.getIn(['users', me, 'id']) === oldMe;
return stillValid && didChange && !userUpgradedUrl;
};
const maybeReload = (oldState, state, action) => {

Wyświetl plik

@ -53,6 +53,7 @@ import backups from './backups';
import admin_log from './admin_log';
import security from './security';
import scheduled_statuses from './scheduled_statuses';
import aliases from './aliases';
const appReducer = combineReducers({
dropdown_menu,
@ -107,6 +108,7 @@ const appReducer = combineReducers({
admin_log,
security,
scheduled_statuses,
aliases,
});
// Clear the state (mostly) when the user logs out

Wyświetl plik

@ -1,7 +1,7 @@
import {
FETCH_TOKENS_SUCCESS,
REVOKE_TOKEN_SUCCESS,
} from '../actions/auth';
} from '../actions/security';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({

Wyświetl plik

@ -6,6 +6,9 @@ import {
} from 'immutable';
import { getDomain } from 'soapbox/utils/accounts';
import ConfigDB from 'soapbox/utils/config_db';
import { getSettings } from 'soapbox/actions/settings';
import { shouldFilter } from 'soapbox/utils/timelines';
import { validId } from 'soapbox/utils/auth';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@ -205,15 +208,27 @@ export const makeGetReport = () => {
);
};
const getAuthUserIds = createSelector([
state => state.getIn(['auth', 'users'], ImmutableMap()),
], authUsers => {
return authUsers.reduce((ids, authUser) => {
try {
const id = authUser.get('id');
return validId(id) ? ids.add(id) : ids;
} catch {
return ids;
}
}, ImmutableOrderedSet());
});
export const makeGetOtherAccounts = () => {
return createSelector([
state => state.get('accounts'),
state => state.getIn(['auth', 'users']),
getAuthUserIds,
state => state.get('me'),
],
(accounts, authUsers, me) => {
return authUsers
.keySeq()
(accounts, authUserIds, me) => {
return authUserIds
.reduce((list, id) => {
if (id === me) return list;
const account = accounts.get(id);
@ -262,3 +277,16 @@ export const makeGetRemoteInstance = () => {
});
});
};
export const makeGetStatusIds = () => createSelector([
(state, { type, prefix }) => getSettings(state).get(prefix || type, ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableOrderedSet()),
(state) => state.get('statuses'),
(state) => state.get('me'),
], (columnSettings, statusIds, statuses, me) => {
return statusIds.filter(id => {
const status = statuses.get(id);
if (!status) return true;
return !shouldFilter(status, columnSettings);
});
});

Wyświetl plik

@ -1,14 +1,59 @@
import { List as ImmutableList } from 'immutable';
export const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined';
export const isURL = url => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export const parseBaseURL = url => {
try {
return new URL(url).origin;
} catch {
return '';
}
};
export const getLoggedInAccount = state => {
const me = state.get('me');
return state.getIn(['accounts', me]);
};
export const isLoggedIn = getState => {
return typeof getState().get('me') === 'string';
return validId(getState().get('me'));
};
export const getAppToken = state => state.getIn(['auth', 'app', 'access_token']);
export const getUserToken = (state, accountId) => {
return state.getIn(['auth', 'users', accountId, 'access_token']);
const accountUrl = state.getIn(['accounts', accountId, 'url']);
return state.getIn(['auth', 'users', accountUrl, 'access_token']);
};
export const getAccessToken = state => {
const me = state.get('me');
return getUserToken(state, me);
};
export const getAuthUserId = state => {
const me = state.getIn(['auth', 'me']);
return ImmutableList([
state.getIn(['auth', 'users', me, 'id']),
me,
]).find(validId);
};
export const getAuthUserUrl = state => {
const me = state.getIn(['auth', 'me']);
return ImmutableList([
state.getIn(['auth', 'users', me, 'url']),
me,
]).find(isURL);
};

Wyświetl plik

@ -33,6 +33,7 @@ const version = pkg => {
module.exports = {
name: pkg.name,
displayName: pkg.displayName,
url: pkg.repository.url,
repository: shortRepoName(pkg.repository.url),
version: version(pkg),

Wyświetl plik

@ -16,6 +16,7 @@ export const getFeatures = createSelector([
focalPoint: v.software === 'Mastodon' && gte(v.compatVersion, '2.3.0'),
importMutes: v.software === 'Pleroma' && gte(v.version, '2.2.0'),
emailList: f.includes('email_list'),
chats: v.software === 'Pleroma' && gte(v.version, '2.1.0'),
};
});

Wyświetl plik

@ -1,9 +1,9 @@
// Fonts from elsewhere
@import '~fork-awesome/css/fork-awesome.css';
@import '~fontsource-roboto/300.css';
@import '~fontsource-roboto/400.css';
@import '~fontsource-roboto/700.css';
@import '~fontsource-montserrat/800.css';
@import '~@fontsource/roboto/300.css';
@import '~@fontsource/roboto/400.css';
@import '~@fontsource/roboto/700.css';
@import '~@fontsource/montserrat/800.css';
@import 'mixins';
@import 'themes';
@ -84,6 +84,7 @@
@import 'components/datepicker';
@import 'components/remote-timeline';
@import 'components/federation-restrictions';
@import 'components/aliases';
// Holiday
@import 'holiday/halloween';

Wyświetl plik

@ -0,0 +1,107 @@
.aliases {
&__accounts {
overflow-y: auto;
.account__display-name {
&:hover strong {
text-decoration: none;
}
}
.account__avatar {
cursor: default;
}
&.empty-column-indicator {
min-height: unset;
overflow-y: unset;
}
}
&_search {
display: flex;
flex-direction: row;
margin: 10px;
.search__input {
padding: 7px 30px 6px 10px;
}
> label {
flex: 1 1;
}
> .search__icon .fa {
top: 8px;
right: 102px !important;
}
> .button {
width: 80px;
margin-left: 10px;
}
}
}
.aliases-settings-panel {
flex: 1;
.item-list article {
border-bottom: 1px solid var(--primary-text-color--faint);
&:last-child {
border-bottom: 0;
}
}
.alias__container {
padding: 20px;
display: flex;
justify-content: space-between;
font-size: 14px;
span.alias__list-label {
padding-right: 5px;
color: var(--primary-text-color--faint);
}
span.alias__list-value span {
padding-right: 5px;
text-transform: capitalize;
&::after {
content: ',';
}
&:last-of-type {
&::after {
content: '';
}
}
}
.alias__delete {
display: flex;
align-items: baseline;
cursor: pointer;
span.alias__delete-label {
color: var(--primary-text-color--faint);
font-size: 14px;
font-weight: 800;
}
.alias__delete-icon {
background: none;
color: var(--primary-text-color--faint);
padding: 0 5px;
margin: 0 auto;
font-size: 16px;
}
}
}
.slist--flex {
height: 100%;
}
}

Wyświetl plik

@ -297,6 +297,14 @@
height: 100%;
object-fit: cover;
}
> i.fa {
width: 100%;
color: hsla(var(--primary-text-color_hsl), 0.4);
font-size: 64px;
text-align: center;
line-height: 160px;
}
}
}

Wyświetl plik

@ -27,3 +27,44 @@
padding-right: 10px;
}
}
.pinned-hosts-picker {
margin-left: 10px;
display: inline-flex;
flex-wrap: wrap;
.pinned-host {
margin-right: 10px;
margin-bottom: 10px;
}
&:hover {
.pinned-host {
background: var(--background-color);
&:hover {
background: var(--brand-color--faint);
}
}
}
}
.pinned-host {
background: var(--background-color);
border-radius: 999px;
transition: 0.2s;
&.active {
background: var(--brand-color--faint);
}
a {
display: block;
color: var(--primary-text-color);
text-decoration: none;
padding: 5px 11px;
max-width: 115px;
overflow: hidden;
text-overflow: ellipsis;
}
}

Wyświetl plik

@ -1,5 +1,6 @@
{
"name": "soapbox-fe",
"displayName": "Soapbox FE",
"version": "1.3.0",
"description": "Soapbox frontend for Pleroma.",
"homepage": "https://soapbox.pub/",
@ -45,6 +46,8 @@
"@babel/preset-env": "^7.14.7",
"@babel/preset-react": "^7.14.5",
"@babel/runtime": "^7.14.6",
"@fontsource/montserrat": "^4.5.1",
"@fontsource/roboto": "^4.5.0",
"@popperjs/core": "^2.4.4",
"@welldone-software/why-did-you-render": "^6.2.0",
"array-includes": "^3.0.3",
@ -74,8 +77,6 @@
"escape-html": "^1.0.3",
"exif-js": "^2.3.0",
"file-loader": "^6.0.0",
"fontsource-montserrat": "^3.0.9",
"fontsource-roboto": "^3.0.3",
"fork-awesome": "https://github.com/alexgleason/Fork-Awesome#c23fd34246a9f33c4bf24ea095a4cf26e7abe265",
"html-webpack-harddisk-plugin": "^1.0.1",
"html-webpack-plugin": "^4.3.0",

Wyświetl plik

@ -1695,6 +1695,16 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@fontsource/montserrat@^4.5.1":
version "4.5.1"
resolved "https://registry.yarnpkg.com/@fontsource/montserrat/-/montserrat-4.5.1.tgz#64a33ffdb77bbc63484c0321710bed272cc5b16f"
integrity sha512-xdMkzsnFlzDt5Vj9+AWuUp4Vd9F3hYN2I0GTZZYscWN/bQ20wVOGtdWLO8Okfv7SZ9t/z39/EayAAt+f/8tAvw==
"@fontsource/roboto@^4.5.0":
version "4.5.0"
resolved "https://registry.yarnpkg.com/@fontsource/roboto/-/roboto-4.5.0.tgz#d6f925668ba6af46707f1040c43aff498ba204bb"
integrity sha512-ja4XYw/9kNRFM5Ndk9vwzHWsdBMXczyBazFkTXJQ74QQBnT0BbSsHn0pF60AU0Iznig1Wt9x3rADfG8LANvMpw==
"@formatjs/ecma402-abstract@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.4.0.tgz#ac6c17a8fffac43c6d68c849a7b732626d32654c"
@ -5760,16 +5770,6 @@ follow-redirects@^1.10.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
fontsource-montserrat@^3.0.9:
version "3.0.9"
resolved "https://registry.yarnpkg.com/fontsource-montserrat/-/fontsource-montserrat-3.0.9.tgz#ff614725c420839c4aefbca6f2b49c6224050991"
integrity sha512-kSE4GwWoEKJlIp0UyuoYtBvjKv5kwOTK7EvCwdQ7ukvKvgIK1LR5+GpZC5c/juonasNtFTKav67Zd7/C7m+a5w==
fontsource-roboto@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/fontsource-roboto/-/fontsource-roboto-3.0.3.tgz#99c312babeabce22b3e933b3edf2951d4508f4f7"
integrity sha512-kfsC9qAP6XhwnSDAhg2lhWeaUJfLGXZh7GcLxFiz/4lXdkV2pVhWv2Xp9ES3b3BHdc9UuPrWXXLOphzHIStcOw==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"