Merge remote-tracking branch 'origin/develop' into scroll-position
This commit is contained in:
commit
e4b95534dc
|
@ -11,22 +11,19 @@ cache:
|
|||
- node_modules/
|
||||
|
||||
stages:
|
||||
- install
|
||||
- lint
|
||||
- deps
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
install-dependencies:
|
||||
stage: install
|
||||
script:
|
||||
- yarn install --ignore-scripts
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
deps:
|
||||
stage: deps
|
||||
script: yarn install --ignore-scripts
|
||||
only:
|
||||
changes:
|
||||
- yarn.lock
|
||||
|
||||
lint-js:
|
||||
stage: lint
|
||||
stage: test
|
||||
script: yarn lint:js
|
||||
only:
|
||||
changes:
|
||||
|
@ -38,7 +35,7 @@ lint-js:
|
|||
- ".eslintrc.js"
|
||||
|
||||
lint-sass:
|
||||
stage: lint
|
||||
stage: test
|
||||
script: yarn lint:sass
|
||||
only:
|
||||
changes:
|
||||
|
@ -55,18 +52,29 @@ jest:
|
|||
- "**/*.json"
|
||||
- "app/soapbox/**/*"
|
||||
- "webpack/**/*"
|
||||
- "custom/**/*"
|
||||
- "jest.config.js"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
|
||||
|
||||
nginx-test:
|
||||
stage: test
|
||||
image: nginx:latest
|
||||
before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
|
||||
script: nginx -t
|
||||
only:
|
||||
changes:
|
||||
- "installation/mastodon.conf"
|
||||
|
||||
build-production:
|
||||
stage: build
|
||||
stage: test
|
||||
script: yarn build
|
||||
variables:
|
||||
NODE_ENV: production
|
||||
artifacts:
|
||||
paths:
|
||||
- static
|
||||
- static
|
||||
|
||||
docs-deploy:
|
||||
stage: deploy
|
||||
|
@ -99,6 +107,7 @@ review:
|
|||
url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub
|
||||
script:
|
||||
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
|
||||
allow_failure: true
|
||||
|
||||
pages:
|
||||
stage: deploy
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -1,6 +1,7 @@
|
|||
import { jest } from '@jest/globals';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import LinkHeader from 'http-link-header';
|
||||
|
||||
const api = jest.requireActual('../api') as Record<string, Function>;
|
||||
let mocks: Array<Function> = [];
|
||||
|
@ -15,6 +16,10 @@ const setupMock = (axios: AxiosInstance) => {
|
|||
|
||||
export const staticClient = api.staticClient;
|
||||
|
||||
export const getLinks = (response: AxiosResponse): LinkHeader => {
|
||||
return new LinkHeader(response.headers?.link);
|
||||
};
|
||||
|
||||
export const baseClient = (...params: any[]) => {
|
||||
const axios = api.baseClient(...params);
|
||||
setupMock(axios);
|
||||
|
|
|
@ -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,8 +1,13 @@
|
|||
import { fromJS, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { STATUSES_IMPORT } from 'soapbox/actions/importer';
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeStatus } from 'soapbox/normalizers/status';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { fetchContext } from '../statuses';
|
||||
import { deleteStatus } from '../statuses';
|
||||
|
||||
describe('fetchContext()', () => {
|
||||
it('handles Mitra context', done => {
|
||||
|
@ -25,3 +30,133 @@ describe('fetchContext()', () => {
|
|||
}).catch(console.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteStatus()', () => {
|
||||
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(deleteStatus('1', {}));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if logged in', () => {
|
||||
const statusId = 'AHU2RrX0wdcwzCYjFQ';
|
||||
const cachedStatus = normalizeStatus({
|
||||
id: statusId,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('me', '1234')
|
||||
.set('statuses', fromJS({
|
||||
[statusId]: cachedStatus,
|
||||
}));
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
let status;
|
||||
|
||||
beforeEach(() => {
|
||||
status = require('soapbox/__fixtures__/pleroma-status-deleted.json');
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onDelete(`/api/v1/statuses/${statusId}`).reply(200, status);
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the status from the API', async() => {
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'STATUS_DELETE_REQUEST',
|
||||
params: cachedStatus,
|
||||
},
|
||||
{ type: 'STATUS_DELETE_SUCCESS', id: statusId },
|
||||
{
|
||||
type: 'TIMELINE_DELETE',
|
||||
id: statusId,
|
||||
accountId: null,
|
||||
references: ImmutableMap({}),
|
||||
reblogOf: null,
|
||||
},
|
||||
];
|
||||
await store.dispatch(deleteStatus(statusId, {}));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('should handle redraft', async() => {
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'STATUS_DELETE_REQUEST',
|
||||
params: cachedStatus,
|
||||
},
|
||||
{ type: 'STATUS_DELETE_SUCCESS', id: statusId },
|
||||
{
|
||||
type: 'TIMELINE_DELETE',
|
||||
id: statusId,
|
||||
accountId: null,
|
||||
references: ImmutableMap({}),
|
||||
reblogOf: null,
|
||||
},
|
||||
{
|
||||
type: 'COMPOSE_SET_STATUS',
|
||||
status: cachedStatus,
|
||||
rawText: status.text,
|
||||
explicitAddressing: false,
|
||||
spoilerText: '',
|
||||
contentType: 'text/markdown',
|
||||
v: {
|
||||
build: undefined,
|
||||
compatVersion: '0.0.0',
|
||||
software: 'Mastodon',
|
||||
version: '0.0.0',
|
||||
},
|
||||
withRedraft: true,
|
||||
},
|
||||
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
|
||||
];
|
||||
await store.dispatch(deleteStatus(statusId, {}, true));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onDelete(`/api/v1/statuses/${statusId}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'STATUS_DELETE_REQUEST',
|
||||
params: cachedStatus,
|
||||
},
|
||||
{
|
||||
type: 'STATUS_DELETE_FAIL',
|
||||
params: cachedStatus,
|
||||
error: new Error('Network Error'),
|
||||
},
|
||||
];
|
||||
await store.dispatch(deleteStatus(statusId, {}, true));
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -57,6 +57,10 @@ export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
|
|||
export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
|
||||
export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL';
|
||||
|
||||
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST';
|
||||
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS';
|
||||
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
|
||||
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
|
||||
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
|
||||
|
@ -132,17 +136,20 @@ export function fetchAccount(id) {
|
|||
const account = getState().getIn(['accounts', id]);
|
||||
|
||||
if (account && !account.get('should_refetch')) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
dispatch(fetchAccountRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||
dispatch(importFetchedAccount(response.data));
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(id, error));
|
||||
});
|
||||
return api(getState)
|
||||
.get(`/api/v1/accounts/${id}`)
|
||||
.then(response => {
|
||||
dispatch(importFetchedAccount(response.data));
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(fetchAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -520,6 +527,42 @@ export function unsubscribeAccountFail(error) {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
export function removeFromFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
|
||||
dispatch(muteAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/remove_from_followers`).then(response => {
|
||||
dispatch(removeFromFollowersSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(removeFromFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function removeFromFollowersRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeFromFollowersSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeFromFollowersFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowersRequest(id));
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { importFetchedAccount, importFetchedStatuses } from 'soapbox/actions/importer';
|
||||
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
export const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
|
||||
export const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
|
||||
|
@ -99,9 +100,25 @@ export function updateConfig(configs) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchReports(params) {
|
||||
function fetchMastodonReports(params) {
|
||||
return (dispatch, getState) => {
|
||||
return api(getState)
|
||||
.get('/api/v1/admin/reports', { params })
|
||||
.then(({ data: reports }) => {
|
||||
reports.forEach(report => {
|
||||
dispatch(importFetchedAccount(report.account?.account));
|
||||
dispatch(importFetchedAccount(report.target_account?.account));
|
||||
dispatch(importFetchedStatuses(report.statuses));
|
||||
});
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function fetchPleromaReports(params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/reports', { params })
|
||||
.then(({ data: { reports } }) => {
|
||||
|
@ -117,10 +134,42 @@ export function fetchReports(params) {
|
|||
};
|
||||
}
|
||||
|
||||
function patchReports(ids, state) {
|
||||
const reports = ids.map(id => ({ id, state }));
|
||||
export function fetchReports(params = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params });
|
||||
|
||||
if (features.mastodonAdminApi) {
|
||||
return dispatch(fetchMastodonReports(params));
|
||||
} else {
|
||||
const { resolved } = params;
|
||||
|
||||
return dispatch(fetchPleromaReports({
|
||||
state: resolved === false ? 'open' : (resolved ? 'resolved' : null),
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function patchMastodonReports(reports) {
|
||||
return (dispatch, getState) => {
|
||||
return Promise.all(reports.map(({ id, state }) => api(getState)
|
||||
.post(`/api/v1/admin/reports/${id}/${state === 'resolved' ? 'reopen' : 'resolve'}`)
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports });
|
||||
}),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
function patchPleromaReports(reports) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/reports', { reports })
|
||||
.then(() => {
|
||||
|
@ -130,16 +179,64 @@ function patchReports(ids, state) {
|
|||
});
|
||||
};
|
||||
}
|
||||
|
||||
function patchReports(ids, reportState) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
const reports = ids.map(id => ({ id, state: reportState }));
|
||||
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports });
|
||||
|
||||
if (features.mastodonAdminApi) {
|
||||
return dispatch(patchMastodonReports(reports));
|
||||
} else {
|
||||
return dispatch(patchPleromaReports(reports));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function closeReports(ids) {
|
||||
return patchReports(ids, 'closed');
|
||||
}
|
||||
|
||||
export function fetchUsers(filters = [], page = 1, query, pageSize = 50) {
|
||||
function fetchMastodonUsers(filters, page, query, pageSize, next) {
|
||||
return (dispatch, getState) => {
|
||||
const params = {
|
||||
username: query,
|
||||
};
|
||||
|
||||
if (filters.includes('local')) params.local = true;
|
||||
if (filters.includes('active')) params.active = true;
|
||||
if (filters.includes('need_approval')) params.pending = true;
|
||||
|
||||
return api(getState)
|
||||
.get(next || '/api/v1/admin/accounts', { params })
|
||||
.then(({ data: accounts, ...response }) => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
const count = next
|
||||
? page * pageSize + 1
|
||||
: (page - 1) * pageSize + accounts.length;
|
||||
|
||||
dispatch(importFetchedAccounts(accounts.map(({ account }) => account)));
|
||||
dispatch(fetchRelationships(accounts.map(account => account.id)));
|
||||
dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users: accounts, count, pageSize, filters, page, next: next?.uri || false });
|
||||
return { users: accounts, count, pageSize, next: next?.uri || false };
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function fetchPleromaUsers(filters, page, query, pageSize) {
|
||||
return (dispatch, getState) => {
|
||||
const params = { filters: filters.join(), page, page_size: pageSize };
|
||||
if (query) params.query = query;
|
||||
|
||||
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/users', { params })
|
||||
.then(({ data: { users, count, page_size: pageSize } }) => {
|
||||
|
@ -152,10 +249,43 @@ export function fetchUsers(filters = [], page = 1, query, pageSize = 50) {
|
|||
};
|
||||
}
|
||||
|
||||
export function deactivateUsers(accountIds) {
|
||||
export function fetchUsers(filters = [], page = 1, query, pageSize = 50, next) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize });
|
||||
|
||||
if (features.mastodonAdminApi) {
|
||||
return dispatch(fetchMastodonUsers(filters, page, query, pageSize, next));
|
||||
} else {
|
||||
return dispatch(fetchPleromaUsers(filters, page, query, pageSize));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function deactivateMastodonUsers(accountIds, reportId) {
|
||||
return (dispatch, getState) => {
|
||||
return Promise.all(accountIds.map(accountId => {
|
||||
api(getState)
|
||||
.post(`/api/v1/admin/accounts/${accountId}/action`, {
|
||||
type: 'disable',
|
||||
report_id: reportId,
|
||||
})
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, accountIds: [accountId] });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds: [accountId] });
|
||||
});
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
function deactivatePleromaUsers(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, accountIds });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/deactivate', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
|
@ -166,6 +296,23 @@ export function deactivateUsers(accountIds) {
|
|||
};
|
||||
}
|
||||
|
||||
export function deactivateUsers(accountIds, reportId) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, accountIds });
|
||||
|
||||
if (features.mastodonAdminApi) {
|
||||
return dispatch(deactivateMastodonUsers(accountIds, reportId));
|
||||
} else {
|
||||
return dispatch(deactivatePleromaUsers(accountIds));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUsers(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
|
@ -180,10 +327,23 @@ export function deleteUsers(accountIds) {
|
|||
};
|
||||
}
|
||||
|
||||
export function approveUsers(accountIds) {
|
||||
function approveMastodonUsers(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
return Promise.all(accountIds.map(accountId => {
|
||||
api(getState)
|
||||
.post(`/api/v1/admin/accounts/${accountId}/approve`)
|
||||
.then(({ data: user }) => {
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] });
|
||||
});
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
function approvePleromaUsers(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/approve', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
|
@ -194,6 +354,23 @@ export function approveUsers(accountIds) {
|
|||
};
|
||||
}
|
||||
|
||||
export function approveUsers(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds });
|
||||
|
||||
if (features.mastodonAdminApi) {
|
||||
return dispatch(approveMastodonUsers(accountIds));
|
||||
} else {
|
||||
return dispatch(approvePleromaUsers(accountIds));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id });
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -93,7 +93,7 @@ export const changeAliasesSuggestions = value => ({
|
|||
value,
|
||||
});
|
||||
|
||||
export const addToAliases = (intl, account) => (dispatch, getState) => {
|
||||
export const addToAliases = (account) => (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const state = getState();
|
||||
|
||||
|
@ -108,7 +108,7 @@ export const addToAliases = (intl, account) => (dispatch, getState) => {
|
|||
|
||||
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.getIn(['pleroma', 'ap_id'])] })
|
||||
.then((response => {
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.createSuccess)));
|
||||
dispatch(snackbar.success(messages.createSuccess));
|
||||
dispatch(addToAliasesSuccess);
|
||||
dispatch(patchMeSuccess(response.data));
|
||||
}))
|
||||
|
@ -123,7 +123,7 @@ export const addToAliases = (intl, account) => (dispatch, getState) => {
|
|||
alias: account.get('acct'),
|
||||
})
|
||||
.then(response => {
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.createSuccess)));
|
||||
dispatch(snackbar.success(messages.createSuccess));
|
||||
dispatch(addToAliasesSuccess);
|
||||
dispatch(fetchAliases);
|
||||
})
|
||||
|
@ -143,7 +143,7 @@ export const addToAliasesFail = error => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const removeFromAliases = (intl, account) => (dispatch, getState) => {
|
||||
export const removeFromAliases = (account) => (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const state = getState();
|
||||
|
||||
|
@ -158,7 +158,7 @@ export const removeFromAliases = (intl, account) => (dispatch, getState) => {
|
|||
|
||||
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter(id => id !== account) })
|
||||
.then(response => {
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess)));
|
||||
dispatch(snackbar.success(messages.removeSuccess));
|
||||
dispatch(removeFromAliasesSuccess);
|
||||
dispatch(patchMeSuccess(response.data));
|
||||
})
|
||||
|
@ -175,7 +175,7 @@ export const removeFromAliases = (intl, account) => (dispatch, getState) => {
|
|||
},
|
||||
})
|
||||
.then(response => {
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess)));
|
||||
dispatch(snackbar.success(messages.removeSuccess));
|
||||
dispatch(removeFromAliasesSuccess);
|
||||
dispatch(fetchAliases);
|
||||
})
|
||||
|
|
|
@ -207,9 +207,12 @@ export function rememberAuthAccount(accountUrl) {
|
|||
|
||||
export function loadCredentials(token, accountUrl) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(rememberAuthAccount(accountUrl)).finally(() => {
|
||||
return dispatch(verifyCredentials(token, accountUrl));
|
||||
});
|
||||
return dispatch(rememberAuthAccount(accountUrl))
|
||||
.then(account => account)
|
||||
.then(() => {
|
||||
dispatch(verifyCredentials(token, accountUrl));
|
||||
})
|
||||
.catch(error => dispatch(verifyCredentials(token, accountUrl)));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -83,7 +83,7 @@ export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
|
|||
const messages = defineMessages({
|
||||
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
|
||||
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
|
||||
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
|
||||
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
|
||||
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
|
||||
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import snackbar from './snackbar';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import api, { getLinks } from 'soapbox/api';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
|
||||
import type { SnackbarAction } from './snackbar';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
@ -60,7 +60,7 @@ const listAccounts = (getState: () => RootState) => async(apiResponse: AxiosResp
|
|||
Array.prototype.push.apply(followings, apiResponse.data);
|
||||
}
|
||||
|
||||
accounts = followings.map((account: { fqn: string }) => account.fqn);
|
||||
accounts = followings.map((account: any) => normalizeAccount(account).fqn);
|
||||
return Array.from(new Set(accounts));
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
};
|
|
@ -11,7 +11,7 @@ export function openModal(type: string, props?: any) {
|
|||
}
|
||||
|
||||
/** Close the modal */
|
||||
export function closeModal(type: string) {
|
||||
export function closeModal(type?: string) {
|
||||
return {
|
||||
type: MODAL_CLOSE,
|
||||
modalType: type,
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { createPushSubsription, updatePushSubscription } from 'soapbox/actions/push_subscriptions';
|
||||
import { pushNotificationsSetting } from 'soapbox/settings';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
|
||||
import { pushNotificationsSetting } from '../../settings';
|
||||
import { decode as decodeBase64 } from '../../utils/base64';
|
||||
import { decode as decodeBase64 } from 'soapbox/utils/base64';
|
||||
|
||||
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ export function pinHost(host) {
|
|||
const state = getState();
|
||||
const pinnedHosts = getPinnedHosts(state);
|
||||
|
||||
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.add(host)));
|
||||
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.push(host)));
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,6 @@ export function unpinHost(host) {
|
|||
const state = getState();
|
||||
const pinnedHosts = getPinnedHosts(state);
|
||||
|
||||
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.delete(host)));
|
||||
return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.filter((value) => value !== host)));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ALERT_SHOW } from './alerts';
|
|||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
type SnackbarActionSeverity = 'info' | 'success' | 'error'
|
||||
export type SnackbarActionSeverity = 'info' | 'success' | 'error'
|
||||
|
||||
type SnackbarMessage = string | MessageDescriptor
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ export function fetchStatus(id) {
|
|||
|
||||
export function deleteStatus(id, routerHistory, withRedraft = false) {
|
||||
return (dispatch, getState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
|
||||
let status = getState().getIn(['statuses', id]);
|
||||
|
||||
|
@ -132,19 +132,22 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
|
|||
status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
|
||||
}
|
||||
|
||||
dispatch({ type: STATUS_DELETE_REQUEST, id });
|
||||
dispatch({ type: STATUS_DELETE_REQUEST, params: status });
|
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch({ type: STATUS_DELETE_SUCCESS, id });
|
||||
dispatch(deleteFromTimelines(id));
|
||||
return api(getState)
|
||||
.delete(`/api/v1/statuses/${id}`)
|
||||
.then(response => {
|
||||
dispatch({ type: STATUS_DELETE_SUCCESS, id });
|
||||
dispatch(deleteFromTimelines(id));
|
||||
|
||||
if (withRedraft) {
|
||||
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type, withRedraft));
|
||||
dispatch(openModal('COMPOSE'));
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch({ type: STATUS_DELETE_FAIL, id, error });
|
||||
});
|
||||
if (withRedraft) {
|
||||
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type, withRedraft));
|
||||
dispatch(openModal('COMPOSE'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({ type: STATUS_DELETE_FAIL, params: status, error });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -244,7 +244,9 @@ function checkEmailAvailability(email) {
|
|||
|
||||
return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => dispatch({ type: SET_LOADING, value: false }));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<button className='w-4 h-4 flex-none' onClick={handleClick}>
|
||||
<button className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2' onClick={handleClick}>
|
||||
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
|
||||
</button>
|
||||
);
|
||||
|
@ -56,6 +56,7 @@ interface IAccount {
|
|||
showProfileHoverCard?: boolean,
|
||||
timestamp?: string | Date,
|
||||
timestampUrl?: string,
|
||||
futureTimestamp?: boolean,
|
||||
withDate?: boolean,
|
||||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
|
@ -75,6 +76,7 @@ const Account = ({
|
|||
showProfileHoverCard = true,
|
||||
timestamp,
|
||||
timestampUrl,
|
||||
futureTimestamp = false,
|
||||
withDate = false,
|
||||
withRelationship = true,
|
||||
showEdit = false,
|
||||
|
@ -205,10 +207,10 @@ const Account = ({
|
|||
|
||||
{timestampUrl ? (
|
||||
<Link to={timestampUrl} className='hover:underline'>
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
|
||||
</Link>
|
||||
) : (
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
@ -296,7 +296,6 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
'absolute top-full w-full z-50 shadow bg-white dark:bg-slate-800 rounded-lg py-1': true,
|
||||
hidden: !visible,
|
||||
block: visible,
|
||||
'autosuggest-textarea__suggestions--visible': visible,
|
||||
})}
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
|
|
|
@ -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;
|
|
@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|||
import React from 'react';
|
||||
|
||||
interface IBadge {
|
||||
title: string,
|
||||
title: React.ReactNode,
|
||||
slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque',
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { useMemo } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||
import { DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
birthdayPlaceholder: { id: 'edit_profile.fields.birthday_placeholder', defaultMessage: 'Your birthday' },
|
||||
|
@ -17,29 +14,37 @@ const messages = defineMessages({
|
|||
nextYear: { id: 'datepicker.next_year', defaultMessage: 'Next year' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const features = getFeatures(state.get('instance'));
|
||||
interface IBirthdayInput {
|
||||
value?: string,
|
||||
onChange: (value: string) => void,
|
||||
required?: boolean,
|
||||
}
|
||||
|
||||
return {
|
||||
supportsBirthdays: features.birthdays,
|
||||
minAge: state.getIn(['instance', 'pleroma', 'metadata', 'birthday_min_age']),
|
||||
};
|
||||
};
|
||||
const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class BirthdayInput extends ImmutablePureComponent {
|
||||
const supportsBirthdays = features.birthdays;
|
||||
const minAge = useAppSelector((state) => state.instance.getIn(['pleroma', 'metadata', 'birthday_min_age'])) as number;
|
||||
|
||||
static propTypes = {
|
||||
hint: PropTypes.node,
|
||||
required: PropTypes.bool,
|
||||
supportsBirthdays: PropTypes.bool,
|
||||
minAge: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.instanceOf(Date),
|
||||
};
|
||||
const maxDate = useMemo(() => {
|
||||
if (!supportsBirthdays) return null;
|
||||
|
||||
renderHeader = ({
|
||||
let maxDate = new Date();
|
||||
maxDate = new Date(maxDate.getTime() - minAge * 1000 * 60 * 60 * 24 + maxDate.getTimezoneOffset() * 1000 * 60);
|
||||
return maxDate;
|
||||
}, [minAge]);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!supportsBirthdays || !value) return null;
|
||||
|
||||
const date = new Date(value);
|
||||
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000));
|
||||
}, [value]);
|
||||
|
||||
if (!supportsBirthdays) return null;
|
||||
|
||||
const renderCustomHeader = ({
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
|
@ -49,12 +54,20 @@ class BirthdayInput extends ImmutablePureComponent {
|
|||
prevYearButtonDisabled,
|
||||
nextYearButtonDisabled,
|
||||
date,
|
||||
}: {
|
||||
decreaseMonth(): void,
|
||||
increaseMonth(): void,
|
||||
prevMonthButtonDisabled: boolean,
|
||||
nextMonthButtonDisabled: boolean,
|
||||
decreaseYear(): void,
|
||||
increaseYear(): void,
|
||||
prevYearButtonDisabled: boolean,
|
||||
nextYearButtonDisabled: boolean,
|
||||
date: Date,
|
||||
}) => {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='datepicker__header'>
|
||||
<div className='datepicker__months'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<IconButton
|
||||
className='datepicker__button'
|
||||
src={require('@tabler/icons/icons/chevron-left.svg')}
|
||||
|
@ -73,7 +86,7 @@ class BirthdayInput extends ImmutablePureComponent {
|
|||
title={intl.formatMessage(messages.nextMonth)}
|
||||
/>
|
||||
</div>
|
||||
<div className='datepicker__years'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<IconButton
|
||||
className='datepicker__button'
|
||||
src={require('@tabler/icons/icons/chevron-left.svg')}
|
||||
|
@ -94,39 +107,26 @@ class BirthdayInput extends ImmutablePureComponent {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, value, onChange, supportsBirthdays, hint, required, minAge } = this.props;
|
||||
const handleChange = (date: Date) => onChange(new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10));
|
||||
|
||||
if (!supportsBirthdays) return null;
|
||||
return (
|
||||
<div className='mt-1 relative rounded-md shadow-sm'>
|
||||
<BundleContainer fetchComponent={DatePicker}>
|
||||
{Component => (<Component
|
||||
selected={selected}
|
||||
wrapperClassName='react-datepicker-wrapper'
|
||||
onChange={handleChange}
|
||||
placeholderText={intl.formatMessage(messages.birthdayPlaceholder)}
|
||||
minDate={new Date('1900-01-01')}
|
||||
maxDate={maxDate}
|
||||
required={required}
|
||||
renderCustomHeader={renderCustomHeader}
|
||||
/>)}
|
||||
</BundleContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let maxDate = new Date();
|
||||
maxDate = new Date(maxDate.getTime() - minAge * 1000 * 60 * 60 * 24 + maxDate.getTimezoneOffset() * 1000 * 60);
|
||||
|
||||
return (
|
||||
<div className='datepicker'>
|
||||
{hint && (
|
||||
<div className='datepicker__hint'>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
<div className='datepicker__input'>
|
||||
<BundleContainer fetchComponent={DatePicker}>
|
||||
{Component => (<Component
|
||||
selected={value}
|
||||
wrapperClassName='react-datepicker-wrapper'
|
||||
onChange={onChange}
|
||||
placeholderText={intl.formatMessage(messages.birthdayPlaceholder)}
|
||||
minDate={new Date('1900-01-01')}
|
||||
maxDate={maxDate}
|
||||
required={required}
|
||||
renderCustomHeader={this.renderHeader}
|
||||
/>)}
|
||||
</BundleContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export default BirthdayInput;
|
|
@ -12,6 +12,7 @@ export default class IconButton extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
iconClassName: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
|
@ -99,6 +100,7 @@ export default class IconButton extends React.PureComponent {
|
|||
active,
|
||||
animate,
|
||||
className,
|
||||
iconClassName,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
|
@ -144,7 +146,7 @@ export default class IconButton extends React.PureComponent {
|
|||
<div style={src ? {} : style}>
|
||||
{emoji
|
||||
? <div className='icon-button__emoji' dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
|
||||
: <Icon id={icon} src={src} fixedWidth aria-hidden='true' />}
|
||||
: <Icon className={iconClassName} id={icon} src={src} fixedWidth aria-hidden='true' />}
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
|
@ -174,7 +176,7 @@ export default class IconButton extends React.PureComponent {
|
|||
<div style={src ? {} : style}>
|
||||
{emoji
|
||||
? <div className='icon-button__emoji' style={{ transform: `rotate(${rotate}deg)` }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
|
||||
: <Icon id={icon} src={src} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />}
|
||||
: <Icon className={iconClassName} id={icon} src={src} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />}
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
|
|
|
@ -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;
|
|
@ -614,12 +614,12 @@ class MediaGallery extends React.PureComponent {
|
|||
<div className='space-y-1'>
|
||||
<Text weight='semibold'>{warning}</Text>
|
||||
<Text size='sm'>
|
||||
{intl.formatMessage({ id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' })}
|
||||
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button type='button' theme='primary' size='sm' icon={require('@tabler/icons/icons/eye.svg')}>
|
||||
{intl.formatMessage({ id: 'status.sensitive_warning.action', defaultMessage: 'Show content' })}
|
||||
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -64,7 +64,6 @@ interface IProfileHoverCard {
|
|||
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
|
@ -130,7 +129,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
|||
<div className='absolute top-2 left-2'>
|
||||
<Badge
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.follows_you', defaultMessage: 'Follows you' })}
|
||||
title={<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -4,7 +4,9 @@ import PTRComponent from 'react-simple-pull-to-refresh';
|
|||
import { Spinner } from 'soapbox/components/ui';
|
||||
|
||||
interface IPullToRefresh {
|
||||
onRefresh?: () => Promise<any>
|
||||
onRefresh?: () => Promise<any>;
|
||||
refreshingContent?: JSX.Element | string;
|
||||
pullingContent?: JSX.Element | string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
|
@ -42,6 +42,7 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
|||
onRefresh?: () => Promise<any>,
|
||||
className?: string,
|
||||
itemClassName?: string,
|
||||
id?: string,
|
||||
style?: React.CSSProperties,
|
||||
useWindowScroll?: boolean
|
||||
}
|
||||
|
@ -60,6 +61,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
onLoadMore,
|
||||
className,
|
||||
itemClassName,
|
||||
id,
|
||||
hasMore,
|
||||
placeholderComponent: Placeholder,
|
||||
placeholderCount = 0,
|
||||
|
@ -146,6 +148,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
const renderFeed = (): JSX.Element => (
|
||||
<Virtuoso
|
||||
ref={ref}
|
||||
id={id}
|
||||
useWindowScroll={useWindowScroll}
|
||||
className={className}
|
||||
data={data}
|
||||
|
|
|
@ -6,7 +6,6 @@ import { getSettings } from 'soapbox/actions/settings';
|
|||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
import ComposeButton from 'soapbox/features/ui/components/compose-button';
|
||||
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
@ -23,7 +22,6 @@ const SidebarNavigation = () => {
|
|||
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
|
||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
||||
const baseURL = account ? getBaseURL(account) : '';
|
||||
const features = getFeatures(instance);
|
||||
|
||||
const makeMenu = (): Menu => {
|
||||
|
@ -55,14 +53,6 @@ const SidebarNavigation = () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (instance.invites_enabled) {
|
||||
menu.push({
|
||||
to: `${baseURL}/invites`,
|
||||
icon: require('@tabler/icons/icons/mailbox.svg'),
|
||||
text: <FormattedMessage id='navigation.invites' defaultMessage='Invites' />,
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.get('isDeveloper')) {
|
||||
menu.push({
|
||||
to: '/developers',
|
||||
|
|
|
@ -14,7 +14,6 @@ import { Stack } from 'soapbox/components/ui';
|
|||
import ProfileStats from 'soapbox/features/ui/components/profile_stats';
|
||||
import { useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
|
||||
import { HStack, Icon, IconButton, Text } from './ui';
|
||||
|
||||
|
@ -43,14 +42,15 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface ISidebarLink {
|
||||
to: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
icon: string,
|
||||
text: string | JSX.Element,
|
||||
onClick: React.EventHandler<React.MouseEvent>,
|
||||
}
|
||||
|
||||
const SidebarLink: React.FC<ISidebarLink> = ({ to, icon, text, onClick }) => (
|
||||
<NavLink className='group py-1 rounded-md' to={to} onClick={onClick}>
|
||||
const SidebarLink: React.FC<ISidebarLink> = ({ href, to, icon, text, onClick }) => {
|
||||
const body = (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<div className='bg-primary-50 dark:bg-slate-700 relative rounded inline-flex p-2'>
|
||||
<Icon src={icon} className='text-primary-600 h-5 w-5' />
|
||||
|
@ -58,8 +58,22 @@ const SidebarLink: React.FC<ISidebarLink> = ({ to, icon, text, onClick }) => (
|
|||
|
||||
<Text tag='span' weight='medium' theme='muted' className='group-hover:text-gray-800 dark:group-hover:text-gray-200'>{text}</Text>
|
||||
</HStack>
|
||||
</NavLink>
|
||||
);
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<NavLink className='group py-1 rounded-md' to={to} onClick={onClick}>
|
||||
{body}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a className='group py-1 rounded-md' href={href} target='_blank' onClick={onClick}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const getOtherAccounts = makeGetOtherAccounts();
|
||||
|
||||
|
@ -76,8 +90,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
|
||||
const baseURL = account ? getBaseURL(account) : '';
|
||||
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
const [switcher, setSwitcher] = React.useState(false);
|
||||
|
@ -220,15 +232,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{instance.invites_enabled && (
|
||||
<SidebarLink
|
||||
to={`${baseURL}/invites`}
|
||||
icon={require('@tabler/icons/icons/mailbox.svg')}
|
||||
text={intl.formatMessage(messages.invites)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.get('isDeveloper') && (
|
||||
<SidebarLink
|
||||
to='/developers'
|
||||
|
|
|
@ -6,16 +6,20 @@ import { useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
|
|||
interface ISiteLogo extends React.ComponentProps<'img'> {
|
||||
/** Extra class names for the <img> element. */
|
||||
className?: string,
|
||||
/** Override theme setting for <SitePreview /> */
|
||||
theme?: 'dark' | 'light',
|
||||
}
|
||||
|
||||
/** Display the most appropriate site logo based on the theme and configuration. */
|
||||
const SiteLogo: React.FC<ISiteLogo> = ({ className, ...rest }) => {
|
||||
const SiteLogo: React.FC<ISiteLogo> = ({ className, theme, ...rest }) => {
|
||||
const { logo, logoDarkMode } = useSoapboxConfig();
|
||||
const settings = useSettings();
|
||||
|
||||
const systemTheme = useSystemTheme();
|
||||
const userTheme = settings.get('themeMode');
|
||||
const darkMode = userTheme === 'dark' || (userTheme === 'system' && systemTheme === 'dark');
|
||||
const darkMode = theme
|
||||
? theme === 'dark'
|
||||
: (userTheme === 'dark' || (userTheme === 'system' && systemTheme === 'dark'));
|
||||
|
||||
/** Soapbox logo. */
|
||||
const soapboxLogo = darkMode
|
||||
|
|
|
@ -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;
|
|
@ -2,22 +2,17 @@ import classNames from 'classnames';
|
|||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage, IntlShape } from 'react-intl';
|
||||
import { injectIntl, FormattedMessage, IntlShape, defineMessages } from 'react-intl';
|
||||
import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
|
||||
|
||||
import AccountContainer from '../containers/account_container';
|
||||
import Card from '../features/status/components/card';
|
||||
import Bundle from '../features/ui/components/bundle';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
|
||||
import AttachmentThumbs from './attachment-thumbs';
|
||||
import StatusMedia from './status-media';
|
||||
import StatusReplyMentions from './status-reply-mentions';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusContent from './status_content';
|
||||
import StatusReplyMentions from './status_reply_mentions';
|
||||
import { HStack, Text } from './ui';
|
||||
|
||||
import type { History } from 'history';
|
||||
|
@ -31,6 +26,10 @@ import type {
|
|||
// Defined in components/scrollable_list
|
||||
export type ScrollPosition = { height: number, top: number };
|
||||
|
||||
const messages = defineMessages({
|
||||
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
|
||||
});
|
||||
|
||||
export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => {
|
||||
const { account } = status;
|
||||
if (!account || typeof account !== 'object') return '';
|
||||
|
@ -106,7 +105,6 @@ interface IStatusState {
|
|||
showMedia: boolean,
|
||||
statusId?: string,
|
||||
emojiSelectorFocused: boolean,
|
||||
mediaWrapperWidth?: number,
|
||||
}
|
||||
|
||||
class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||
|
@ -219,26 +217,6 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
this.props.onToggleHidden(this._properStatus());
|
||||
};
|
||||
|
||||
renderLoadingMediaGallery(): JSX.Element {
|
||||
return <div className='media_gallery' style={{ height: '285px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer(): JSX.Element {
|
||||
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingAudioPlayer(): JSX.Element {
|
||||
return <div className='media-spoiler-audio' style={{ height: '285px' }} />;
|
||||
}
|
||||
|
||||
handleOpenVideo = (media: ImmutableMap<string, any>, startTime: number): void => {
|
||||
this.props.onOpenVideo(media, startTime);
|
||||
}
|
||||
|
||||
handleOpenAudio = (media: ImmutableMap<string, any>, startTime: number): void => {
|
||||
this.props.onOpenAudio(media, startTime);
|
||||
}
|
||||
|
||||
handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
|
||||
const { onOpenMedia, onOpenVideo } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
@ -282,13 +260,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
}
|
||||
|
||||
handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
|
||||
// FIXME: what's going on here?
|
||||
// this.props.onMoveUp(this.props.status.id, e?.target?.getAttribute('data-featured'));
|
||||
this.props.onMoveUp(this.props.status.id, this.props.featured);
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
|
||||
// FIXME: what's going on here?
|
||||
// this.props.onMoveDown(this.props.status.id, e?.target?.getAttribute('data-featured'));
|
||||
this.props.onMoveDown(this.props.status.id, this.props.featured);
|
||||
}
|
||||
|
||||
handleHotkeyToggleHidden = (): void => {
|
||||
|
@ -334,14 +310,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
this.node = c;
|
||||
}
|
||||
|
||||
setRef = (c: HTMLDivElement): void => {
|
||||
if (c) {
|
||||
this.setState({ mediaWrapperWidth: c.offsetWidth });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let media = null;
|
||||
const poll = null;
|
||||
let prepend, rebloggedByText, reblogElement, reblogElementMobile;
|
||||
|
||||
|
@ -439,132 +408,16 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
</div>
|
||||
);
|
||||
|
||||
rebloggedByText = intl.formatMessage({
|
||||
id: 'status.reblogged_by',
|
||||
defaultMessage: '{name} reposted',
|
||||
}, {
|
||||
name: String(status.getIn(['account', 'acct'])),
|
||||
});
|
||||
rebloggedByText = intl.formatMessage(
|
||||
messages.reblogged_by,
|
||||
{ name: String(status.getIn(['account', 'acct'])) },
|
||||
);
|
||||
|
||||
// @ts-ignore what the FUCK
|
||||
account = status.account;
|
||||
status = status.reblog;
|
||||
}
|
||||
|
||||
const size = status.media_attachments.size;
|
||||
const firstAttachment = status.media_attachments.first();
|
||||
|
||||
if (size > 0 && firstAttachment) {
|
||||
if (this.props.muted) {
|
||||
media = (
|
||||
<AttachmentThumbs
|
||||
media={status.media_attachments}
|
||||
onClick={this.handleClick}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
);
|
||||
} else if (size === 1 && firstAttachment.type === 'video') {
|
||||
const video = firstAttachment;
|
||||
|
||||
if (video.external_video_id && status.card) {
|
||||
const { mediaWrapperWidth } = this.state;
|
||||
|
||||
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={this.setRef}
|
||||
className='status-card__image status-card-video'
|
||||
style={height ? { height } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: status.card.html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
preview={video.preview_url}
|
||||
blurhash={video.blurhash}
|
||||
src={video.url}
|
||||
alt={video.description}
|
||||
aspectRatio={video.meta.getIn(['original', 'aspect'])}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={285}
|
||||
inline
|
||||
sensitive={status.sensitive}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (size === 1 && firstAttachment.type === 'audio') {
|
||||
const attachment = firstAttachment;
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={this.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)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={263}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={status.media_attachments}
|
||||
sensitive={status.sensitive}
|
||||
height={285}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||
media = (
|
||||
<Card
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
card={status.card}
|
||||
compact
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
/>
|
||||
);
|
||||
} else if (status.expectsCard) {
|
||||
media = (
|
||||
<PlaceholderCard />
|
||||
);
|
||||
}
|
||||
|
||||
let quote;
|
||||
|
||||
if (status.quote) {
|
||||
|
@ -601,7 +454,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
<div
|
||||
className='status cursor-pointer'
|
||||
className={classNames('status cursor-pointer', { focusable: this.props.focusable })}
|
||||
tabIndex={this.props.focusable && !this.props.muted ? 0 : undefined}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
||||
|
@ -654,7 +507,14 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
collapsable
|
||||
/>
|
||||
|
||||
{media}
|
||||
<StatusMedia
|
||||
status={status}
|
||||
muted={this.props.muted}
|
||||
onClick={this.handleClick}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
|
||||
{poll}
|
||||
{quote}
|
||||
|
||||
|
|
|
@ -77,18 +77,18 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
this.props.onLoadMore(loadMoreID);
|
||||
}, 300, { leading: true })
|
||||
|
||||
_selectChild(index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_selectChild(index) {
|
||||
this.node.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleDequeueTimeline = () => {
|
||||
|
@ -102,7 +102,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
renderLoadGap(index) {
|
||||
const { statusIds, onLoadMore, isLoading } = this.props;
|
||||
const { statusIds, onLoadMore, isLoading } = this.props;
|
||||
|
||||
return (
|
||||
<LoadGap
|
||||
|
@ -115,7 +115,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
renderStatus(statusId) {
|
||||
const { timelineId, withGroupAdmin, group } = this.props;
|
||||
const { timelineId, withGroupAdmin, group } = this.props;
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
|
@ -148,7 +148,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
renderFeaturedStatuses() {
|
||||
const { featuredStatusIds, timelineId } = this.props;
|
||||
const { featuredStatusIds, timelineId } = this.props;
|
||||
if (!featuredStatusIds) return null;
|
||||
|
||||
return featuredStatusIds.map(statusId => (
|
||||
|
@ -164,7 +164,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
renderStatuses() {
|
||||
const { statusIds, isLoading } = this.props;
|
||||
const { statusIds, isLoading } = this.props;
|
||||
|
||||
if (isLoading || statusIds.size > 0) {
|
||||
return statusIds.map((statusId, index) => {
|
||||
|
@ -193,7 +193,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { statusIds, divideType, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
|
||||
const { statusIds, divideType, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
|
||||
|
||||
if (isPartial) {
|
||||
return (
|
||||
|
@ -216,6 +216,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
message={messages.queue}
|
||||
/>,
|
||||
<ScrollableList
|
||||
id='status-list'
|
||||
key='scrollable-list'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && statusIds.size === 0}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -63,7 +63,7 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
|
|||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||
|
||||
return (
|
||||
<Comp {...backAttributes} className='mr-2 text-gray-900 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
|
||||
<Comp {...backAttributes} className='mr-2 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
|
||||
<SvgIcon src={require('@tabler/icons/icons/arrow-left.svg')} className='h-6 w-6' />
|
||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||
</Comp>
|
||||
|
@ -80,7 +80,7 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
|
|||
};
|
||||
|
||||
interface ICardTitle {
|
||||
title: string | React.ReactNode
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
/** A card's title. */
|
||||
|
|
|
@ -4,6 +4,8 @@ import React from 'react';
|
|||
const justifyContentOptions = {
|
||||
between: 'justify-between',
|
||||
center: 'justify-center',
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
};
|
||||
|
||||
const alignItemsOptions = {
|
||||
|
@ -29,7 +31,7 @@ interface IHStack {
|
|||
/** Extra class names on the <div> element. */
|
||||
className?: string,
|
||||
/** Horizontal alignment of children. */
|
||||
justifyContent?: 'between' | 'center',
|
||||
justifyContent?: 'between' | 'center' | 'start' | 'end',
|
||||
/** Size of the gap between elements. */
|
||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
|
||||
/** Whether to let the flexbox grow. */
|
||||
|
|
|
@ -64,8 +64,8 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'dark:bg-slate-800 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||
true,
|
||||
'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||
true,
|
||||
'pr-7': isPassword,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
'pl-8': typeof icon !== 'undefined',
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
z-index: 1003;
|
||||
}
|
||||
|
||||
[data-reach-menu-button] {
|
||||
@apply focus:ring-primary-500 focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
div:focus[data-reach-menu-list] {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ interface IModal {
|
|||
/** Don't focus the "confirm" button on mount. */
|
||||
skipFocus?: boolean,
|
||||
/** Title text for the modal. */
|
||||
title: string | React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
width?: Widths,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
|
||||
/**
|
||||
* iOS style loading spinner.
|
||||
* Adapted from: https://loading.io/css/
|
||||
* With some help scaling it: https://signalvnoise.com/posts/2577-loading-spinner-animation-using-css-and-webkit
|
||||
*/
|
||||
|
||||
.spinner {
|
||||
@apply inline-block relative w-20 h-20;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import Text from '../text/text';
|
|||
|
||||
import './spinner.css';
|
||||
|
||||
interface ILoadingIndicator {
|
||||
interface ISpinner {
|
||||
/** Width and height of the spinner in pixels. */
|
||||
size?: number,
|
||||
/** Whether to display "Loading..." beneath the spinner. */
|
||||
|
@ -14,7 +14,7 @@ interface ILoadingIndicator {
|
|||
}
|
||||
|
||||
/** Spinning loading placeholder. */
|
||||
const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
|
||||
const Spinner = ({ size = 30, withText = true }: ISpinner) => (
|
||||
<Stack space={2} justifyContent='center' alignItems='center'>
|
||||
<div className='spinner' style={{ width: size, height: size }}>
|
||||
{Array.from(Array(12).keys()).map(i => (
|
||||
|
@ -30,4 +30,4 @@ const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) =>
|
|||
</Stack>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
||||
export default Spinner;
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
[data-reach-tab] {
|
||||
@apply flex-1 flex justify-center items-center
|
||||
py-4 px-1 text-center font-medium text-sm text-gray-500
|
||||
dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200;
|
||||
dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200
|
||||
focus:ring-primary-500 focus:ring-2;
|
||||
}
|
||||
|
||||
[data-reach-tab][data-selected] {
|
||||
|
|
|
@ -9,6 +9,7 @@ type TrackingSizes = 'normal' | 'wide'
|
|||
type TransformProperties = 'uppercase' | 'normal'
|
||||
type Families = 'sans' | 'mono'
|
||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
|
||||
type Directions = 'ltr' | 'rtl'
|
||||
|
||||
const themes = {
|
||||
default: 'text-gray-900 dark:text-gray-100',
|
||||
|
@ -64,6 +65,8 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
|||
align?: Alignments,
|
||||
/** Extra class names for the outer element. */
|
||||
className?: string,
|
||||
/** Text direction. */
|
||||
direction?: Directions,
|
||||
/** Typeface of the text. */
|
||||
family?: Families,
|
||||
/** The "for" attribute specifies which form element a label is bound to. */
|
||||
|
@ -90,6 +93,7 @@ const Text: React.FC<IText> = React.forwardRef(
|
|||
const {
|
||||
align,
|
||||
className,
|
||||
direction,
|
||||
family = 'sans',
|
||||
size = 'md',
|
||||
tag = 'p',
|
||||
|
@ -109,7 +113,10 @@ const Text: React.FC<IText> = React.forwardRef(
|
|||
<Comp
|
||||
{...filteredProps}
|
||||
ref={ref}
|
||||
style={tag === 'abbr' ? { textDecoration: 'underline dotted' } : undefined}
|
||||
style={{
|
||||
textDecoration: tag === 'abbr' ? 'underline dotted' : undefined,
|
||||
direction,
|
||||
}}
|
||||
className={classNames({
|
||||
'cursor-default': tag === 'abbr',
|
||||
truncate: truncate,
|
||||
|
|
|
@ -28,6 +28,7 @@ interface IWidget {
|
|||
actionIcon?: string,
|
||||
/** Text for the action. */
|
||||
actionTitle?: string,
|
||||
action?: JSX.Element,
|
||||
}
|
||||
|
||||
/** Sidebar widget. */
|
||||
|
@ -37,19 +38,20 @@ const Widget: React.FC<IWidget> = ({
|
|||
onActionClick,
|
||||
actionIcon = require('@tabler/icons/icons/arrow-right.svg'),
|
||||
actionTitle,
|
||||
action,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<HStack alignItems='center'>
|
||||
<WidgetTitle title={title} />
|
||||
{onActionClick && (
|
||||
{action || (onActionClick && (
|
||||
<IconButton
|
||||
className='w-6 h-6 ml-2 text-black dark:text-white'
|
||||
src={actionIcon}
|
||||
onClick={onActionClick}
|
||||
title={actionTitle}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</HStack>
|
||||
<WidgetBody>{children}</WidgetBody>
|
||||
</Stack>
|
||||
|
|
|
@ -14,12 +14,16 @@ import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|||
import { fetchVerificationConfig } from 'soapbox/actions/verification';
|
||||
import * as BuildConfig from 'soapbox/build_config';
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import LoadingScreen from 'soapbox/components/loading-screen';
|
||||
import AuthLayout from 'soapbox/features/auth_layout';
|
||||
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
|
||||
import PublicLayout from 'soapbox/features/public_layout';
|
||||
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
|
||||
import { ModalContainer } from 'soapbox/features/ui/util/async-components';
|
||||
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||
import {
|
||||
ModalContainer,
|
||||
NotificationsContainer,
|
||||
OnboardingWizard,
|
||||
WaitlistPage,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { createGlobals } from 'soapbox/globals';
|
||||
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/locales/messages';
|
||||
|
@ -30,7 +34,6 @@ import { checkOnboardingStatus } from '../actions/onboarding';
|
|||
import { preload } from '../actions/preload';
|
||||
import ErrorBoundary from '../components/error_boundary';
|
||||
import UI from '../features/ui';
|
||||
import BundleContainer from '../features/ui/containers/bundle_container';
|
||||
import { store } from '../store';
|
||||
|
||||
/** Ensure the given locale exists in our codebase */
|
||||
|
@ -66,6 +69,7 @@ const loadInitial = () => {
|
|||
};
|
||||
};
|
||||
|
||||
/** Highest level node with the Redux store. */
|
||||
const SoapboxMount = () => {
|
||||
useCachedLocationHandler();
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -79,7 +83,9 @@ const SoapboxMount = () => {
|
|||
|
||||
const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en';
|
||||
|
||||
const waitlisted = account && !account.source.get('approved', true);
|
||||
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
|
||||
const showOnboarding = account && !waitlisted && needsOnboarding;
|
||||
const singleUserMode = soapboxConfig.singleUserMode && soapboxConfig.singleUserModeProfile;
|
||||
|
||||
const [messages, setMessages] = useState<Record<string, string>>({});
|
||||
|
@ -115,12 +121,13 @@ const SoapboxMount = () => {
|
|||
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
|
||||
};
|
||||
|
||||
if (me === null) return null;
|
||||
if (me && !account) return null;
|
||||
if (!isLoaded) return null;
|
||||
if (localeLoading) return null;
|
||||
|
||||
const waitlisted = account && !account.source.get('approved', true);
|
||||
/** Whether to display a loading indicator. */
|
||||
const showLoading = [
|
||||
me === null,
|
||||
me && !account,
|
||||
!isLoaded,
|
||||
localeLoading,
|
||||
].some(Boolean);
|
||||
|
||||
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base h-full', {
|
||||
'no-reduce-motion': !settings.get('reduceMotion'),
|
||||
|
@ -129,94 +136,118 @@ const SoapboxMount = () => {
|
|||
'demetricator': settings.get('demetricator'),
|
||||
});
|
||||
|
||||
if (account && !waitlisted && needsOnboarding) {
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Helmet>
|
||||
<html lang={locale} className={classNames({ dark: darkMode })} />
|
||||
<body className={bodyClass} />
|
||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||
</Helmet>
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<html lang={locale} className={classNames('h-full', { dark: darkMode })} />
|
||||
<body className={bodyClass} />
|
||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||
{darkMode && <style type='text/css'>{':root { color-scheme: dark; }'}</style>}
|
||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
|
||||
<OnboardingWizard />
|
||||
<NotificationsContainer />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</IntlProvider>
|
||||
/** Render the onboarding flow. */
|
||||
const renderOnboarding = () => (
|
||||
<BundleContainer fetchComponent={OnboardingWizard} loading={LoadingScreen}>
|
||||
{(Component) => <Component />}
|
||||
</BundleContainer>
|
||||
);
|
||||
|
||||
/** Render the auth layout or UI. */
|
||||
const renderSwitch = () => (
|
||||
<Switch>
|
||||
<Redirect from='/v1/verify_email/:token' to='/verify/email/:token' />
|
||||
|
||||
{/* Redirect signup route depending on Pepe enablement. */}
|
||||
{/* We should prefer using /signup in components. */}
|
||||
{pepeEnabled ? (
|
||||
<Redirect from='/signup' to='/verify' />
|
||||
) : (
|
||||
<Redirect from='/verify' to='/signup' />
|
||||
)}
|
||||
|
||||
{waitlisted && (
|
||||
<Route render={(props) => (
|
||||
<BundleContainer fetchComponent={WaitlistPage} loading={LoadingScreen}>
|
||||
{(Component) => <Component {...props} account={account} />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!me && (singleUserMode
|
||||
? <Redirect exact from='/' to={`/${singleUserMode}`} />
|
||||
: <Route exact path='/' component={PublicLayout} />)}
|
||||
|
||||
{!me && (
|
||||
<Route exact path='/' component={PublicLayout} />
|
||||
)}
|
||||
|
||||
<Route exact path='/about/:slug?' component={PublicLayout} />
|
||||
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
||||
<Route path='/login' component={AuthLayout} />
|
||||
|
||||
{(features.accountCreation && instance.registrations) && (
|
||||
<Route exact path='/signup' component={AuthLayout} />
|
||||
)}
|
||||
|
||||
{pepeEnabled && (
|
||||
<Route path='/verify' component={AuthLayout} />
|
||||
)}
|
||||
|
||||
<Route path='/reset-password' component={AuthLayout} />
|
||||
<Route path='/edit-password' component={AuthLayout} />
|
||||
<Route path='/invite/:token' component={AuthLayout} />
|
||||
|
||||
<Route path='/' component={UI} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
/** Render the onboarding flow or UI. */
|
||||
const renderBody = () => {
|
||||
if (showOnboarding) {
|
||||
return renderOnboarding();
|
||||
} else {
|
||||
return renderSwitch();
|
||||
}
|
||||
};
|
||||
|
||||
// intl is part of loading.
|
||||
// It's important nothing in here depends on intl.
|
||||
if (showLoading) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<LoadingScreen />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Helmet>
|
||||
<html lang={locale} className={classNames('h-full', { dark: darkMode })} />
|
||||
<body className={bodyClass} />
|
||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||
</Helmet>
|
||||
|
||||
{helmet}
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
|
||||
<>
|
||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
||||
<Switch>
|
||||
<Redirect from='/v1/verify_email/:token' to='/verify/email/:token' />
|
||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
||||
<>
|
||||
{renderBody()}
|
||||
|
||||
{/* Redirect signup route depending on Pepe enablement. */}
|
||||
{/* We should prefer using /signup in components. */}
|
||||
{pepeEnabled ? (
|
||||
<Redirect from='/signup' to='/verify' />
|
||||
) : (
|
||||
<Redirect from='/verify' to='/signup' />
|
||||
)}
|
||||
<BundleContainer fetchComponent={NotificationsContainer}>
|
||||
{(Component) => <Component />}
|
||||
</BundleContainer>
|
||||
|
||||
{waitlisted && (
|
||||
<>
|
||||
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
|
||||
|
||||
<BundleContainer fetchComponent={ModalContainer}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!me && (singleUserMode
|
||||
? <Redirect exact from='/' to={`/${singleUserMode}`} />
|
||||
: <Route exact path='/' component={PublicLayout} />)}
|
||||
|
||||
{!me && (
|
||||
<Route exact path='/' component={PublicLayout} />
|
||||
)}
|
||||
|
||||
<Route exact path='/about/:slug?' component={PublicLayout} />
|
||||
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
||||
<Route path='/login' component={AuthLayout} />
|
||||
|
||||
{(features.accountCreation && instance.registrations) && (
|
||||
<Route exact path='/signup' component={AuthLayout} />
|
||||
)}
|
||||
|
||||
{pepeEnabled && (
|
||||
<Route path='/verify' component={AuthLayout} />
|
||||
)}
|
||||
|
||||
<Route path='/reset-password' component={AuthLayout} />
|
||||
<Route path='/edit-password' component={AuthLayout} />
|
||||
<Route path='/invite/:token' component={AuthLayout} />
|
||||
|
||||
<Route path='/' component={UI} />
|
||||
</Switch>
|
||||
</ScrollContext>
|
||||
</>
|
||||
<BundleContainer fetchComponent={ModalContainer}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
</>
|
||||
</ScrollContext>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** The root React node of the application. */
|
||||
const Soapbox = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
|
|
|
@ -6,7 +6,7 @@ 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 { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
@ -48,6 +48,7 @@ const messages = defineMessages({
|
|||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
|
||||
|
@ -283,6 +284,14 @@ class Header extends ImmutablePureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
if (features.removeFromFollowers && account.getIn(['relationship', 'followed_by'])) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers),
|
||||
action: this.props.onRemoveFromFollowers,
|
||||
icon: require('@tabler/icons/icons/user-x.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.unmute, { name: account.get('username') }),
|
||||
|
@ -448,7 +457,7 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
makeInfo() {
|
||||
const { account, intl, me } = this.props;
|
||||
const { account, me } = this.props;
|
||||
|
||||
const info = [];
|
||||
|
||||
|
@ -459,7 +468,7 @@ class Header extends ImmutablePureComponent {
|
|||
<Badge
|
||||
key='followed_by'
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.follows_you', defaultMessage: 'Follows you' })}
|
||||
title={<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />}
|
||||
/>,
|
||||
);
|
||||
} else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
|
||||
|
@ -467,7 +476,7 @@ class Header extends ImmutablePureComponent {
|
|||
<Badge
|
||||
key='blocked'
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.blocked', defaultMessage: 'Blocked' })}
|
||||
title={<FormattedMessage id='account.blocked' defaultMessage='Blocked' />}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
@ -477,7 +486,7 @@ class Header extends ImmutablePureComponent {
|
|||
<Badge
|
||||
key='muted'
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.muted', defaultMessage: 'Muted' })}
|
||||
title={<FormattedMessage id='account.muted' defaultMessage='Muted' />}
|
||||
/>,
|
||||
);
|
||||
} else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
|
||||
|
@ -485,7 +494,7 @@ class Header extends ImmutablePureComponent {
|
|||
<Badge
|
||||
key='domain_blocked'
|
||||
slug='opaque'
|
||||
title={intl.formatMessage({ id: 'account.domain_blocked', defaultMessage: 'Domain hidden' })}
|
||||
title={<FormattedMessage id='account.domain_blocked' defaultMessage='Domain hidden' />}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
fetchAccountByUsername,
|
||||
} from 'soapbox/actions/accounts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { expandAccountMediaTimeline } from 'soapbox/actions/timelines';
|
||||
import LoadMore from 'soapbox/components/load_more';
|
||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
|
@ -17,8 +18,6 @@ import { Spinner } from 'soapbox/components/ui';
|
|||
import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import { expandAccountMediaTimeline } from '../../actions/timelines';
|
||||
|
||||
import MediaItem from './components/media_item';
|
||||
|
||||
const mapStateToProps = (state, { params, withReplies = false }) => {
|
||||
|
|
|
@ -25,6 +25,7 @@ class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onRemoveFromFollowers: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
@ -141,6 +142,10 @@ class Header extends ImmutablePureComponent {
|
|||
this.props.onShowNote(this.props.account);
|
||||
}
|
||||
|
||||
handleRemoveFromFollowers = () => {
|
||||
this.props.onRemoveFromFollowers(this.props.account);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { account } = this.props;
|
||||
const moved = (account) ? account.get('moved') : false;
|
||||
|
@ -177,6 +182,7 @@ class Header extends ImmutablePureComponent {
|
|||
onSuggestUser={this.handleSuggestUser}
|
||||
onUnsuggestUser={this.handleUnsuggestUser}
|
||||
onShowNote={this.handleShowNote}
|
||||
onRemoveFromFollowers={this.handleRemoveFromFollowers}
|
||||
username={this.props.username}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { initAccountNoteModal } from 'soapbox/actions/account_notes';
|
||||
import { initAccountNoteModal } from 'soapbox/actions/account-notes';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
|
@ -13,6 +13,7 @@ import {
|
|||
unpinAccount,
|
||||
subscribeAccount,
|
||||
unsubscribeAccount,
|
||||
removeFromFollowers,
|
||||
} from 'soapbox/actions/accounts';
|
||||
import {
|
||||
verifyUser,
|
||||
|
@ -56,6 +57,7 @@ const messages = defineMessages({
|
|||
demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' },
|
||||
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
|
||||
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
||||
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
|
@ -269,6 +271,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
onShowNote(account) {
|
||||
dispatch(initAccountNoteModal(account));
|
||||
},
|
||||
|
||||
onRemoveFromFollowers(account) {
|
||||
dispatch((_, getState) => {
|
||||
const unfollowModal = getSettings(getState()).get('unfollowModal');
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
|
||||
onConfirm: () => dispatch(removeFromFollowers(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(removeFromFollowers(account.get('id')));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
|
||||
|
|
|
@ -6,18 +6,17 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts';
|
||||
import { fetchPatronAccount } from 'soapbox/actions/patron';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'soapbox/actions/timelines';
|
||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||
import StatusList from 'soapbox/components/status_list';
|
||||
import { Card, CardBody, Spinner, Text } from 'soapbox/components/ui';
|
||||
import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import { fetchAccount, fetchAccountByUsername } from '../../actions/accounts';
|
||||
import { fetchPatronAccount } from '../../actions/patron';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
|
||||
import StatusList from '../../components/status_list';
|
||||
import { Card, CardBody, Spinner, Text } from '../../components/ui';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatusIds = makeGetStatusIds();
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { OrderedSet as ImmutableOrderedSet, is } from 'immutable';
|
||||
import React, { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -32,8 +31,8 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
|||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUsers(['local', 'active'], 1, null, limit))
|
||||
.then((value) => {
|
||||
setTotal((value as { count: number }).count);
|
||||
.then((value: { count: number }) => {
|
||||
setTotal(value.count);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
|
|
@ -14,8 +14,8 @@ import { useAppDispatch } from 'soapbox/hooks';
|
|||
|
||||
import ReportStatus from './report_status';
|
||||
|
||||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Account, AdminReport, Status } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
|
||||
|
@ -24,7 +24,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface IReport {
|
||||
report: ImmutableMap<string, any>;
|
||||
report: AdminReport;
|
||||
}
|
||||
|
||||
const Report: React.FC<IReport> = ({ report }) => {
|
||||
|
@ -33,32 +33,35 @@ const Report: React.FC<IReport> = ({ report }) => {
|
|||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState(false);
|
||||
|
||||
const account = report.account as Account;
|
||||
const targetAccount = report.target_account as Account;
|
||||
|
||||
const makeMenu = () => {
|
||||
return [{
|
||||
text: intl.formatMessage(messages.deactivateUser, { name: report.getIn(['account', 'username']) as string }),
|
||||
text: intl.formatMessage(messages.deactivateUser, { name: targetAccount.username as string }),
|
||||
action: handleDeactivateUser,
|
||||
icon: require('@tabler/icons/icons/user-off.svg'),
|
||||
}, {
|
||||
text: intl.formatMessage(messages.deleteUser, { name: report.getIn(['account', 'username']) as string }),
|
||||
text: intl.formatMessage(messages.deleteUser, { name: targetAccount.username as string }),
|
||||
action: handleDeleteUser,
|
||||
icon: require('@tabler/icons/icons/user-minus.svg'),
|
||||
}];
|
||||
};
|
||||
|
||||
const handleCloseReport = () => {
|
||||
dispatch(closeReports([report.get('id')])).then(() => {
|
||||
const message = intl.formatMessage(messages.reportClosed, { name: report.getIn(['account', 'username']) as string });
|
||||
dispatch(closeReports([report.id])).then(() => {
|
||||
const message = intl.formatMessage(messages.reportClosed, { name: targetAccount.username as string });
|
||||
dispatch(snackbar.success(message));
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleDeactivateUser = () => {
|
||||
const accountId = report.getIn(['account', 'id']);
|
||||
const accountId = targetAccount.id;
|
||||
dispatch(deactivateUserModal(intl, accountId, () => handleCloseReport()));
|
||||
};
|
||||
|
||||
const handleDeleteUser = () => {
|
||||
const accountId = report.getIn(['account', 'id']) as string;
|
||||
const accountId = targetAccount.id as string;
|
||||
dispatch(deleteUserModal(intl, accountId, () => handleCloseReport()));
|
||||
};
|
||||
|
||||
|
@ -67,17 +70,17 @@ const Report: React.FC<IReport> = ({ report }) => {
|
|||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
const statuses = report.get('statuses') as ImmutableList<Status>;
|
||||
const statuses = report.statuses as ImmutableList<Status>;
|
||||
const statusCount = statuses.count();
|
||||
const acct = report.getIn(['account', 'acct']) as string;
|
||||
const reporterAcct = report.getIn(['actor', 'acct']) as string;
|
||||
const acct = targetAccount.acct as string;
|
||||
const reporterAcct = account.acct as string;
|
||||
|
||||
return (
|
||||
<div className='admin-report' key={report.get('id')}>
|
||||
<div className='admin-report' key={report.id}>
|
||||
<div className='admin-report__avatar'>
|
||||
<HoverRefWrapper accountId={report.getIn(['account', 'id']) as string} inline>
|
||||
<HoverRefWrapper accountId={targetAccount.id as string} inline>
|
||||
<Link to={`/@${acct}`} title={acct}>
|
||||
<Avatar account={report.get('account')} size={32} />
|
||||
<Avatar account={targetAccount} size={32} />
|
||||
</Link>
|
||||
</HoverRefWrapper>
|
||||
</div>
|
||||
|
@ -87,7 +90,7 @@ const Report: React.FC<IReport> = ({ report }) => {
|
|||
id='admin.reports.report_title'
|
||||
defaultMessage='Report on {acct}'
|
||||
values={{ acct: (
|
||||
<HoverRefWrapper accountId={report.getIn(['account', 'id']) as string} inline>
|
||||
<HoverRefWrapper accountId={account.id as string} inline>
|
||||
<Link to={`/@${acct}`} title={acct}>@{acct}</Link>
|
||||
</HoverRefWrapper>
|
||||
) }}
|
||||
|
@ -105,12 +108,12 @@ const Report: React.FC<IReport> = ({ report }) => {
|
|||
)}
|
||||
</div>
|
||||
<div className='admin-report__quote'>
|
||||
{report.get('content', '').length > 0 && (
|
||||
<blockquote className='md' dangerouslySetInnerHTML={{ __html: report.get('content') }} />
|
||||
{(report.comment || '').length > 0 && (
|
||||
<blockquote className='md' dangerouslySetInnerHTML={{ __html: report.comment }} />
|
||||
)}
|
||||
<span className='byline'>
|
||||
—
|
||||
<HoverRefWrapper accountId={report.getIn(['actor', 'id']) as string} inline>
|
||||
<HoverRefWrapper accountId={account.id as string} inline>
|
||||
<Link to={`/@${reporterAcct}`} title={reporterAcct}>@{reporterAcct}</Link>
|
||||
</HoverRefWrapper>
|
||||
</span>
|
||||
|
|
|
@ -10,8 +10,7 @@ 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 { Map as ImmutableMap } from 'immutable';
|
||||
import type { Status, Attachment } from 'soapbox/types/entities';
|
||||
import type { AdminReport, Attachment, Status } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
|
||||
|
@ -20,7 +19,7 @@ const messages = defineMessages({
|
|||
|
||||
interface IReportStatus {
|
||||
status: Status,
|
||||
report?: ImmutableMap<string, any>,
|
||||
report?: AdminReport,
|
||||
}
|
||||
|
||||
const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
|
||||
|
|
|
@ -2,13 +2,12 @@ import React from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { approveUsers } from 'soapbox/actions/admin';
|
||||
import { rejectUserModal } from 'soapbox/actions/moderation';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
import { rejectUserModal } from '../../../actions/moderation';
|
||||
|
||||
const messages = defineMessages({
|
||||
approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
|
||||
rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
|
||||
|
@ -26,6 +25,7 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const account = useAppSelector(state => getAccount(state, accountId));
|
||||
const adminAccount = useAppSelector(state => state.admin.users.get(accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
|
@ -45,12 +45,11 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
|||
}));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className='unapproved-account'>
|
||||
<div className='unapproved-account__bio'>
|
||||
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
|
||||
<blockquote className='md'>{account.pleroma.getIn(['admin', 'registration_reason'], '') as string}</blockquote>
|
||||
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote>
|
||||
</div>
|
||||
<div className='unapproved-account__actions'>
|
||||
<IconButton src={require('@tabler/icons/icons/check.svg')} onClick={handleApprove} />
|
||||
|
|
|
@ -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;
|
|
@ -42,7 +42,7 @@ const Reports: React.FC = () => {
|
|||
scrollKey='admin-reports'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
>
|
||||
{reports.map(report => <Report report={report} key={report.get('id')} />)}
|
||||
{reports.map(report => report && <Report report={report} key={report?.id} />)}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -34,6 +34,7 @@ class UserIndex extends ImmutablePureComponent {
|
|||
pageSize: 50,
|
||||
page: 0,
|
||||
query: '',
|
||||
nextLink: undefined,
|
||||
}
|
||||
|
||||
clearState = callback => {
|
||||
|
@ -45,11 +46,11 @@ class UserIndex extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
fetchNextPage = () => {
|
||||
const { filters, page, query, pageSize } = this.state;
|
||||
const { filters, page, query, pageSize, nextLink } = this.state;
|
||||
const nextPage = page + 1;
|
||||
|
||||
this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize))
|
||||
.then(({ users, count }) => {
|
||||
this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize, nextLink))
|
||||
.then(({ users, count, next }) => {
|
||||
const newIds = users.map(user => user.id);
|
||||
|
||||
this.setState({
|
||||
|
@ -57,6 +58,7 @@ class UserIndex extends ImmutablePureComponent {
|
|||
accountIds: this.state.accountIds.union(newIds),
|
||||
total: count,
|
||||
page: nextPage,
|
||||
nextLink: next,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
@ -97,7 +99,7 @@ class UserIndex extends ImmutablePureComponent {
|
|||
render() {
|
||||
const { intl } = this.props;
|
||||
const { accountIds, isLoading } = this.state;
|
||||
const hasMore = accountIds.count() < this.state.total;
|
||||
const hasMore = accountIds.count() < this.state.total && this.state.nextLink !== false;
|
||||
|
||||
const showLoading = isLoading && accountIds.isEmpty();
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -4,8 +4,6 @@ import { Link, Redirect, Route, Switch, useHistory } from 'react-router-dom';
|
|||
|
||||
import LandingGradient from 'soapbox/components/landing-gradient';
|
||||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||
import { NotificationsContainer } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Button, Card, CardBody } from '../../components/ui';
|
||||
|
@ -86,10 +84,6 @@ const AuthLayout = () => {
|
|||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BundleContainer fetchComponent={NotificationsContainer}>
|
||||
{(Component) => <Component />}
|
||||
</BundleContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,7 +26,7 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
|||
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'>{intl.formatMessage({ id: 'login_form.header', defaultMessage: 'Sign In' })}</h1>
|
||||
<h1 className='text-center font-bold text-2xl'><FormattedMessage id='login_form.header' defaultMessage='Sign In' /></h1>
|
||||
</div>
|
||||
|
||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
|
|
|
@ -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;
|
|
@ -1,14 +1,17 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage, injectIntl, useIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { resetPasswordConfirm } from 'soapbox/actions/security';
|
||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const token = new URLSearchParams(window.location.search).get('reset_password_token');
|
||||
|
||||
const messages = defineMessages({
|
||||
resetPasswordFail: { id: 'reset_password.fail', defaultMessage: 'Expired token, please try again.' },
|
||||
});
|
||||
|
||||
const Statuses = {
|
||||
IDLE: 'IDLE',
|
||||
LOADING: 'LOADING',
|
||||
|
@ -16,12 +19,9 @@ const Statuses = {
|
|||
FAIL: 'FAIL',
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
resetPasswordConfirm: (password, token) => dispatch(resetPasswordConfirm(password, token)),
|
||||
});
|
||||
|
||||
const PasswordResetConfirm = ({ resetPasswordConfirm }) => {
|
||||
const PasswordResetConfirm = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
||||
|
@ -32,10 +32,10 @@ const PasswordResetConfirm = ({ resetPasswordConfirm }) => {
|
|||
event.preventDefault();
|
||||
|
||||
setStatus(Statuses.LOADING);
|
||||
resetPasswordConfirm(password, token)
|
||||
dispatch(resetPasswordConfirm(password, token))
|
||||
.then(() => setStatus(Statuses.SUCCESS))
|
||||
.catch(() => setStatus(Statuses.FAIL));
|
||||
}, [resetPasswordConfirm, password]);
|
||||
}, [password]);
|
||||
|
||||
const onChange = React.useCallback((event) => {
|
||||
setPassword(event.target.value);
|
||||
|
@ -43,7 +43,7 @@ const PasswordResetConfirm = ({ resetPasswordConfirm }) => {
|
|||
|
||||
const renderErrors = () => {
|
||||
if (status === Statuses.FAIL) {
|
||||
return [intl.formatMessage({ id: 'reset_password.fail', defaultMessage: 'Expired token, please try again.' })];
|
||||
return [intl.formatMessage(messages.resetPasswordFail)];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
@ -84,8 +84,4 @@ const PasswordResetConfirm = ({ resetPasswordConfirm }) => {
|
|||
);
|
||||
};
|
||||
|
||||
PasswordResetConfirm.propTypes = {
|
||||
resetPasswordConfirm: PropTypes.func,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(null, mapDispatchToProps)(PasswordResetConfirm));
|
||||
export default PasswordResetConfirm;
|
|
@ -58,7 +58,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
const [usernameUnavailable, setUsernameUnavailable] = useState(false);
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
||||
const [passwordMismatch, setPasswordMismatch] = useState(false);
|
||||
const [birthday, setBirthday] = useState<Date | undefined>(undefined);
|
||||
|
||||
const source = useRef(axios.CancelToken.source());
|
||||
|
||||
|
@ -111,8 +110,8 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
setPasswordMismatch(!passwordsMatch());
|
||||
};
|
||||
|
||||
const onBirthdayChange = (newBirthday: Date) => {
|
||||
setBirthday(newBirthday);
|
||||
const onBirthdayChange = (birthday: string) => {
|
||||
updateParams({ birthday });
|
||||
};
|
||||
|
||||
const launchModal = () => {
|
||||
|
@ -187,10 +186,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
if (inviteToken) {
|
||||
params.set('token', inviteToken);
|
||||
}
|
||||
|
||||
if (birthday) {
|
||||
params.set('birthday', new Date(birthday.getTime() - (birthday.getTimezoneOffset() * 60000)).toISOString().slice(0, 10));
|
||||
}
|
||||
});
|
||||
|
||||
setSubmissionLoading(true);
|
||||
|
@ -291,7 +286,7 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
|
||||
{birthdayRequired && (
|
||||
<BirthdayInput
|
||||
value={birthday}
|
||||
value={params.get('birthday')}
|
||||
onChange={onBirthdayChange}
|
||||
required
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, FormattedDate, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
|
||||
|
|
|
@ -3,13 +3,12 @@ import React from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
|
||||
import StatusList from 'soapbox/components/status_list';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
|
||||
import StatusList from '../../components/status_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
|
|
@ -12,8 +12,8 @@ import { createSelector } from 'reselect';
|
|||
import { openChat, launchChat, toggleMainWindow } from 'soapbox/actions/chats';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import ChatList from './chat_list';
|
||||
import ChatWindow from './chat_window';
|
||||
|
@ -83,7 +83,11 @@ class ChatPanes extends ImmutablePureComponent {
|
|||
const mainWindowPane = (
|
||||
<div className={`pane pane--main pane--${mainWindowState}`}>
|
||||
<div className='pane__header'>
|
||||
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
|
||||
{unreadCount > 0 && (
|
||||
<div className='mr-2 flex-none'>
|
||||
<Counter count={unreadCount} />
|
||||
</div>
|
||||
)}
|
||||
<button className='pane__title' onClick={this.handleMainWindowToggle}>
|
||||
<FormattedMessage id='chat_panels.main_window.title' defaultMessage='Chats' />
|
||||
</button>
|
||||
|
|
|
@ -13,9 +13,9 @@ import {
|
|||
import Avatar from 'soapbox/components/avatar';
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { getAcct } from 'soapbox/utils/accounts';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
import { displayFqn } from 'soapbox/utils/state';
|
||||
|
||||
import ChatBox from './chat_box';
|
||||
|
@ -98,9 +98,9 @@ class ChatWindow extends ImmutablePureComponent {
|
|||
const unreadCount = chat.get('unread');
|
||||
|
||||
const unreadIcon = (
|
||||
<i className='icon-with-badge__badge'>
|
||||
{shortNumberFormat(unreadCount)}
|
||||
</i>
|
||||
<div className='mr-2 flex-none'>
|
||||
<Counter count={unreadCount} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const avatar = (
|
||||
|
|
|
@ -4,11 +4,11 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { connectCommunityStream } from 'soapbox/actions/streaming';
|
||||
import { expandCommunityTimeline } from 'soapbox/actions/timelines';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
|
||||
import { connectCommunityStream } from '../../actions/streaming';
|
||||
import { expandCommunityTimeline } from '../../actions/timelines';
|
||||
import { Column } from '../../components/ui';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import ColumnSettings from './containers/column_settings_container';
|
||||
|
|
|
@ -8,12 +8,12 @@ import { defineMessages, FormattedMessage } from 'react-intl';
|
|||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import AutosuggestInput from 'soapbox/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { isMobile } from 'soapbox/is_mobile';
|
||||
|
||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { Button } from '../../../components/ui';
|
||||
import { isMobile } from '../../../is_mobile';
|
||||
import ReplyMentions from '../components/reply_mentions';
|
||||
import UploadForm from '../components/upload_form';
|
||||
import Warning from '../components/warning';
|
||||
|
@ -208,9 +208,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleEmojiPick = (data) => {
|
||||
const { text } = this.props;
|
||||
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||
const { text } = this.props;
|
||||
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||
|
||||
this.props.onPickEmoji(position, data, needsSpace);
|
||||
}
|
||||
|
@ -365,7 +365,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
</AutosuggestTextarea>
|
||||
|
||||
<QuotedStatusContainer />
|
||||
<div className='mb-2'>
|
||||
<QuotedStatusContainer />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames('flex flex-wrap items-center justify-between', {
|
||||
|
|
|
@ -11,6 +11,7 @@ import { connect } from 'react-redux';
|
|||
import AutosuggestInput from 'soapbox/components/autosuggest_input';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
|
||||
|
@ -177,7 +178,7 @@ class PollForm extends ImmutablePureComponent {
|
|||
))}
|
||||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
<HStack className='text-black dark:text-white' space={2}>
|
||||
{options.size < maxOptions && (
|
||||
<button className='button button-secondary' onClick={this.handleAddOption}><Icon src={require('@tabler/icons/icons/plus.svg')} /> <FormattedMessage {...messages.add_option} /></button>
|
||||
)}
|
||||
|
@ -191,7 +192,7 @@ class PollForm extends ImmutablePureComponent {
|
|||
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
|
||||
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
|
||||
</select>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue