Merge branch 'ts' into 'develop'

TypeScript, FC (reducers, search)

See merge request soapbox-pub/soapbox-fe!1502
This commit is contained in:
marcin mikołajczak 2022-06-10 16:24:12 +00:00
commit 02a65608ba
108 changed files with 1430 additions and 1591 deletions

View File

@ -1,8 +1,9 @@
import { jest } from '@jest/globals';
import { AxiosInstance, AxiosResponse } from 'axios';
import MockAdapter from 'axios-mock-adapter';
import LinkHeader from 'http-link-header';
import type { AxiosInstance, AxiosResponse } from 'axios';
const api = jest.requireActual('../api') as Record<string, Function>;
let mocks: Array<Function> = [];

View File

@ -1,7 +1,7 @@
import { AnyAction } from 'redux';
import { staticClient } from '../api';
import type { AnyAction } from 'redux';
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';

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

@ -1,10 +1,10 @@
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';
import type { SnackbarActionSeverity } from './snackbar';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },

View File

@ -1,6 +1,3 @@
import { AnyAction } from '@reduxjs/toolkit';
import { AxiosError } from 'axios';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getNextLinkName } from 'soapbox/utils/quirks';
@ -9,6 +6,9 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';

View File

@ -27,7 +27,6 @@ export const submit = (routerHistory) => (dispatch, getState) => {
}
};
export const create = (title, description, coverImage, routerHistory) => (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
@ -47,7 +46,6 @@ export const create = (title, description, coverImage, routerHistory) => (dispat
}).catch(err => dispatch(createFail(err)));
};
export const createRequest = id => ({
type: GROUP_CREATE_REQUEST,
id,
@ -82,7 +80,6 @@ export const update = (groupId, title, description, coverImage, routerHistory) =
}).catch(err => dispatch(updateFail(err)));
};
export const updateRequest = id => ({
type: GROUP_UPDATE_REQUEST,
id,

View File

@ -23,9 +23,11 @@ export interface MenuItem {
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon: string,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,
active?: boolean,
}
export type Menu = Array<MenuItem | null>;

View File

@ -10,24 +10,24 @@ import { shortNumberFormat } from '../utils/numbers';
import Permalink from './permalink';
import { HStack, Stack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
import type { Tag } from 'soapbox/types/entities';
interface IHashtag {
hashtag: ImmutableMap<string, any>,
hashtag: Tag,
}
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
const count = Number(hashtag.getIn(['history', 0, 'accounts']));
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
const count = Number(hashtag.history?.get(0)?.accounts);
const brandColor = useSelector((state) => getSoapboxConfig(state).brandColor);
return (
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
<Stack>
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`} className='hover:underline'>
<Text tag='span' size='sm' weight='semibold'>#{hashtag.get('name')}</Text>
<Permalink href={hashtag.url} to={`/tags/${hashtag.name}`} className='hover:underline'>
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
</Permalink>
{hashtag.get('history') && (
{hashtag.history && (
<Text theme='muted' size='sm'>
<FormattedMessage
id='trends.count_by_accounts'
@ -41,12 +41,12 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
)}
</Stack>
{hashtag.get('history') && (
{hashtag.history && (
<div className='w-[40px]' data-testid='sparklines'>
<Sparklines
width={40}
height={28}
data={hashtag.get('history').reverse().map((day: ImmutableMap<string, any>) => day.get('uses')).toArray()}
data={hashtag.history.reverse().map((day) => +day.uses).toArray()}
>
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
</Sparklines>

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

@ -90,7 +90,6 @@ class ModalRoot extends React.PureComponent {
}
};
handleKeyDown = (e) => {
if (e.key === 'Tab') {
const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');

View File

@ -29,7 +29,6 @@ const MoreFollows: React.FC<IMoreFollows> = ({ visible = true, count, type }) =>
return intl.formatMessage(messages[type], { count });
};
// If the instance isn't federating, there are no remote followers
if (!features.federating) {
return null;

View File

@ -3,7 +3,7 @@ import React from 'react';
import PullToRefresh from './pull-to-refresh';
interface IPullable {
children: JSX.Element,
children: React.ReactNode,
}
/**

View File

@ -34,7 +34,6 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
}
};
return (
<div data-testid='still-image-container' className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}>
<img src={src} alt={alt} ref={img} onLoad={handleImageLoad} />

View File

@ -7,7 +7,7 @@ import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
interface IColumn {
export interface IColumn {
/** Route the back button goes to. */
backHref?: string,
/** Column title text. */

View File

@ -10,7 +10,7 @@ import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown_m
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
isModalOpen: Boolean(state.modals.size && state.modals.last().modalType === 'ACTIONS'),
isModalOpen: Boolean(state.modals.size && state.modals.last()!.modalType === 'ACTIONS'),
dropdownPlacement: state.dropdown_menu.placement,
openDropdownId: state.dropdown_menu.openId,
openedViaKeyboard: state.dropdown_menu.keyboard,

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

@ -50,8 +50,8 @@ const Search = (props: ISearch) => {
const history = useHistory();
const intl = useIntl();
const value = useAppSelector((state) => state.search.get('value'));
const submitted = useAppSelector((state) => state.search.get('submitted'));
const value = useAppSelector((state) => state.search.value);
const submitted = useAppSelector((state) => state.search.submitted);
const debouncedSubmit = debounce(() => {
dispatch(submitSearch());

View File

@ -1,172 +0,0 @@
import classNames from 'classnames';
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, FormattedMessage } from 'react-intl';
import Hashtag from 'soapbox/components/hashtag';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Tabs } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import StatusContainer from 'soapbox/containers/status_container';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
});
export default @injectIntl
class SearchResults extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
results: ImmutablePropTypes.map.isRequired,
submitted: PropTypes.bool,
expandSearch: PropTypes.func.isRequired,
selectedFilter: PropTypes.string.isRequired,
selectFilter: PropTypes.func.isRequired,
features: PropTypes.object.isRequired,
suggestions: ImmutablePropTypes.list,
trendingStatuses: ImmutablePropTypes.list,
trends: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
};
handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter);
handleSelectFilter = newActiveFilter => this.props.selectFilter(newActiveFilter);
componentDidMount() {
this.props.fetchTrendingStatuses();
}
renderFilterBar() {
const { intl, selectedFilter } = this.props;
const items = [
{
text: intl.formatMessage(messages.accounts),
action: () => this.handleSelectFilter('accounts'),
name: 'accounts',
},
{
text: intl.formatMessage(messages.statuses),
action: () => this.handleSelectFilter('statuses'),
name: 'statuses',
},
{
text: intl.formatMessage(messages.hashtags),
action: () => this.handleSelectFilter('hashtags'),
name: 'hashtags',
},
];
return <Tabs items={items} activeItem={selectedFilter} />;
}
render() {
const { value, results, submitted, selectedFilter, suggestions, trendingStatuses, trends } = this.props;
let searchResults;
let hasMore = false;
let loaded;
let noResultsMessage;
let placeholderComponent = PlaceholderStatus;
if (selectedFilter === 'accounts') {
hasMore = results.get('accountsHasMore');
loaded = results.get('accountsLoaded');
placeholderComponent = PlaceholderAccount;
if (results.get('accounts') && results.get('accounts').size > 0) {
searchResults = results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />);
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.get('account')} id={suggestion.get('account')} />);
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.accounts'
defaultMessage='There are no people results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'statuses') {
hasMore = results.get('statusesHasMore');
loaded = results.get('statusesLoaded');
if (results.get('statuses') && results.get('statuses').size > 0) {
searchResults = results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />);
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
searchResults = trendingStatuses.map(statusId => <StatusContainer key={statusId} id={statusId} />);
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.statuses'
defaultMessage='There are no posts results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'hashtags') {
hasMore = results.get('hashtagsHasMore');
loaded = results.get('hashtagsLoaded');
placeholderComponent = PlaceholderHashtag;
if (results.get('hashtags') && results.get('hashtags').size > 0) {
searchResults = results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />);
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
searchResults = trends.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />);
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.hashtags'
defaultMessage='There are no hashtags results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
return (
<>
{this.renderFilterBar()}
{noResultsMessage || (
<ScrollableList
key={selectedFilter}
scrollKey={`${selectedFilter}:${value}`}
isLoading={submitted && !loaded}
showLoading={submitted && !loaded && results.isEmpty()}
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
placeholderComponent={placeholderComponent}
placeholderCount={20}
className={classNames({
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
})}
itemClassName={classNames({ 'pb-4': selectedFilter === 'accounts' })}
>
{searchResults}
</ScrollableList>
)}
</>
);
}
}

View File

@ -0,0 +1,170 @@
import classNames from 'classnames';
import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { expandSearch, setFilter } from 'soapbox/actions/search';
import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses';
import Hashtag from 'soapbox/components/hashtag';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Tabs } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import StatusContainer from 'soapbox/containers/status_container';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { SearchFilter } from 'soapbox/reducers/search';
const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
});
const SearchResults = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const value = useAppSelector((state) => state.search.submittedValue);
const results = useAppSelector((state) => state.search.results);
const suggestions = useAppSelector((state) => state.suggestions.items);
const trendingStatuses = useAppSelector((state) => state.trending_statuses.items);
const trends = useAppSelector((state) => state.trends.items);
const submitted = useAppSelector((state) => state.search.submitted);
const selectedFilter = useAppSelector((state) => state.search.filter);
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
const renderFilterBar = () => {
const items = [
{
text: intl.formatMessage(messages.accounts),
action: () => selectFilter('accounts'),
name: 'accounts',
},
{
text: intl.formatMessage(messages.statuses),
action: () => selectFilter('statuses'),
name: 'statuses',
},
{
text: intl.formatMessage(messages.hashtags),
action: () => selectFilter('hashtags'),
name: 'hashtags',
},
];
return <Tabs items={items} activeItem={selectedFilter} />;
};
useEffect(() => {
dispatch(fetchTrendingStatuses());
}, []);
let searchResults;
let hasMore = false;
let loaded;
let noResultsMessage;
let placeholderComponent = PlaceholderStatus as React.ComponentType;
if (selectedFilter === 'accounts') {
hasMore = results.accountsHasMore;
loaded = results.accountsLoaded;
placeholderComponent = PlaceholderAccount;
if (results.accounts && results.accounts.size > 0) {
searchResults = results.accounts.map(accountId => <AccountContainer key={accountId} id={accountId} />);
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.account} id={suggestion.account} />);
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.accounts'
defaultMessage='There are no people results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'statuses') {
hasMore = results.statusesHasMore;
loaded = results.statusesLoaded;
if (results.statuses && results.statuses.size > 0) {
searchResults = results.statuses.map((statusId: string) => (
// @ts-ignore
<StatusContainer key={statusId} id={statusId} />
));
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
searchResults = trendingStatuses.map((statusId: string) => (
// @ts-ignore
<StatusContainer key={statusId} id={statusId} />
));
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.statuses'
defaultMessage='There are no posts results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'hashtags') {
hasMore = results.hashtagsHasMore;
loaded = results.hashtagsLoaded;
placeholderComponent = PlaceholderHashtag;
if (results.hashtags && results.hashtags.size > 0) {
searchResults = results.hashtags.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
searchResults = trends.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.hashtags'
defaultMessage='There are no hashtags results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
return (
<>
{renderFilterBar()}
{noResultsMessage || (
<ScrollableList
key={selectedFilter}
scrollKey={`${selectedFilter}:${value}`}
isLoading={submitted && !loaded}
showLoading={submitted && !loaded && searchResults?.isEmpty()}
hasMore={hasMore}
onLoadMore={handleLoadMore}
placeholderComponent={placeholderComponent}
placeholderCount={20}
className={classNames({
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
})}
itemClassName={classNames({ 'pb-4': selectedFilter === 'accounts' })}
>
{searchResults || []}
</ScrollableList>
)}
</>
);
};
export default SearchResults;

View File

@ -1,33 +0,0 @@
import { connect } from 'react-redux';
import { expandSearch, setFilter } from 'soapbox/actions/search';
import { fetchSuggestions, dismissSuggestion } from 'soapbox/actions/suggestions';
import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses';
import { getFeatures } from 'soapbox/utils/features';
import SearchResults from '../components/search_results';
const mapStateToProps = state => {
const instance = state.get('instance');
return {
value: state.getIn(['search', 'submittedValue']),
results: state.getIn(['search', 'results']),
suggestions: state.getIn(['suggestions', 'items']),
trendingStatuses: state.getIn(['trending_statuses', 'items']),
trends: state.getIn(['trends', 'items']),
submitted: state.getIn(['search', 'submitted']),
selectedFilter: state.getIn(['search', 'filter']),
features: getFeatures(instance),
};
};
const mapDispatchToProps = dispatch => ({
fetchSuggestions: () => dispatch(fetchSuggestions()),
fetchTrendingStatuses: () => dispatch(fetchTrendingStatuses()),
expandSearch: type => dispatch(expandSearch(type)),
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
selectFilter: newActiveFilter => dispatch(setFilter(newActiveFilter)),
});
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);

View File

@ -6,7 +6,6 @@ import { changeSettingImmediate } from 'soapbox/actions/settings';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import Column from '../ui/components/column';
const messages = defineMessages({

View File

@ -13,7 +13,6 @@ const emojiMap = require('./emoji_map.json');
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
if (data.compressed) {
data = emojiMartUncompress(data);
}

View File

@ -48,7 +48,6 @@ const mapStateToProps = state => ({
filters: state.get('filters'),
});
export default @connect(mapStateToProps)
@injectIntl
class Filters extends ImmutablePureComponent {

View File

@ -21,7 +21,6 @@ const CSVImporter: React.FC<ICSVImporter> = ({ messages, action }) => {
const [isLoading, setIsLoading] = useState(false);
const [file, setFile] = useState<File | null | undefined>(null);
const handleSubmit: React.FormEventHandler = (event) => {
const params = new FormData();
params.append('list', file!);

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,4 +1,3 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -8,6 +7,8 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import type { AxiosError } from 'axios';
const BioStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
@ -11,6 +10,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 header filenames from various backends */
const DEFAULT_HEADERS = [
'/headers/original/missing.png', // Mastodon

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -8,6 +7,8 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Input, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import type { AxiosError } from 'axios';
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();

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,20 +0,0 @@
import { Set as ImmutableSet } from 'immutable';
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.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onToggle(e) {
dispatch(toggleStatusReport(id, e.target.checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);

View File

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
class Header extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
submitted: PropTypes.bool,
};
state = {
submittedValue: '',
};
componentDidUpdate(prevProps) {
if (this.props.submitted) {
const submittedValue = this.props.value;
this.setState({ submittedValue });
}
}
render() {
const { submittedValue } = this.state;
if (!submittedValue) {
return null;
}
return (
<div className='search-header'>
<div className='search-header__text-container'>
<h1 className='search-header__title-text'>
{submittedValue}
</h1>
</div>
<div className='search-header__type-filters'>
<div className='account__section-headline'>
<div className='search-header__type-filters-tabs'>
<NavLink to='/search' activeClassName='active'>
<FormattedMessage id='search_results.top' defaultMessage='Top' />
</NavLink>
</div>
</div>
</div>
</div>
);
}
}
export default connect(mapStateToProps)(Header);

View File

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Column } from 'soapbox/components/ui';
import Search from 'soapbox/features/compose/components/search';
import SearchResultsContainer from 'soapbox/features/compose/containers/search_results_container';
import SearchResults from 'soapbox/features/compose/components/search_results';
const messages = defineMessages({
heading: { id: 'column.search', defaultMessage: 'Search' },
@ -16,7 +16,7 @@ const SearchPage = () => {
<Column label={intl.formatMessage(messages.heading)}>
<div className='space-y-4'>
<Search autoFocus autoSubmit />
<SearchResultsContainer />
<SearchResults />
</div>
</Column>
);

View File

@ -14,7 +14,6 @@ const messages = defineMessages({
passwordPlaceholder: { id: 'mfa.mfa_setup.password_placeholder', defaultMessage: 'Password' },
});
const DisableOtpForm: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [password, setPassword] = useState('');
@ -39,7 +38,6 @@ const DisableOtpForm: React.FC = () => {
setPassword(event.target.value);
};
return (
<Form onSubmit={handleSubmit}>
<Stack>

View File

@ -29,7 +29,6 @@ const MfaForm: React.FC = () => {
dispatch(fetchMfa());
}, []);
const handleSetupProceedClick = (event: React.MouseEvent) => {
event.preventDefault();
setDisplayOtpForm(true);

View File

@ -525,7 +525,6 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
const canShare = ('share' in navigator) && status.visibility === 'public';
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
if (status.visibility === 'direct') {

View File

@ -1,22 +1,26 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeTag } from '../../../../normalizers';
import TrendsPanel from '../trends-panel';
describe('<TrendsPanel />', () => {
it('renders trending hashtags', () => {
const store = {
trends: ImmutableMap({
items: fromJS([{
trends: ImmutableRecord({
items: ImmutableList([
normalizeTag({
name: 'hashtag 1',
history: [{
day: '1652745600',
uses: '294',
accounts: '180',
}],
}]),
}),
]),
isLoading: false,
})(),
};
render(<TrendsPanel limit={1} />, null, store);
@ -27,18 +31,19 @@ describe('<TrendsPanel />', () => {
it('renders multiple trends', () => {
const store = {
trends: ImmutableMap({
items: fromJS([
{
trends: ImmutableRecord({
items: ImmutableList([
normalizeTag({
name: 'hashtag 1',
history: [{ accounts: [] }],
},
{
name: 'hashtag 2',
history: [{ accounts: [] }],
},
]),
history: ImmutableList([{ accounts: [] }]),
}),
normalizeTag({
name: 'hashtag 2',
history: ImmutableList([{ accounts: [] }]),
}),
]),
isLoading: false,
})(),
};
render(<TrendsPanel limit={3} />, null, store);
@ -47,18 +52,19 @@ describe('<TrendsPanel />', () => {
it('respects the limit prop', () => {
const store = {
trends: ImmutableMap({
items: fromJS([
{
trends: ImmutableRecord({
items: ImmutableList([
normalizeTag({
name: 'hashtag 1',
history: [{ accounts: [] }],
},
{
}),
normalizeTag({
name: 'hashtag 2',
history: [{ accounts: [] }],
},
]),
}),
]),
isLoading: false,
})(),
};
render(<TrendsPanel limit={1} />, null, store);
@ -67,9 +73,10 @@ describe('<TrendsPanel />', () => {
it('renders empty', () => {
const store = {
trends: ImmutableMap({
items: fromJS([]),
}),
trends: ImmutableRecord({
items: ImmutableList([]),
isLoading: false,
})(),
};
render(<TrendsPanel limit={1} />, null, store);

View File

@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { changeAccountNoteComment, submitAccountNote } from 'soapbox/actions/account-notes';
import { closeModal } from 'soapbox/actions/modals';
import { Modal, Text } from 'soapbox/components/ui';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
save: { id: 'account_note.save', defaultMessage: 'Save' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = state => ({
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
account: getAccount(state, state.getIn(['account_notes', 'edit', 'account_id'])),
comment: state.getIn(['account_notes', 'edit', 'comment']),
});
return mapStateToProps;
};
const mapDispatchToProps = dispatch => {
return {
onConfirm() {
dispatch(submitAccountNote());
},
onClose() {
dispatch(closeModal());
},
onCommentChange(comment) {
dispatch(changeAccountNoteComment(comment));
},
};
};
export default @connect(makeMapStateToProps, mapDispatchToProps)
@injectIntl
class AccountNoteModal extends React.PureComponent {
static propTypes = {
isSubmitting: PropTypes.bool,
account: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
onCommentChange: PropTypes.func.isRequired,
comment: PropTypes.string,
intl: PropTypes.object.isRequired,
};
handleCommentChange = e => {
this.props.onCommentChange(e.target.value);
}
handleSubmit = () => {
this.props.onConfirm();
}
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
render() {
const { account, isSubmitting, comment, onClose, intl } = this.props;
return (
<Modal
title={<FormattedMessage id='account_note.target' defaultMessage='Note for @{target}' values={{ target: account.get('acct') }} />}
onClose={onClose}
confirmationAction={this.handleSubmit}
confirmationText={intl.formatMessage(messages.save)}
confirmationDisabled={isSubmitting}
>
<Text theme='muted'>
<FormattedMessage id='account_note.hint' defaultMessage='You can keep notes about this user for yourself (this will not be shared with them):' />
</Text>
<textarea
className='setting-text light'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
onKeyDown={this.handleKeyDown}
disabled={isSubmitting}
autoFocus
/>
</Modal>
);
}
}

View File

@ -0,0 +1,68 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { changeAccountNoteComment, submitAccountNote } from 'soapbox/actions/account-notes';
import { closeModal } from 'soapbox/actions/modals';
import { Modal, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
save: { id: 'account_note.save', defaultMessage: 'Save' },
});
const getAccount = makeGetAccount();
const AccountNoteModal = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const isSubmitting = useAppSelector((state) => state.account_notes.edit.isSubmitting);
const account = useAppSelector((state) => getAccount(state, state.account_notes.edit.account!));
const comment = useAppSelector((state) => state.account_notes.edit.comment);
const onClose = () => {
dispatch(closeModal('ACCOUNT_NOTE'));
};
const handleCommentChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
dispatch(changeAccountNoteComment(e.target.value));
};
const handleSubmit = () => {
dispatch(submitAccountNote());
};
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
}
};
return (
<Modal
title={<FormattedMessage id='account_note.target' defaultMessage='Note for @{target}' values={{ target: account!.acct }} />}
onClose={onClose}
confirmationAction={handleSubmit}
confirmationText={intl.formatMessage(messages.save)}
confirmationDisabled={isSubmitting}
>
<Text theme='muted'>
<FormattedMessage id='account_note.hint' defaultMessage='You can keep notes about this user for yourself (this will not be shared with them):' />
</Text>
<textarea
className='setting-text light'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={handleCommentChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
autoFocus
/>
</Modal>
);
};
export default AccountNoteModal;

View File

@ -1,18 +1,27 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import spring from 'react-motion/lib/spring';
import { spring } from 'react-motion';
import Icon from 'soapbox/components/icon';
import StatusContent from 'soapbox/components/status_content';
import { Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import Icon from '../../../components/icon';
import StatusContent from '../../../components/status_content';
import { Stack } from '../../../components/ui';
import AccountContainer from '../../../containers/account_container';
import Motion from '../util/optional_motion';
const ActionsModal = ({ status, actions, onClick, onClose }) => {
const renderAction = (action, i) => {
import type { Menu, MenuItem } from 'soapbox/components/dropdown_menu';
import type { Status as StatusEntity } from 'soapbox/types/entities';
interface IActionsModal {
status: StatusEntity,
actions: Menu,
onClick: () => void,
onClose: () => void,
}
const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClose }) => {
const renderAction = (action: MenuItem | null, i: number) => {
if (action === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
@ -48,9 +57,10 @@ const ActionsModal = ({ status, actions, onClick, onClose }) => {
{status && (
<Stack space={2} className='p-4 bg-gray-50 dark:bg-slate-800 border-b border-solid border-gray-200 dark:border-gray-700'>
<AccountContainer
account={status.get('account')}
key={status.account as string}
id={status.account as string}
showProfileHoverCard={false}
timestamp={status.get('created_at')}
timestamp={status.created_at}
/>
<StatusContent status={status} />
</Stack>
@ -73,11 +83,4 @@ const ActionsModal = ({ status, actions, onClick, onClose }) => {
);
};
ActionsModal.propTypes = {
status: ImmutablePropTypes.record,
actions: PropTypes.array,
onClick: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
export default ActionsModal;

View File

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Pullable from 'soapbox/components/pullable';
import { Column } from 'soapbox/components/ui';
import ColumnHeader from './column_header';
export default class UIColumn extends React.PureComponent {
static propTypes = {
heading: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.node,
active: PropTypes.bool,
showBackBtn: PropTypes.bool,
};
static defaultProps = {
showBackBtn: true,
}
render() {
const { heading, icon, children, active, showBackBtn, ...rest } = this.props;
const columnHeaderId = heading && heading.replace(/ /g, '-');
return (
<Column aria-labelledby={columnHeaderId} {...rest}>
{heading && <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} showBackBtn={showBackBtn} />}
<Pullable>
{children}
</Pullable>
</Column>
);
}
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import Pullable from 'soapbox/components/pullable';
import { Column } from 'soapbox/components/ui';
import ColumnHeader from './column_header';
import type { IColumn } from 'soapbox/components/ui/column/column';
interface IUIColumn extends IColumn {
heading?: string,
icon?: string,
active?: boolean,
}
const UIColumn: React.FC<IUIColumn> = ({
heading,
icon,
children,
active,
...rest
}) => {
const columnHeaderId = heading && heading.replace(/ /g, '-');
return (
<Column aria-labelledby={columnHeaderId} {...rest}>
{heading && <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />}
<Pullable>
{children}
</Pullable>
</Column>
);
};
export default UIColumn;

View File

@ -1,32 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Column from './column';
const messages = defineMessages({
title: { id: 'column_forbidden.title', defaultMessage: 'Forbidden' },
body: { id: 'column_forbidden.body', defaultMessage: 'You do not have permission to access this page.' },
});
class ColumnForbidden extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
}
render() {
const { intl: { formatMessage } } = this.props;
return (
<Column label={formatMessage(messages.title)}>
<div className='error-column'>
{formatMessage(messages.body)}
</div>
</Column>
);
}
}
export default injectIntl(ColumnForbidden);

View File

@ -0,0 +1,23 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Column from './column';
const messages = defineMessages({
title: { id: 'column_forbidden.title', defaultMessage: 'Forbidden' },
body: { id: 'column_forbidden.body', defaultMessage: 'You do not have permission to access this page.' },
});
const ColumnForbidden = () => {
const intl = useIntl();
return (
<Column label={intl.formatMessage(messages.title)}>
<div className='error-column'>
{intl.formatMessage(messages.body)}
</div>
</Column>
);
};
export default ColumnForbidden;

View File

@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
// import classNames from 'classnames';
// import Icon from 'soapbox/components/icon';
import SubNavigation from 'soapbox/components/sub_navigation';
export default class ColumnHeader extends React.PureComponent {
static propTypes = {
icon: PropTypes.string,
type: PropTypes.string,
active: PropTypes.bool,
onClick: PropTypes.func,
columnHeaderId: PropTypes.string,
};
handleClick = () => {
this.props.onClick();
}
render() {
const { type } = this.props;
return <SubNavigation message={type} />;
}
// render() {
// const { icon, type, active, columnHeaderId } = this.props;
//
// return (
// <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
// <button onClick={this.handleClick}>
// {icon && <Icon id={icon} fixedWidth className='column-header__icon' />}
// {type}
// </button>
// </h1>
// );
// }
}

View File

@ -0,0 +1,47 @@
import React from 'react';
// import classNames from 'classnames';
// import Icon from 'soapbox/components/icon';
import SubNavigation from 'soapbox/components/sub_navigation';
interface IColumnHeader {
icon?: string,
type: string
active?: boolean,
columnHeaderId?: string,
}
const ColumnHeader: React.FC<IColumnHeader> = ({ type }) => {
return <SubNavigation message={type} />;
};
export default ColumnHeader;
// export default class ColumnHeader extends React.PureComponent {
// static propTypes = {
// icon: PropTypes.string,
// type: PropTypes.string,
// active: PropTypes.bool,
// onClick: PropTypes.func,
// columnHeaderId: PropTypes.string,
// };
// handleClick = () => {
// this.props.onClick();
// }
// render() {
// const { icon, type, active, columnHeaderId } = this.props;
// return (
// <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
// <button onClick={this.handleClick}>
// {icon && <Icon id={icon} fixedWidth className='column-header__icon' />}
// {type}
// </button>
// </h1>
// );
// }
// }

View File

@ -39,7 +39,6 @@ import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error';
import ModalLoading from './modal_loading';
const MODAL_COMPONENTS = {
'MEDIA': MediaModal,
'VIDEO': VideoModal,

View File

@ -1,5 +1,5 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
import { Map as ImmutableMap, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import React from 'react';
import { __stub } from 'soapbox/api';
@ -24,13 +24,13 @@ describe('<ReportModal />', () => {
avatar: 'test.jpg',
}),
}),
reports: ImmutableMap({
new: {
reports: ImmutableRecord({
new: ImmutableRecord({
account_id: '1',
status_ids: ImmutableSet(['1']),
rule_ids: ImmutableSet(),
},
}),
})(),
})(),
statuses: ImmutableMap({
'1': normalizeStatus(status),
}),

View File

@ -1,5 +1,3 @@
import { AxiosError } from 'axios';
import { Set as ImmutableSet } from 'immutable';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -16,6 +14,8 @@ import ConfirmationStep from './steps/confirmation-step';
import OtherActionsStep from './steps/other-actions-step';
import ReasonStep from './steps/reason-step';
import type { AxiosError } from 'axios';
const messages = defineMessages({
blankslate: { id: 'report.reason.blankslate', defaultMessage: 'You have removed all statuses from being selected.' },
done: { id: 'report.done', defaultMessage: 'Done' },
@ -77,14 +77,14 @@ const ReportModal = ({ onClose }: IReportModal) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accountId = useAppSelector((state) => state.reports.getIn(['new', 'account_id']) as string);
const account = useAccount(accountId);
const accountId = useAppSelector((state) => state.reports.new.account_id);
const account = useAccount(accountId as string);
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
const isBlocked = useAppSelector((state) => state.reports.new.block);
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet<string>);
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
const shouldRequireRule = rules.length > 0;

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';
@ -30,11 +30,11 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => {
const features = useFeatures();
const intl = useIntl();
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.getIn(['new', 'status_ids']) as Iterable<unknown>) as OrderedSet<string>);
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
const isForward = useAppSelector((state) => state.reports.getIn(['new', 'forward']) as boolean);
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.new.status_ids) as OrderedSet<string>);
const isBlocked = useAppSelector((state) => state.reports.new.block);
const isForward = useAppSelector((state) => state.reports.new.forward);
const canForward = isRemote(account as any) && features.federating;
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
const [showAdditionalStatuses, setShowAdditionalStatuses] = useState<boolean>(false);
@ -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

@ -8,7 +8,6 @@ import { fetchRules } from 'soapbox/actions/rules';
import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { Set as ImmutableSet } from 'immutable';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
const messages = defineMessages({
@ -31,12 +30,12 @@ const ReasonStep = (_props: IReasonStep) => {
const [isNearBottom, setNearBottom] = useState<boolean>(false);
const [isNearTop, setNearTop] = useState<boolean>(true);
const comment = useAppSelector((state) => state.reports.getIn(['new', 'comment']) as string);
const comment = useAppSelector((state) => state.reports.new.comment);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const shouldRequireRule = rules.length > 0;
const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet<string>);
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {

View File

@ -11,7 +11,7 @@ import { Modal, HStack, Stack, Text } from 'soapbox/components/ui';
const mapStateToProps = state => {
return {
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
isSubmitting: state.reports.new.isSubmitting,
account: state.getIn(['mutes', 'new', 'account']),
notifications: state.getIn(['mutes', 'new', 'notifications']),
};

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import React, { useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -15,6 +14,8 @@ import { useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import ProfileDropdown from './profile-dropdown';
import type { AxiosError } from 'axios';
const messages = defineMessages({
login: { id: 'navbar.login.action', defaultMessage: 'Log in' },
username: { id: 'navbar.login.username.placeholder', defaultMessage: 'Email or username' },

View File

@ -1,4 +1,3 @@
import { Map as ImmutableMap } from 'immutable';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -15,10 +14,10 @@ interface ITrendsPanel {
const TrendsPanel = ({ limit }: ITrendsPanel) => {
const dispatch = useDispatch();
const trends: any = useAppSelector((state) => state.trends.get('items'));
const trends = useAppSelector((state) => state.trends.items);
const sortedTrends = React.useMemo(() => {
return trends.sort((a: ImmutableMap<string, any>, b: ImmutableMap<string, any>) => {
return trends.sort((a, b) => {
const num_a = Number(a.getIn(['history', 0, 'accounts']));
const num_b = Number(b.getIn(['history', 0, 'accounts']));
return num_b - num_a;
@ -35,8 +34,8 @@ const TrendsPanel = ({ limit }: ITrendsPanel) => {
return (
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
{sortedTrends.map((hashtag: ImmutableMap<string, any>) => (
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
{sortedTrends.map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
))}
</Widget>
);

View File

@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles';
import Bundle from '../components/bundle';
const mapDispatchToProps = dispatch => ({
onFetch() {
dispatch(fetchBundleRequest());

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -8,6 +7,8 @@ import snackbar from 'soapbox/actions/snackbar';
import { confirmEmailVerification } from 'soapbox/actions/verification';
import { Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import type { AxiosError } from 'axios';
const Statuses = {
IDLE: 'IDLE',
SUCCESS: 'SUCCESS',

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
@ -15,6 +14,8 @@ import { getRedirectUrl } from 'soapbox/utils/redirect';
import PasswordIndicator from './components/password-indicator';
import type { AxiosError } from 'axios';
const messages = defineMessages({
success: {
id: 'registrations.success',

View File

@ -1,11 +1,10 @@
import PropTypes from 'prop-types';
import * as React from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar';
import { verifyAge } from 'soapbox/actions/verification';
import { Button, Datepicker, Form, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
fail: {
@ -14,7 +13,7 @@ const messages = defineMessages({
},
});
function meetsAgeMinimum(birthday, ageMinimum) {
function meetsAgeMinimum(birthday: Date, ageMinimum: number) {
const month = birthday.getUTCMonth();
const day = birthday.getUTCDate();
const year = birthday.getUTCFullYear();
@ -24,11 +23,11 @@ function meetsAgeMinimum(birthday, ageMinimum) {
const AgeVerification = () => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const isLoading = useSelector((state) => state.verification.get('isLoading'));
const ageMinimum = useSelector((state) => state.verification.get('ageMinimum'));
const siteTitle = useSelector((state) => state.instance.get('title'));
const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean;
const ageMinimum = useAppSelector((state) => state.verification.get('ageMinimum')) as any;
const siteTitle = useAppSelector((state) => state.instance.title);
const [date, setDate] = React.useState('');
const isValid = typeof date === 'object';
@ -44,9 +43,7 @@ const AgeVerification = () => {
dispatch(verifyAge(birthday));
} else {
dispatch(
snackbar.error(intl.formatMessage(messages.fail, {
ageMinimum,
})),
snackbar.error(intl.formatMessage(messages.fail, { ageMinimum })),
);
}
}, [date, ageMinimum]);
@ -78,8 +75,4 @@ const AgeVerification = () => {
);
};
AgeVerification.propTypes = {
verifyAge: PropTypes.func,
};
export default AgeVerification;

View File

@ -1,12 +1,12 @@
import PropTypes from 'prop-types';
import * as React from 'react';
import { AxiosError } from 'axios';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar';
import { checkEmailVerification, postEmailVerification, requestEmailVerification } from 'soapbox/actions/verification';
import Icon from 'soapbox/components/icon';
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const Statuses = {
IDLE: 'IDLE',
@ -16,8 +16,12 @@ const Statuses = {
const EMAIL_REGEX = /^[^@\s]+@[^@\s]+$/;
const EmailSent = ({ handleSubmit }) => {
const dispatch = useDispatch();
interface IEmailSent {
handleSubmit: React.FormEventHandler,
}
const EmailSent: React.FC<IEmailSent> = ({ handleSubmit }) => {
const dispatch = useAppDispatch();
const checkEmailConfirmation = () => {
dispatch(checkEmailVerification())
@ -47,19 +51,19 @@ const EmailSent = ({ handleSubmit }) => {
const EmailVerification = () => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const isLoading = useSelector((state) => state.verification.get('isLoading'));
const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean;
const [email, setEmail] = React.useState('');
const [status, setStatus] = React.useState(Statuses.IDLE);
const [errors, setErrors] = React.useState([]);
const [errors, setErrors] = React.useState<Array<string>>([]);
const isValid = email.length > 0 && EMAIL_REGEX.test(email);
const onChange = React.useCallback((event) => setEmail(event.target.value), []);
const handleSubmit = React.useCallback((event) => {
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
setErrors([]);
@ -80,8 +84,8 @@ const EmailVerification = () => {
),
);
})
.catch(error => {
const isEmailTaken = error.response?.data?.error === 'email_taken';
.catch((error: AxiosError) => {
const isEmailTaken = (error.response?.data as any)?.error === 'email_taken';
const message = isEmailTaken ? (
intl.formatMessage({ id: 'email_verification.exists', defaultMessage: 'This email has already been taken.' })
@ -130,8 +134,4 @@ const EmailVerification = () => {
);
};
EmailSent.propTypes = {
handleSubmit: PropTypes.func.isRequired,
};
export default EmailVerification;

View File

@ -1,11 +1,11 @@
import * as React from 'react';
import React from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar';
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { formatPhoneNumber } from 'soapbox/utils/phone';
const Statuses = {
@ -18,9 +18,9 @@ const validPhoneNumberRegex = /^\+1\s\(\d{3}\)\s\d{3}-\d{4}/;
const SmsVerification = () => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const isLoading = useSelector((state) => state.verification.get('isLoading'));
const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean;
const [phone, setPhone] = React.useState('');
const [status, setStatus] = React.useState(Statuses.IDLE);
@ -165,5 +165,4 @@ const SmsVerification = () => {
);
};
export { SmsVerification as default, validPhoneNumberRegex };

View File

@ -0,0 +1,22 @@
/**
* History normalizer:
* Converts API daily usage history of a hashtag into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/history/}
*/
import {
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
// https://docs.joinmastodon.org/entities/history/
export const HistoryRecord = ImmutableRecord({
accounts: '',
day: '',
uses: '',
});
export const normalizeHistory = (history: Record<string, any>) => {
return HistoryRecord(
ImmutableMap(fromJS(history)),
);
};

View File

@ -6,6 +6,7 @@ export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat_message';
export { EmojiRecord, normalizeEmoji } from './emoji';
export { HistoryRecord, normalizeHistory } from './history';
export { InstanceRecord, normalizeInstance } from './instance';
export { ListRecord, normalizeList } from './list';
export { MentionRecord, normalizeMention } from './mention';
@ -14,5 +15,6 @@ export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
export { RelationshipRecord, normalizeRelationship } from './relationship';
export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
export { TagRecord, normalizeTag } from './tag';
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';

View File

@ -0,0 +1,40 @@
/**
* Tag normalizer:
* Converts API tags into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/tag/}
*/
import {
List as ImmutableList,
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import { normalizeHistory } from './history';
import type { History } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/tag/
export const TagRecord = ImmutableRecord({
name: '',
url: '',
history: null as ImmutableList<History> | null,
});
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
if (tag.get('history')){
return tag.update('history', ImmutableList(), attachments => {
return attachments.map(normalizeHistory);
});
} else {
return tag.set('history', null);
}
};
export const normalizeTag = (tag: Record<string, any>) => {
return TagRecord(
ImmutableMap(fromJS(tag)).withMutations(tag => {
normalizeHistoryList(tag);
}),
);
};

View File

@ -7,7 +7,6 @@ import reducer from '../accounts_counters';
// import accounts_counter_unfollow from 'soapbox/__fixtures__/accounts_counter_unfollow.json';
// import accounts_counter_follow from 'soapbox/__fixtures__/accounts_counter_follow.json';
describe('accounts_counters reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());

View File

@ -16,10 +16,10 @@ describe('modal reducer', () => {
modalType: 'type1',
modalProps: { props1: '1' },
};
expect(reducer(state, action)).toMatchObject(ImmutableList([{
expect(reducer(state, action).toJS()).toMatchObject([{
modalType: 'type1',
modalProps: { props1: '1' },
}]));
}]);
});
it('should handle MODAL_CLOSE', () => {

View File

@ -1,19 +1,17 @@
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
import reducer from '../reports';
describe('reports reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
new: ImmutableMap({
expect(reducer(undefined, {}).toJS()).toEqual({
new: {
isSubmitting: false,
account_id: null,
status_ids: ImmutableSet(),
status_ids: [],
comment: '',
forward: false,
block: false,
rule_ids: ImmutableSet(),
}),
}));
rule_ids: [],
},
});
});
});

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import {
SEARCH_CHANGE,
@ -10,14 +10,24 @@ import reducer from '../search';
describe('search reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
expect(reducer(undefined, {}).toJS()).toEqual({
value: '',
submitted: false,
submittedValue: '',
hidden: false,
results: ImmutableMap(),
results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts',
}));
});
});
describe('SEARCH_CHANGE', () => {
@ -30,42 +40,54 @@ describe('search reducer', () => {
describe('SEARCH_CLEAR', () => {
it('resets the state', () => {
const state = ImmutableMap({
const state = ImmutableRecord({
value: 'hello world',
submitted: true,
submittedValue: 'hello world',
hidden: false,
results: ImmutableMap(),
results: ImmutableRecord({})(),
filter: 'statuses',
});
})();
const action = { type: SEARCH_CLEAR };
const expected = ImmutableMap({
const expected = {
value: '',
submitted: false,
submittedValue: '',
hidden: false,
results: ImmutableMap(),
results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts',
});
};
expect(reducer(state, action)).toEqual(expected);
expect(reducer(state, action).toJS()).toEqual(expected);
});
});
describe(SEARCH_EXPAND_SUCCESS, () => {
it('imports hashtags as maps', () => {
const state = ImmutableMap({
const state = ImmutableRecord({
value: 'artist',
submitted: true,
submittedValue: 'artist',
hidden: false,
results: ImmutableMap({
results: ImmutableRecord({
hashtags: ImmutableList(),
}),
hashtagsHasMore: false,
hashtagsLoaded: true,
})(),
filter: 'hashtags',
});
})();
const action = {
type: SEARCH_EXPAND_SUCCESS,
@ -82,24 +104,26 @@ describe('search reducer', () => {
searchType: 'hashtags',
};
const expected = ImmutableMap({
const expected = {
value: 'artist',
submitted: true,
submittedValue: 'artist',
hidden: false,
results: ImmutableMap({
hashtags: fromJS([{
results: {
hashtags: [
{
name: 'artist',
url: 'https://gleasonator.com/tags/artist',
history: [],
}]),
},
],
hashtagsHasMore: false,
hashtagsLoaded: true,
}),
},
filter: 'hashtags',
});
};
expect(reducer(state, action)).toEqual(expected);
expect(reducer(state, action).toJS()).toEqual(expected);
});
});
});

View File

@ -1,12 +1,10 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import reducer from '../trends';
describe('trends reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
items: ImmutableList(),
expect(reducer(undefined, {}).toJS()).toEqual({
items: [],
isLoading: false,
}));
});
});
});

View File

@ -1,5 +1,4 @@
import { Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import {
ACCOUNT_NOTE_INIT_MODAL,
@ -9,10 +8,12 @@ import {
ACCOUNT_NOTE_SUBMIT_SUCCESS,
} from '../actions/account-notes';
import type { AnyAction } from 'redux';
const EditRecord = ImmutableRecord({
isSubmitting: false,
account: null,
comment: null,
comment: '',
});
const ReducerRecord = ImmutableRecord({
@ -26,7 +27,7 @@ export default function account_notes(state: State = ReducerRecord(), action: An
case ACCOUNT_NOTE_INIT_MODAL:
return state.withMutations((state) => {
state.setIn(['edit', 'isSubmitting'], false);
state.setIn(['edit', 'account_id'], action.account.get('id'));
state.setIn(['edit', 'account'], action.account.get('id'));
state.setIn(['edit', 'comment'], action.comment);
});
case ACCOUNT_NOTE_CHANGE_COMMENT:

View File

@ -4,7 +4,6 @@ import {
OrderedSet as ImmutableOrderedSet,
fromJS,
} from 'immutable';
import { AnyAction } from 'redux';
import {
ADMIN_USERS_FETCH_SUCCESS,
@ -39,6 +38,8 @@ import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { normalizeAccount } from 'soapbox/normalizers/account';
import { normalizeId } from 'soapbox/utils/normalizers';
import type { AnyAction } from 'redux';
type AccountRecord = ReturnType<typeof normalizeAccount>;
type AccountMap = ImmutableMap<string, any>;
type APIEntity = Record<string, any>;

View File

@ -10,7 +10,6 @@ import {
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const ReducerRecord = ImmutableRecord({
aliases: ImmutableRecord({
items: ImmutableList<string>(),

View File

@ -257,7 +257,6 @@ const importMastodonPreload = (state, data) => {
});
};
const persistAuthAccount = account => {
if (account && account.url) {
const key = `authAccount:${account.url}`;

View File

@ -1,5 +1,4 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { AnyAction } from 'redux';
import {
CHATS_FETCH_SUCCESS,
@ -11,6 +10,8 @@ import {
} from 'soapbox/actions/chats';
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import type { AnyAction } from 'redux';
type APIEntity = Record<string, any>;
type APIEntities = Array<APIEntity>;

View File

@ -1,5 +1,4 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { AnyAction } from 'redux';
import {
CHATS_FETCH_SUCCESS,
@ -13,6 +12,8 @@ import {
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
import { normalizeChatMessage } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
type APIEntity = Record<string, any>;
type APIEntities = Array<APIEntity>;

View File

@ -1,9 +1,10 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'soapbox/actions/history';
import { normalizeStatusEdit } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
type StatusEditRecord = ReturnType<typeof normalizeStatusEdit>;
const HistoryRecord = ImmutableRecord({

View File

@ -1,5 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { AnyAction } from 'redux';
import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin';
import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload';
@ -13,6 +12,8 @@ import {
fetchNodeinfo,
} from '../actions/instance';
import type { AnyAction } from 'redux';
const initialState = normalizeInstance(ImmutableMap());
const nodeinfoToInstance = (nodeinfo: ImmutableMap<string, any>) => {

View File

@ -1,5 +1,4 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import {
LIST_ADDER_RESET,
@ -11,6 +10,8 @@ import {
LIST_EDITOR_REMOVE_SUCCESS,
} from '../actions/lists';
import type { AnyAction } from 'redux';
const ListsRecord = ImmutableRecord({
items: ImmutableList<string>(),
loaded: false,

View File

@ -1,5 +1,4 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import {
LIST_CREATE_REQUEST,
@ -21,6 +20,8 @@ import {
LIST_EDITOR_REMOVE_SUCCESS,
} from '../actions/lists';
import type { AnyAction } from 'redux';
const AccountsRecord = ImmutableRecord({
items: ImmutableList<string>(),
loaded: false,

View File

@ -1,5 +1,4 @@
import { Map as ImmutableMap } from 'immutable';
import { AnyAction } from 'redux';
import {
LIST_FETCH_SUCCESS,
@ -11,6 +10,8 @@ import {
} from 'soapbox/actions/lists';
import { normalizeList } from 'soapbox/normalizers';
import type { AnyAction } from 'redux';
type ListRecord = ReturnType<typeof normalizeList>;
type APIEntity = Record<string, any>;
type APIEntities = Array<APIEntity>;

View File

@ -1,13 +1,21 @@
import { List as ImmutableList } from 'immutable';
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modals';
const initialState = ImmutableList();
import type { AnyAction } from 'redux';
export default function modal(state = initialState, action) {
const ModalRecord = ImmutableRecord({
modalType: '',
modalProps: null as Record<string, any> | null,
});
type Modal = ReturnType<typeof ModalRecord>;
type State = ImmutableList<Modal>;
export default function modal(state: State = ImmutableList<Modal>(), action: AnyAction) {
switch (action.type) {
case MODAL_OPEN:
return state.push({ modalType: action.modalType, modalProps: action.modalProps });
return state.push(ModalRecord({ modalType: action.modalType, modalProps: action.modalProps }));
case MODAL_CLOSE:
if (state.size === 0) {
return state;

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
import { Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import {
REPORT_INIT,
@ -13,39 +13,45 @@ import {
REPORT_RULE_CHANGE,
} from '../actions/reports';
const initialState = ImmutableMap({
new: ImmutableMap({
import type { AnyAction } from 'redux';
const NewReportRecord = ImmutableRecord({
isSubmitting: false,
account_id: null,
status_ids: ImmutableSet(),
account_id: null as string | null,
status_ids: ImmutableSet<string>(),
comment: '',
forward: false,
block: false,
rule_ids: ImmutableSet(),
}),
rule_ids: ImmutableSet<string>(),
});
export default function reports(state = initialState, action) {
const ReducerRecord = ImmutableRecord({
new: NewReportRecord(),
});
type State = ReturnType<typeof ReducerRecord>;
export default function reports(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case REPORT_INIT:
return state.withMutations(map => {
map.setIn(['new', 'isSubmitting'], false);
map.setIn(['new', 'account_id'], action.account.get('id'));
map.setIn(['new', 'account_id'], action.account.id);
if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
if (state.new.account_id !== action.account.id) {
map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.reblog?.id || action.status.id]) : ImmutableSet());
map.setIn(['new', 'comment'], '');
} else if (action.status) {
map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
map.updateIn(['new', 'status_ids'], set => (set as ImmutableSet<string>).add(action.status.reblog?.id || action.status.id));
}
});
case REPORT_STATUS_TOGGLE:
return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => {
return state.updateIn(['new', 'status_ids'], set => {
if (action.checked) {
return set.add(action.statusId);
return (set as ImmutableSet<string>).add(action.statusId);
}
return set.remove(action.statusId);
return (set as ImmutableSet<string>).remove(action.statusId);
});
case REPORT_COMMENT_CHANGE:
return state.setIn(['new', 'comment'], action.comment);
@ -54,12 +60,12 @@ export default function reports(state = initialState, action) {
case REPORT_BLOCK_CHANGE:
return state.setIn(['new', 'block'], action.block);
case REPORT_RULE_CHANGE:
return state.updateIn(['new', 'rule_ids'], ImmutableSet(), (set) => {
if (set.includes(action.rule_id)) {
return set.remove(action.rule_id);
return state.updateIn(['new', 'rule_ids'], (set) => {
if ((set as ImmutableSet<string>).includes(action.rule_id)) {
return (set as ImmutableSet<string>).remove(action.rule_id);
}
return set.add(action.rule_id);
return (set as ImmutableSet<string>).add(action.rule_id);
});
case REPORT_SUBMIT_REQUEST:
return state.setIn(['new', 'isSubmitting'], true);

View File

@ -1,4 +1,6 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable';
import { normalizeTag } from 'soapbox/normalizers';
import {
COMPOSE_MENTION,
@ -17,26 +19,45 @@ import {
SEARCH_EXPAND_SUCCESS,
} from '../actions/search';
const initialState = ImmutableMap({
import type { AnyAction } from 'redux';
import type { APIEntity, Tag } from 'soapbox/types/entities';
const ResultsRecord = ImmutableRecord({
accounts: ImmutableOrderedSet<string>(),
statuses: ImmutableOrderedSet<string>(),
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
});
const ReducerRecord = ImmutableRecord({
value: '',
submitted: false,
submittedValue: '',
hidden: false,
results: ImmutableMap(),
filter: 'accounts',
results: ResultsRecord(),
filter: 'accounts' as SearchFilter,
});
const toIds = items => {
type State = ReturnType<typeof ReducerRecord>;
type APIEntities = Array<APIEntity>;
export type SearchFilter = 'accounts' | 'statuses' | 'hashtags';
const toIds = (items: APIEntities) => {
return ImmutableOrderedSet(items.map(item => item.id));
};
const importResults = (state, results, searchTerm, searchType) => {
const importResults = (state: State, results: APIEntity, searchTerm: string, searchType: SearchFilter) => {
return state.withMutations(state => {
if (state.get('value') === searchTerm && state.get('filter') === searchType) {
state.set('results', ImmutableMap({
if (state.value === searchTerm && state.filter === searchType) {
state.set('results', ResultsRecord({
accounts: toIds(results.accounts),
statuses: toIds(results.statuses),
hashtags: fromJS(results.hashtags), // it's a list of maps
hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
accountsHasMore: results.accounts.length >= 20,
statusesHasMore: results.statuses.length >= 20,
hashtagsHasMore: results.hashtags.length >= 20,
@ -50,38 +71,38 @@ const importResults = (state, results, searchTerm, searchType) => {
});
};
const paginateResults = (state, searchType, results, searchTerm) => {
const paginateResults = (state: State, searchType: SearchFilter, results: APIEntity, searchTerm: string) => {
return state.withMutations(state => {
if (state.get('value') === searchTerm) {
if (state.value === searchTerm) {
state.setIn(['results', `${searchType}HasMore`], results[searchType].length >= 20);
state.setIn(['results', `${searchType}Loaded`], true);
state.updateIn(['results', searchType], items => {
const data = results[searchType];
// Hashtags are a list of maps. Others are IDs.
if (searchType === 'hashtags') {
return items.concat(fromJS(data));
return (items as ImmutableOrderedSet<string>).concat(fromJS(data));
} else {
return items.concat(toIds(data));
return (items as ImmutableOrderedSet<string>).concat(toIds(data));
}
});
}
});
};
const handleSubmitted = (state, value) => {
const handleSubmitted = (state: State, value: string) => {
return state.withMutations(state => {
state.set('results', ImmutableMap());
state.set('results', ResultsRecord());
state.set('submitted', true);
state.set('submittedValue', value);
});
};
export default function search(state = initialState, action) {
export default function search(state = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case SEARCH_CHANGE:
return state.set('value', action.value);
case SEARCH_CLEAR:
return initialState;
return ReducerRecord();
case SEARCH_SHOW:
return state.set('hidden', false);
case COMPOSE_REPLY:

View File

@ -1,5 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord, fromJS } from 'immutable';
import { AnyAction } from 'redux';
import {
MFA_FETCH_SUCCESS,
@ -11,6 +10,8 @@ import {
REVOKE_TOKEN_SUCCESS,
} from '../actions/security';
import type { AnyAction } from 'redux';
const TokenRecord = ImmutableRecord({
id: 0,
app_name: '',

View File

@ -1,6 +1,5 @@
import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'soapbox/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'soapbox/actions/domain_blocks';
import {

View File

@ -1,31 +0,0 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import {
TRENDING_STATUSES_FETCH_REQUEST,
TRENDING_STATUSES_FETCH_SUCCESS,
} from 'soapbox/actions/trending_statuses';
const initialState = ImmutableMap({
items: ImmutableOrderedSet(),
isLoading: false,
});
const toIds = items => ImmutableOrderedSet(items.map(item => item.id));
const importStatuses = (state, statuses) => {
return state.withMutations(state => {
state.set('items', toIds(statuses));
state.set('isLoading', false);
});
};
export default function trending_statuses(state = initialState, action) {
switch (action.type) {
case TRENDING_STATUSES_FETCH_REQUEST:
return state.set('isLoading', true);
case TRENDING_STATUSES_FETCH_SUCCESS:
return importStatuses(state, action.statuses);
default:
return state;
}
}

View File

@ -0,0 +1,37 @@
import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
import {
TRENDING_STATUSES_FETCH_REQUEST,
TRENDING_STATUSES_FETCH_SUCCESS,
} from 'soapbox/actions/trending_statuses';
import { APIEntity } from 'soapbox/types/entities';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
items: ImmutableOrderedSet<string>(),
isLoading: false,
});
type State = ReturnType<typeof ReducerRecord>;
type APIEntities = Array<APIEntity>;
const toIds = (items: APIEntities) => ImmutableOrderedSet(items.map(item => item.id));
const importStatuses = (state: State, statuses: APIEntities) => {
return state.withMutations(state => {
state.set('items', toIds(statuses));
state.set('isLoading', false);
});
};
export default function trending_statuses(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case TRENDING_STATUSES_FETCH_REQUEST:
return state.set('isLoading', true);
case TRENDING_STATUSES_FETCH_SUCCESS:
return importStatuses(state, action.statuses);
default:
return state;
}
}

View File

@ -1,28 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
TRENDS_FETCH_REQUEST,
TRENDS_FETCH_SUCCESS,
TRENDS_FETCH_FAIL,
} from '../actions/trends';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
});
export default function trendsReducer(state = initialState, action) {
switch (action.type) {
case TRENDS_FETCH_REQUEST:
return state.set('isLoading', true);
case TRENDS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', fromJS(action.tags.map((x => x))));
map.set('isLoading', false);
});
case TRENDS_FETCH_FAIL:
return state.set('isLoading', false);
default:
return state;
}
}

View File

@ -0,0 +1,35 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { normalizeTag } from 'soapbox/normalizers';
import {
TRENDS_FETCH_REQUEST,
TRENDS_FETCH_SUCCESS,
TRENDS_FETCH_FAIL,
} from '../actions/trends';
import type { AnyAction } from 'redux';
import type { APIEntity, Tag } from 'soapbox/types/entities';
const ReducerRecord = ImmutableRecord({
items: ImmutableList<Tag>(),
isLoading: false,
});
type State = ReturnType<typeof ReducerRecord>;
export default function trendsReducer(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case TRENDS_FETCH_REQUEST:
return state.set('isLoading', true);
case TRENDS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', ImmutableList(action.tags.map((item: APIEntity) => normalizeTag(item))));
map.set('isLoading', false);
});
case TRENDS_FETCH_FAIL:
return state.set('isLoading', false);
default:
return state;
}
}

Some files were not shown because too many files have changed in this diff Show More