TypeScript, React.FC

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-06-09 20:56:14 +02:00
parent a8a1567917
commit 95e037f8c0
24 changed files with 631 additions and 796 deletions

View file

@ -1,10 +1,9 @@
import { AxiosError } from 'axios';
import { AnyAction } from 'redux';
import api from '../api';
import { openModal, closeModal } from './modals';
import type { AxiosError } from 'axios';
import type { AnyAction } from 'redux';
import type { Account } from 'soapbox/types/entities';
const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';

View file

@ -86,7 +86,7 @@ const messages = defineMessages({
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.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
view: { id: 'snackbar.view', defaultMessage: 'View' },
});

View file

@ -23,7 +23,7 @@ export interface MenuItem {
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon: string,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,

View file

@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui';
interface ILoadMore {
onClick: () => void,
onClick: React.MouseEventHandler,
disabled?: boolean,
visible?: Boolean,
}

View file

@ -1,220 +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 } from 'react-intl';
import { connect } from 'react-redux';
import {
fetchAccount,
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, Spinner } from 'soapbox/components/ui';
import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
import MediaItem from './components/media_item';
const mapStateToProps = (state, { params, withReplies = false }) => {
const username = params.username || '';
const me = state.get('me');
const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase());
const features = getFeatures(state.get('instance'));
let accountId = -1;
let accountUsername = username;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? account.getIn(['id'], null) : -1;
accountUsername = account ? account.getIn(['acct'], '') : '';
}
const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false);
const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible);
return {
accountId,
unavailable,
accountUsername,
isAccount: !!state.getIn(['accounts', accountId]),
attachments: getAccountGallery(state, accountId),
isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
};
};
class LoadMoreMedia extends ImmutablePureComponent {
static propTypes = {
maxId: PropTypes.string,
onLoadMore: PropTypes.func.isRequired,
};
handleLoadMore = () => {
this.props.onLoadMore(this.props.maxId);
}
render() {
return (
<LoadMore
disabled={this.props.disabled}
onClick={this.handleLoadMore}
/>
);
}
}
export default @connect(mapStateToProps)
class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
unavailable: PropTypes.bool,
};
state = {
width: 323,
};
componentDidMount() {
const { params: { username }, accountId } = this.props;
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
this.props.dispatch(expandAccountMediaTimeline(accountId));
} else {
this.props.dispatch(fetchAccountByUsername(username));
}
}
componentDidUpdate(prevProps) {
const { accountId, params } = this.props;
if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) {
this.props.dispatch(fetchAccount(params.accountId));
this.props.dispatch(expandAccountMediaTimeline(accountId));
}
}
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
}
}
handleScroll = e => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
if (150 > offset && !this.props.isLoading) {
this.handleScrollToBottom();
}
}
handleLoadMore = maxId => {
if (this.props.accountId && this.props.accountId !== -1) {
this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
}
};
handleLoadOlder = e => {
e.preventDefault();
this.handleScrollToBottom();
}
handleOpenMedia = attachment => {
if (attachment.get('type') === 'video') {
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), account: attachment.get('account') }));
} else {
const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status'), account: attachment.get('account') }));
}
}
handleRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
}
render() {
const { attachments, isLoading, hasMore, isAccount, accountId, unavailable, accountUsername } = this.props;
const { width } = this.state;
if (!isAccount && accountId !== -1) {
return (
<MissingIndicator />
);
}
if (accountId === -1 || (!attachments && isLoading)) {
return (
<Column>
<Spinner />
</Column>
);
}
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
if (unavailable) {
return (
<Column>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
</Column>
);
}
return (
<Column label={`@${accountUsername}`} transparent withHeader={false}>
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem
key={`${attachment.getIn(['status', 'id'])}+${attachment.get('id')}`}
attachment={attachment}
displayWidth={width}
onOpenMedia={this.handleOpenMedia}
/>
))}
{
attachments.size === 0 &&
<div className='empty-column-indicator'>
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
</div>
}
{loadOlder}
</div>
{isLoading && attachments.size === 0 && (
<div className='slist__append'>
<Spinner />
</div>
)}
</Column>
);
}
}

View file

