Gab Social. All are welcome.

This commit is contained in:
robcolbert
2019-07-02 03:10:25 -04:00
commit bd0b5afc92
5366 changed files with 222812 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { mount } from 'enzyme';
import Column from '../column';
import ColumnHeader from '../column_header';
describe('<Column />', () => {
describe('<ColumnHeader /> click handler', () => {
const originalRaf = global.requestAnimationFrame;
beforeEach(() => {
global.requestAnimationFrame = jest.fn();
});
afterAll(() => {
global.requestAnimationFrame = originalRaf;
});
it('runs the scroll animation if the column contains scrollable content', () => {
const wrapper = mount(
<Column heading='notifications'>
<div className='scrollable' />
</Column>
);
wrapper.find(ColumnHeader).find('button').simulate('click');
expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
});
it('does not try to scroll if there is no scrollable content', () => {
const wrapper = mount(<Column heading='notifications' />);
wrapper.find(ColumnHeader).find('button').simulate('click');
expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
});
});
});

View File

@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusContent from '../../../components/status_content';
import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';
import IconButton from '../../../components/icon_button';
import classNames from 'classnames';
export default class ActionsModal extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
actions: PropTypes.array,
onClick: PropTypes.func,
};
renderAction = (action, i) => {
if (action === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { icon = null, text, meta = null, active = false, href = '#' } = action;
return (
<li key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />}
<div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>
</div>
</a>
</li>
);
}
render () {
const status = this.props.status && (
<div className='status light'>
<div className='boost-modal__status-header'>
<div className='boost-modal__status-time'>
<a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
<RelativeTimestamp timestamp={this.props.status.get('created_at')} />
</a>
</div>
<a href={`/${status.getIn(['account', 'acct'])}`} className='status__display-name'>
<div className='status__avatar'>
<Avatar account={this.props.status.get('account')} size={48} />
</div>
<DisplayName account={this.props.status.get('account')} />
</a>
</div>
<StatusContent status={this.props.status} />
</div>
);
return (
<div className='modal-root__modal actions-modal'>
{status}
<ul className={classNames({ 'with-status': !!status })}>
{this.props.actions.map(this.renderAction)}
</ul>
</div>
);
}
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from '../../../components/button';
import StatusContent from '../../../components/status_content';
import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
});
export default @injectIntl
class BoostModal extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
onReblog: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
this.button.focus();
}
handleReblog = () => {
this.props.onReblog(this.props.status);
this.props.onClose();
}
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onClose();
this.context.router.history.push(`/${this.props.status.getIn(['account', 'acct'])}`);
}
}
handleStatusClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onClose();
this.context.router.history.push(`/${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('url')}`);
}
}
setRef = (c) => {
this.button = c;
}
render () {
const { status, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
const statusUrl = `/${status.getIn(['account', 'acct'])}/posts/${status.get('url')}`;
return (
<div className='modal-root__modal boost-modal'>
<div className='boost-modal__container'>
<div className='status light'>
<div className='boost-modal__status-header'>
<div className='boost-modal__status-time'>
<a onClick={this.handleStatusClick} href={statusUrl} className='status__relative-time'>
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
</div>
<a onClick={this.handleAccountClick} href={`/${status.getIn(['account', 'acct'])}`} className='status__display-name'>
<div className='status__avatar'>
<Avatar account={status.get('account')} size={48} />
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} />
</div>
</div>
<div className='boost-modal__action-bar'>
<div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div>
<Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
const emptyComponent = () => null;
const noop = () => { };
class Bundle extends React.PureComponent {
static propTypes = {
fetchComponent: PropTypes.func.isRequired,
loading: PropTypes.func,
error: PropTypes.func,
children: PropTypes.func.isRequired,
renderDelay: PropTypes.number,
onFetch: PropTypes.func,
onFetchSuccess: PropTypes.func,
onFetchFail: PropTypes.func,
}
static defaultProps = {
loading: emptyComponent,
error: emptyComponent,
renderDelay: 0,
onFetch: noop,
onFetchSuccess: noop,
onFetchFail: noop,
}
static cache = new Map
state = {
mod: undefined,
forceRender: false,
}
componentWillMount() {
this.load(this.props);
}
componentWillReceiveProps(nextProps) {
if (nextProps.fetchComponent !== this.props.fetchComponent) {
this.load(nextProps);
}
}
componentWillUnmount () {
if (this.timeout) {
clearTimeout(this.timeout);
}
}
load = (props) => {
const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
const cachedMod = Bundle.cache.get(fetchComponent);
if (fetchComponent === undefined) {
this.setState({ mod: null });
return Promise.resolve();
}
onFetch();
if (cachedMod) {
this.setState({ mod: cachedMod.default });
onFetchSuccess();
return Promise.resolve();
}
this.setState({ mod: undefined });
if (renderDelay !== 0) {
this.timestamp = new Date();
this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
}
return fetchComponent()
.then((mod) => {
Bundle.cache.set(fetchComponent, mod);
this.setState({ mod: mod.default });
onFetchSuccess();
})
.catch((error) => {
this.setState({ mod: null });
onFetchFail(error);
});
}
render() {
const { loading: Loading, error: Error, children, renderDelay } = this.props;
const { mod, forceRender } = this.state;
const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
if (mod === undefined) {
return (elapsed >= renderDelay || forceRender) ? <Loading /> : null;
}
if (mod === null) {
return <Error onRetry={this.load} />;
}
return children(mod);
}
}
export default Bundle;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import Column from './column';
import ColumnHeader from './column_header';
import ColumnBackButtonSlim from '../../../components/column_back_button_slim';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
});
class BundleColumnError extends React.PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
handleRetry = () => {
this.props.onRetry();
}
render () {
const { intl: { formatMessage } } = this.props;
return (
<Column>
<ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} />
<ColumnBackButtonSlim />
<div className='error-column'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.body)}
</div>
</Column>
);
}
}
export default injectIntl(BundleColumnError);

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
class BundleModalError extends React.PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
handleRetry = () => {
this.props.onRetry();
}
render () {
const { onClose, intl: { formatMessage } } = this.props;
// Keep the markup in sync with <ModalLoading />
// (make sure they have the same dimensions)
return (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.error)}
</div>
<div className='error-modal__footer'>
<div>
<button
onClick={onClose}
className='error-modal__nav onboarding-modal__skip'
>
{formatMessage(messages.close)}
</button>
</div>
</div>
</div>
);
}
}
export default injectIntl(BundleModalError);

