Gab Social. All are welcome.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
106
app/javascript/gabsocial/features/ui/components/bundle.js
Normal file
106
app/javascript/gabsocial/features/ui/components/bundle.js
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
33
app/javascript/gabsocial/features/ui/components/column.js
Normal file
33
app/javascript/gabsocial/features/ui/components/column.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
}
|
||||
171
app/javascript/gabsocial/features/ui/components/hotkeys_modal.js
Normal file
171
app/javascript/gabsocial/features/ui/components/hotkeys_modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
160
app/javascript/gabsocial/features/ui/components/image_loader.js
Normal file
160
app/javascript/gabsocial/features/ui/components/image_loader.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
243
app/javascript/gabsocial/features/ui/components/media_modal.js
Normal file
243
app/javascript/gabsocial/features/ui/components/media_modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
105
app/javascript/gabsocial/features/ui/components/mute_modal.js
Normal file
105
app/javascript/gabsocial/features/ui/components/mute_modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
136
app/javascript/gabsocial/features/ui/components/report_modal.js
Normal file
136
app/javascript/gabsocial/features/ui/components/report_modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
130
app/javascript/gabsocial/features/ui/components/tabs_bar.js
Normal file
130
app/javascript/gabsocial/features/ui/components/tabs_bar.js
Normal 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))
|
||||
@@ -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))
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Bundle from '../components/bundle';
|
||||
|
||||
import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles';
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onFetch () {
|
||||
dispatch(fetchBundleRequest());
|
||||
},
|
||||
onFetchSuccess () {
|
||||
dispatch(fetchBundleSuccess());
|
||||
},
|
||||
onFetchFail (error) {
|
||||
dispatch(fetchBundleFail(error));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(Bundle);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { connect } from 'react-redux';
|
||||
import ColumnsArea from '../components/columns_area';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
isModalOpen: !!state.get('modal').modalType,
|
||||
});
|
||||
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onOpenCompose() {
|
||||
dispatch(openModal('COMPOSE'));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { connect } from 'react-redux';
|
||||
import LoadingBar from 'react-redux-loading-bar';
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
loading: state.get('loadingBar')[ownProps.scope || 'default'],
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { closeModal } from '../../../actions/modal';
|
||||
import { cancelReplyCompose } from '../../../actions/compose';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
type: state.get('modal').modalType,
|
||||
props: state.get('modal').modalProps,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onClose (optionalType) {
|
||||
if (optionalType === 'COMPOSE') {
|
||||
dispatch(cancelReplyCompose());
|
||||
}
|
||||
|
||||
dispatch(closeModal());
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { NotificationStack } from 'react-notification';
|
||||
import { dismissAlert } from '../../../actions/alerts';
|
||||
import { getAlerts } from '../../../selectors';
|
||||
|
||||
const mapStateToProps = (state, { intl }) => {
|
||||
const notifications = getAlerts(state);
|
||||
|
||||
notifications.forEach(notification => ['title', 'message'].forEach(key => {
|
||||
const value = notification[key];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
notification[key] = intl.formatMessage(value);
|
||||
}
|
||||
}));
|
||||
|
||||
return { notifications };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
onDismiss: alert => {
|
||||
dispatch(dismissAlert(alert));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
|
||||
@@ -0,0 +1,44 @@
|
||||
import { connect } from 'react-redux';
|
||||
import StatusList from '../../../components/status_list';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
import { debounce } from 'lodash';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const makeGetStatusIds = () => createSelector([
|
||||
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
|
||||
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
|
||||
(state) => state.get('statuses'),
|
||||
], (columnSettings, statusIds, statuses) => {
|
||||
return statusIds.filter(id => {
|
||||
if (id === null) return true;
|
||||
|
||||
const statusForId = statuses.get(id);
|
||||
let showStatus = true;
|
||||
|
||||
if (columnSettings.getIn(['shows', 'reblog']) === false) {
|
||||
showStatus = showStatus && statusForId.get('reblog') === null;
|
||||
}
|
||||
|
||||
if (columnSettings.getIn(['shows', 'reply']) === false) {
|
||||
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
|
||||
}
|
||||
|
||||
return showStatus;
|
||||
});
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatusIds = makeGetStatusIds();
|
||||
|
||||
const mapStateToProps = (state, { timelineId }) => ({
|
||||
statusIds: getStatusIds(state, { type: timelineId }),
|
||||
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
|
||||
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
|
||||
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(StatusList);
|
||||
536
app/javascript/gabsocial/features/ui/index.js
Normal file
536
app/javascript/gabsocial/features/ui/index.js
Normal file
@@ -0,0 +1,536 @@
|
||||
'use strict';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Switch, Redirect, withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import NotificationsContainer from './containers/notifications_container';
|
||||
import LoadingBarContainer from './containers/loading_bar_container';
|
||||
import ModalContainer from './containers/modal_container';
|
||||
import { isMobile } from '../../is_mobile';
|
||||
import { debounce } from 'lodash';
|
||||
import { uploadCompose, resetCompose } from '../../actions/compose';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import { expandNotifications } from '../../actions/notifications';
|
||||
import { fetchFilters } from '../../actions/filters';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { openModal } from '../../actions/modal';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
import UploadArea from './components/upload_area';
|
||||
import TabsBar from './components/tabs_bar';
|
||||
import TrendsPanel from './components/trends_panel';
|
||||
import WhoToFollowPanel from './components/who_to_follow_panel';
|
||||
import LinkFooter from './components/link_footer';
|
||||
import ProfileInfoPanel from './components/profile_info_panel';
|
||||
import UserPanel from './components/user_panel';
|
||||
import PromoPanel from './components/promo_panel';
|
||||
import ProfilePage from 'gabsocial/pages/profile_page';
|
||||
import SearchPage from 'gabsocial/pages/search_page';
|
||||
|
||||
import {
|
||||
Compose,
|
||||
Status,
|
||||
GettingStarted,
|
||||
PublicTimeline,
|
||||
CommunityTimeline,
|
||||
AccountTimeline,
|
||||
AccountGallery,
|
||||
HomeTimeline,
|
||||
Followers,
|
||||
Following,
|
||||
Reblogs,
|
||||
Favourites,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
Notifications,
|
||||
FollowRequests,
|
||||
GenericNotFound,
|
||||
FavouritedStatuses,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
Mutes,
|
||||
PinnedStatuses,
|
||||
Lists,
|
||||
Search,
|
||||
Explore,
|
||||
Groups,
|
||||
GroupTimeline,
|
||||
} from './util/async-components';
|
||||
import { me, meUsername } from '../../initial_state';
|
||||
import { previewState as previewMediaState } from './components/media_modal';
|
||||
import { previewState as previewVideoState } from './components/video_modal';
|
||||
|
||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||
// Without this it ends up in ~8 very commonly used bundles.
|
||||
import '../../components/status';
|
||||
|
||||
const messages = defineMessages({
|
||||
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Gab Social.' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isComposing: state.getIn(['compose', 'is_composing']),
|
||||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
|
||||
});
|
||||
|
||||
const keyMap = {
|
||||
help: '?',
|
||||
new: 'n',
|
||||
search: 's',
|
||||
forceNew: 'option+n',
|
||||
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
||||
reply: 'r',
|
||||
favourite: 'f',
|
||||
boost: 'b',
|
||||
mention: 'm',
|
||||
open: ['enter', 'o'],
|
||||
openProfile: 'p',
|
||||
moveDown: ['down', 'j'],
|
||||
moveUp: ['up', 'k'],
|
||||
back: 'backspace',
|
||||
goToHome: 'g h',
|
||||
goToNotifications: 'g n',
|
||||
goToStart: 'g s',
|
||||
goToFavourites: 'g f',
|
||||
goToPinned: 'g p',
|
||||
goToProfile: 'g u',
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
goToRequests: 'g r',
|
||||
toggleHidden: 'x',
|
||||
toggleSensitive: 'h',
|
||||
};
|
||||
|
||||
const LAYOUT = {
|
||||
EMPTY: {
|
||||
LEFT: null,
|
||||
RIGHT: null,
|
||||
},
|
||||
HOME: {
|
||||
LEFT: [
|
||||
<UserPanel />,
|
||||
<PromoPanel />,
|
||||
<LinkFooter />,
|
||||
],
|
||||
RIGHT: [
|
||||
<TrendsPanel />,
|
||||
<WhoToFollowPanel />,
|
||||
],
|
||||
},
|
||||
DEFAULT: {
|
||||
LEFT: [
|
||||
<WhoToFollowPanel />,
|
||||
<LinkFooter />,
|
||||
],
|
||||
RIGHT: [
|
||||
<TrendsPanel />,
|
||||
],
|
||||
},
|
||||
STATUS: {
|
||||
TOP: null,
|
||||
LEFT: null,
|
||||
RIGHT: [
|
||||
<WhoToFollowPanel />,
|
||||
<TrendsPanel />,
|
||||
<LinkFooter />,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
class SwitchingColumnsArea extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
location: PropTypes.object,
|
||||
onLayoutChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
mobile: isMobile(window.innerWidth),
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
// The cached heights are no longer accurate, invalidate
|
||||
this.props.onLayoutChange();
|
||||
|
||||
this.setState({ mobile: isMobile(window.innerWidth) });
|
||||
}, 500, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
setRef = c => {
|
||||
this.node = c.getWrappedInstance();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, account } = this.props;
|
||||
const { mobile } = this.state;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Redirect from='/' to='/home' exact />
|
||||
<WrappedRoute path='/home' exact layout={LAYOUT.HOME} component={HomeTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path='/groups' component={Groups} content={children} />
|
||||
<WrappedRoute path='/groups/:id' component={GroupTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path='/lists' layout={LAYOUT.DEFAULT} component={Lists} content={children} />
|
||||
<WrappedRoute path='/list/:id' layout={LAYOUT.HOME} component={ListTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path='/notifications' layout={LAYOUT.DEFAULT} component={Notifications} content={children} />
|
||||
|
||||
<WrappedRoute path='/search' publicRoute page={SearchPage} component={Search} content={children} />
|
||||
|
||||
<WrappedRoute path='/follow_requests' layout={LAYOUT.DEFAULT} component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' layout={LAYOUT.DEFAULT} component={Blocks} content={children} />
|
||||
<WrappedRoute path='/domain_blocks' layout={LAYOUT.DEFAULT} component={DomainBlocks} content={children} />
|
||||
<WrappedRoute path='/mutes' layout={LAYOUT.DEFAULT} component={Mutes} content={children} />
|
||||
|
||||
<Redirect from='/@:username' to='/:username' exact />
|
||||
<WrappedRoute path='/:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/with_replies' to='/:username/with_replies' />
|
||||
<WrappedRoute path='/:username/with_replies' component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
|
||||
|
||||
<Redirect from='/@:username/followers' to='/:username/followers' />
|
||||
<WrappedRoute path='/:username/followers' component={Followers} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/following' to='/:username/following' />
|
||||
<WrappedRoute path='/:username/following' component={Following} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/media' to='/:username/media' />
|
||||
<WrappedRoute path='/:username/media' component={AccountGallery} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/tagged/:tag' to='/:username/tagged/:tag' exact />
|
||||
<WrappedRoute path='/:username/tagged/:tag' exact component={AccountTimeline} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/favorites' to='/:username/favorites' />
|
||||
<WrappedRoute path='/:username/favorites' component={FavouritedStatuses} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/pins' to='/:username/pins' />
|
||||
<WrappedRoute path='/:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
|
||||
|
||||
<Redirect from='/@:username/posts/:statusId' to='/:username/posts/:statusId' exact />
|
||||
<WrappedRoute path='/:username/posts/:statusId' publicRoute exact layout={LAYOUT.STATUS} component={Status} content={children} />
|
||||
|
||||
<Redirect from='/@:username/posts/:statusId/reblogs' to='/:username/posts/:statusId/reblogs' />
|
||||
<WrappedRoute path='/:username/posts/:statusId/reblogs' layout={LAYOUT.STATUS} component={Reblogs} content={children} />
|
||||
|
||||
<Redirect from='/@:username/posts/:statusId/favorites' to='/:username/posts/:statusId/favorites' />
|
||||
<WrappedRoute path='/:username/posts/:statusId/favorites' layout={LAYOUT.STATUS} component={Favourites} content={children} />
|
||||
|
||||
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
@withRouter
|
||||
class UI extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
isComposing: PropTypes.bool,
|
||||
hasComposingText: PropTypes.bool,
|
||||
hasMediaAttachments: PropTypes.bool,
|
||||
location: PropTypes.object,
|
||||
intl: PropTypes.object.isRequired,
|
||||
dropdownMenuIsOpen: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
draggingOver: false,
|
||||
};
|
||||
|
||||
handleBeforeUnload = (e) => {
|
||||
const { intl, isComposing, hasComposingText, hasMediaAttachments } = this.props;
|
||||
|
||||
if (isComposing && (hasComposingText || hasMediaAttachments)) {
|
||||
// Setting returnValue to any string causes confirmation dialog.
|
||||
// Many browsers no longer display this text to users,
|
||||
// but we set user-friendly message for other browsers, e.g. Edge.
|
||||
e.returnValue = intl.formatMessage(messages.beforeUnload);
|
||||
}
|
||||
}
|
||||
|
||||
handleLayoutChange = () => {
|
||||
// The cached heights are no longer accurate, invalidate
|
||||
this.props.dispatch(clearHeight());
|
||||
}
|
||||
|
||||
handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.dragTargets) {
|
||||
this.dragTargets = [];
|
||||
}
|
||||
|
||||
if (this.dragTargets.indexOf(e.target) === -1) {
|
||||
this.dragTargets.push(e.target);
|
||||
}
|
||||
|
||||
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) {
|
||||
this.setState({ draggingOver: true });
|
||||
}
|
||||
}
|
||||
|
||||
handleDragOver = (e) => {
|
||||
if (this.dataTransferIsText(e.dataTransfer)) return false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} catch (err) {
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
handleDrop = (e) => {
|
||||
if (!me) return;
|
||||
|
||||
if (this.dataTransferIsText(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({ draggingOver: false });
|
||||
this.dragTargets = [];
|
||||
|
||||
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
|
||||
this.props.dispatch(uploadCompose(e.dataTransfer.files));
|
||||
}
|
||||
}
|
||||
|
||||
handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
|
||||
|
||||
if (this.dragTargets.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ draggingOver: false });
|
||||
}
|
||||
|
||||
dataTransferIsText = (dataTransfer) => {
|
||||
return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1);
|
||||
}
|
||||
|
||||
closeUploadModal = () => {
|
||||
this.setState({ draggingOver: false });
|
||||
}
|
||||
|
||||
handleServiceWorkerPostMessage = ({ data }) => {
|
||||
if (data.type === 'navigate') {
|
||||
this.context.router.history.push(data.path);
|
||||
} else {
|
||||
console.warn('Unknown message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
|
||||
|
||||
document.addEventListener('dragenter', this.handleDragEnter, false);
|
||||
document.addEventListener('dragover', this.handleDragOver, false);
|
||||
document.addEventListener('drop', this.handleDrop, false);
|
||||
document.addEventListener('dragleave', this.handleDragLeave, false);
|
||||
document.addEventListener('dragend', this.handleDragEnd, false);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
|
||||
}
|
||||
|
||||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
|
||||
}
|
||||
|
||||
if (me) {
|
||||
this.props.dispatch(expandHomeTimeline());
|
||||
this.props.dispatch(expandNotifications());
|
||||
|
||||
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (!me) return;
|
||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||||
document.removeEventListener('dragenter', this.handleDragEnter);
|
||||
document.removeEventListener('dragover', this.handleDragOver);
|
||||
document.removeEventListener('drop', this.handleDrop);
|
||||
document.removeEventListener('dragleave', this.handleDragLeave);
|
||||
document.removeEventListener('dragend', this.handleDragEnd);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleHotkeyNew = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeySearch = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const element = this.node.querySelector('.search__input');
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyForceNew = e => {
|
||||
this.handleHotkeyNew(e);
|
||||
this.props.dispatch(resetCompose());
|
||||
}
|
||||
|
||||
handleHotkeyFocusColumn = e => {
|
||||
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
||||
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
||||
if (!column) return;
|
||||
const container = column.querySelector('.scrollable');
|
||||
|
||||
if (container) {
|
||||
const status = container.querySelector('.focusable');
|
||||
|
||||
if (status) {
|
||||
if (container.scrollTop > status.offsetTop) {
|
||||
status.scrollIntoView(true);
|
||||
}
|
||||
status.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyBack = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/');
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
setHotkeysRef = c => {
|
||||
this.hotkeys = c;
|
||||
}
|
||||
|
||||
handleHotkeyToggleHelp = () => {
|
||||
this.props.dispatch(openModal("HOTKEYS"));
|
||||
}
|
||||
|
||||
handleHotkeyGoToHome = () => {
|
||||
this.context.router.history.push('/home');
|
||||
}
|
||||
|
||||
handleHotkeyGoToNotifications = () => {
|
||||
this.context.router.history.push('/notifications');
|
||||
}
|
||||
|
||||
handleHotkeyGoToStart = () => {
|
||||
this.context.router.history.push('/getting-started');
|
||||
}
|
||||
|
||||
handleHotkeyGoToFavourites = () => {
|
||||
this.context.router.history.push(`/${meUsername}/favorites`);
|
||||
}
|
||||
|
||||
handleHotkeyGoToPinned = () => {
|
||||
this.context.router.history.push(`/${meUsername}/pins`);
|
||||
}
|
||||
|
||||
handleHotkeyGoToProfile = () => {
|
||||
this.context.router.history.push(`/${meUsername}`);
|
||||
}
|
||||
|
||||
handleHotkeyGoToBlocked = () => {
|
||||
this.context.router.history.push('/blocks');
|
||||
}
|
||||
|
||||
handleHotkeyGoToMuted = () => {
|
||||
this.context.router.history.push('/mutes');
|
||||
}
|
||||
|
||||
handleHotkeyGoToRequests = () => {
|
||||
this.context.router.history.push('/follow_requests');
|
||||
}
|
||||
|
||||
render () {
|
||||
const { draggingOver } = this.state;
|
||||
const { children, isComposing, location, dropdownMenuIsOpen } = this.props;
|
||||
|
||||
const handlers = me ? {
|
||||
help: this.handleHotkeyToggleHelp,
|
||||
new: this.handleHotkeyNew,
|
||||
search: this.handleHotkeySearch,
|
||||
forceNew: this.handleHotkeyForceNew,
|
||||
focusColumn: this.handleHotkeyFocusColumn,
|
||||
back: this.handleHotkeyBack,
|
||||
goToHome: this.handleHotkeyGoToHome,
|
||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||
goToStart: this.handleHotkeyGoToStart,
|
||||
goToFavourites: this.handleHotkeyGoToFavourites,
|
||||
goToPinned: this.handleHotkeyGoToPinned,
|
||||
goToProfile: this.handleHotkeyGoToProfile,
|
||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||
goToMuted: this.handleHotkeyGoToMuted,
|
||||
goToRequests: this.handleHotkeyGoToRequests,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
|
||||
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
|
||||
<TabsBar />
|
||||
<SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}>
|
||||
{children}
|
||||
</SwitchingColumnsArea>
|
||||
|
||||
<NotificationsContainer />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
143
app/javascript/gabsocial/features/ui/util/async-components.js
Normal file
143
app/javascript/gabsocial/features/ui/util/async-components.js
Normal file
@@ -0,0 +1,143 @@
|
||||
export function EmojiPicker () {
|
||||
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
|
||||
}
|
||||
|
||||
export function Compose () {
|
||||
return import(/* webpackChunkName: "features/compose" */'../../compose');
|
||||
}
|
||||
|
||||
export function Notifications () {
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
|
||||
}
|
||||
|
||||
export function HomeTimeline () {
|
||||
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
|
||||
}
|
||||
|
||||
export function PublicTimeline () {
|
||||
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
|
||||
}
|
||||
|
||||
export function CommunityTimeline () {
|
||||
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
|
||||
}
|
||||
|
||||
export function HashtagTimeline () {
|
||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
|
||||
}
|
||||
|
||||
export function DirectTimeline() {
|
||||
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
|
||||
}
|
||||
|
||||
export function ListTimeline () {
|
||||
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
|
||||
}
|
||||
|
||||
export function GroupTimeline () {
|
||||
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/timeline');
|
||||
}
|
||||
|
||||
export function Groups () {
|
||||
return import(/* webpackChunkName: "features/groups/index" */'../../groups/index');
|
||||
}
|
||||
|
||||
export function Lists () {
|
||||
return import(/* webpackChunkName: "features/lists" */'../../lists');
|
||||
}
|
||||
|
||||
export function Status () {
|
||||
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||
}
|
||||
|
||||
export function GettingStarted () {
|
||||
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
|
||||
}
|
||||
|
||||
export function PinnedStatuses () {
|
||||
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
|
||||
}
|
||||
|
||||
export function AccountTimeline () {
|
||||
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
|
||||
}
|
||||
|
||||
export function AccountGallery () {
|
||||
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
|
||||
}
|
||||
|
||||
export function Followers () {
|
||||
return import(/* webpackChunkName: "features/followers" */'../../followers');
|
||||
}
|
||||
|
||||
export function Following () {
|
||||
return import(/* webpackChunkName: "features/following" */'../../following');
|
||||
}
|
||||
|
||||
export function Reblogs () {
|
||||
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
|
||||
}
|
||||
|
||||
export function Favourites () {
|
||||
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
|
||||
}
|
||||
|
||||
export function FollowRequests () {
|
||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
|
||||
}
|
||||
|
||||
export function GenericNotFound () {
|
||||
return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found');
|
||||
}
|
||||
|
||||
export function FavouritedStatuses () {
|
||||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
||||
}
|
||||
|
||||
export function Blocks () {
|
||||
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
|
||||
}
|
||||
|
||||
export function DomainBlocks () {
|
||||
return import(/* webpackChunkName: "features/domain_blocks" */'../../domain_blocks');
|
||||
}
|
||||
|
||||
export function Mutes () {
|
||||
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
|
||||
}
|
||||
|
||||
export function MuteModal () {
|
||||
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
|
||||
}
|
||||
|
||||
export function ReportModal () {
|
||||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
||||
export function Video () {
|
||||
return import(/* webpackChunkName: "features/video" */'../../video');
|
||||
}
|
||||
|
||||
export function EmbedModal () {
|
||||
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
|
||||
}
|
||||
|
||||
export function ListEditor () {
|
||||
return import(/* webpackChunkName: "features/list_editor" */'../../list_editor');
|
||||
}
|
||||
|
||||
export function ListAdder () {
|
||||
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
|
||||
}
|
||||
|
||||
export function Search () {
|
||||
return import(/*webpackChunkName: "features/search" */'../../search');
|
||||
}
|
||||
|
||||
export function Explore () {
|
||||
return import(/* webpackChunkName: "features/explore" */'../../explore');
|
||||
}
|
||||
46
app/javascript/gabsocial/features/ui/util/fullscreen.js
Normal file
46
app/javascript/gabsocial/features/ui/util/fullscreen.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// APIs for normalizing fullscreen operations. Note that Edge uses
|
||||
// the WebKit-prefixed APIs currently (as of Edge 16).
|
||||
|
||||
export const isFullscreen = () => document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement;
|
||||
|
||||
export const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFullscreen = el => {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
el.webkitRequestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
el.mozRequestFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const attachFullscreenListener = (listener) => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.addEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
document.addEventListener('webkitfullscreenchange', listener);
|
||||
} else if ('onmozfullscreenchange' in document) {
|
||||
document.addEventListener('mozfullscreenchange', listener);
|
||||
}
|
||||
};
|
||||
|
||||
export const detachFullscreenListener = (listener) => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.removeEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
document.removeEventListener('webkitfullscreenchange', listener);
|
||||
} else if ('onmozfullscreenchange' in document) {
|
||||
document.removeEventListener('mozfullscreenchange', listener);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
// Get the bounding client rect from an IntersectionObserver entry.
|
||||
// This is to work around a bug in Chrome: https://crbug.com/737228
|
||||
|
||||
let hasBoundingRectBug;
|
||||
|
||||
function getRectFromEntry(entry) {
|
||||
if (typeof hasBoundingRectBug !== 'boolean') {
|
||||
const boundingRect = entry.target.getBoundingClientRect();
|
||||
const observerRect = entry.boundingClientRect;
|
||||
hasBoundingRectBug = boundingRect.height !== observerRect.height ||
|
||||
boundingRect.top !== observerRect.top ||
|
||||
boundingRect.width !== observerRect.width ||
|
||||
boundingRect.bottom !== observerRect.bottom ||
|
||||
boundingRect.left !== observerRect.left ||
|
||||
boundingRect.right !== observerRect.right;
|
||||
}
|
||||
return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
|
||||
}
|
||||
|
||||
export default getRectFromEntry;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Wrapper for IntersectionObserver in order to make working with it
|
||||
// a bit easier. We also follow this performance advice:
|
||||
// "If you need to observe multiple elements, it is both possible and
|
||||
// advised to observe multiple elements using the same IntersectionObserver
|
||||
// instance by calling observe() multiple times."
|
||||
// https://developers.google.com/web/updates/2016/04/intersectionobserver
|
||||
|
||||
class IntersectionObserverWrapper {
|
||||
|
||||
callbacks = {};
|
||||
observerBacklog = [];
|
||||
observer = null;
|
||||
|
||||
connect (options) {
|
||||
const onIntersection = (entries) => {
|
||||
entries.forEach(entry => {
|
||||
const id = entry.target.getAttribute('data-id');
|
||||
if (this.callbacks[id]) {
|
||||
this.callbacks[id](entry);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.observer = new IntersectionObserver(onIntersection, options);
|
||||
this.observerBacklog.forEach(([ id, node, callback ]) => {
|
||||
this.observe(id, node, callback);
|
||||
});
|
||||
this.observerBacklog = null;
|
||||
}
|
||||
|
||||
observe (id, node, callback) {
|
||||
if (!this.observer) {
|
||||
this.observerBacklog.push([ id, node, callback ]);
|
||||
} else {
|
||||
this.callbacks[id] = callback;
|
||||
this.observer.observe(node);
|
||||
}
|
||||
}
|
||||
|
||||
unobserve (id, node) {
|
||||
if (this.observer) {
|
||||
delete this.callbacks[id];
|
||||
this.observer.unobserve(node);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect () {
|
||||
if (this.observer) {
|
||||
this.callbacks = {};
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default IntersectionObserverWrapper;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { reduceMotion } from '../../../initial_state';
|
||||
import ReducedMotion from './reduced_motion';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
|
||||
export default reduceMotion ? ReducedMotion : Motion;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import ColumnsAreaContainer from '../containers/columns_area_container';
|
||||
import ColumnLoading from '../components/column_loading';
|
||||
import BundleColumnError from '../components/bundle_column_error';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import { me } from 'gabsocial/initial_state';
|
||||
|
||||
export class WrappedRoute extends React.Component {
|
||||
static propTypes = {
|
||||
component: PropTypes.func.isRequired,
|
||||
page: PropTypes.func,
|
||||
content: PropTypes.node,
|
||||
componentParams: PropTypes.object,
|
||||
layout: PropTypes.object,
|
||||
publicRoute: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
componentParams: {},
|
||||
};
|
||||
|
||||
renderComponent = ({ match }) => {
|
||||
const { component, content, componentParams, layout, page: Page } = this.props;
|
||||
|
||||
if (Page) {
|
||||
return (
|
||||
<BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
|
||||
{Component =>
|
||||
(
|
||||
<Page params={match.params} {...componentParams}>
|
||||
<Component params={match.params} {...componentParams}>
|
||||
{content}
|
||||
</Component>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
</BundleContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
|
||||
{Component =>
|
||||
(
|
||||
<ColumnsAreaContainer layout={layout}>
|
||||
<Component params={match.params} {...componentParams}>
|
||||
{content}
|
||||
</Component>
|
||||
</ColumnsAreaContainer>
|
||||
)
|
||||
}
|
||||
</BundleContainer>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoading = () => {
|
||||
return <ColumnLoading />;
|
||||
}
|
||||
|
||||
renderError = (props) => {
|
||||
return <BundleColumnError {...props} />;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { component: Component, content, publicRoute, ...rest } = this.props;
|
||||
|
||||
if (!publicRoute && !me) {
|
||||
const actualUrl = encodeURIComponent(this.props.computedMatch.url);
|
||||
return <Route path={this.props.path} component={() => {
|
||||
window.location.href = `/auth/sign_in?redirect_uri=${actualUrl}`;
|
||||
return null;
|
||||
}}/>
|
||||
}
|
||||
|
||||
return <Route {...rest} render={this.renderComponent} />;
|
||||
}
|
||||
}
|
||||
44
app/javascript/gabsocial/features/ui/util/reduced_motion.js
Normal file
44
app/javascript/gabsocial/features/ui/util/reduced_motion.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Like react-motion's Motion, but reduces all animations to cross-fades
|
||||
// for the benefit of users with motion sickness.
|
||||
import React from 'react';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const stylesToKeep = ['opacity', 'backgroundOpacity'];
|
||||
|
||||
const extractValue = (value) => {
|
||||
// This is either an object with a "val" property or it's a number
|
||||
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
|
||||
};
|
||||
|
||||
class ReducedMotion extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
defaultStyle: PropTypes.object,
|
||||
style: PropTypes.object,
|
||||
children: PropTypes.func,
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const { style, defaultStyle, children } = this.props;
|
||||
|
||||
Object.keys(style).forEach(key => {
|
||||
if (stylesToKeep.includes(key)) {
|
||||
return;
|
||||
}
|
||||
// If it's setting an x or height or scale or some other value, we need
|
||||
// to preserve the end-state value without actually animating it
|
||||
style[key] = defaultStyle[key] = extractValue(style[key]);
|
||||
});
|
||||
|
||||
return (
|
||||
<Motion style={style} defaultStyle={defaultStyle}>
|
||||
{children}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ReducedMotion;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Wrapper to call requestIdleCallback() to schedule low-priority work.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
|
||||
// for a good breakdown of the concepts behind this.
|
||||
|
||||
import Queue from 'tiny-queue';
|
||||
|
||||
const taskQueue = new Queue();
|
||||
let runningRequestIdleCallback = false;
|
||||
|
||||
function runTasks(deadline) {
|
||||
while (taskQueue.length && deadline.timeRemaining() > 0) {
|
||||
taskQueue.shift()();
|
||||
}
|
||||
if (taskQueue.length) {
|
||||
requestIdleCallback(runTasks);
|
||||
} else {
|
||||
runningRequestIdleCallback = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIdleTask(task) {
|
||||
taskQueue.push(task);
|
||||
if (!runningRequestIdleCallback) {
|
||||
runningRequestIdleCallback = true;
|
||||
requestIdleCallback(runTasks);
|
||||
}
|
||||
}
|
||||
|
||||
export default scheduleIdleTask;
|
||||
Reference in New Issue
Block a user