TypeScript, React.FC
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
a8a1567917
commit
95e037f8c0
|
@ -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';
|
||||
|
|
|
@ -23,7 +23,7 @@ export interface MenuItem {
|
|||
to?: string,
|
||||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon: string,
|
||||
icon?: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
meta?: string,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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'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;
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -1,19 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { toggleStatusReport } from '../../../actions/reports';
|
||||
import StatusCheckBox from '../components/status_check_box';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
status: state.getIn(['statuses', id]),
|
||||
checked: state.reports.new.status_ids.includes(id),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
|
||||
onToggle(e) {
|
||||
dispatch(toggleStatusReport(id, e.target.checked));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
|
|
@ -7,7 +7,7 @@ import Toggle from 'react-toggle';
|
|||
import { changeReportBlock, changeReportForward } from 'soapbox/actions/reports';
|
||||
import { fetchRules } from 'soapbox/actions/rules';
|
||||
import { Button, FormGroup, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import StatusCheckBox from 'soapbox/features/report/containers/status_check_box_container';
|
||||
import StatusCheckBox from 'soapbox/features/report/components/status_check_box';
|
||||
import { useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { isRemote, getDomain } from 'soapbox/utils/accounts';
|
||||
|
||||
|
@ -61,7 +61,7 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => {
|
|||
<FormGroup labelText={intl.formatMessage(messages.addAdditionalStatuses)}>
|
||||
{showAdditionalStatuses ? (
|
||||
<Stack space={2}>
|
||||
<div className='bg-gray-100 rounded-lg p-4'>
|
||||
<div className='bg-gray-100 dark:bg-slate-600 rounded-lg p-4'>
|
||||
{statusIds.map((statusId) => <StatusCheckBox id={statusId} key={statusId} />)}
|
||||
</div>
|
||||
|
||||
|
|
Loading…
Reference in New Issue