Merge remote-tracking branch 'origin/develop' into scroll-position

environments/review-scroll-pos-dnhc2t/deployments/154
Alex Gleason 2 years ago
commit e4b95534dc
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -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;
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;
renderHeader = ({
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;
if (!supportsBirthdays) return null;
};
let maxDate = new Date();
maxDate = new Date(maxDate.getTime() - minAge * 1000 * 60 * 60 * 24 + maxDate.getTimezoneOffset() * 1000 * 60);
const handleChange = (date: Date) => onChange(new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10));
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>
);
}
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>
);
};
}
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`);
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();
}
_selectChild(index) {
this.node.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`);
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) {
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>
);
/** 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 (
<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>
<ErrorBoundary>
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
<OnboardingWizard />
<NotificationsContainer />
</BrowserRouter>
</ErrorBoundary>
</IntlProvider>
<>
{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' />
{/* 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) => <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>
</>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<>
{renderBody()}
<BundleContainer fetchComponent={NotificationsContainer}>
{(Component) => <Component />}
</BundleContainer>
<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'>
&mdash;
<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…
Cancel
Save