@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import {
fetchAccount,
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, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
import MediaItem from './components/media_item';
import type { List as ImmutableList } from 'immutable';
import type { Attachment, Status } from 'soapbox/types/entities';
interface ILoadMoreMedia {
maxId: string | null,
onLoadMore: (value: string | null) => void,
}
const LoadMoreMedia: React.FC<ILoadMoreMedia> = ({ maxId, onLoadMore }) => {
const handleLoadMore = () => {
onLoadMore(maxId);
};
return (
<LoadMore onClick={handleLoadMore} />
);
};
const AccountGallery = () => {
const dispatch = useAppDispatch();
const { username } = useParams<{ username: string }>();
const { accountId, unavailable, accountUsername } = useAppSelector((state) => {
const me = state.me;
const accountFetchError = (state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase();
const features = getFeatures(state.instance);
let accountId: string | number | null = -1;
let accountUsername = username;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? (account.id || null) : -1;
accountUsername = account?.acct || '';
}
const isBlocked = state.relationships.get(String(accountId))?.blocked_by || false;
return {
accountId,
unavailable: (me === accountId) ? false : (isBlocked && !features.blockersVisible),
accountUsername,
};
});
const isAccount = useAppSelector((state) => !!state.accounts.get(accountId));
const attachments: ImmutableList<Attachment> = useAppSelector((state) => getAccountGallery(state, accountId as string));
const isLoading = useAppSelector((state) => state.timelines.getIn([`account:${accountId}:media`, 'isLoading']));
const hasMore = useAppSelector((state) => state.timelines.getIn([`account:${accountId}:media`, 'hasMore']));
const ref = useRef<HTMLDivElement>(null);
const [width] = useState(323);
const handleScrollToBottom = () => {
if (hasMore) {
handleLoadMore(attachments.size > 0 ? attachments.last()!.status.id : undefined);
}
};
const handleLoadMore = (maxId: string | null) => {
if (accountId && accountId !== -1) {
dispatch(expandAccountMediaTimeline(accountId, { maxId }));
}
};
const handleLoadOlder: React.MouseEventHandler = e => {
e.preventDefault();
handleScrollToBottom();
};
const handleOpenMedia = (attachment: Attachment) => {
if (attachment.type === 'video') {
dispatch(openModal('VIDEO', { media: attachment, status: attachment.status, account: attachment.account }));
} else {
const media = (attachment.status as Status).media_attachments;
const index = media.findIndex((x) => x.id === attachment.id);
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
}
};
useEffect(() => {
if (accountId && accountId !== -1) {
dispatch(fetchAccount(accountId));
dispatch(expandAccountMediaTimeline(accountId));
} else {
dispatch(fetchAccountByUsername(username));
}
}, [accountId]);
if (!isAccount && accountId !== -1) {
return (
<MissingIndicator />
);
}
if (accountId === -1 || (!attachments && isLoading)) {
return (
<Column>
<Spinner />
</Column>
);
}
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={handleLoadOlder} />;
}
if (unavailable) {
return (
<Column>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
</Column>
);
}
return (
<Column label={`@${accountUsername}`} transparent withHeader={false}>
<div role='feed' className='account-gallery__container' ref={ref}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.get(index + 1)?.id} maxId={index > 0 ? (attachments.get(index - 1)?.id || null) : null} onLoadMore={handleLoadMore} />
) : (
<MediaItem
key={`${attachment.status.id}+${attachment.id}`}
attachment={attachment}
displayWidth={width}
onOpenMedia={handleOpenMedia}
/>
))}
{!isLoading && attachments.size === 0 && (
<div className='empty-column-indicator'>
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
</div>
)}
{loadOlder}
</div>
{isLoading && attachments.size === 0 && (
<div className='slist__append'>
<Spinner />
</div>
)}
</Column>
);
};
export default AccountGallery;

View file

@ -8,25 +8,30 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const hex2rgba = (hex, alpha = 1) => {
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
const hex2rgba = (hex: string, alpha = 1) => {
const [r, g, b] = hex.match(/\w\w/g)!.map(x => parseInt(x, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export default class Visualizer {
constructor(tickSize) {
tickSize: number
canvas?: HTMLCanvasElement
context?: CanvasRenderingContext2D
analyser?: AnalyserNode
constructor(tickSize: number) {
this.tickSize = tickSize;
}
setCanvas(canvas) {
setCanvas(canvas: HTMLCanvasElement) {
this.canvas = canvas;
if (canvas) {
this.context = canvas.getContext('2d');
this.context = canvas.getContext('2d')!;
}
}
setAudioContext(context, source) {
setAudioContext(context: AudioContext, source: MediaElementAudioSourceNode) {
const analyser = context.createAnalyser();
analyser.smoothingTimeConstant = 0.6;
@ -37,7 +42,7 @@ export default class Visualizer {
this.analyser = analyser;
}
getTickPoints(count) {
getTickPoints(count: number) {
const coords = [];
for (let i = 0; i < count; i++) {
@ -48,13 +53,13 @@ export default class Visualizer {
return coords;
}
drawTick(cx, cy, mainColor, x1, y1, x2, y2) {
drawTick(cx: number, cy: number, mainColor: string, x1: number, y1: number, x2: number, y2: number) {
const dx1 = Math.ceil(cx + x1);
const dy1 = Math.ceil(cy + y1);
const dx2 = Math.ceil(cx + x2);
const dy2 = Math.ceil(cy + y2);
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
const gradient = this.context!.createLinearGradient(dx1, dy1, dx2, dy2);
const lastColor = hex2rgba(mainColor, 0);
@ -62,21 +67,21 @@ export default class Visualizer {
gradient.addColorStop(0.6, mainColor);
gradient.addColorStop(1, lastColor);
this.context.beginPath();
this.context.strokeStyle = gradient;
this.context.lineWidth = 2;
this.context.moveTo(dx1, dy1);
this.context.lineTo(dx2, dy2);
this.context.stroke();
this.context!.beginPath();
this.context!.strokeStyle = gradient;
this.context!.lineWidth = 2;
this.context!.moveTo(dx1, dy1);
this.context!.lineTo(dx2, dy2);
this.context!.stroke();
}
getTicks(count, size, radius, scaleCoefficient) {
getTicks(count: number, size: number, radius: number, scaleCoefficient: number) {
const ticks = this.getTickPoints(count);
const lesser = 200;
const m = [];
const m: Array<Record<'x1' | 'y1' | 'x2' | 'y2', number>> = [];
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
const frequencyData = new Uint8Array(bufferLength);
const allScales = [];
const allScales: Array<number> = [];
if (this.analyser) {
this.analyser.getByteFrequencyData(frequencyData);
@ -117,20 +122,20 @@ export default class Visualizer {
}));
}
clear(width, height) {
this.context.clearRect(0, 0, width, height);
clear(width: number, height: number) {
this.context!.clearRect(0, 0, width, height);
}
draw(cx, cy, color, radius, coefficient) {
this.context.save();
draw(cx: number, cy: number, color: string, radius: number, coefficient: number) {
this.context!.save();
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
const ticks = this.getTicks(parseInt(360 * coefficient as any), this.tickSize, radius, coefficient);
ticks.forEach(tick => {
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
});
this.context.restore();
this.context!.restore();
}
}

View file

@ -1,92 +0,0 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { closeModal } from 'soapbox/actions/modals';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import { isStandalone } from 'soapbox/utils/state';
import LoginForm from './login_form';
import OtpAuthForm from './otp_auth_form';
const mapStateToProps = state => ({
me: state.get('me'),
isLoading: false,
standalone: isStandalone(state),
});
export default @connect(mapStateToProps)
@injectIntl
class LoginPage extends ImmutablePureComponent {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
state = {
isLoading: false,
mfa_auth_needed: false,
mfa_token: '',
shouldRedirect: false,
}
getFormData = (form) => {
return Object.fromEntries(
Array.from(form).map(i => [i.name, i.value]),
);
}
componentDidMount() {
const token = new URLSearchParams(window.location.search).get('token');
if (token) {
this.setState({ mfa_token: token, mfa_auth_needed: true });
}
}
handleSubmit = (event) => {
const { dispatch, intl, me } = this.props;
const { username, password } = this.getFormData(event.target);
dispatch(logIn(intl, username, password)).then(({ access_token }) => {
return dispatch(verifyCredentials(access_token))
// Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance()));
}).then(account => {
dispatch(closeModal());
this.setState({ shouldRedirect: true });
if (typeof me === 'string') {
dispatch(switchAccount(account.id));
}
}).catch(error => {
const data = error.response?.data;
if (data?.error === 'mfa_required') {
this.setState({ mfa_auth_needed: true, mfa_token: data.mfa_token });
}
this.setState({ isLoading: false });
});
this.setState({ isLoading: true });
event.preventDefault();
}
render() {
const { standalone } = this.props;
const { isLoading, mfa_auth_needed, mfa_token, shouldRedirect } = this.state;
if (standalone) return <Redirect to='/login/external' />;
if (shouldRedirect) {
const redirectUri = getRedirectUrl();
return <Redirect to={redirectUri} />;
}
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;
return <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />;
}
}

View file

@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { closeModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import { isStandalone } from 'soapbox/utils/state';
import LoginForm from './login_form';
import OtpAuthForm from './otp_auth_form';
import type { AxiosError } from 'axios';
const LoginPage = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const me = useAppSelector((state) => state.me);
const standalone = useAppSelector((state) => isStandalone(state));
const token = new URLSearchParams(window.location.search).get('token');
const [isLoading, setIsLoading] = useState(false);
const [mfaAuthNeeded, setMfaAuthNeeded] = useState(!!token);
const [mfaToken, setMfaToken] = useState(token || '');
const [shouldRedirect, setShouldRedirect] = useState(false);
const getFormData = (form: HTMLFormElement) => {
return Object.fromEntries(
Array.from(form).map((i: any) => [i.name, i.value]),
);
};
const handleSubmit: React.FormEventHandler = (event) => {
const { username, password } = getFormData(event.target as HTMLFormElement);
dispatch(logIn(intl, username, password)).then(({ access_token }: { access_token: string }) => {
return dispatch(verifyCredentials(access_token))
// Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance() as any));
}).then((account: { id: string }) => {
dispatch(closeModal());
setShouldRedirect(true);
if (typeof me === 'string') {
dispatch(switchAccount(account.id));
}
}).catch((error: AxiosError) => {
const data: any = error.response?.data;
if (data?.error === 'mfa_required') {
setMfaAuthNeeded(true);
setMfaToken(data.mfa_token);
}
setIsLoading(false);
});
setIsLoading(true);
event.preventDefault();
};
if (standalone) return <Redirect to='/login/external' />;
if (shouldRedirect) {
const redirectUri = getRedirectUrl();
return <Redirect to={redirectUri} />;
}
if (mfaAuthNeeded) return <OtpAuthForm mfa_token={mfaToken} />;
return <LoginForm handleSubmit={handleSubmit} isLoading={isLoading} />;
};
export default LoginPage;

View file

@ -1,96 +0,0 @@
import classNames from 'classnames';
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 {
fetchBackups,
createBackup,
} from 'soapbox/actions/backups';
import ScrollableList from 'soapbox/components/scrollable_list';
import Column from '../ui/components/better_column';
const messages = defineMessages({
heading: { id: 'column.backups', defaultMessage: 'Backups' },
create: { id: 'backups.actions.create', defaultMessage: 'Create backup' },
emptyMessage: { id: 'backups.empty_message', defaultMessage: 'No backups found. {action}' },
emptyMessageAction: { id: 'backups.empty_message.action', defaultMessage: 'Create one now?' },
pending: { id: 'backups.pending', defaultMessage: 'Pending' },
});
const mapStateToProps = state => ({
backups: state.get('backups').toList().sortBy(backup => backup.get('inserted_at')),
});
export default @connect(mapStateToProps)
@injectIntl
class Backups extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
};
state = {
isLoading: true,
}
handleCreateBackup = e => {
this.props.dispatch(createBackup());
e.preventDefault();
}
componentDidMount() {
this.props.dispatch(fetchBackups()).then(() => {
this.setState({ isLoading: false });
}).catch(() => {});
}
makeColumnMenu = () => {
const { intl } = this.props;
return [{
text: intl.formatMessage(messages.create),
action: this.handleCreateBackup,
icon: require('@tabler/icons/icons/plus.svg'),
}];
}
render() {
const { intl, backups } = this.props;
const { isLoading } = this.state;
const showLoading = isLoading && backups.count() === 0;
const emptyMessageAction = (
<a href='#' onClick={this.handleCreateBackup}>
{intl.formatMessage(messages.emptyMessageAction)}
</a>
);
return (
<Column icon='cloud-download' label={intl.formatMessage(messages.heading)} menu={this.makeColumnMenu()}>
<ScrollableList
isLoading={isLoading}
showLoading={showLoading}
scrollKey='backups'
emptyMessage={intl.formatMessage(messages.emptyMessage, { action: emptyMessageAction })}
>
{backups.map(backup => (
<div
className={classNames('backup', { 'backup--pending': !backup.get('processed') })}
key={backup.get('id')}
>
{backup.get('processed')
? <a href={backup.get('url')} target='_blank'>{backup.get('inserted_at')}</a>
: <div>{intl.formatMessage(messages.pending)}: {backup.get('inserted_at')}</div>
}
</div>
))}
</ScrollableList>
</Column>
);
}
}

View file

@ -0,0 +1,83 @@
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
fetchBackups,
createBackup,
} from 'soapbox/actions/backups';
import ScrollableList from 'soapbox/components/scrollable_list';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Column from '../ui/components/better_column';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
const messages = defineMessages({
heading: { id: 'column.backups', defaultMessage: 'Backups' },
create: { id: 'backups.actions.create', defaultMessage: 'Create backup' },
emptyMessage: { id: 'backups.empty_message', defaultMessage: 'No backups found. {action}' },
emptyMessageAction: { id: 'backups.empty_message.action', defaultMessage: 'Create one now?' },
pending: { id: 'backups.pending', defaultMessage: 'Pending' },
});
const Backups = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const backups = useAppSelector<ImmutableList<ImmutableMap<string, any>>>((state) => state.backups.toList().sortBy((backup: ImmutableMap<string, any>) => backup.get('inserted_at')));
const [isLoading, setIsLoading] = useState(true);
const handleCreateBackup: React.MouseEventHandler<HTMLAnchorElement> = e => {
dispatch(createBackup());
e.preventDefault();
};
const makeColumnMenu = () => {
return [{
text: intl.formatMessage(messages.create),
action: handleCreateBackup,
icon: require('@tabler/icons/icons/plus.svg'),
}];
};
useEffect(() => {
dispatch(fetchBackups()).then(() => {
setIsLoading(true);
}).catch(() => {});
}, []);
const showLoading = isLoading && backups.count() === 0;
const emptyMessageAction = (
<a href='#' onClick={handleCreateBackup}>
{intl.formatMessage(messages.emptyMessageAction)}
</a>
);
return (
<Column icon='cloud-download' label={intl.formatMessage(messages.heading)} menu={makeColumnMenu()}>
<ScrollableList
isLoading={isLoading}
showLoading={showLoading}
scrollKey='backups'
emptyMessage={intl.formatMessage(messages.emptyMessage, { action: emptyMessageAction })}
>
{backups.map((backup) => (
<div
className={classNames('backup', { 'backup--pending': !backup.get('processed') })}
key={backup.get('id')}
>
{backup.get('processed')
? <a href={backup.get('url')} target='_blank'>{backup.get('inserted_at')}</a>
: <div>{intl.formatMessage(messages.pending)}: {backup.get('inserted_at')}</div>
}
</div>
))}
</ScrollableList>
</Column>
);
};
export default Backups;

View file

@ -1,96 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
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 Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
const mapStateToProps = state => {
const onlyMedia = getSettings(state).getIn(['community', 'other', 'onlyMedia']);
const timelineId = 'community';
return {
timelineId,
onlyMedia,
hasUnread: state.getIn(['timelines', `${timelineId}${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
};
};
export default @connect(mapStateToProps)
@injectIntl
class CommunityTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
onlyMedia: PropTypes.bool,
timelineId: PropTypes.string,
};
componentDidMount() {
const { dispatch, onlyMedia } = this.props;
dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
componentDidUpdate(prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
this.disconnect();
dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
}
componentWillUnmount() {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
handleLoadMore = maxId => {
const { dispatch, onlyMedia } = this.props;
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
}
handleRefresh = () => {
const { dispatch, onlyMedia } = this.props;
return dispatch(expandCommunityTimeline({ onlyMedia }));
}
render() {
const { intl, onlyMedia, timelineId } = this.props;
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
onRefresh={this.handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
divideType='space'
/>
</Column>
);
}
}

View file

@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
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 { useAppDispatch, useSettings } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
const CommunityTimeline = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const onlyMedia = settings.getIn(['community', 'other', 'onlyMedia']);
const timelineId = 'community';
const handleLoadMore = (maxId: string) => {
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
};
const handleRefresh = () => {
return dispatch(expandCommunityTimeline({ onlyMedia } as any));
};
useEffect(() => {
dispatch(expandCommunityTimeline({ onlyMedia } as any));
const disconnect = dispatch(connectCommunityStream({ onlyMedia } as any));
return () => {
disconnect();
};
}, [onlyMedia]);
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
divideType='space'
/>
</Column>
);
};
export default CommunityTimeline;

View file

@ -13,7 +13,7 @@ const makeMapStateToProps = state => {
return {
selectedFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']),
advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']),
supportsEmojiReacts: features.emojiReacts,
};
};

View file

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
@ -10,6 +9,8 @@ import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soap
import { useOwnAccount } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize_image';
import type { AxiosError } from 'axios';
/** Default avatar filenames from various backends */
const DEFAULT_AVATARS = [
'/avatars/original/missing.png', // Mastodon

View file

@ -1,145 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { changeSetting, getSettings } from 'soapbox/actions/settings';
import { connectPublicStream } from 'soapbox/actions/streaming';
import { expandPublicTimeline } from 'soapbox/actions/timelines';
import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui';
import Accordion from 'soapbox/features/ui/components/accordion';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
});
const mapStateToProps = state => {
const settings = getSettings(state);
const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']);
const timelineId = 'public';
return {
timelineId,
onlyMedia,
hasUnread: state.getIn(['timelines', `${timelineId}${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
siteTitle: state.getIn(['instance', 'title']),
explanationBoxExpanded: settings.get('explanationBox'),
showExplanationBox: settings.get('showExplanationBox'),
};
};
export default @connect(mapStateToProps)
@injectIntl
class CommunityTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
onlyMedia: PropTypes.bool,
timelineId: PropTypes.string,
siteTitle: PropTypes.string,
showExplanationBox: PropTypes.bool,
explanationBoxExpanded: PropTypes.bool,
};
componentDidMount() {
const { dispatch, onlyMedia } = this.props;
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
}
componentDidUpdate(prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
this.disconnect();
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
}
}
componentWillUnmount() {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
explanationBoxMenu = () => {
const { intl } = this.props;
return [{ text: intl.formatMessage(messages.dismiss), action: this.dismissExplanationBox }];
}
dismissExplanationBox = () => {
this.props.dispatch(changeSetting(['showExplanationBox'], false));
}
toggleExplanationBox = (setting) => {
this.props.dispatch(changeSetting(['explanationBox'], setting));
}
handleLoadMore = maxId => {
const { dispatch, onlyMedia } = this.props;
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
}
handleRefresh = () => {
const { dispatch, onlyMedia } = this.props;
return dispatch(expandPublicTimeline({ onlyMedia }));
}
render() {
const { intl, onlyMedia, timelineId, siteTitle, showExplanationBox, explanationBoxExpanded } = this.props;
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<PinnedHostsPicker />
{showExplanationBox && <div className='explanation-box'>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
menu={this.explanationBoxMenu()}
expanded={explanationBoxExpanded}
onToggle={this.toggleExplanationBox}
>
<FormattedMessage
id='fediverse_tab.explanation_box.explanation'
defaultMessage='{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka "servers"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don&apos;t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.'
values={{
site_title: siteTitle,
local: (
<Link to='/timeline/local'>
<FormattedMessage
id='empty_column.home.local_tab'
defaultMessage='the {site_title} tab'
values={{ site_title: siteTitle }}
/>
</Link>
),
}}
/>
</Accordion>
</div>}
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
onRefresh={this.handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
divideType='space'
/>
</Column>
);
}
}

View file

@ -0,0 +1,106 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { changeSetting } from 'soapbox/actions/settings';
import { connectPublicStream } from 'soapbox/actions/streaming';
import { expandPublicTimeline } from 'soapbox/actions/timelines';
import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui';
import Accordion from 'soapbox/features/ui/components/accordion';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
});
const CommunityTimeline = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']);
const timelineId = 'public';
const siteTitle = useAppSelector((state) => state.instance.title);
const explanationBoxExpanded = settings.get('explanationBox');
const showExplanationBox = settings.get('showExplanationBox');
const explanationBoxMenu = () => {
return [{ text: intl.formatMessage(messages.dismiss), action: dismissExplanationBox }];
};
const dismissExplanationBox = () => {
dispatch(changeSetting(['showExplanationBox'], false));
};
const toggleExplanationBox = (setting: boolean) => {
dispatch(changeSetting(['explanationBox'], setting));
};
const handleLoadMore = (maxId: string) => {
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
};
const handleRefresh = () => {
return dispatch(expandPublicTimeline({ onlyMedia } as any));
};
useEffect(() => {
dispatch(expandPublicTimeline({ onlyMedia } as any));
const disconnect = dispatch(connectPublicStream({ onlyMedia }));
return () => {
disconnect();
};
}, [onlyMedia]);
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<PinnedHostsPicker />
{showExplanationBox && <div className='mb-4'>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
menu={explanationBoxMenu()}
expanded={explanationBoxExpanded}
onToggle={toggleExplanationBox}
>
<FormattedMessage
id='fediverse_tab.explanation_box.explanation'
defaultMessage='{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka "servers"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don&apos;t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.'
values={{
site_title: siteTitle,
local: (
<Link to='/timeline/local'>
<FormattedMessage
id='empty_column.home.local_tab'
defaultMessage='the {site_title} tab'
values={{ site_title: siteTitle }}
/>
</Link>
),
}}
/>
</Accordion>
</div>}
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
divideType='space'
/>
</Column>
);
};
export default CommunityTimeline;

View file

@ -7,7 +7,7 @@ import { useSettings } from 'soapbox/hooks';
interface IPinnedHostsPicker {
/** The active host among pinned hosts. */
host: string,
host?: string,
}
const PinnedHostsPicker: React.FC<IPinnedHostsPicker> = ({ host: activeHost }) => {

View file

@ -1,91 +0,0 @@
import noop from 'lodash/noop';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
import StatusContent from '../../../components/status_content';
import Bundle from '../../ui/components/bundle';
import { MediaGallery, Video, Audio } from '../../ui/util/async-components';
export default class StatusCheckBox extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.record.isRequired,
checked: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
render() {
const { status, checked, onToggle, disabled } = this.props;
let media = null;
if (status.get('reblog')) {
return null;
}
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
// Do nothing
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'original', 'aspect'])}
width={239}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const audio = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={audio.get('url')}
alt={audio.get('description')}
inline
sensitive={status.get('sensitive')}
onOpenAudio={noop}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={noop} />}
</Bundle>
);
}
}
return (
<div className='status-check-box'>
<div className='status-check-box__status'>
<StatusContent status={status} />
{media}
</div>
<div className='status-check-box-toggle'>
<Toggle checked={checked} onChange={onToggle} disabled={disabled} icons={false} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,97 @@
import noop from 'lodash/noop';
import React from 'react';
import Toggle from 'react-toggle';
import { toggleStatusReport } from 'soapbox/actions/reports';
import StatusContent from 'soapbox/components/status_content';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Bundle from '../../ui/components/bundle';
import { MediaGallery, Video, Audio } from '../../ui/util/async-components';
interface IStatusCheckBox {
id: string,
disabled?: boolean,
}
const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.statuses.get(id));
const checked = useAppSelector((state) => state.reports.new.status_ids.includes(id));
const onToggle: React.ChangeEventHandler<HTMLInputElement> = (e) => dispatch(toggleStatusReport(id, e.target.checked));
if (!status || status.reblog) {
return null;
}
let media;
if (status.media_attachments.size > 0) {
if (status.media_attachments.some(item => item.type === 'unknown')) {
// Do nothing
} else if (status.media_attachments.get(0)?.type === 'video') {
const video = status.media_attachments.get(0);
if (video) {
media = (
<Bundle fetchComponent={Video} >
{(Component: any) => (
<Component
preview={video.preview_url}
blurhash={video.blurhash}
src={video.url}
alt={video.description}
aspectRatio={video.meta.getIn(['original', 'aspect'])}
width={239}
height={110}
inline
sensitive={status.sensitive}
onOpenVideo={noop}
/>
)}
</Bundle>
);
}
} else if (status.media_attachments.get(0)?.type === 'audio') {
const audio = status.media_attachments.get(0);
if (audio) {
media = (
<Bundle fetchComponent={Audio} >
{(Component: any) => (
<Component
src={audio.url}
alt={audio.description}
inline
sensitive={status.sensitive}
onOpenAudio={noop}
/>
)}
</Bundle>
);
}
} else {
media = (
<Bundle fetchComponent={MediaGallery} >
{(Component: any) => <Component media={status.media_attachments} sensitive={status.sensitive} height={110} onOpenMedia={noop} />}
</Bundle>
);
}
}
return (
<div className='status-check-box'>
<div className='status-check-box__status'>
<StatusContent status={status} />
{media}
</div>
<div className='status-check-box-toggle'>
<Toggle checked={checked} onChange={onToggle} disabled={disabled} icons={false} />
</div>
</div>
);
};
export default StatusCheckBox;

View file

@ -1,19 +0,0 @@
import { connect } from 'react-redux';
import { toggleStatusReport } from '../../../actions/reports';
import StatusCheckBox from '../components/status_check_box';
const mapStateToProps = (state, { id }) => ({
status: state.getIn(['statuses', id]),
checked: state.reports.new.status_ids.includes(id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onToggle(e) {
dispatch(toggleStatusReport(id, e.target.checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);

View file

@ -7,7 +7,7 @@ import Toggle from 'react-toggle';
import { changeReportBlock, changeReportForward } from 'soapbox/actions/reports';
import { fetchRules } from 'soapbox/actions/rules';
import { Button, FormGroup, HStack, Stack, Text } from 'soapbox/components/ui';
import StatusCheckBox from 'soapbox/features/report/containers/status_check_box_container';
import StatusCheckBox from 'soapbox/features/report/components/status_check_box';
import { useAppSelector, useFeatures } from 'soapbox/hooks';
import { isRemote, getDomain } from 'soapbox/utils/accounts';
@ -61,7 +61,7 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => {
<FormGroup labelText={intl.formatMessage(messages.addAdditionalStatuses)}>
{showAdditionalStatuses ? (
<Stack space={2}>
<div className='bg-gray-100 rounded-lg p-4'>
<div className='bg-gray-100 dark:bg-slate-600 rounded-lg p-4'>
{statusIds.map((statusId) => <StatusCheckBox id={statusId} key={statusId} />)}
</div>

View file

@ -366,7 +366,7 @@ type ColumnQuery = { type: string, prefix?: string };
export const makeGetStatusIds = () => createSelector([
(state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()),
(state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()),
(state: RootState) => state.statuses,
(state: RootState) => state.statuses,
], (columnSettings, statusIds: ImmutableOrderedSet<string>, statuses) => {
return statusIds.filter((id: string) => {
const status = statuses.get(id);

View file

@ -63,7 +63,7 @@
@font-face {
font-family: 'soapbox';
src: url('../fonts/soapbox/soapbox.eot?pryg6i');
src: url('../fonts/soapbox/soapbox.eot?pryg6i#iefix') format('embedded-opentype'),
src: url('../fonts/soapbox/soapbox.eot?pryg6i#iefix') format('embedded-opentype'),
url('../fonts/soapbox/soapbox.ttf?pryg6i') format('truetype'),
url('../fonts/soapbox/soapbox.woff?pryg6i') format('woff'),
url('../fonts/soapbox/soapbox.svg?pryg6i#soapbox') format('svg');