View File

@@ -0,0 +1,33 @@
import React from 'react';
import ColumnHeader from './column_header';
import PropTypes from 'prop-types';
import { isMobile } from '../../../is_mobile';
export default class Column extends React.PureComponent {
static propTypes = {
heading: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.node,
active: PropTypes.bool,
hideHeadingOnMobile: PropTypes.bool,
};
render () {
const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
const columnHeaderId = showHeading && heading.replace(/ /g, '-');
const header = showHeading && (
<ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />
);
return (
<div role='region' aria-labelledby={columnHeaderId} className='column'>
{header}
{children}
</div>
);
}
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'gabsocial/components/icon';
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;
let iconElement = '';
if (icon) {
iconElement = <Icon id={icon} fixedWidth className='column-header__icon' />;
}
return (
<h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
<button onClick={this.handleClick}>
{iconElement}
{type}
</button>
</h1>
);
}
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Icon from 'gabsocial/components/icon';
const ColumnLink = ({ icon, text, to, href, method, badge }) => {
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
if (href) {
return (
<a href={href} className='column-link' data-method={method}>
<Icon id={icon} fixedWidth className='column-link__icon' />
{text}
{badgeElement}
</a>
);
} else {
return (
<Link to={to} className='column-link'>
<Icon id={icon} fixedWidth className='column-link__icon' />
{text}
{badgeElement}
</Link>
);
}
};
ColumnLink.propTypes = {
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
to: PropTypes.string,
href: PropTypes.string,
method: PropTypes.string,
badge: PropTypes.node,
};
export default ColumnLink;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class ColumnLoading extends ImmutablePureComponent {
static propTypes = {
title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
icon: PropTypes.string,
};
static defaultProps = {
title: '',
icon: '',
};
render() {
let { title, icon } = this.props;
return (
<Column>
<ColumnHeader icon={icon} title={title} focusable={false} />
<div />
</Column>
);
}
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
const ColumnSubheading = ({ text }) => {
return (
<div className='column-subheading'>
{text}
</div>
);
};
ColumnSubheading.propTypes = {
text: PropTypes.string.isRequired,
};
export default ColumnSubheading;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ReactSwipeableViews from 'react-swipeable-views';
import { links, getIndex, getLink } from './tabs_bar';
import { Link } from 'react-router-dom';
import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
publish: { id: 'compose_form.publish', defaultMessage: 'Gab' },
});
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
export default @(component => injectIntl(component, { withRef: true }))
class ColumnsArea extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
intl: PropTypes.object.isRequired,
columns: ImmutablePropTypes.list.isRequired,
isModalOpen: PropTypes.bool.isRequired,
children: PropTypes.node,
layout: PropTypes.object,
};
render () {
const { columns, children, isModalOpen, intl, onOpenCompose } = this.props;
const layout = this.props.layout || {LEFT:null,RIGHT:null};
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <button key='floating-action-button' onClick={onOpenCompose} className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}></button>;
return (
<div className='page'>
<div className='page__columns'>
<div className='columns-area__panels'>
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
<div className='columns-area__panels__pane__inner'>
{layout.LEFT}
</div>
</div>
<div className='columns-area__panels__main'>
<div className='columns-area columns-area--mobile'>
{children}
</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
<div className='columns-area__panels__pane__inner'>
{layout.RIGHT}
</div>
</div>
{floatingActionButton}
</div>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { me } from '../../../initial_state';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Avatar from '../../../components/avatar';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import IconButton from 'gabsocial/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
class ComposeModal extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};
onClickClose = () => {
this.props.onClose('COMPOSE');
};
render () {
const { intl, onClose, account } = this.props;
return (
<div className='modal-root__modal compose-modal'>
<div className='compose-modal__header'>
<h3 className='compose-modal__header__title'><FormattedMessage id='navigation_bar.compose' defaultMessage='Compose new gab' /></h3>
<IconButton className='compose-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={this.onClickClose} size={20} />
</div>
<div className='compose-modal__content'>
<div className='timeline-compose-block'>
<div className='timeline-compose-block__avatar'>
<Avatar account={account} size={32} />
</div>
<ComposeFormContainer />
</div>
</div>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps)(ComposeModal));

View File

@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import Button from '../../../components/button';
export default @injectIntl
class ConfirmationModal extends React.PureComponent {
static propTypes = {
message: PropTypes.node.isRequired,
confirm: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
secondary: PropTypes.string,
onSecondary: PropTypes.func,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
this.button.focus();
}
handleClick = () => {
this.props.onClose();
this.props.onConfirm();
}
handleSecondary = () => {
this.props.onClose();
this.props.onSecondary();
}
handleCancel = () => {
this.props.onClose();
}
setRef = (c) => {
this.button = c;
}
render () {
const { message, confirm, secondary } = this.props;
return (
<div className='modal-root__modal confirmation-modal'>
<div className='confirmation-modal__container'>
{message}
</div>
<div className='confirmation-modal__action-bar'>
<Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</Button>
{secondary !== undefined && (
<Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' />
)}
<Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DrawerLoading = () => (
<div className='drawer'>
<div className='drawer__pager'>
<div className='drawer__inner' />
</div>
</div>
);
export default DrawerLoading;

View File

@@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage, injectIntl } from 'react-intl';
import api from '../../../api';
export default @injectIntl
class EmbedModal extends ImmutablePureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
state = {
loading: false,
oembed: null,
};
componentDidMount () {
const { url } = this.props;
this.setState({ loading: true });
api().post('/api/web/embed', { url }).then(res => {
this.setState({ loading: false, oembed: res.data });
const iframeDocument = this.iframe.contentWindow.document;
iframeDocument.open();
iframeDocument.write(res.data.html);
iframeDocument.close();
iframeDocument.body.style.margin = 0;
this.iframe.width = iframeDocument.body.scrollWidth;
this.iframe.height = iframeDocument.body.scrollHeight;
}).catch(error => {
this.props.onError(error);
});
}
setIframeRef = c => {
this.iframe = c;
}
handleTextareaClick = (e) => {
e.target.select();
}
render () {
const { oembed } = this.state;
return (
<div className='modal-root__modal embed-modal'>
<h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
<div className='embed-modal__container'>
<p className='hint'>
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
</p>
<input
type='text'
className='embed-modal__html'
readOnly
value={oembed && oembed.html || ''}
onClick={this.handleTextareaClick}
/>
<p className='hint'>
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
</p>
<iframe
className='embed-modal__iframe'
frameBorder='0'
ref={this.setIframeRef}
sandbox='allow-same-origin'
title='preview'
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,122 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onSave: (x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},
});
export default @connect(mapStateToProps, mapDispatchToProps)
class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
};
state = {
x: 0,
y: 0,
focusX: 0,
focusY: 0,
dragging: false,
};
componentWillMount () {
this.updatePositionFromMedia(this.props.media);
}
componentWillReceiveProps (nextProps) {
if (this.props.media.get('id') !== nextProps.media.get('id')) {
this.updatePositionFromMedia(nextProps.media);
}
}
componentWillUnmount () {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
this.updatePosition(e);
this.setState({ dragging: true });
}
handleMouseMove = e => {
this.updatePosition(e);
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}
updatePosition = e => {
const { x, y } = getPointerPosition(this.node, e);
const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2;
this.setState({ x, y, focusX, focusY });
}
updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY });
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
}
}
setRef = c => {
this.node = c;
}
render () {
const { media } = this.props;
const { x, y, dragging } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
return (
<div className='modal-root__modal video-modal focal-point-modal'>
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
<ImageLoader
previewSrc={media.get('preview_url')}
src={media.get('url')}
width={width}
height={height}
/>
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fetchFollowRequests } from 'gabsocial/actions/accounts';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import IconWithBadge from 'gabsocial/components/icon_with_badge';
import { me } from 'gabsocial/initial_state';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
locked: state.getIn(['accounts', me, 'locked']),
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
export default @withRouter
@connect(mapStateToProps)
class FollowRequestsNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
locked: PropTypes.bool,
count: PropTypes.number.isRequired,
};
componentDidMount () {
const { dispatch, locked } = this.props;
if (locked) {
dispatch(fetchFollowRequests());
}
}
render () {
const { locked, count } = this.props;
if (!locked || count === 0) {
return null;
}
return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
}
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'gabsocial/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class HotkeysModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
const { intl, onClose } = this.props;
return (
<div className='modal-root__modal hotkeys-modal'>
<div className='keyboard-shortcuts'>
<div className='keyboard-shortcuts__header'>
<h3 className='keyboard-shortcuts__header__title'><FormattedMessage id='keyboard_shortcuts.heading' defaultMessage='Keyboard Shortcuts' /></h3>
<IconButton className='keyboard-shortcuts__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
</div>
<div className='keyboard-shortcuts__content'>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='reply' /></td>
</tr>
<tr>
<td><kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='mention author' /></td>
</tr>
<tr>
<td><kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="open author's profile" /></td>
</tr>
<tr>
<td><kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='favorite' /></td>
</tr>
<tr>
<td><kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='repost' /></td>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='open status' /></td>
</tr>
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='show/hide text behind CW' /></td>
</tr>
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='show/hide media' /></td>
</tr>
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='move up in the list' /></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>down</kbd>, <kbd>j</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='move down in the list' /></td>
</tr>
<tr>
<td><kbd>1</kbd> - <kbd>9</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='focus a status in one of the columns' /></td>
</tr>
<tr>
<td><kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='focus the compose textarea' /></td>
</tr>
<tr>
<td><kbd>alt</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='start a brand new gab' /></td>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='navigate back' /></td>
</tr>
<tr>
<td><kbd>s</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='focus search' /></td>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='un-focus compose textarea/search' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='open home timeline' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='open notifications column' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>d</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.direct' defaultMessage='open direct messages column' /></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>g</kbd> + <kbd>s</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.start' defaultMessage='open "get started" column' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='open favorites list' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='open pinned gabs list' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>u</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='open your profile' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='open blocked users list' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='open muted users list' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='open follow requests list' /></td>
</tr>
<tr>
<td><kbd>?</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='display this legend' /></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { LoadingBar } from 'react-redux-loading-bar';
import ZoomableImage from './zoomable_image';
export default class ImageLoader extends React.PureComponent {
static propTypes = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
static defaultProps = {
alt: '',
width: null,
height: null,
};
state = {
loading: true,
error: false,
width: null,
}
removers = [];
canvas = null;
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentDidMount () {
this.loadImage(this.props);
}
componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
componentWillUnmount () {
this.removeEventListeners();
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
props.previewSrc && this.loadPreviewCanvas(props),
this.hasSize() && this.loadOriginalImage(props),
].filter(Boolean))
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
})
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
hasSize () {
const { width, height } = this.props;
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
}
render () {
const { alt, src, width, height, onClick } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
'image-loader--amorphous': !this.hasSize(),
});
return (
<div className={className}>
<LoadingBar loading={loading ? 1 : 0} className='loading-bar' style={{ width: this.state.width || width }} />
{loading ? (
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
width={width}
height={height}
/>
) : (
<ZoomableImage
alt={alt}
src={src}
onClick={onClick}
/>
)}
</div>
);
}
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url, me } from 'gabsocial/initial_state';
import { connect } from 'react-redux';
import { openModal } from '../../../actions/modal';
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
});
const mapDispatchToProps = (dispatch) => ({
onOpenHotkeys() {
dispatch(openModal('HOTKEYS'));
},
});
const LinkFooter = ({ onOpenHotkeys, account }) => (
<div className='getting-started__footer'>
<ul>
{(invitesEnabled && account) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{account && <li><a href='#' onClick={onOpenHotkeys}><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></a> · </li>}
{account && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
<li><a href='/about'><FormattedMessage id='navigation_bar.info' defaultMessage='About' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='/about/guidelines'><FormattedMessage id='getting_started.guidelines' defaultMessage='Guidelines' /></a> · </li>
<li><a href='/about/tos'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of Service' /></a> · </li>
<li><a href='/about/privacy'><FormattedMessage id='getting_started.privacy' defaultMessage='Privacy Policy' /></a></li>
{account && <li> · <a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>}
</ul>
<p>
{/* <FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Gab Social is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/> */}
Gab Social is open source software. You can download the source <span><a href='/src/gab-social.zip'>here</a></span>
</p>
<p>© 2019 Gab AI Inc.</p>
</div>
);
LinkFooter.propTypes = {
withHotkeys: PropTypes.bool,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LinkFooter));

View File

@@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { fetchLists } from 'gabsocial/actions/lists';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { NavLink, withRouter } from 'react-router-dom';
import Icon from 'gabsocial/components/icon';
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
export default @withRouter
@connect(mapStateToProps)
class ListPanel extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchLists());
}
render () {
const { lists } = this.props;
if (!lists || lists.isEmpty()) {
return null;
}
return (
<div>
<hr />
{lists.map(list => (
<NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
))}
</div>
);
}
}

View File

@@ -0,0 +1,243 @@
import React from 'react';
import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from 'gabsocial/features/video';
import ExtendedVideoPlayer from 'gabsocial/components/extended_video_player';
import classNames from 'classnames';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from 'gabsocial/components/icon_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImageLoader from './image_loader';
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
export const previewState = 'previewMediaModal';
export default @injectIntl
class MediaModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.map,
index: PropTypes.number.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
static contextTypes = {
router: PropTypes.object,
};
state = {
index: null,
navigationHidden: false,
};
handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size });
}
handleNextClick = () => {
this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
}
handlePrevClick = () => {
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
}
handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ index: index % this.props.media.size });
}
handleKeyDown = (e) => {
switch(e.key) {
case 'ArrowLeft':
this.handlePrevClick();
e.preventDefault();
e.stopPropagation();
break;
case 'ArrowRight':
this.handleNextClick();
e.preventDefault();
e.stopPropagation();
break;
}
}
componentDidMount () {
window.addEventListener('keydown', this.handleKeyDown, false);
if (this.context.router) {
const history = this.context.router.history;
history.push(history.location.pathname, previewState);
this.unlistenHistory = history.listen(() => {
this.props.onClose();
});
}
}
componentWillUnmount () {
window.removeEventListener('keydown', this.handleKeyDown);
if (this.context.router) {
this.unlistenHistory();
if (this.context.router.history.location.state === previewState) {
this.context.router.history.goBack();
}
}
}
getIndex () {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('id')}`);
}
}
render () {
const { media, status, intl, onClose } = this.props;
const { navigationHidden } = this.state;
const index = this.getIndex();
let pagination = [];
const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>;
const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>;
if (media.size > 1) {
pagination = media.map((item, i) => {
const classes = ['media-modal__button'];
if (i === index) {
classes.push('media-modal__button--active');
}
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
});
}
const content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null;
if (image.get('type') === 'image') {
return (
<ImageLoader
previewSrc={image.get('preview_url')}
src={image.get('url')}
width={width}
height={height}
alt={image.get('description')}
key={image.get('url')}
onClick={this.toggleNavigation}
/>
);
} else if (image.get('type') === 'video') {
const { time } = this.props;
return (
<Video
preview={image.get('preview_url')}
blurhash={image.get('blurhash')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
startTime={time || 0}
onCloseVideo={onClose}
detailed
alt={image.get('description')}
key={image.get('url')}
/>
);
} else if (image.get('type') === 'gifv') {
return (
<ExtendedVideoPlayer
src={image.get('url')}
muted
controls={false}
width={width}
height={height}
key={image.get('preview_url')}
alt={image.get('description')}
onClick={this.toggleNavigation}
/>
);
}
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle = {
width: '100%',
height: '100%',
};
const containerStyle = {
alignItems: 'center', // center vertically
};
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
onSwitching={this.handleSwitching}
index={index}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
{leftNav}
{rightNav}
{status && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import LoadingIndicator from '../../../components/loading_indicator';
// Keep the markup in sync with <BundleModalError />
// (make sure they have the same dimensions)
const ModalLoading = () => (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<LoadingIndicator />
</div>
<div className='error-modal__footer'>
<div>
<button className='error-modal__nav onboarding-modal__skip' />
</div>
</div>
</div>
);
export default ModalLoading;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import Base from '../../../components/modal_root';
import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error';
import ModalLoading from './modal_loading';
import ActionsModal from './actions_modal';
import MediaModal from './media_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import HotkeysModal from './hotkeys_modal';
import ComposeModal from './compose_modal';
import UnauthorizedModal from './unauthorized_modal';
import {
MuteModal,
ReportModal,
EmbedModal,
ListEditor,
ListAdder,
} from '../../../features/ui/util/async-components';
const MODAL_COMPONENTS = {
'MEDIA': () => Promise.resolve({ default: MediaModal }),
'VIDEO': () => Promise.resolve({ default: VideoModal }),
'BOOST': () => Promise.resolve({ default: BoostModal }),
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'MUTE': MuteModal,
'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER':ListAdder,
'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }),
'COMPOSE': () => Promise.resolve({ default: ComposeModal }),
'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }),
};
export default class ModalRoot extends React.PureComponent {
static propTypes = {
type: PropTypes.string,
props: PropTypes.object,
onClose: PropTypes.func.isRequired,
};
getSnapshotBeforeUpdate () {
return { visible: !!this.props.type };
}
componentDidUpdate (prevProps, prevState, { visible }) {
if (visible) {
document.body.classList.add('with-modals--active');
} else {
document.body.classList.remove('with-modals--active');
}
}
renderLoading = modalId => () => {
return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
}
renderError = (props) => {
return <BundleModalError {...props} onClose={this.onClickClose} />;
}
onClickClose = () => {
const { onClose, type } = this.props;
onClose(type);
}
render () {
const { type, props } = this.props;
const visible = !!type;
return (
<Base onClose={this.onClickClose}>
{visible && (
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onClose={this.onClickClose} />}
</BundleContainer>
)}
</Base>
);
}
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import Button from '../../../components/button';
import { closeModal } from '../../../actions/modal';
import { muteAccount } from '../../../actions/accounts';
import { toggleHideNotifications } from '../../../actions/mutes';
const mapStateToProps = state => {
return {
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
account: state.getIn(['mutes', 'new', 'account']),
notifications: state.getIn(['mutes', 'new', 'notifications']),
};
};
const mapDispatchToProps = dispatch => {
return {
onConfirm(account, notifications) {
dispatch(muteAccount(account.get('id'), notifications));
},
onClose() {
dispatch(closeModal());
},
onToggleNotifications() {
dispatch(toggleHideNotifications());
},
};
};
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class MuteModal extends React.PureComponent {
static propTypes = {
isSubmitting: PropTypes.bool.isRequired,
account: PropTypes.object.isRequired,
notifications: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
onToggleNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
this.button.focus();
}
handleClick = () => {
this.props.onClose();
this.props.onConfirm(this.props.account, this.props.notifications);
}
handleCancel = () => {
this.props.onClose();
}
setRef = (c) => {
this.button = c;
}
toggleNotifications = () => {
this.props.onToggleNotifications();
}
render () {
const { account, notifications } = this.props;
return (
<div className='modal-root__modal mute-modal'>
<div className='mute-modal__container'>
<p>
<FormattedMessage
id='confirmations.mute.message'
defaultMessage='Are you sure you want to mute {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
</p>
<div>
<label htmlFor='mute-modal__hide-notifications-checkbox'>
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
{' '}
<Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
</label>
</div>
</div>
<div className='mute-modal__action-bar'>
<Button onClick={this.handleCancel} className='mute-modal__cancel-button'>
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</Button>
<Button onClick={this.handleClick} ref={this.setRef}>
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
</Button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import IconWithBadge from 'gabsocial/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.getIn(['notifications', 'unread']),
id: 'bell',
});
export default connect(mapStateToProps)(IconWithBadge);

View File

@@ -0,0 +1,130 @@
'use strict';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from 'gabsocial/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'gabsocial/components/icon';
import DisplayNameBadge from 'gabsocial/components/display_name_badge';
import VerificationBadge from 'gabsocial/components/verification_badge';
import { List as ImmutableList } from 'immutable';
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
});
const dateFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour12: false,
hour: '2-digit',
minute: '2-digit',
};
class ProfileInfoPanel extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
identity_proofs: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
username: PropTypes.string,
};
render () {
const { account, intl, identity_proofs, username } = this.props;
if (!account) {
return (
<div className='profile-info-panel'>
<div className='profile-info-panel__content'>
<div className='profile-info-panel-content__name'>
<h1>
<span/>
<small>@{username}</small>
</h1>
</div>
</div>
</div>
);
}
const lockedIcon = account.get('locked') ? (<Icon id='lock' title={intl.formatMessage(messages.account_locked)} />) : '';
const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null;
const content = { __html: account.get('note_emojified') };
const fields = account.get('fields');
const acct = account.get('acct');
const displayNameHtml = { __html: account.get('display_name_html') };
return (
<div className='profile-info-panel'>
<div className='profile-info-panel__content'>
<div className='profile-info-panel-content__name'>
<h1>
<span dangerouslySetInnerHTML={displayNameHtml} />
{account.get('is_verified') && <VerificationBadge />}
{account.get('is_pro') && <DisplayNameBadge label="PRO" />}
{badge}
<small>@{acct} {lockedIcon}</small>
</h1>
</div>
{
(account.get('note').length > 0 && account.get('note') !== '<p></p>') &&
<div className='profile-info-panel-content__bio' dangerouslySetInnerHTML={content} />
}
{(fields.size > 0 || identity_proofs.size > 0) && (
<div className='profile-info-panel-content__fields'>
{identity_proofs.map((proof, i) => (
<dl className='test' key={i}>
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
<dd className='verified'>
<a href={proof.get('proof_url')} target='_blank' rel='noopener'>
<span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
<Icon id='check' className='verified__mark' />
</span>
</a>
<a href={proof.get('profile_url')} target='_blank' rel='noopener'>
<span dangerouslySetInnerHTML={{ __html: ' ' + proof.get('provider_username') }} />
</a>
</dd>
</dl>
))}
{fields.map((pair, i) => (
<dl className='profile-info-panel-content__fields__item' key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
</div>
)}
</div>
</div>
);
}
}
const mapStateToProps = (state, { account }) => {
const identity_proofs = account ? state.getIn(['identity_proofs', account.get('id')], ImmutableList()) : ImmutableList();
return {
identity_proofs,
domain: state.getIn(['meta', 'domain']),
};
};
export default injectIntl(
connect(mapStateToProps, null, null, {
forwardRef: true,
}
)(ProfileInfoPanel))

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { injectIntl, FormattedMessage } from 'react-intl';
import { me } from '../../../initial_state';
import { makeGetAccount } from '../../../selectors';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Icon from 'gabsocial/components/icon';
const PromoPanel = () => (
<div className='promo-panel'>
<div className='promo-panel__container'>
<div className='promo-panel-item'>
<a className='promo-panel-item__btn button button-alternative-2' href='https://invest.gab.com'>
<Icon id='check-circle' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='promo.invest_heading' defaultMessage='Invest in Gab' />
</a>
<p className='promo-panel-item__message'>
<FormattedMessage
id='promo.invest_message'
defaultMessage='Learn more about investing in Gab and our vision for the future.'
/>
</p>
</div>
<div className='promo-panel-item'>
<a className='promo-panel-item__btn button button-alternative-2' href='/invites'>
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='promo.invite_heading' defaultMessage='Invite Friends' />
</a>
<p className='promo-panel-item__message promo-panel-item__message--dark'>
<FormattedMessage
id='promo.invite_message'
defaultMessage='Invite others to be a member of Gab.'
/>
</p>
</div>
</div>
</div>
);
export default PromoPanel;

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { connect } from 'react-redux';
import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports';
import { expandAccountTimeline } from '../../../actions/timelines';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { makeGetAccount } from '../../../selectors';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import StatusCheckBox from '../../report/containers/status_check_box_container';
import { OrderedSet } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Button from '../../../components/button';
import Toggle from 'react-toggle';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = state => {
const accountId = state.getIn(['reports', 'new', 'account_id']);
return {
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
account: getAccount(state, accountId),
comment: state.getIn(['reports', 'new', 'comment']),
forward: state.getIn(['reports', 'new', 'forward']),
statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
};
};
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
@injectIntl
class ReportModal extends ImmutablePureComponent {
static propTypes = {
isSubmitting: PropTypes.bool,
account: ImmutablePropTypes.map,
statusIds: ImmutablePropTypes.orderedSet.isRequired,
comment: PropTypes.string.isRequired,
forward: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleCommentChange = e => {
this.props.dispatch(changeReportComment(e.target.value));
}
handleForwardChange = e => {
this.props.dispatch(changeReportForward(e.target.checked));
}
handleSubmit = () => {
this.props.dispatch(submitReport());
}
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
componentDidMount () {
this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
}
componentWillReceiveProps (nextProps) {
if (this.props.account !== nextProps.account && nextProps.account) {
this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
}
}
render () {
const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props;
if (!account) {
return null;
}
const domain = account.get('acct').split('@')[1];
return (
<div className='modal-root__modal report-modal'>
<div className='report-modal__target'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
</div>
<div className='report-modal__container'>
<div className='report-modal__comment'>
<p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p>
<textarea
className='setting-text light'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
onKeyDown={this.handleKeyDown}
disabled={isSubmitting}
autoFocus
/>
{domain && (
<div>
<p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
<div className='setting-toggle'>
<Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} />
<label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label>
</div>
</div>
)}
<Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
</div>
<div className='report-modal__statuses'>
<div>
{statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { me } from 'gabsocial/initial_state';
const SignUpPanel = () => {
if (me) return null;
return (
<div className='wtf-panel'>
<div className='wtf-panel-header'>
<span className='wtf-panel-header__label'>
<FormattedMessage id='signup_panel.title' defaultMessage='New to Gab?' />
</span>
</div>
<div className='wtf-panel__content'>
<span className='wtf-panel__subtitle'>
<FormattedMessage id='signup_panel.subtitle' defaultMessage='Sign up now to speak freely.' />
</span>
<div className='wtf-panel__form'>
<a className='button' href="/auth/sign_up">
<FormattedMessage id='account.register' defaultMessage='Sign up' />
</a>
</div>
</div>
</div>
)
}
export default SignUpPanel;

View File

@@ -0,0 +1,130 @@
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage, injectIntl } from 'react-intl';
import { debounce } from 'lodash';
import { connect } from 'react-redux';
import { isUserTouching } from '../../../is_mobile';
import { me } from '../../../initial_state';
import { Link } from 'react-router-dom';
import NotificationsCounterIcon from './notifications_counter_icon';
import SearchContainer from 'gabsocial/features/compose/containers/search_container';
import Avatar from '../../../components/avatar';
import ActionBar from 'gabsocial/features/compose/components/action_bar';
import { openModal } from '../../../actions/modal';
export const privateLinks = [
<NavLink className='tabs-bar__link--logo' to='/home#' data-preview-title-id='column.home' style={{ padding: '0' }}>
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
</NavLink>,
<NavLink className='tabs-bar__link home' to='/home' data-preview-title-id='column.home' >
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
</NavLink>,
<NavLink className='tabs-bar__link notifications' to='/notifications' data-preview-title-id='column.notifications' >
<NotificationsCounterIcon />
<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />
</NavLink>,
<NavLink className='tabs-bar__link home' to='/groups' data-preview-title-id='column.groups' >
<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />
</NavLink>,
<NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' >
<FormattedMessage id='tabs_bar.search' defaultMessage='Search' />
</NavLink>,
];
export const publicLinks = [
<a className='tabs-bar__link--logo' href='/#' data-preview-title-id='column.home' style={{ padding: '0' }}>
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
</a>,
<a className='tabs-bar__link home' href='/home' data-preview-title-id='column.home' >
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
</a>,
<NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' >
<FormattedMessage id='tabs_bar.search' defaultMessage='Search' />
</NavLink>,
];
@withRouter
class TabsBar extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
onOpenCompose: PropTypes.func,
}
setRef = ref => {
this.node = ref;
}
render () {
const { intl: { formatMessage }, account, onOpenCompose } = this.props;
const links = account ? privateLinks : publicLinks;
return (
<nav className='tabs-bar' ref={this.setRef}>
<div className='tabs-bar__container'>
<div className='tabs-bar__split tabs-bar__split--left'>
{
account && links.map((link) =>
React.cloneElement(link, {
key: link.props.to,
'aria-label': formatMessage({
id: link.props['data-preview-title-id']
})
}))
}
{
!account && links.map((link, i) => React.cloneElement(link, {
key: i,
}))
}
</div>
<div className='tabs-bar__split tabs-bar__split--right'>
<div className='tabs-bar__search-container'>
<SearchContainer openInRoute />
</div>
{ account &&
<div className='flex'>
<div className='tabs-bar__profile'>
<Avatar account={account} />
<ActionBar account={account} size={34} />
</div>
<button className='tabs-bar__button-compose button' onClick={onOpenCompose} aria-label='Gab'>
<span>Gab</span>
</button>
</div>
}
{
!account &&
<div className='flex'>
<a className='tabs-bar__button button' href='/auth/sign_in'>
<FormattedMessage id='account.login' defaultMessage='Log In' />
</a>
<a className='tabs-bar__button button button-alternative-2' href='/auth/sign_up'>
<FormattedMessage id='account.register' defaultMessage='Sign up' />
</a>
</div>
}
</div>
</div>
</nav>
);
}
}
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
const mapDispatchToProps = (dispatch) => ({
onOpenCompose() {
dispatch(openModal('COMPOSE'));
},
});
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true }
)(TabsBar))

View File

@@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchTrends } from '../../../actions/trends';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Icon from 'gabsocial/components/icon';
import Hashtag from '../../../components/hashtag';
class TrendsPanel extends ImmutablePureComponent {
static propTypes = {
trends: ImmutablePropTypes.list.isRequired,
fetchTrends: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
this.props.fetchTrends();
}
render() {
const { trends } = this.props;
if (trends.isEmpty()) {
return null;
}
return (
<div className='wtf-panel'>
<div className='wtf-panel-header'>
<Icon id='hashtag' className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>
<FormattedMessage id='trends.title' defaultMessage='Trends' />
</span>
</div>
<div className='wtf-panel__content'>
<div className='wtf-panel__list'>
{trends && trends.map(hashtag => (
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
))}
</div>
</div>
</div>
)
};
};
const mapStateToProps = state => ({
trends: state.getIn(['trends', 'items']),
});
const mapDispatchToProps = dispatch => {
return {
fetchTrends: () => dispatch(fetchTrends()),
}
};
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps, null, {
forwardRef: true,
}
)(TrendsPanel))

View File

@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { me } from '../../../initial_state';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Avatar from '../../../components/avatar';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import IconButton from 'gabsocial/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
class UnauthorizedModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};
onClickClose = () => {
this.props.onClose('UNAUTHORIZED');
};
render () {
const { intl, onClose, account } = this.props;
return (
<div className='modal-root__modal compose-modal unauthorized-modal'>
<div className='compose-modal__header'>
<h3 className='compose-modal__header__title'><FormattedMessage id='unauthorized_modal.title' defaultMessage='Sign up for Gab' /></h3>
<IconButton className='compose-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={this.onClickClose} size={20} />
</div>
<div className='compose-modal__content'>
<div className='unauthorized-modal__content'>
<span className='unauthorized-modal-content__text'>
<FormattedMessage id='unauthorized_modal.text' defaultMessage='You need to be logged in to do that.' />
</span>
<a href='/auth/sign_up' className='unauthorized-modal-content__button button'>
<FormattedMessage id='account.register' defaultMessage='Sign up' />
</a>
</div>
</div>
<div className='unauthorized-modal__footer'>
<FormattedMessage id='unauthorized_modal.footer' defaultMessage='Already have an account? {login}.' values={{
login: <a href='/auth/sign_in'><FormattedMessage id='account.login' defaultMessage='Log in' /></a>
}} />
</div>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps)(UnauthorizedModal));

View File

@@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';
export default class UploadArea extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
onClose: PropTypes.func,
};
handleKeyUp = (e) => {
const keyCode = e.keyCode;
if (this.props.active) {
switch(keyCode) {
case 27:
e.preventDefault();
e.stopPropagation();
this.props.onClose();
break;
}
}
}
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
}
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
}
render () {
const { active } = this.props;
return (
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
{({ backgroundOpacity, backgroundScale }) => (
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
<div className='upload-area__drop'>
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
</div>
</div>
)}
</Motion>
);
}
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { injectIntl, FormattedMessage } from 'react-intl';
import { autoPlayGif, me } from '../../../initial_state';
import { makeGetAccount } from '../../../selectors';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from 'gabsocial/components/avatar';
import { shortNumberFormat } from 'gabsocial/utils/numbers';
class UserPanel extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
domain: PropTypes.string,
}
render() {
const { account, intl, domain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
return (
<div className='user-panel'>
<div className='user-panel__container'>
<div className='user-panel__header'>
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
</div>
<div className='user-panel__profile'>
<Link to={`/${account.get('acct')}`} title={acct}>
<Avatar account={account} size={88} />
</Link>
</div>
<div className='user-panel__meta'>
<div className='user-panel__account'>
<h1>
<Link to={`/${account.get('acct')}`}>
<span className='user-panel__account__name' dangerouslySetInnerHTML={displayNameHtml} />
<small className='user-panel__account__username'>@{acct}</small>
</Link>
</h1>
</div>
<div className='user-panel__stats-block'>
<div className='user-panel-stats-item'>
<Link to={`/${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<strong className='user-panel-stats-item__value'>{shortNumberFormat(account.get('statuses_count'))}</strong>
<span className='user-panel-stats-item__label'><FormattedMessage className='user-panel-stats-item__label' id='account.posts' defaultMessage='Gabs' /></span>
</Link>
</div>
<div className='user-panel-stats-item'>
<Link to={`/${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<strong className='user-panel-stats-item__value'>{shortNumberFormat(account.get('followers_count'))}</strong>
<span className='user-panel-stats-item__label'><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
</Link>
</div>
<div className='user-panel-stats-item'>
<Link to={`/${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<strong className='user-panel-stats-item__value'>{shortNumberFormat(account.get('following_count'))}</strong>
<span className='user-panel-stats-item__label'><FormattedMessage className='user-panel-stats-item__label' id='account.follows' defaultMessage='Follows' /></span>
</Link>
</div>
</div>
</div>
</div>
</div>
)
}
};
const mapStateToProps = state => {
const getAccount = makeGetAccount();
return {
account: getAccount(state, me),
};
};
export default injectIntl(
connect(mapStateToProps, null, null, {
forwardRef: true,
}
)(UserPanel))

View File

@@ -0,0 +1,75 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from 'gabsocial/features/video';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
export const previewState = 'previewVideoModal';
export default class VideoModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
time: PropTypes.number,
onClose: PropTypes.func.isRequired,
};
static contextTypes = {
router: PropTypes.object,
};
componentDidMount () {
if (this.context.router) {
const history = this.context.router.history;
history.push(history.location.pathname, previewState);
this.unlistenHistory = history.listen(() => {
this.props.onClose();
});
}
}
componentWillUnmount () {
if (this.context.router) {
this.unlistenHistory();
if (this.context.router.history.location.state === previewState) {
this.context.router.history.goBack();
}
}
}
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('id')}`);
}
}
render () {
const { media, status, time, onClose } = this.props;
const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
return (
<div className='modal-root__modal video-modal'>
<div>
<Video
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
startTime={time}
onCloseVideo={onClose}
link={link}
detailed
alt={media.get('description')}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Icon from 'gabsocial/components/icon';
import AccountContainer from '../../../containers/account_container';
const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
});
class WhoToFollowPanel extends ImmutablePureComponent {
static propTypes = {
suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
this.props.fetchSuggestions();
}
render() {
const { intl, suggestions, dismissSuggestion } = this.props;
if (suggestions.isEmpty()) {
return null;
}
return (
<div className='wtf-panel'>
<div className='wtf-panel-header'>
<Icon id='users' className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>
<FormattedMessage id='who_to_follow.title' defaultMessage='Who To Follow' />
</span>
</div>
<div className='wtf-panel__content'>
<div className='wtf-panel__list'>
{suggestions && suggestions.map(accountId => (
<AccountContainer
key={accountId}
id={accountId}
actionIcon='times'
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={dismissSuggestion}
/>
))}
</div>
</div>
</div>
);
};
};
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
});
const mapDispatchToProps = dispatch => {
return {
fetchSuggestions: () => dispatch(fetchSuggestions()),
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
}
};
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps, null, {
forwardRef: true,
}
)(WhoToFollowPanel))

View File

@@ -0,0 +1,152 @@
import React from 'react';
import PropTypes from 'prop-types';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const getMidpoint = (p1, p2) => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const getDistance = (p1, p2) =>
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
export default class ZoomableImage extends React.PureComponent {
static propTypes = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
static defaultProps = {
alt: '',
width: null,
height: null,
};
state = {
scale: MIN_SCALE,
}
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
lastDistance = 0;
componentDidMount () {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler));
}
componentWillUnmount () {
this.removeEventListeners();
}
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
handleTouchStart = e => {
if (e.touches.length !== 2) return;
this.lastDistance = getDistance(...e.touches);
}
handleTouchMove = e => {
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
e.stopPropagation();
return;
}
if (e.touches.length !== 2) return;
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
this.zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
}
zoom(nextScale, midpoint) {
const { scale } = this.state;
const { scrollLeft, scrollTop } = this.container;
// math memo:
// x = (scrollLeft + midpoint.x) / scrollWidth
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
// scrollWidth = clientWidth * scale
// scrollWidth' = clientWidth * nextScale
// Solve x = x' for nextScrollLeft
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
this.setState({ scale: nextScale }, () => {
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
});
}
handleClick = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
}
setContainerRef = c => {
this.container = c;
}
setImageRef = c => {
this.image = c;
}
render () {
const { alt, src } = this.props;
const { scale } = this.state;
const overflow = scale === 1 ? 'hidden' : 'scroll';
return (
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
src={src}
style={{
transform: `scale(${scale})`,
transformOrigin: '0 0',
}}
onClick={this.handleClick}
/>
</div>
);
}
}