environments/review-scroll-pos-dnhc2t/deployments/154
commit
e4b95534dc
@ -0,0 +1,7 @@
|
||||
### Environment
|
||||
|
||||
* Soapbox version:
|
||||
* Backend (Mastodon, Pleroma, etc):
|
||||
* Browser/OS:
|
||||
|
||||
### Bug description
|
@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": "22",
|
||||
"username": "twoods",
|
||||
"acct": "twoods",
|
||||
"display_name": "Tiger Woods"
|
||||
}
|
||||
]
|
@ -0,0 +1,106 @@
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { normalizeAccount } from '../../normalizers';
|
||||
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
|
||||
|
||||
describe('submitAccountNote()', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('account_notes', { edit: { account_id: 1, comment: 'hello' } });
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onPost('/api/v1/accounts/1/note').reply(200, {});
|
||||
});
|
||||
});
|
||||
|
||||
it('post the note to the API', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' },
|
||||
{ type: 'MODAL_CLOSE', modalType: undefined },
|
||||
{ type: 'ACCOUNT_NOTE_SUBMIT_SUCCESS', relationship: {} },
|
||||
];
|
||||
await store.dispatch(submitAccountNote());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onPost('/api/v1/accounts/1/note').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' },
|
||||
{
|
||||
type: 'ACCOUNT_NOTE_SUBMIT_FAIL',
|
||||
error: new Error('Network Error'),
|
||||
},
|
||||
];
|
||||
await store.dispatch(submitAccountNote());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initAccountNoteModal()', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('relationships', { 1: { note: 'hello' } });
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('dispatches the proper actions', async() => {
|
||||
const account = normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
verified: true,
|
||||
});
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' },
|
||||
{ type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' },
|
||||
];
|
||||
await store.dispatch(initAccountNoteModal(account));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeAccountNoteComment()', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {});
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('dispatches the proper actions', async() => {
|
||||
const comment = 'hello world';
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_NOTE_CHANGE_COMMENT', comment },
|
||||
];
|
||||
await store.dispatch(changeAccountNoteComment(comment));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
@ -0,0 +1,132 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { normalizeAccount } from '../../normalizers';
|
||||
import { createAccount, fetchAccount } from '../accounts';
|
||||
|
||||
let store;
|
||||
|
||||
describe('createAccount()', () => {
|
||||
const params = {
|
||||
email: 'foo@bar.com',
|
||||
};
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {});
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onPost('/api/v1/accounts').reply(200, { token: '123 ' });
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches the correct actions', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_CREATE_REQUEST', params },
|
||||
{
|
||||
type: 'ACCOUNT_CREATE_SUCCESS',
|
||||
params,
|
||||
token: { token: '123 ' },
|
||||
},
|
||||
];
|
||||
await store.dispatch(createAccount(params));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAccount()', () => {
|
||||
const id = '123';
|
||||
|
||||
describe('when the account has "should_refetch" set to false', () => {
|
||||
beforeEach(() => {
|
||||
const account = normalizeAccount({
|
||||
id,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
});
|
||||
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('accounts', ImmutableMap({
|
||||
[id]: account,
|
||||
}));
|
||||
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).reply(200, account);
|
||||
});
|
||||
});
|
||||
|
||||
it('should do nothing', async() => {
|
||||
await store.dispatch(fetchAccount(id));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
const account = require('soapbox/__fixtures__/pleroma-account.json');
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {});
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).reply(200, account);
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch the correct actions', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_FETCH_REQUEST', id: '123' },
|
||||
{ type: 'ACCOUNTS_IMPORT', accounts: [account] },
|
||||
{
|
||||
type: 'ACCOUNT_FETCH_SUCCESS',
|
||||
account,
|
||||
},
|
||||
];
|
||||
|
||||
await store.dispatch(fetchAccount(id));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {});
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch the correct actions', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_FETCH_REQUEST', id: '123' },
|
||||
{
|
||||
type: 'ACCOUNT_FETCH_FAIL',
|
||||
id,
|
||||
error: new Error('Network Error'),
|
||||
skipAlert: true,
|
||||
},
|
||||
];
|
||||
|
||||
await store.dispatch(fetchAccount(id));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,149 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { dismissAlert, showAlert, showAlertForError } from '../alerts';
|
||||
|
||||
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), null, null, {
|
||||
data: {
|
||||
error: message,
|
||||
},
|
||||
statusText: String(status),
|
||||
status,
|
||||
headers: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {});
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('dismissAlert()', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const alert = 'hello world';
|
||||
const expectedActions = [
|
||||
{ type: 'ALERT_DISMISS', alert },
|
||||
];
|
||||
await store.dispatch(dismissAlert(alert));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAlert()', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const title = 'title';
|
||||
const message = 'msg';
|
||||
const severity = 'info';
|
||||
const expectedActions = [
|
||||
{ type: 'ALERT_SHOW', title, message, severity },
|
||||
];
|
||||
await store.dispatch(showAlert(title, message, severity));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAlert()', () => {
|
||||
describe('with a 502 status code', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const message = 'The server is down';
|
||||
const error = buildError(message, 502);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
|
||||
];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a 404 status code', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const error = buildError('', 404);
|
||||
|
||||
const expectedActions = [];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a 410 status code', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const error = buildError('', 410);
|
||||
|
||||
const expectedActions = [];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an accepted status code', () => {
|
||||
describe('with a message from the server', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const message = 'custom message';
|
||||
const error = buildError(message, 200);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
|
||||
];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a message from the server', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const message = 'The request has been accepted for processing';
|
||||
const error = buildError(message, 202);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
|
||||
];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a response', () => {
|
||||
it('dispatches the proper actions', async() => {
|
||||
const error = new AxiosError();
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'ALERT_SHOW',
|
||||
title: {
|
||||
defaultMessage: 'Oops!',
|
||||
id: 'alert.unexpected.title',
|
||||
},
|
||||
message: {
|
||||
defaultMessage: 'An unexpected error occurred.',
|
||||
id: 'alert.unexpected.message',
|
||||
},
|
||||
severity: 'error',
|
||||
},
|
||||
];
|
||||
await store.dispatch(showAlertForError(error));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,183 @@
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { expandBlocks, fetchBlocks } from '../blocks';
|
||||
|
||||
const account = {
|
||||
acct: 'twoods',
|
||||
display_name: 'Tiger Woods',
|
||||
id: '22',
|
||||
username: 'twoods',
|
||||
};
|
||||
|
||||
describe('fetchBlocks()', () => {
|
||||
let store;
|
||||
|
||||
describe('if logged out', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {}).set('me', null);
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('should do nothing', async() => {
|
||||
await store.dispatch(fetchBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if logged in', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {}).set('me', '1234');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
const blocks = require('soapbox/__fixtures__/blocks.json');
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/blocks').reply(200, blocks, {
|
||||
link: '<https://example.com/api/v1/blocks?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch blocks from the API', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'BLOCKS_FETCH_REQUEST' },
|
||||
{ type: 'ACCOUNTS_IMPORT', accounts: [account] },
|
||||
{ type: 'BLOCKS_FETCH_SUCCESS', accounts: [account], next: null },
|
||||
{
|
||||
type: 'RELATIONSHIPS_FETCH_REQUEST',
|
||||
ids: ['22'],
|
||||
skipLoading: true,
|
||||
},
|
||||
];
|
||||
await store.dispatch(fetchBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/blocks').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'BLOCKS_FETCH_REQUEST' },
|
||||
{ type: 'BLOCKS_FETCH_FAIL', error: new Error('Network Error') },
|
||||
];
|
||||
await store.dispatch(fetchBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandBlocks()', () => {
|
||||
let store;
|
||||
|
||||
describe('if logged out', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {}).set('me', null);
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('should do nothing', async() => {
|
||||
await store.dispatch(expandBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if logged in', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {}).set('me', '1234');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('without a url', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('me', '1234')
|
||||
.set('user_lists', { blocks: { next: null } });
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
it('should do nothing', async() => {
|
||||
await store.dispatch(expandBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a url', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('me', '1234')
|
||||
.set('user_lists', { blocks: { next: 'example' } });
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
const blocks = require('soapbox/__fixtures__/blocks.json');
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet('example').reply(200, blocks, {
|
||||
link: '<https://example.com/api/v1/blocks?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch blocks from the url', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'BLOCKS_EXPAND_REQUEST' },
|
||||
{ type: 'ACCOUNTS_IMPORT', accounts: [account] },
|
||||
{ type: 'BLOCKS_EXPAND_SUCCESS', accounts: [account], next: null },
|
||||
{
|
||||
type: 'RELATIONSHIPS_FETCH_REQUEST',
|
||||
ids: ['22'],
|
||||
skipLoading: true,
|
||||
},
|
||||
];
|
||||
await store.dispatch(expandBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('example').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'BLOCKS_EXPAND_REQUEST' },
|
||||
{ type: 'BLOCKS_EXPAND_FAIL', error: new Error('Network Error') },
|
||||
];
|
||||
await store.dispatch(expandBlocks());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,19 +0,0 @@
|
||||
import { staticClient } from '../api';
|
||||
|
||||
export const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST';
|
||||
export const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS';
|
||||
export const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL';
|
||||
|
||||
export function fetchAboutPage(slug = 'index', locale) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale });
|
||||
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
|
||||
return staticClient.get(`/instance/about/${filename}`).then(({ data: html }) => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html });
|
||||
return html;
|
||||
}).catch(error => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error });
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { staticClient } from '../api';
|
||||
|
||||
const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST';
|
||||
const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS';
|
||||
const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL';
|
||||
|
||||
const fetchAboutPage = (slug = 'index', locale?: string) => (dispatch: React.Dispatch<AnyAction>, getState: any) => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale });
|
||||
|
||||
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
|
||||
return staticClient.get(`/instance/about/${filename}`)
|
||||
.then(({ data: html }) => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html });
|
||||
return html;
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error });
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
fetchAboutPage,
|
||||
FETCH_ABOUT_PAGE_REQUEST,
|
||||
FETCH_ABOUT_PAGE_SUCCESS,
|
||||
FETCH_ABOUT_PAGE_FAIL,
|
||||
};
|
@ -0,0 +1,82 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { openModal, closeModal } from './modals';
|
||||
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL';
|
||||
|
||||
const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
||||
|
||||
const submitAccountNote = () => (dispatch: React.Dispatch<AnyAction>, getState: any) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
||||
|
||||
return api(getState)
|
||||
.post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
||||
})
|
||||
.then(response => {
|
||||
dispatch(closeModal());
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
})
|
||||
.catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
|
||||
function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
function submitAccountNoteSuccess(relationship: any) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
}
|
||||
|
||||
function submitAccountNoteFail(error: AxiosError) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const initAccountNoteModal = (account: Account) => (dispatch: React.Dispatch<AnyAction>, getState: any) => {
|
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
||||
|
||||
dispatch({
|
||||
type: ACCOUNT_NOTE_INIT_MODAL,
|
||||
account,
|
||||
comment,
|
||||
});
|
||||
|
||||
dispatch(openModal('ACCOUNT_NOTE'));
|
||||
};
|
||||
|
||||
function changeAccountNoteComment(comment: string) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
submitAccountNote,
|
||||
initAccountNoteModal,
|
||||
changeAccountNoteComment,
|
||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
ACCOUNT_NOTE_INIT_MODAL,
|
||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
};
|
@ -1,67 +0,0 @@
|
||||
import api from '../api';
|
||||
|
||||
import { openModal, closeModal } from './modals';
|
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
export const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL';
|
||||
|
||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
||||
|
||||
export function submitAccountNote() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
||||
}).then(response => {
|
||||
dispatch(closeModal());
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitAccountNoteSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitAccountNoteFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function initAccountNoteModal(account) {
|
||||
return (dispatch, getState) => {
|
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
||||
|
||||
dispatch({
|
||||
type: ACCOUNT_NOTE_INIT_MODAL,
|
||||
account,
|
||||
comment,
|
||||
});
|
||||
|
||||
dispatch(openModal('ACCOUNT_NOTE'));
|
||||
};
|
||||
}
|
||||
|
||||
export function changeAccountNoteComment(comment) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
comment,
|
||||
};
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { httpErrorMessages } from 'soapbox/utils/errors';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
export function dismissAlert(alert) {
|
||||
return {
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAlert() {
|
||||
return {
|
||||
type: ALERT_CLEAR,
|
||||
};
|
||||
}
|
||||
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, severity = 'info') {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
severity,
|
||||
};
|
||||
}
|
||||
|
||||
export function showAlertForError(error) {
|
||||
return (dispatch, _getState) => {
|
||||
if (error.response) {
|
||||
const { data, status, statusText } = error.response;
|
||||
|
||||
if (status === 502) {
|
||||
return dispatch(showAlert('', 'The server is down', 'error'));
|
||||
}
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
let message = statusText;
|
||||
|
||||
if (data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = httpErrorMessages.find((httpError) => httpError.code === status)?.description;
|
||||
}
|
||||
|
||||
return dispatch(showAlert('', message, 'error'));
|
||||
} else {
|
||||
console.error(error);
|
||||
return dispatch(showAlert(undefined, undefined, 'error'));
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
import { AxiosError } from 'axios';
|
||||
import { defineMessages, MessageDescriptor } from 'react-intl';
|
||||
|
||||
import { httpErrorMessages } from 'soapbox/utils/errors';
|
||||
|
||||
import { SnackbarActionSeverity } from './snackbar';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
|
||||
const noOp = () => { };
|
||||
|
||||
function dismissAlert(alert: any) {
|
||||
return {
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
};
|
||||
}
|
||||
|
||||
function showAlert(
|
||||
title: MessageDescriptor | string = messages.unexpectedTitle,
|
||||
message: MessageDescriptor | string = messages.unexpectedMessage,
|
||||
severity: SnackbarActionSeverity = 'info',
|
||||
) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
severity,
|
||||
};
|
||||
}
|
||||
|
||||
const showAlertForError = (error: AxiosError<any>) => (dispatch: React.Dispatch<AnyAction>, _getState: any) => {
|
||||
if (error.response) {
|
||||
const { data, status, statusText } = error.response;
|
||||
|
||||
if (status === 502) {
|
||||
return dispatch(showAlert('', 'The server is down', 'error'));
|
||||
}
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return dispatch(noOp as any);
|
||||
}
|
||||
|
||||
let message: string | undefined = statusText;
|
||||
|
||||
if (data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = httpErrorMessages.find((httpError) => httpError.code === status)?.description;
|
||||
}
|
||||
|
||||
return dispatch(showAlert('', message, 'error'));
|
||||
} else {
|
||||
console.error(error);
|
||||
return dispatch(showAlert(undefined, undefined, 'error'));
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
dismissAlert,
|
||||
showAlert,
|
||||
showAlertForError,
|
||||
};
|
@ -1,95 +0,0 @@
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
import { getNextLinkName } from 'soapbox/utils/quirks';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
|
||||
export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
|
||||
export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';
|
||||
|
||||
export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
|
||||
export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
|
||||
export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
|
||||
|
||||
export function fetchBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
dispatch(fetchBlocksRequest());
|
||||
|
||||
api(getState).get('/api/v1/blocks').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(fetchBlocksFail(error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBlocksRequest() {
|
||||
return {
|
||||
type: BLOCKS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBlocksSuccess(accounts, next) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBlocksFail(error) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
const url = getState().getIn(['user_lists', 'blocks', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandBlocksRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandBlocksFail(error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBlocksRequest() {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBlocksSuccess(accounts, next) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBlocksFail(error) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
import { getNextLinkName } from 'soapbox/utils/quirks';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
|
||||
const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
|
||||
const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';
|
||||
|
||||
const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
|
||||
const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
|
||||
const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
|
||||
|
||||
const fetchBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: any) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
dispatch(fetchBlocksRequest());
|
||||
|
||||
return api(getState)
|
||||
.get('/api/v1/blocks')
|
||||
.then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any);
|
||||
})
|
||||
.catch(error => dispatch(fetchBlocksFail(error)));
|
||||
};
|
||||
|
||||
function fetchBlocksRequest() {
|
||||
return { type: BLOCKS_FETCH_REQUEST };
|
||||
}
|
||||
|
||||
function fetchBlocksSuccess(accounts: any, next: any) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchBlocksFail(error: AxiosError) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const expandBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: any) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
const url = getState().getIn(['user_lists', 'blocks', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dispatch(expandBlocksRequest());
|
||||
|
||||
return api(getState)
|
||||
.get(url)
|
||||
.then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any);
|
||||
})
|
||||
.catch(error => dispatch(expandBlocksFail(error)));
|
||||
};
|
||||
|
||||
function expandBlocksRequest() {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
function expandBlocksSuccess(accounts: any, next: any) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
function expandBlocksFail(error: AxiosError) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
fetchBlocks,
|
||||
expandBlocks,
|
||||
BLOCKS_FETCH_REQUEST,
|
||||
BLOCKS_FETCH_SUCCESS,
|
||||
BLOCKS_FETCH_FAIL,
|
||||
BLOCKS_EXPAND_REQUEST,
|
||||
BLOCKS_EXPAND_SUCCESS,
|
||||
BLOCKS_EXPAND_FAIL,
|
||||
};
|
@ -0,0 +1,59 @@
|
||||
import { RootState } from 'soapbox/store';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer';
|
||||
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST';
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS';
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
type FamiliarFollowersFetchRequestAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST,
|
||||
id: string,
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestSuccessAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
id: string,
|
||||
accounts: Array<APIEntity>,
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestFailAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL,
|
||||
id: string,
|
||||
error: any,
|
||||
}
|
||||
|
||||
type AccountsImportAction = {
|
||||
type: typeof ACCOUNTS_IMPORT,
|
||||
accounts: Array<APIEntity>,
|
||||
}
|
||||
|
||||
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction
|
||||
|
||||
export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch<FamiliarFollowersActions>, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: FAMILIAR_FOLLOWERS_FETCH_REQUEST,
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
api(getState).get(`/api/v1/accounts/familiar_followers?id=${accountId}`)
|
||||
.then(({ data }) => {
|
||||
const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts;
|
||||
|
||||
dispatch(importFetchedAccounts(accounts) as AccountsImportAction);
|
||||
dispatch({
|
||||
type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
id: accountId,
|
||||
accounts,
|
||||
});
|
||||
})
|
||||
.catch(error => dispatch({
|
||||
type: FAMILIAR_FOLLOWERS_FETCH_FAIL,
|
||||
id: accountId,
|
||||
error,
|
||||
}));
|
||||
};
|
@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import StillImage from 'soapbox/components/still_image';
|
||||
|
||||
export default class AvatarOverlay extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
friend: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, friend } = this.props;
|
||||
|
||||
return (
|
||||
<div className='account__avatar-overlay'>
|
||||
<StillImage src={account.get('avatar')} className='account__avatar-overlay-base' />
|
||||
<StillImage src={friend.get('avatar')} className='account__avatar-overlay-overlay' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still_image';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAvatarOverlay {
|
||||
account: AccountEntity,
|
||||
friend: AccountEntity,
|
||||
}
|
||||
|
||||
const AvatarOverlay: React.FC<IAvatarOverlay> = ({ account, friend }) => (
|
||||
<div className='account__avatar-overlay'>
|
||||
<StillImage src={account.avatar} className='account__avatar-overlay-base' />
|
||||
<StillImage src={friend.avatar} className='account__avatar-overlay-overlay' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AvatarOverlay;
|
@ -1,35 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class LoadGap extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
maxId: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onClick(this.props.maxId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, intl } = this.props;
|
||||
|
||||
return (
|
||||
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
|
||||
<Icon id='ellipsis-h' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
});
|
||||
|
||||
interface ILoadGap {
|
||||
disabled?: boolean,
|
||||
maxId: string,
|
||||
onClick: (id: string) => void,
|
||||
}
|
||||
|
||||
const LoadGap: React.FC<ILoadGap> = ({ disabled, maxId, onClick }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleClick = () => onClick(maxId);
|
||||
|
||||
return (
|
||||
<button className='load-more load-gap' disabled={disabled} onClick={handleClick} aria-label={intl.formatMessage(messages.load_more)}>
|
||||
<Icon id='ellipsis-h' />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadGap;
|
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import LandingGradient from 'soapbox/components/landing-gradient';
|
||||
import { Spinner } from 'soapbox/components/ui';
|
||||
|
||||
/** Fullscreen loading indicator. */
|
||||
const LoadingScreen: React.FC = () => {
|
||||
return (
|
||||
<div className='fixed h-screen w-screen'>
|
||||
<LandingGradient />
|
||||
|
||||
<div className='fixed d-screen w-screen flex items-center justify-center z-10'>
|
||||
<div className='p-4'>
|
||||
<Spinner size={40} withText={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
@ -1,30 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import PullToRefresh from './pull-to-refresh';
|
||||
|
||||
/**
|
||||
* Pullable:
|
||||
* Basic "pull to refresh" without the refresh.
|
||||
* Just visual feedback.
|
||||
*/
|
||||
export default class Pullable extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return (
|
||||
<PullToRefresh
|
||||
pullingContent={null}
|
||||
refreshingContent={null}
|
||||
>
|
||||
{children}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import PullToRefresh from './pull-to-refresh';
|
||||
|
||||
interface IPullable {
|
||||
children: JSX.Element,
|
||||
}
|
||||
|
||||
/**
|
||||
* Pullable:
|
||||
* Basic "pull to refresh" without the refresh.
|
||||
* Just visual feedback.
|
||||
*/
|
||||
const Pullable: React.FC<IPullable> = ({ children }) =>(
|
||||
<PullToRefresh
|
||||
pullingContent={undefined}
|
||||
// @ts-ignore
|
||||
refreshingContent={null}
|
||||
>
|
||||
{children}
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
export default Pullable;
|
@ -1,35 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default class RadioButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
checked: PropTypes.bool,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, value, checked, onChange, label } = this.props;
|
||||
|
||||
return (
|
||||
<label className='radio-button'>
|
||||
<input
|
||||
name={name}
|
||||
type='radio'
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('radio-button__input', { checked })} />
|
||||
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface IRadioButton {
|
||||
value: string,
|
||||
checked?: boolean,
|
||||
name: string,
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||
label: React.ReactNode,
|
||||
}
|
||||
|
||||
const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, label }) => (
|
||||
<label className='radio-button'>
|
||||
<input
|
||||
name={name}
|
||||
type='radio'
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('radio-button__input', { checked })} />
|
||||
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
export default RadioButton;
|
@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
||||
import Card from 'soapbox/features/status/components/card';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Status, Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface IStatusMedia {
|
||||
/** Status entity to render media for. */
|
||||
status: Status,
|
||||
/** Whether to display compact media. */
|
||||
muted?: boolean,
|
||||
/** Callback when compact media is clicked. */
|
||||
onClick?: () => void,
|
||||
/** Whether or not the media is concealed behind a NSFW banner. */
|
||||
showMedia?: boolean,
|
||||
/** Callback when visibility is toggled (eg clicked through NSFW). */
|
||||
onToggleVisibility?: () => void,
|
||||
}
|
||||
|
||||
/** Render media attachments for a status. */
|
||||
const StatusMedia: React.FC<IStatusMedia> = ({
|
||||
status,
|
||||
muted = false,
|
||||
onClick,
|
||||
showMedia = true,
|
||||
onToggleVisibility = () => {},
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [mediaWrapperWidth, setMediaWrapperWidth] = useState<number | undefined>(undefined);
|
||||
|
||||
const size = status.media_attachments.size;
|
||||
const firstAttachment = status.media_attachments.first();
|
||||
|
||||
let media = null;
|
||||
|
||||
const setRef = (c: HTMLDivElement): void => {
|
||||
if (c) {
|
||||
setMediaWrapperWidth(c.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const renderLoadingMediaGallery = (): JSX.Element => {
|
||||
return <div className='media_gallery' style={{ height: '285px' }} />;
|
||||
};
|
||||
|
||||
const renderLoadingVideoPlayer = (): JSX.Element => {
|
||||
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
|
||||
};
|
||||
|
||||
const renderLoadingAudioPlayer = (): JSX.Element => {
|
||||
return <div className='media-spoiler-audio' style={{ height: '285px' }} />;
|
||||
};
|
||||
|
||||
const openMedia = (media: ImmutableList<Attachment>, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
};
|
||||
|
||||
const openVideo = (media: Attachment, time: number): void => {
|
||||
dispatch(openModal('VIDEO', { media, time }));
|
||||
};
|
||||
|
||||
if (size > 0 && firstAttachment) {
|
||||
if (muted) {
|
||||
media = (
|
||||
<AttachmentThumbs
|
||||
media={status.media_attachments}
|
||||
onClick={onClick}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
);
|
||||
} else if (size === 1 && firstAttachment.type === 'video') {
|
||||
const video = firstAttachment;
|
||||
|
||||
if (video.external_video_id && status.card) {
|
||||
const getHeight = (): number => {
|
||||
const width = Number(video.meta.getIn(['original', 'width']));
|
||||
const height = Number(video.meta.getIn(['original', 'height']));
|
||||
return Number(mediaWrapperWidth) / (width / height);
|
||||
};
|
||||
|
||||
const height = getHeight();
|
||||
|
||||
media = (
|
||||
<div className='status-card horizontal compact interactive status-card--video'>
|
||||
<div
|
||||
ref={setRef}
|
||||
className='status-card__image status-card-video'
|
||||
style={height ? { height } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: status.card.html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={renderLoadingVideoPlayer} >
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
preview={video.preview_url}
|
||||
blurhash={video.blurhash}
|
||||
src={video.url}
|
||||
alt={video.description}
|
||||
aspectRatio={video.meta.getIn(['original', 'aspect'])}
|
||||
height={285}
|
||||
inline
|
||||
sensitive={status.sensitive}
|
||||
onOpenVideo={openVideo}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (size === 1 && firstAttachment.type === 'audio') {
|
||||
const attachment = firstAttachment;
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer} >
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
src={attachment.url}
|
||||
alt={attachment.description}
|
||||
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.meta.getIn(['colors', 'background'])}
|
||||
foregroundColor={attachment.meta.getIn(['colors', 'foreground'])}
|
||||
accentColor={attachment.meta.getIn(['colors', 'accent'])}
|
||||
duration={attachment.meta.getIn(['original', 'duration'], 0)}
|
||||
height={263}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={renderLoadingMediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={status.media_attachments}
|
||||
sensitive={status.sensitive}
|
||||
height={285}
|
||||
onOpenMedia={openMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||
media = (
|
||||
<Card
|
||||
onOpenMedia={openMedia}
|
||||
card={status.card}
|
||||
compact
|
||||
/>
|
||||
);
|
||||
} else if (status.expectsCard) {
|
||||
media = (
|
||||
<PlaceholderCard />
|
||||
);
|
||||
}
|
||||
|
||||
return media;
|
||||
};
|
||||
|
||||
export default StatusMedia;
|
@ -0,0 +1,75 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { FormattedList, FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
interface IStatusReplyMentions {
|
||||
status: Status,
|
||||
}
|
||||
|
||||
const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleOpenMentionsModal: React.MouseEventHandler<HTMLSpanElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('MENTIONS', {
|
||||
username: status.getIn(['account', 'acct']),
|
||||
statusId: status.get('id'),
|
||||
}));
|
||||
};
|
||||
|
||||
if (!status.get('in_reply_to_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const to = status.get('mentions', ImmutableList());
|
||||
|
||||
// The post is a reply, but it has no mentions.
|
||||
// Rare, but it can happen.
|
||||
if (to.size === 0) {
|
||||
return (
|
||||
<div className='reply-mentions'>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The typical case with a reply-to and a list of mentions.
|
||||
const accounts = to.slice(0, 2).map(account => (
|
||||
<HoverRefWrapper accountId={account.get('id')} inline>
|
||||
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
|
||||
</HoverRefWrapper>
|
||||
)).toArray();
|
||||
|
||||
if (to.size > 2) {
|
||||
accounts.push(
|
||||
<span className='hover:underline cursor-pointer' role='presentation' onClick={handleOpenMentionsModal}>
|
||||
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='reply-mentions'>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}'
|
||||
values={{
|
||||
accounts: <FormattedList type='conjunction' value={accounts} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusReplyMentions;
|
@ -1,84 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onOpenMentionsModal(username, statusId) {
|
||||
dispatch(openModal('MENTIONS', {
|
||||
username,
|
||||
statusId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default @connect(null, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class StatusReplyMentions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.record.isRequired,
|
||||
onOpenMentionsModal: PropTypes.func,
|
||||
}
|
||||
|
||||
handleOpenMentionsModal = (e) => {
|
||||
const { status, onOpenMentionsModal } = this.props;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
onOpenMentionsModal(status.getIn(['account', 'acct']), status.get('id'));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status } = this.props;
|
||||
|
||||
if (!status.get('in_reply_to_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const to = status.get('mentions', []);
|
||||
|
||||
// The post is a reply, but it has no mentions.
|
||||
// Rare, but it can happen.
|
||||
if (to.size === 0) {
|
||||
return (
|
||||
<div className='reply-mentions'>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The typical case with a reply-to and a list of mentions.
|
||||
return (
|
||||
<div className='reply-mentions'>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}{more}'
|
||||
values={{
|
||||
accounts: to.slice(0, 2).map(account => (<>
|
||||
<HoverRefWrapper accountId={account.get('id')} inline>
|
||||
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
|
||||
</HoverRefWrapper>
|
||||
{' '}
|
||||
</>)),
|
||||
more: to.size > 2 && (
|
||||
<span className='hover:underline cursor-pointer' role='presentation' onClick={this.handleOpenMentionsModal}>
|
||||
<FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedDate } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchModerationLog } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
|
||||
emptyMessage: { id: 'admin.moderation_log.empty_message', defaultMessage: 'You have not performed any moderation actions yet. When you do, a history will be shown here.' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
items: state.getIn(['admin_log', 'index']).map(i => state.getIn(['admin_log', 'items', String(i)])),
|
||||
hasMore: state.getIn(['admin_log', 'total'], 0) - state.getIn(['admin_log', 'index']).count() > 0,
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class ModerationLog extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
list: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
state = {
|
||||
isLoading: true,
|
||||
lastPage: 0,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchModerationLog())
|
||||
.then(data => this.setState({
|
||||
isLoading: false,
|
||||
lastPage: 1,
|
||||
}))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const page = this.state.lastPage + 1;
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
this.props.dispatch(fetchModerationLog({ page }))
|
||||
.then(data => this.setState({
|
||||
isLoading: false,
|
||||
lastPage: page,
|
||||
}))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, items, hasMore } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
const showLoading = isLoading && items.count() === 0;
|
||||
|
||||
return (
|
||||
<Column icon='balance-scale' label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='moderation-log'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<div className='logentry' key={i}>
|
||||
<div className='logentry__message'>{item.get('message')}</div>
|
||||
<div className='logentry__timestamp'>
|
||||
<FormattedDate
|
||||
value={new Date(item.get('time') * 1000)}
|
||||
hour12={false}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedDate, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchModerationLog } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
|
||||
emptyMessage: { id: 'admin.moderation_log.empty_message', defaultMessage: 'You have not performed any moderation actions yet. When you do, a history will be shown here.' },
|
||||
});
|
||||
|
||||
const ModerationLog = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const items = useAppSelector((state) => state.admin_log.get('index').map((i: number) => state.admin_log.getIn(['items', String(i)]))) as ImmutableMap<string, any>;
|
||||
const hasMore = useAppSelector((state) => state.admin_log.get('total', 0) - state.admin_log.get('index').count() > 0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastPage, setLastPage] = useState(0);
|
||||
|
||||
const showLoading = isLoading && items.count() === 0;
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchModerationLog())
|
||||
.then(() => {
|
||||
setIsLoading(false);
|
||||
setLastPage(1);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const page = lastPage + 1;
|
||||
|
||||
setIsLoading(true);
|
||||
dispatch(fetchModerationLog({ page }))
|
||||
.then(() => {
|
||||
setIsLoading(false);
|
||||
setLastPage(page);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Column icon='balance-scale' label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='moderation-log'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<div className='logentry' key={i}>
|
||||
<div className='logentry__message'>{item.get('message')}</div>
|
||||
<div className='logentry__timestamp'>
|
||||
<FormattedDate
|
||||
value={new Date(item.get('time') * 1000)}
|
||||
hour12={false}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationLog;
|
@ -1,92 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { addToAliases } from 'soapbox/actions/aliases';
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display-name';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
const messages = defineMessages({
|
||||
add: { id: 'aliases.account.add', defaultMessage: 'Create alias' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added, aliases }) => {
|
||||
const me = state.get('me');
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
const account = getAccount(state, accountId);
|
||||
const apId = account.getIn(['pleroma', 'ap_id']);
|
||||
const name = features.accountMoving ? account.get('acct') : apId;
|
||||
|
||||
return {
|
||||
account,
|
||||
apId,
|
||||
added: typeof added === 'undefined' ? aliases.includes(name) : added,
|
||||
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.record.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.account);
|
||||
|
||||
render() {
|
||||
const { account, accountId, intl, added, me } = this.props;
|
||||
|
||||
let button;
|
||||
|
||||
if (!added && accountId !== me) {
|
||||
button = (
|
||||
<div className='account__relationship'>
|
||||
<IconButton src={require('@tabler/icons/icons/plus.svg')} 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { addToAliases } from 'soapbox/actions/aliases';
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display-name';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
add: { id: 'aliases.account.add', defaultMessage: 'Create alias' },
|
||||
});
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
interface IAccount {
|
||||
accountId: string,
|
||||
aliases: ImmutableList<string>
|
||||
}
|
||||
|
||||
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
const added = useAppSelector((state) => {
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
const account = getAccount(state, accountId);
|
||||
const apId = account?.pleroma.get('ap_id');
|
||||
const name = features.accountMoving ? account?.acct : apId;
|
||||
|
||||
return aliases.includes(name);
|
||||
});
|
||||
const me = useAppSelector((state) => state.me);
|
||||
|
||||
const handleOnAdd = () => dispatch(addToAliases(account));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
let button;
|
||||
|
||||
if (!added && accountId !== me) {
|
||||
button = (
|
||||
<div className='account__relationship'>
|
||||
<IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
@ -1,115 +0,0 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchAliases, removeFromAliases } from 'soapbox/actions/aliases';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { CardHeader, CardTitle, Column, HStack, Text } from 'soapbox/components/ui';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
|
||||
import Account from './components/account';
|
||||
import Search from './components/search';
|
||||
|
||||
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 makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = getAccount(state, me);
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
let aliases;
|
||||
|
||||
if (features.accountMoving) aliases = state.getIn(['aliases', 'aliases', 'items'], ImmutableList());
|
||||
else aliases = account.getIn(['pleroma', 'also_known_as']);
|
||||
|
||||
return {
|
||||
aliases,
|
||||
searchAccountIds: state.getIn(['aliases', 'suggestions', 'items']),
|
||||
loaded: state.getIn(['aliases', 'suggestions', 'loaded']),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
class Aliases extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount = e => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchAliases);
|
||||
}
|
||||
|
||||
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' label={intl.formatMessage(messages.heading)}>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
|
||||
</CardHeader>
|
||||
<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} aliases={aliases} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading_aliases)} />
|
||||
</CardHeader>
|
||||
<div className='aliases-settings-panel'>
|
||||
<ScrollableList
|
||||
scrollKey='aliases'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{aliases.map((alias, i) => (
|
||||
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
||||
<div>
|
||||
<Text tag='span' theme='muted'><FormattedMessage id='aliases.account_label' defaultMessage='Old account:' /></Text>
|
||||
{' '}
|
||||
<Text tag='span'>{alias}</Text>
|
||||
</div>
|
||||
<div className='flex items-center' role='button' tabIndex='0' onClick={this.handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<Icon className='pr-1.5 text-lg' id='times' size={40} />
|
||||
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
|
||||
</div>
|
||||
</HStack>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchAliases, removeFromAliases } from 'soapbox/actions/aliases';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { CardHeader, CardTitle, Column, HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import Account from './components/account';
|
||||
import Search from './components/search';
|
||||
|
||||
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 getAccount = makeGetAccount();
|
||||
|
||||
const Aliases = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const aliases = useAppSelector((state) => {
|
||||
const me = state.me as string;
|
||||
const account = getAccount(state, me);
|
||||
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (features.accountMoving) return state.aliases.getIn(['aliases', 'items'], ImmutableList());
|
||||
return account!.pleroma.get('also_known_as');
|
||||
}) as ImmutableList<string>;
|
||||
const searchAccountIds = useAppSelector((state) => state.aliases.getIn(['suggestions', 'items'])) as ImmutableList<string>;
|
||||
const loaded = useAppSelector((state) => state.aliases.getIn(['suggestions', 'loaded']));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAliases);
|
||||
}, []);
|
||||
|
||||
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
|
||||
dispatch(removeFromAliases(e.currentTarget.dataset.value));
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.aliases' defaultMessage="You haven't created any account alias yet." />;
|
||||
|
||||
return (
|
||||
<Column className='aliases-settings-panel' label={intl.formatMessage(messages.heading)}>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
|
||||
</CardHeader>
|
||||
<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} aliases={aliases} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading_aliases)} />
|
||||
</CardHeader>
|
||||
<div className='aliases-settings-panel'>
|
||||
<ScrollableList
|
||||
scrollKey='aliases'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{aliases.map((alias, i) => (
|
||||
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
||||
<div>
|
||||
<Text tag='span' theme='muted'><FormattedMessage id='aliases.account_label' defaultMessage='Old account:' /></Text>
|
||||
{' '}
|
||||
<Text tag='span'>{alias}</Text>
|
||||
</div>
|
||||
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<Icon className='pr-1.5 text-lg' id='times' size={40} />
|
||||
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
|
||||
</div>
|
||||
</HStack>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Aliases;
|
@ -1,100 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { otpVerify, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
|
||||
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
class OtpAuthForm extends ImmutablePureComponent {
|
||||
|
||||
state = {
|
||||
isLoading: false,
|
||||
code_error: '',
|
||||
shouldRedirect: false,
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
mfa_token: PropTypes.string,
|
||||
};
|
||||
|
||||
getFormData = (form) => {
|
||||
return Object.fromEntries(
|
||||
Array.from(form).map(i => [i.name, i.value]),
|
||||
);
|
||||
}
|
||||
|
||||
handleSubmit = (event) => {
|
||||
const { dispatch, mfa_token } = this.props;
|
||||
const { code } = this.getFormData(event.target);
|
||||
dispatch(otpVerify(code, mfa_token)).then(({ access_token }) => {
|
||||
this.setState({ code_error: false });
|
||||
return dispatch(verifyCredentials(access_token));
|
||||
}).then(account => {
|
||||
this.setState({ shouldRedirect: true });
|
||||
return dispatch(switchAccount(account.id));
|
||||
}).catch(error => {
|
||||
this.setState({ isLoading: false, code_error: true });
|
||||
});
|
||||
this.setState({ isLoading: true });
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
const { code_error, shouldRedirect } = this.state;
|
||||
|
||||
if (shouldRedirect) return <Redirect to='/' />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
|
||||
<h1 className='text-center font-bold text-2xl'>
|
||||
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.otpCodeLabel)}
|
||||
hintText={intl.formatMessage(messages.otpCodeHint)}
|
||||
errors={code_error ? [intl.formatMessage({ id: 'login.otp_log_in.fail', defaultMessage: 'Invalid code, please try again.' })] : []}
|
||||
>
|
||||
<Input
|
||||
name='code'
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
onChange={this.onInputChange}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button
|
||||
theme='primary'
|
||||
type='submit'
|
||||
disabled={this.state.isLoading}
|
||||
>
|
||||
<FormattedMessage id='login.sign_in' defaultMessage='Sign in' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { otpVerify, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
|
||||
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
|
||||
otpLoginFail: { id: 'login.otp_log_in.fail', defaultMessage: 'Invalid code, please try again.' },
|
||||
});
|
||||
|
||||
interface IOtpAuthForm {
|
||||
mfa_token: string,
|
||||
}
|
||||
|
||||
const OtpAuthForm: React.FC<IOtpAuthForm> = ({ mfa_token }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [shouldRedirect, setShouldRedirect] = useState(false);
|
||||
const [codeError, setCodeError] = useState<string | boolean>('');
|
||||
|
||||
const getFormData = (form: any) => Object.fromEntries(
|
||||
Array.from(form).map((i: any) => [i.name, i.value]),
|
||||
);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<Element>) => {
|
||||
const { code } = getFormData(event.target);
|
||||
dispatch(otpVerify(code, mfa_token)).then(({ access_token }) => {
|
||||
setCodeError(false);
|
||||
return dispatch(verifyCredentials(access_token));
|
||||
}).then(account => {
|
||||
setShouldRedirect(true);
|
||||
return dispatch(switchAccount(account.id));
|
||||
}).catch(() => {
|
||||
setIsLoading(false);
|
||||
setCodeError(true);
|
||||
});
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
if (shouldRedirect) return <Redirect to='/' />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
|
||||
<h1 className='text-center font-bold text-2xl'>
|
||||
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.otpCodeLabel)}
|
||||
hintText={intl.formatMessage(messages.otpCodeHint)}
|
||||
errors={codeError ? [intl.formatMessage(messages.otpLoginFail)] : []}
|
||||
>
|
||||
<Input
|
||||
name='code'
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button
|
||||
theme='primary'
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FormattedMessage id='login.sign_in' defaultMessage='Sign in' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpAuthForm;
|
@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { resetPassword } from 'soapbox/actions/security';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
|
||||
import { Button, Form, FormActions, FormGroup, Input } from '../../../components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'Email or username' },
|
||||
confirmation: { id: 'password_reset.confirmation', defaultMessage: 'Check your email for confirmation.' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
class PasswordReset extends ImmutablePureComponent {
|
||||
|
||||
state = {
|
||||
isLoading: false,
|
||||
success: false,
|
||||
}
|
||||
|
||||
handleSubmit = e => {
|
||||
const { dispatch, intl } = this.props;
|
||||
const nicknameOrEmail = e.target.nickname_or_email.value;
|
||||
this.setState({ isLoading: true });
|
||||
dispatch(resetPassword(nicknameOrEmail)).then(() => {
|
||||
this.setState({ isLoading: false, success: true });
|
||||
dispatch(snackbar.info(intl.formatMessage(messages.confirmation)));
|
||||
}).catch(error => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
|
||||
if (this.state.success) return <Redirect to='/' />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-600 border-solid -mx-4 sm:-mx-10'>
|
||||
<h1 className='text-center font-bold text-2xl'>
|
||||
{intl.formatMessage({ id: 'password_reset.header', defaultMessage: 'Reset Password' })}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.nicknameOrEmail)}>
|
||||
<Input
|
||||
name='nickname_or_email'
|
||||
placeholder='me@example.com'
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit' theme='primary' disabled={this.state.isLoading}>
|
||||
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { resetPassword } from 'soapbox/actions/security';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'Email or username' },
|
||||
confirmation: { id: 'password_reset.confirmation', defaultMessage: 'Check your email for confirmation.' },
|
||||
});
|
||||
|
||||
const PasswordReset = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<Element>) => {
|
||||
const nicknameOrEmail = (e.target as any).nickname_or_email.value;
|
||||
setIsLoading(true);
|
||||
dispatch(resetPassword(nicknameOrEmail)).then(() => {
|
||||
setIsLoading(false);
|
||||
setSuccess(true);
|
||||
dispatch(snackbar.info(intl.formatMessage(messages.confirmation)));
|
||||
}).catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (success) return <Redirect to='/' />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-600 border-solid -mx-4 sm:-mx-10'>
|
||||
<h1 className='text-center font-bold text-2xl'>
|
||||
<FormattedMessage id='password_reset.header' defaultMessage='Reset Password' />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.nicknameOrEmail)}>
|
||||
<Input
|
||||
type='text'
|
||||
name='nickname_or_email'
|
||||
placeholder='me@example.com'
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit' theme='primary' disabled={isLoading}>
|
||||
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordReset;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue