Merge tag 'v4.0.2'
This commit is contained in:
commit
b0fa7842db
1563 changed files with 59605 additions and 38210 deletions
219
app/javascript/mastodon/features/about/index.js
Normal file
219
app/javascript/mastodon/features/about/index.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Column from 'mastodon/components/column';
|
||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
||||
import Account from 'mastodon/containers/account_container';
|
||||
import Skeleton from 'mastodon/components/skeleton';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import Image from 'mastodon/components/image';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
|
||||
blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
|
||||
silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
|
||||
silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
|
||||
suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
|
||||
suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
|
||||
});
|
||||
|
||||
const severityMessages = {
|
||||
silence: {
|
||||
title: messages.silenced,
|
||||
explanation: messages.silencedExplanation,
|
||||
},
|
||||
|
||||
suspend: {
|
||||
title: messages.suspended,
|
||||
explanation: messages.suspendedExplanation,
|
||||
},
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
server: state.getIn(['server', 'server']),
|
||||
extendedDescription: state.getIn(['server', 'extendedDescription']),
|
||||
domainBlocks: state.getIn(['server', 'domainBlocks']),
|
||||
});
|
||||
|
||||
class Section extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
open: PropTypes.bool,
|
||||
onOpen: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: !this.props.open,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { onOpen } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('about__section', { active: !collapsed })}>
|
||||
<div className='about__section__title' role='button' tabIndex='0' onClick={this.handleClick}>
|
||||
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} fixedWidth /> {title}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className='about__section__body'>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class About extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
server: ImmutablePropTypes.map,
|
||||
extendedDescription: ImmutablePropTypes.map,
|
||||
domainBlocks: ImmutablePropTypes.contains({
|
||||
isLoading: PropTypes.bool,
|
||||
isAvailable: PropTypes.bool,
|
||||
items: ImmutablePropTypes.list,
|
||||
}),
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchServer());
|
||||
dispatch(fetchExtendedDescription());
|
||||
}
|
||||
|
||||
handleDomainBlocksOpen = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchDomainBlocks());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
|
||||
const isLoading = server.get('isLoading');
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
<div className='about__header'>
|
||||
<Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
||||
<div className='about__meta'>
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
|
||||
|
||||
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
|
||||
</div>
|
||||
|
||||
<hr className='about__meta__divider' />
|
||||
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
|
||||
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section open title={intl.formatMessage(messages.title)}>
|
||||
{extendedDescription.get('isLoading') ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (extendedDescription.get('content')?.length > 0 ? (
|
||||
<div
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
|
||||
/>
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={intl.formatMessage(messages.rules)}>
|
||||
{!isLoading && (server.get('rules').isEmpty() ? (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
) : (
|
||||
<ol className='rules-list'>
|
||||
{server.get('rules').map(rule => (
|
||||
<li key={rule.get('id')}>
|
||||
<span className='rules-list__text'>{rule.get('text')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (domainBlocks.get('isAvailable') ? (
|
||||
<>
|
||||
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
|
||||
|
||||
<div className='about__domain-blocks'>
|
||||
{domainBlocks.get('items').map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.get('domain')}>
|
||||
<div className='about__domain-blocks__domain__header'>
|
||||
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
|
||||
</div>
|
||||
|
||||
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<LinkFooter />
|
||||
|
||||
<div className='about__footer'>
|
||||
<p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
|
||||
const messages = defineMessages({
|
||||
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
|
||||
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class FeaturedTags extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
featuredTags: ImmutablePropTypes.list,
|
||||
tagged: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, featuredTags, intl } = this.props;
|
||||
|
||||
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='getting-started__trends'>
|
||||
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
|
||||
|
||||
{featuredTags.take(3).map(featuredTag => (
|
||||
<Hashtag
|
||||
key={featuredTag.get('name')}
|
||||
name={featuredTag.get('name')}
|
||||
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
|
||||
uses={featuredTag.get('statuses_count') * 1}
|
||||
withGraph={false}
|
||||
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Button from 'mastodon/components/button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
|
||||
import { autoPlayGif, me, domain } from 'mastodon/initial_state';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
|
|
@ -14,11 +14,13 @@ import ShortNumber from 'mastodon/components/short_number';
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AccountNoteContainer from '../containers/account_note_container';
|
||||
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
|
|
@ -50,8 +52,18 @@ const messages = defineMessages({
|
|||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
|
||||
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
||||
});
|
||||
|
||||
const titleFromAccount = account => {
|
||||
const displayName = account.get('display_name');
|
||||
const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct');
|
||||
const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
|
||||
|
||||
return `${prefix} (@${acct})`;
|
||||
};
|
||||
|
||||
const dateFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
|
@ -64,6 +76,10 @@ const dateFormatOptions = {
|
|||
export default @injectIntl
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
identity_props: ImmutablePropTypes.list,
|
||||
|
|
@ -80,6 +96,9 @@ class Header extends ImmutablePureComponent {
|
|||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
onChangeLanguages: PropTypes.func.isRequired,
|
||||
onInteractionModal: PropTypes.func.isRequired,
|
||||
onOpenAvatar: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
domain: PropTypes.string.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
|
|
@ -123,14 +142,24 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleAvatarClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.props.onOpenAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hidden, intl, domain } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suspended = account.get('suspended');
|
||||
const suspended = account.get('suspended');
|
||||
const isRemote = account.get('acct') !== account.get('username');
|
||||
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
|
||||
|
||||
let info = [];
|
||||
let actionBtn = '';
|
||||
|
|
@ -155,12 +184,12 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
||||
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
|
||||
actionBtn = '';
|
||||
} else if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
|
||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
|
||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
|
||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
|
||||
}
|
||||
|
|
@ -176,12 +205,17 @@ class Header extends ImmutablePureComponent {
|
|||
lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
|
||||
}
|
||||
|
||||
if (account.get('id') !== me) {
|
||||
if (signedIn && account.get('id') !== me) {
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
|
||||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
if ('share' in navigator) {
|
||||
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
|
||||
menu.push(null);
|
||||
|
|
@ -199,7 +233,7 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||
} else {
|
||||
} else if (signedIn) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
if (!account.getIn(['relationship', 'muting'])) {
|
||||
if (account.getIn(['relationship', 'showing_reblogs'])) {
|
||||
|
|
@ -207,6 +241,9 @@ class Header extends ImmutablePureComponent {
|
|||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
|
||||
|
|
@ -229,19 +266,17 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
|
||||
}
|
||||
|
||||
if (account.get('acct') !== account.get('username')) {
|
||||
const domain = account.get('acct').split('@')[1];
|
||||
|
||||
if (signedIn && isRemote) {
|
||||
menu.push(null);
|
||||
|
||||
if (account.getIn(['relationship', 'domain_blocking'])) {
|
||||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
|
||||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
|
||||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
|
||||
}
|
||||
}
|
||||
|
||||
if (account.get('id') !== me && isStaff) {
|
||||
if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
|
||||
}
|
||||
|
|
@ -249,7 +284,9 @@ class Header extends ImmutablePureComponent {
|
|||
const content = { __html: account.get('note_emojified') };
|
||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||
const fields = account.get('fields');
|
||||
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
||||
const isLocal = account.get('acct').indexOf('@') === -1;
|
||||
const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
||||
const isIndexable = !account.get('noindex');
|
||||
|
||||
let badge;
|
||||
|
||||
|
|
@ -273,12 +310,10 @@ class Header extends ImmutablePureComponent {
|
|||
|
||||
<div className='account__header__bar'>
|
||||
<div className='account__header__tabs'>
|
||||
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
|
||||
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
|
||||
<Avatar account={suspended || hidden ? undefined : account} size={90} />
|
||||
</a>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
{!suspended && (
|
||||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && (
|
||||
|
|
@ -288,7 +323,7 @@ class Header extends ImmutablePureComponent {
|
|||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
|
||||
<DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' size={24} direction='right' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -303,25 +338,26 @@ class Header extends ImmutablePureComponent {
|
|||
{!(suspended || hidden) && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{fields.size > 0 && (
|
||||
<div className='account__header__fields'>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i}>
|
||||
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
|
||||
|
||||
<dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} 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>
|
||||
)}
|
||||
|
||||
{account.get('id') !== me && <AccountNoteContainer account={account} />}
|
||||
{(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
|
||||
|
||||
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
|
||||
|
||||
<div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
<dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
|
||||
<dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
|
||||
</dl>
|
||||
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.get('verified_at') })}>
|
||||
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
|
||||
|
||||
<dd className='translate' 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 className='account__header__extra__links'>
|
||||
|
|
@ -349,6 +385,11 @@ class Header extends ImmutablePureComponent {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
|
||||
</Helmet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import FeaturedTags from '../components/featured_tags';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
const mapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
return (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
|
||||
});
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(FeaturedTags);
|
||||
52
app/javascript/mastodon/features/account/navigation.js
Normal file
52
app/javascript/mastodon/features/account/navigation.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { match: { params: { acct } } }) => {
|
||||
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class AccountNavigation extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
tagged: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
|
||||
accountId: PropTypes.string,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<a className='media-gallery__item-thumbnail' href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ import LoadMore from 'mastodon/components/load_more';
|
|||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export default class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onChangeLanguages: PropTypes.func.isRequired,
|
||||
onInteractionModal: PropTypes.func.isRequired,
|
||||
onOpenAvatar: PropTypes.func.isRequired,
|
||||
hideTabs: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
|
|
@ -91,6 +94,18 @@ export default class Header extends ImmutablePureComponent {
|
|||
this.props.onEditAccountNote(this.props.account);
|
||||
}
|
||||
|
||||
handleChangeLanguages = () => {
|
||||
this.props.onChangeLanguages(this.props.account);
|
||||
}
|
||||
|
||||
handleInteractionModal = () => {
|
||||
this.props.onInteractionModal(this.props.account);
|
||||
}
|
||||
|
||||
handleOpenAvatar = () => {
|
||||
this.props.onOpenAvatar(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hidden, hideTabs } = this.props;
|
||||
|
||||
|
|
@ -117,6 +132,9 @@ export default class Header extends ImmutablePureComponent {
|
|||
onEndorseToggle={this.handleEndorseToggle}
|
||||
onAddToList={this.handleAddToList}
|
||||
onEditAccountNote={this.handleEditAccountNote}
|
||||
onChangeLanguages={this.handleChangeLanguages}
|
||||
onInteractionModal={this.handleInteractionModal}
|
||||
onOpenAvatar={this.handleOpenAvatar}
|
||||
domain={this.props.domain}
|
||||
hidden={hidden}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
|||
import { revealAccount } from 'mastodon/actions/accounts';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ class LimitedAccountHint extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<div className='limited-account-hint'>
|
||||
<p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of your server.' /></p>
|
||||
<p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of {domain}.' values={{ domain }} /></p>
|
||||
<Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,47 +1,35 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import AvatarOverlay from '../../../components/avatar_overlay';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default class MovedNote extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
from: ImmutablePropTypes.map.isRequired,
|
||||
to: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
handleAccountClick = e => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/@${this.props.to.get('acct')}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { from, to } = this.props;
|
||||
const displayNameHtml = { __html: from.get('display_name_html') };
|
||||
|
||||
return (
|
||||
<div className='account__moved-note'>
|
||||
<div className='account__moved-note__message'>
|
||||
<div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></div>
|
||||
<FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
|
||||
<div className='moved-account-banner'>
|
||||
<div className='moved-account-banner__message'>
|
||||
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: from.get('display_name_html') }} /></bdi> }} />
|
||||
</div>
|
||||
|
||||
<a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
||||
<DisplayName account={to} />
|
||||
</a>
|
||||
<div className='moved-account-banner__action'>
|
||||
<Link to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
||||
<DisplayName account={to} />
|
||||
</Link>
|
||||
|
||||
<Link to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||
import { unfollowModal } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
|
@ -42,7 +43,7 @@ const makeMapStateToProps = () => {
|
|||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
|
|
@ -52,11 +53,29 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else if (account.getIn(['relationship', 'requested'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onInteractionModal (account) {
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'follow',
|
||||
accountId: account.get('id'),
|
||||
url: account.get('url'),
|
||||
}));
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
|
|
@ -121,12 +140,25 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
dispatch(unblockDomain(domain));
|
||||
},
|
||||
|
||||
onAddToList(account){
|
||||
onAddToList (account) {
|
||||
dispatch(openModal('LIST_ADDER', {
|
||||
accountId: account.get('id'),
|
||||
}));
|
||||
},
|
||||
|
||||
onChangeLanguages (account) {
|
||||
dispatch(openModal('SUBSCRIBED_LANGUAGES', {
|
||||
accountId: account.get('id'),
|
||||
}));
|
||||
},
|
||||
|
||||
onOpenAvatar (account) {
|
||||
dispatch(openModal('IMAGE', {
|
||||
src: account.get('avatar'),
|
||||
alt: account.get('acct'),
|
||||
}));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
|
||||
|
|
|
|||
|
|
@ -18,19 +18,22 @@ import { me } from 'mastodon/initial_state';
|
|||
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
|
||||
import LimitedAccountHint from './components/limited_account_hint';
|
||||
import { getAccountHidden } from 'mastodon/selectors';
|
||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const emptyList = ImmutableList();
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
statusIds: emptyList,
|
||||
};
|
||||
}
|
||||
|
||||
const path = withReplies ? `${accountId}:with_replies` : accountId;
|
||||
const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
|
|
@ -38,7 +41,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
|
|||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
||||
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
|
||||
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
|
||||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
|
||||
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||
|
|
@ -62,6 +65,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
tagged: PropTypes.string,
|
||||
}).isRequired,
|
||||
accountId: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
|
|
@ -80,15 +84,16 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
_load () {
|
||||
const { accountId, withReplies, dispatch } = this.props;
|
||||
const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
|
||||
|
||||
dispatch(fetchAccount(accountId));
|
||||
|
||||
if (!withReplies) {
|
||||
dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||
dispatch(fetchFeaturedTags(accountId));
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
|
||||
|
||||
if (accountId === me) {
|
||||
dispatch(connectTimeline(`account:${me}`));
|
||||
|
|
@ -106,12 +111,17 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
|
||||
|
||||
if (prevProps.accountId !== accountId && accountId) {
|
||||
this._load();
|
||||
} else if (prevProps.params.acct !== acct) {
|
||||
dispatch(lookupAccount(acct));
|
||||
} else if (prevProps.params.tagged !== tagged) {
|
||||
if (!withReplies) {
|
||||
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
|
||||
}
|
||||
|
||||
if (prevProps.accountId === me && accountId !== me) {
|
||||
|
|
@ -128,13 +138,19 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies }));
|
||||
this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
|
||||
if (!isAccount) {
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
} else if (!isLoading && !isAccount) {
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
|
@ -143,14 +159,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (!statusIds && isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
let emptyMessage;
|
||||
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
|
|
@ -174,7 +182,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
<ColumnBackButton multiColumn={multiColumn} />
|
||||
|
||||
<StatusList
|
||||
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />}
|
||||
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
scrollKey='account_timeline'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -8,6 +8,9 @@ import { throttle } from 'lodash';
|
|||
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
import { debounce } from 'lodash';
|
||||
import Visualizer from './visualizer';
|
||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
||||
import Blurhash from '../../components/blurhash';
|
||||
import { is } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
|
|
@ -15,6 +18,7 @@ const messages = defineMessages({
|
|||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
});
|
||||
|
||||
const TICK_SIZE = 10;
|
||||
|
|
@ -30,10 +34,14 @@ class Audio extends React.PureComponent {
|
|||
duration: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
blurhash: PropTypes.string,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
backgroundColor: PropTypes.string,
|
||||
foregroundColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
|
|
@ -53,6 +61,7 @@ class Audio extends React.PureComponent {
|
|||
muted: false,
|
||||
volume: 0.5,
|
||||
dragging: false,
|
||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
||||
};
|
||||
|
||||
constructor (props) {
|
||||
|
|
@ -78,6 +87,8 @@ class Audio extends React.PureComponent {
|
|||
backgroundColor: this.props.backgroundColor,
|
||||
foregroundColor: this.props.foregroundColor,
|
||||
accentColor: this.props.accentColor,
|
||||
sensitive: this.props.sensitive,
|
||||
visible: this.props.visible,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +137,12 @@ class Audio extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
this.setState({ revealed: nextProps.visible });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
|
|
@ -189,6 +206,14 @@ class Audio extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
toggleReveal = () => {
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
this.setState({ revealed: !this.state.revealed });
|
||||
}
|
||||
}
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
|
|
@ -433,13 +458,29 @@ class Audio extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { src, intl, alt, editable, autoPlay } = this.props;
|
||||
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
|
||||
const { src, intl, alt, editable, autoPlay, sensitive, blurhash } = this.props;
|
||||
const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
|
||||
let warning;
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
|
||||
<audio
|
||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
|
||||
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{(revealed || editable) && <audio
|
||||
src={src}
|
||||
ref={this.setAudioRef}
|
||||
preload={autoPlay ? 'auto' : 'none'}
|
||||
|
|
@ -448,7 +489,7 @@ class Audio extends React.PureComponent {
|
|||
onProgress={this.handleProgress}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
crossOrigin='anonymous'
|
||||
/>
|
||||
/>}
|
||||
|
||||
<canvas
|
||||
role='button'
|
||||
|
|
@ -464,13 +505,19 @@ class Audio extends React.PureComponent {
|
|||
aria-label={alt}
|
||||
/>
|
||||
|
||||
<img
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(revealed || editable) && <img
|
||||
src={this.props.poster}
|
||||
alt=''
|
||||
width={(this._getRadius() - TICK_SIZE) * 2}
|
||||
height={(this._getRadius() - TICK_SIZE) * 2}
|
||||
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
|
||||
/>
|
||||
/>}
|
||||
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
|
|
@ -508,6 +555,7 @@ class Audio extends React.PureComponent {
|
|||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
|
||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||
<Icon id={'download'} fixedWidth />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import StatusList from '../../components/status_list';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
|
|
@ -95,6 +96,11 @@ class Bookmarks extends ImmutablePureComponent {
|
|||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
message: state.getIn(['server', 'server', 'registrations', 'message']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class ClosedRegistrationsModal extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchServer());
|
||||
}
|
||||
|
||||
render () {
|
||||
let closedRegistrationsMessage;
|
||||
|
||||
if (this.props.message) {
|
||||
closedRegistrationsMessage = (
|
||||
<p
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: this.props.message }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
closedRegistrationsMessage = (
|
||||
<p className='prose'>
|
||||
<FormattedMessage
|
||||
id='closed_registrations_modal.description'
|
||||
defaultMessage='Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.'
|
||||
values={{ domain: <strong>{domain}</strong> }}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='closed_registrations_modal.preamble'
|
||||
defaultMessage='Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!'
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices'>
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
||||
{closedRegistrationsMessage}
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
||||
<p className='prose'>
|
||||
<FormattedMessage
|
||||
id='closed_registrations.other_server_instructions'
|
||||
defaultMessage='Since Mastodon is decentralized, you can create an account on another server and still interact with this one.'
|
||||
/>
|
||||
</p>
|
||||
<a href='https://joinmastodon.org/servers' className='button button--block'><FormattedMessage id='closed_registrations_modal.find_another_server' defaultMessage='Find another server' /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
|
@ -9,6 +9,9 @@ import { expandCommunityTimeline } from '../../actions/timelines';
|
|||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { connectCommunityStream } from '../../actions/streaming';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.community', defaultMessage: 'Local timeline' },
|
||||
|
|
@ -33,6 +36,7 @@ class CommunityTimeline extends React.PureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -69,18 +73,30 @@ class CommunityTimeline extends React.PureComponent {
|
|||
|
||||
componentDidMount () {
|
||||
const { dispatch, onlyMedia } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
dispatch(expandCommunityTimeline({ onlyMedia }));
|
||||
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (prevProps.onlyMedia !== this.props.onlyMedia) {
|
||||
const { dispatch, onlyMedia } = this.props;
|
||||
|
||||
this.disconnect();
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
dispatch(expandCommunityTimeline({ onlyMedia }));
|
||||
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +136,10 @@ class CommunityTimeline extends React.PureComponent {
|
|||
<ColumnSettingsContainer columnId={columnId} />
|
||||
</ColumnHeader>
|
||||
|
||||
<DismissableBanner id='community_timeline'>
|
||||
<FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
|
||||
</DismissableBanner>
|
||||
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`community_timeline-${columnId}`}
|
||||
|
|
@ -128,6 +148,11 @@ class CommunityTimeline extends React.PureComponent {
|
|||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class ActionBar extends React.PureComponent {
|
|||
return (
|
||||
<div className='compose__action-bar'>
|
||||
<div className='compose__action-bar-dropdown'>
|
||||
<DropdownMenuContainer items={menu} icon='chevron-down' size={16} direction='right' />
|
||||
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
handleSubmit = (e) => {
|
||||
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
|
||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
||||
// Update the state to match the current text
|
||||
|
|
@ -105,6 +105,10 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
this.props.onSubmit(this.context.router ? this.context.router.history : null);
|
||||
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
|
|
@ -217,7 +221,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='compose-form'>
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
<WarningContainer />
|
||||
|
||||
<ReplyIndicatorContainer />
|
||||
|
|
@ -279,10 +283,15 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
|
||||
<div className='compose-form__publish'>
|
||||
<div className='compose-form__publish-button-wrapper'>
|
||||
<Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block />
|
||||
<Button
|
||||
type='submit'
|
||||
text={publishText}
|
||||
disabled={!this.canSubmit()}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,12 +96,12 @@ class ModifierPickerMenu extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
|
|||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
|
||||
import fuzzysort from 'fuzzysort';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -16,22 +17,6 @@ const messages = defineMessages({
|
|||
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
|
||||
});
|
||||
|
||||
// Copied from emoji-mart for consistency with emoji picker and since
|
||||
// they don't export the icons in the package
|
||||
const icons = {
|
||||
loupe: (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
|
||||
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
|
||||
</svg>
|
||||
),
|
||||
|
||||
delete: (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
|
||||
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
class LanguageDropdownMenu extends React.PureComponent {
|
||||
|
|
@ -66,6 +51,15 @@ class LanguageDropdownMenu extends React.PureComponent {
|
|||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
this.setState({ mounted: true });
|
||||
|
||||
// Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
|
||||
// to wait for a frame before focusing
|
||||
requestAnimationFrame(() => {
|
||||
if (this.node) {
|
||||
const element = this.node.querySelector('input[type="search"]');
|
||||
if (element) element.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
|
|
@ -241,8 +235,8 @@ class LanguageDropdownMenu extends React.PureComponent {
|
|||
// react-overlays
|
||||
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className='emoji-mart-search'>
|
||||
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
|
||||
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
|
||||
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
|
||||
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
|
||||
</div>
|
||||
|
||||
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ActionBar from './action_bar';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
|
@ -19,15 +19,15 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
render () {
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
|
||||
<Link to={`/@${this.props.account.get('acct')}`}>
|
||||
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
|
||||
<Avatar account={this.props.account} size={48} />
|
||||
</Permalink>
|
||||
<Avatar account={this.props.account} size={46} />
|
||||
</Link>
|
||||
|
||||
<div className='navigation-bar__profile'>
|
||||
<Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
|
||||
<Link to={`/@${this.props.account.get('acct')}`}>
|
||||
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ class PollForm extends ImmutablePureComponent {
|
|||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
<button disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
|
||||
<button type='button' disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
|
||||
|
||||
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
|
||||
<select value={expiresIn} onChange={this.handleSelectDuration}>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class ReplyIndicator extends ImmutablePureComponent {
|
|||
<div className='reply-indicator__header'>
|
||||
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>
|
||||
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
|
||||
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
|
||||
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon';
|
|||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
||||
});
|
||||
|
||||
class SearchPopout extends React.PureComponent {
|
||||
|
|
@ -49,6 +50,7 @@ class Search extends React.PureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
identity: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -116,6 +118,7 @@ class Search extends React.PureComponent {
|
|||
render () {
|
||||
const { intl, value, submitted } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const { signedIn } = this.context.identity;
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
|
|
@ -126,7 +129,7 @@ class Search extends React.PureComponent {
|
|||
ref={this.setRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ class SearchResults extends ImmutablePureComponent {
|
|||
<AccountContainer
|
||||
key={suggestion.get('account')}
|
||||
id={suggestion.get('account')}
|
||||
actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
|
||||
actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
|
||||
actionIcon={suggestion.get('source') === 'past_interactions' ? 'times' : null}
|
||||
actionTitle={suggestion.get('source') === 'past_interactions' ? intl.formatMessage(messages.dismissSuggestion) : null}
|
||||
onActionClick={dismissSuggestion}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export default class TextIconButton extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className={`text-icon-button ${active ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default class Upload extends ImmutablePureComponent {
|
|||
media: ImmutablePropTypes.map.isRequired,
|
||||
onUndo: PropTypes.func.isRequired,
|
||||
onOpenFocalPoint: PropTypes.func.isRequired,
|
||||
isEditingStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleUndoClick = e => {
|
||||
|
|
@ -31,7 +30,7 @@ export default class Upload extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { media, isEditingStatus } = this.props;
|
||||
const { media } = this.props;
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
|
|
@ -43,13 +42,13 @@ export default class Upload extends ImmutablePureComponent {
|
|||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||
{!isEditingStatus && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
|
||||
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||
{!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
|
||||
</div>
|
||||
|
||||
{(media.get('description') || '').length === 0 && (
|
||||
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import UploadProgressContainer from '../containers/upload_progress_container';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
|
||||
<UploadProgressContainer />
|
||||
|
||||
<div className='compose-form__uploads-wrapper'>
|
||||
{mediaIds.map(id => (
|
||||
|
|
|
|||
|
|
@ -3,27 +3,35 @@ import PropTypes from 'prop-types';
|
|||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class UploadProgress extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
icon: PropTypes.string.isRequired,
|
||||
message: PropTypes.node.isRequired,
|
||||
isProcessing: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { active, progress, icon, message } = this.props;
|
||||
const { active, progress, isProcessing } = this.props;
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
if (isProcessing) {
|
||||
message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
|
||||
} else {
|
||||
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='upload-progress'>
|
||||
<div className='upload-progress__icon'>
|
||||
<Icon id={icon} />
|
||||
<Icon id='upload' />
|
||||
</div>
|
||||
|
||||
<div className='upload-progress__message'>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { submitCompose } from '../../../actions/compose';
|
|||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
isEditingStatus: state.getIn(['compose', 'id']) !== null,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import UploadProgress from '../components/upload_progress';
|
|||
const mapStateToProps = state => ({
|
||||
active: state.getIn(['compose', 'is_uploading']),
|
||||
progress: state.getIn(['compose', 'progress']),
|
||||
isProcessing: state.getIn(['compose', 'is_processing']),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UploadProgress);
|
||||
|
|
|
|||
|
|
@ -4,19 +4,20 @@ import NavigationContainer from './containers/navigation_container';
|
|||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import SearchContainer from './containers/search_container';
|
||||
import Motion from '../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import SearchResultsContainer from './containers/search_results_container';
|
||||
import { changeComposing } from '../../actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { mascot } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
|
|
@ -33,7 +34,7 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
|
||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
|
@ -45,24 +46,17 @@ class Compose extends React.PureComponent {
|
|||
columns: ImmutablePropTypes.list.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
showSearch: PropTypes.bool,
|
||||
isSearchPage: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { isSearchPage } = this.props;
|
||||
|
||||
if (!isSearchPage) {
|
||||
this.props.dispatch(mountCompose());
|
||||
}
|
||||
const { dispatch } = this.props;
|
||||
dispatch(mountCompose());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { isSearchPage } = this.props;
|
||||
|
||||
if (!isSearchPage) {
|
||||
this.props.dispatch(unmountCompose());
|
||||
}
|
||||
const { dispatch } = this.props;
|
||||
dispatch(unmountCompose());
|
||||
}
|
||||
|
||||
handleLogoutClick = e => {
|
||||
|
|
@ -90,59 +84,65 @@ class Compose extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { multiColumn, showSearch, isSearchPage, intl } = this.props;
|
||||
|
||||
let header = '';
|
||||
const { multiColumn, showSearch, intl } = this.props;
|
||||
|
||||
if (multiColumn) {
|
||||
const { columns } = this.props;
|
||||
header = (
|
||||
<nav className='drawer__header'>
|
||||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' fixedWidth /></Link>
|
||||
{!columns.some(column => column.get('id') === 'HOME') && (
|
||||
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
|
||||
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
|
||||
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'PUBLIC') && (
|
||||
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
|
||||
</nav>
|
||||
|
||||
return (
|
||||
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
|
||||
<nav className='drawer__header'>
|
||||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' fixedWidth /></Link>
|
||||
{!columns.some(column => column.get('id') === 'HOME') && (
|
||||
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
|
||||
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
|
||||
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'PUBLIC') && (
|
||||
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
|
||||
</nav>
|
||||
|
||||
{multiColumn && <SearchContainer /> }
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||
<NavigationContainer onClose={this.onBlur} />
|
||||
|
||||
<ComposeFormContainer />
|
||||
|
||||
<div className='drawer__inner__mastodon'>
|
||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
<SearchResultsContainer />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
|
||||
{header}
|
||||
<Column onFocus={this.onFocus}>
|
||||
<NavigationContainer onClose={this.onBlur} />
|
||||
<ComposeFormContainer />
|
||||
|
||||
{(multiColumn || isSearchPage) && <SearchContainer /> }
|
||||
|
||||
<div className='drawer__pager'>
|
||||
{!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
|
||||
<NavigationContainer onClose={this.onBlur} />
|
||||
|
||||
<ComposeFormContainer />
|
||||
|
||||
<div className='drawer__inner__mastodon'>
|
||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
<SearchResultsContainer />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import AttachmentList from 'mastodon/components/attachment_list';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
|
@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
|
|||
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
|
||||
|
||||
const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
|
||||
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
const handlers = {
|
||||
reply: this.handleReply,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { mountConversations, unmountConversations, expandConversations } from '../../actions/conversations';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connectDirectStream } from '../../actions/streaming';
|
||||
import { connect } from 'react-redux';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { mountConversations, unmountConversations, expandConversations } from 'mastodon/actions/conversations';
|
||||
import { connectDirectStream } from 'mastodon/actions/streaming';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ConversationsListContainer from './containers/conversations_list_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -94,6 +95,11 @@ class DirectTimeline extends React.PureComponent {
|
|||
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
|
||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { connect } from 'react-redux';
|
|||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||
|
|
@ -23,7 +23,8 @@ import classNames from 'classnames';
|
|||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
|
|
@ -43,10 +44,7 @@ const makeMapStateToProps = () => {
|
|||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onFollow(account) {
|
||||
if (
|
||||
account.getIn(['relationship', 'following']) ||
|
||||
account.getIn(['relationship', 'requested'])
|
||||
) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(
|
||||
openModal('CONFIRM', {
|
||||
|
|
@ -64,6 +62,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else if (account.getIn(['relationship', 'requested'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
|
|
@ -161,7 +169,7 @@ class AccountCard extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='account-card'>
|
||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
||||
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
||||
<div className='account-card__header'>
|
||||
<img
|
||||
src={
|
||||
|
|
@ -175,7 +183,7 @@ class AccountCard extends ImmutablePureComponent {
|
|||
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
{account.get('note').length > 0 && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import RadioButton from 'mastodon/components/radio_button';
|
|||
import LoadMore from 'mastodon/components/load_more';
|
||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||
|
|
@ -165,6 +166,11 @@ class Directory extends React.PureComponent {
|
|||
/>
|
||||
|
||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
|||
import DomainContainer from '../../containers/domain_container';
|
||||
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
|
|
@ -59,6 +60,7 @@ class Blocks extends ImmutablePureComponent {
|
|||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='domain_blocks'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
|
@ -70,6 +72,10 @@ class Blocks extends ImmutablePureComponent {
|
|||
<DomainContainer key={domain} domain={domain} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -11,6 +11,8 @@ import Statuses from './statuses';
|
|||
import Suggestions from './suggestions';
|
||||
import Search from 'mastodon/features/compose/containers/search_container';
|
||||
import SearchResults from './results';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
|
|
@ -19,7 +21,7 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
layout: state.getIn(['meta', 'layout']),
|
||||
isSearching: state.getIn(['search', 'submitted']),
|
||||
isSearching: state.getIn(['search', 'submitted']) || !showTrends,
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
|
@ -28,13 +30,13 @@ class Explore extends React.PureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
isSearching: PropTypes.bool,
|
||||
layout: PropTypes.string,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
|
|
@ -46,22 +48,21 @@ class Explore extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { intl, multiColumn, isSearching, layout } = this.props;
|
||||
const { intl, multiColumn, isSearching } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
{layout === 'mobile' ? (
|
||||
<div className='explore__search-header'>
|
||||
<Search />
|
||||
</div>
|
||||
) : (
|
||||
<ColumnHeader
|
||||
icon={isSearching ? 'search' : 'hashtag'}
|
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
)}
|
||||
<ColumnHeader
|
||||
icon={isSearching ? 'search' : 'hashtag'}
|
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search />
|
||||
</div>
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
{isSearching ? (
|
||||
|
|
@ -72,7 +73,7 @@ class Explore extends React.PureComponent {
|
|||
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
|
||||
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
|
||||
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
|
||||
<NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>
|
||||
{signedIn && <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
|
|
@ -81,6 +82,11 @@ class Explore extends React.PureComponent {
|
|||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
|
||||
</Helmet>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import Story from './components/story';
|
|||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchTrendingLinks } from 'mastodon/actions/trends';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
links: state.getIn(['trends', 'links', 'items']),
|
||||
|
|
@ -28,8 +30,28 @@ class Links extends React.PureComponent {
|
|||
render () {
|
||||
const { isLoading, links } = this.props;
|
||||
|
||||
const banner = (
|
||||
<DismissableBanner id='explore/links'>
|
||||
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' />
|
||||
</DismissableBanner>
|
||||
);
|
||||
|
||||
if (!isLoading && links.isEmpty()) {
|
||||
return (
|
||||
<div className='explore__links scrollable scrollable--flex'>
|
||||
{banner}
|
||||
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='explore__links'>
|
||||
{banner}
|
||||
|
||||
{isLoading ? (<LoadingIndicator />) : links.map(link => (
|
||||
<Story
|
||||
key={link.get('id')}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { expandSearch } from 'mastodon/actions/search';
|
||||
import Account from 'mastodon/containers/account_container';
|
||||
|
|
@ -10,10 +10,16 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import LoadMore from 'mastodon/components/load_more';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isLoading: state.getIn(['search', 'isLoading']),
|
||||
results: state.getIn(['search', 'results']),
|
||||
q: state.getIn(['search', 'searchTerm']),
|
||||
});
|
||||
|
||||
const appendLoadMore = (id, list, onLoadMore) => {
|
||||
|
|
@ -37,6 +43,7 @@ const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', resul
|
|||
)), onLoadMore);
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Results extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -44,6 +51,8 @@ class Results extends React.PureComponent {
|
|||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
q: PropTypes.string,
|
||||
intl: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
|
@ -64,7 +73,7 @@ class Results extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { isLoading, results } = this.props;
|
||||
const { intl, isLoading, q, results } = this.props;
|
||||
const { type } = this.state;
|
||||
|
||||
let filteredResults = ImmutableList();
|
||||
|
|
@ -106,6 +115,10 @@ class Results extends React.PureComponent {
|
|||
<div className='explore__search-results'>
|
||||
{isLoading ? <LoadingIndicator /> : filteredResults}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
</Helmet>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
|
||||
import { debounce } from 'lodash';
|
||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: state.getIn(['status_lists', 'trending', 'items']),
|
||||
|
|
@ -40,17 +41,23 @@ class Statuses extends React.PureComponent {
|
|||
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
|
||||
|
||||
return (
|
||||
<StatusList
|
||||
trackScroll
|
||||
statusIds={statusIds}
|
||||
scrollKey='explore-statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
withCounters
|
||||
/>
|
||||
<>
|
||||
<DismissableBanner id='explore/statuses'>
|
||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' />
|
||||
</DismissableBanner>
|
||||
|
||||
<StatusList
|
||||
trackScroll
|
||||
statusIds={statusIds}
|
||||
scrollKey='explore-statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
withCounters
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import AccountCard from 'mastodon/features/directory/components/account_card';
|
|||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
suggestions: state.getIn(['suggestions', 'items']),
|
||||
|
|
@ -28,6 +29,16 @@ class Suggestions extends React.PureComponent {
|
|||
render () {
|
||||
const { isLoading, suggestions } = this.props;
|
||||
|
||||
if (!isLoading && suggestions.isEmpty()) {
|
||||
return (
|
||||
<div className='explore__suggestions scrollable scrollable--flex'>
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='explore__suggestions'>
|
||||
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
|||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['trends', 'tags', 'items']),
|
||||
|
|
@ -28,8 +30,28 @@ class Tags extends React.PureComponent {
|
|||
render () {
|
||||
const { isLoading, hashtags } = this.props;
|
||||
|
||||
const banner = (
|
||||
<DismissableBanner id='explore/tags'>
|
||||
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' />
|
||||
</DismissableBanner>
|
||||
);
|
||||
|
||||
if (!isLoading && hashtags.isEmpty()) {
|
||||
return (
|
||||
<div className='explore__links scrollable scrollable--flex'>
|
||||
{banner}
|
||||
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='explore__links'>
|
||||
{banner}
|
||||
|
||||
{isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
|
||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import StatusList from '../../components/status_list';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
||||
|
|
@ -95,6 +96,11 @@ class Favourites extends ImmutablePureComponent {
|
|||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchFavourites } from '../../actions/interactions';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { connect } from 'react-redux';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { fetchFavourites } from 'mastodon/actions/interactions';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import AccountContainer from 'mastodon/containers/account_container';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
|
|
@ -67,7 +68,7 @@ class Favourites extends ImmutablePureComponent {
|
|||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
|
@ -80,6 +81,10 @@ class Favourites extends ImmutablePureComponent {
|
|||
<AccountContainer key={id} id={id} withNote={false} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
102
app/javascript/mastodon/features/filters/added_to_filter.js
Normal file
102
app/javascript/mastodon/features/filters/added_to_filter.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { toServerSideType } from 'mastodon/utils/filters';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const mapStateToProps = (state, { filterId }) => ({
|
||||
filter: state.getIn(['filters', filterId]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class AddedToFilter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
contextType: PropTypes.string,
|
||||
filter: ImmutablePropTypes.map.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleCloseClick = () => {
|
||||
const { onClose } = this.props;
|
||||
onClose();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { filter, contextType } = this.props;
|
||||
|
||||
let expiredMessage = null;
|
||||
if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
|
||||
expiredMessage = (
|
||||
<React.Fragment>
|
||||
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
|
||||
<p className='report-dialog-modal__lead'>
|
||||
<FormattedMessage
|
||||
id='filter_modal.added.expired_explanation'
|
||||
defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
let contextMismatchMessage = null;
|
||||
if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
|
||||
contextMismatchMessage = (
|
||||
<React.Fragment>
|
||||
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
|
||||
<p className='report-dialog-modal__lead'>
|
||||
<FormattedMessage
|
||||
id='filter_modal.added.context_mismatch_explanation'
|
||||
defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const settings_link = (
|
||||
<a href={`/filters/${filter.get('id')}/edit`}>
|
||||
<FormattedMessage
|
||||
id='filter_modal.added.settings_link'
|
||||
defaultMessage='settings page'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
|
||||
<p className='report-dialog-modal__lead'>
|
||||
<FormattedMessage
|
||||
id='filter_modal.added.short_explanation'
|
||||
defaultMessage='This post has been added to the following filter category: {title}.'
|
||||
values={{ title: filter.get('title') }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{expiredMessage}
|
||||
{contextMismatchMessage}
|
||||
|
||||
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
|
||||
<p className='report-dialog-modal__lead'>
|
||||
<FormattedMessage
|
||||
id='filter_modal.added.review_and_configure'
|
||||
defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
|
||||
values={{ settings_link }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<div className='report-dialog-modal__actions'>
|
||||
<Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
192
app/javascript/mastodon/features/filters/select_filter.js
Normal file
192
app/javascript/mastodon/features/filters/select_filter.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { toServerSideType } from 'mastodon/utils/filters';
|
||||
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import fuzzysort from 'fuzzysort';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
|
||||
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { contextType }) => ({
|
||||
filters: Array.from(state.get('filters').values()).map((filter) => [
|
||||
filter.get('id'),
|
||||
filter.get('title'),
|
||||
filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
|
||||
filter.get('expires_at') && filter.get('expires_at') < new Date(),
|
||||
contextType && !filter.get('context').includes(toServerSideType(contextType)),
|
||||
]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class SelectFilter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onSelectFilter: PropTypes.func.isRequired,
|
||||
onNewFilter: PropTypes.func.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
searchValue: '',
|
||||
};
|
||||
|
||||
search () {
|
||||
const { filters } = this.props;
|
||||
const { searchValue } = this.state;
|
||||
|
||||
if (searchValue === '') {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return fuzzysort.go(searchValue, filters, {
|
||||
keys: ['1', '2'],
|
||||
limit: 5,
|
||||
threshold: -10000,
|
||||
}).map(result => result.obj);
|
||||
}
|
||||
|
||||
renderItem = filter => {
|
||||
let warning = null;
|
||||
if (filter[3] || filter[4]) {
|
||||
warning = (
|
||||
<span className='language-dropdown__dropdown__results__item__common-name'>
|
||||
(
|
||||
{filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
|
||||
{filter[3] && filter[4] && ', '}
|
||||
{filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
|
||||
)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
|
||||
<span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCreateNew (name) {
|
||||
return (
|
||||
<div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
|
||||
<Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleSearchChange = ({ target }) => {
|
||||
this.setState({ searchValue: target.value });
|
||||
}
|
||||
|
||||
setListRef = c => {
|
||||
this.listNode = c;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
|
||||
|
||||
let element = null;
|
||||
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
e.currentTarget.click();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
|
||||
} else {
|
||||
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = this.listNode.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = this.listNode.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchKeyDown = e => {
|
||||
let element = null;
|
||||
|
||||
switch(e.key) {
|
||||
case 'Tab':
|
||||
case 'ArrowDown':
|
||||
element = this.listNode.firstChild;
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleClear = () => {
|
||||
this.setState({ searchValue: '' });
|
||||
}
|
||||
|
||||
handleItemClick = e => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onSelectFilter(value);
|
||||
}
|
||||
|
||||
handleNewFilterClick = e => {
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onNewFilter(this.state.searchValue);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
const { searchValue } = this.state;
|
||||
const isSearching = searchValue !== '';
|
||||
const results = this.search();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
|
||||
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
|
||||
|
||||
<div className='emoji-mart-search'>
|
||||
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
|
||||
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
|
||||
</div>
|
||||
|
||||
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
|
||||
{results.map(this.renderItem)}
|
||||
{isSearching && this.renderCreateNew(searchValue) }
|
||||
</div>
|
||||
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { connect } from 'react-redux';
|
|||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
|
||||
|
|
@ -66,13 +66,13 @@ class Account extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='account follow-recommendations-account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
|
||||
<Link className='account__display-name account__display-name--with-note' title={account.get('acct')} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
|
||||
<DisplayName account={account} />
|
||||
|
||||
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import { requestBrowserPermission } from 'mastodon/actions/notifications';
|
|||
import { markAsPartial } from 'mastodon/actions/timelines';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import Account from './components/account';
|
||||
import Logo from 'mastodon/components/logo';
|
||||
import imageGreeting from 'mastodon/../images/cigarmanwoman.svg';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
suggestions: state.getIn(['suggestions', 'items']),
|
||||
|
|
@ -78,7 +78,10 @@ class FollowRecommendations extends ImmutablePureComponent {
|
|||
<Column>
|
||||
<div className='scrollable follow-recommendations-container'>
|
||||
<div className='column-title'>
|
||||
<Logo />
|
||||
<svg viewBox='0 0 79 79' className='logo'>
|
||||
<use xlinkHref='#logo-symbol-icon' />
|
||||
</svg>
|
||||
|
||||
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
|
||||
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
|
||||
</div>
|
||||
|
|
@ -102,6 +105,10 @@ class FollowRecommendations extends ImmutablePureComponent {
|
|||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
|
|
@ -30,10 +30,10 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='account-authorize__wrapper'>
|
||||
<div className='account-authorize'>
|
||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
|
||||
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
|
||||
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import AccountAuthorizeContainer from './containers/account_authorize_container'
|
|||
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { me } from '../../initial_state';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
|
||||
|
|
@ -87,6 +88,10 @@ class FollowRequests extends ImmutablePureComponent {
|
|||
<AccountAuthorizeContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import MissingIndicator from 'mastodon/components/missing_indicator';
|
|||
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
|
||||
import { getAccountHidden } from 'mastodon/selectors';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import MissingIndicator from 'mastodon/components/missing_indicator';
|
|||
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
|
||||
import { getAccountHidden } from 'mastodon/selectors';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', acct]);
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import Column from '../ui/components/column';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ColumnLink from '../ui/components/column_link';
|
||||
import ColumnSubheading from '../ui/components/column_subheading';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
|
@ -11,9 +12,9 @@ import { me, showTrends } from '../../initial_state';
|
|||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import NavigationContainer from '../compose/containers/navigation_container';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
||||
import TrendsContainer from './containers/trends_container';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
|
|
@ -40,7 +41,6 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
myAccount: state.getIn(['accounts', me]),
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
|
||||
});
|
||||
|
||||
|
|
@ -58,20 +58,18 @@ const badgeDisplay = (number, limit) => {
|
|||
}
|
||||
};
|
||||
|
||||
const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
myAccount: ImmutablePropTypes.map.isRequired,
|
||||
columns: ImmutablePropTypes.list,
|
||||
myAccount: ImmutablePropTypes.map,
|
||||
multiColumn: PropTypes.bool,
|
||||
fetchFollowRequests: PropTypes.func.isRequired,
|
||||
unreadFollowRequests: PropTypes.number,
|
||||
|
|
@ -79,10 +77,10 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { fetchFollowRequests, multiColumn } = this.props;
|
||||
const { fetchFollowRequests } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
|
||||
this.context.router.history.replace('/home');
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,91 +88,66 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { intl, myAccount, columns, multiColumn, unreadFollowRequests } = this.props;
|
||||
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
const navItems = [];
|
||||
let height = (multiColumn) ? 0 : 60;
|
||||
|
||||
if (multiColumn) {
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />,
|
||||
);
|
||||
|
||||
if (showTrends) {
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />,
|
||||
<ColumnLink key='explore' icon='hashtag' text={intl.formatMessage(messages.explore)} to='/explore' />,
|
||||
);
|
||||
height += 34;
|
||||
}
|
||||
|
||||
navItems.push(
|
||||
<ColumnLink key='explore' icon='hashtag' text={intl.formatMessage(messages.explore)} to='/explore' />,
|
||||
<ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />,
|
||||
<ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />,
|
||||
);
|
||||
height += 48;
|
||||
|
||||
if (multiColumn) {
|
||||
navItems.push(
|
||||
<ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />,
|
||||
<ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />,
|
||||
);
|
||||
|
||||
height += 48*2;
|
||||
|
||||
if (signedIn) {
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />,
|
||||
);
|
||||
|
||||
height += 34;
|
||||
}
|
||||
|
||||
if (multiColumn && !columns.find(item => item.get('id') === 'HOME')) {
|
||||
navItems.push(
|
||||
<ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />,
|
||||
<ColumnLink key='direct' icon='at' text={intl.formatMessage(messages.direct)} to='/conversations' />,
|
||||
<ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||
<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||
);
|
||||
height += 48;
|
||||
}
|
||||
|
||||
navItems.push(
|
||||
<ColumnLink key='direct' icon='at' text={intl.formatMessage(messages.direct)} to='/conversations' />,
|
||||
<ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||
<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||
);
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
}
|
||||
|
||||
height += 48*4;
|
||||
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
height += 48;
|
||||
}
|
||||
|
||||
if (!multiColumn) {
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />,
|
||||
<ColumnLink key='preferences' icon='gears' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
|
||||
);
|
||||
|
||||
height += 34 + 48;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.menu)}>
|
||||
{multiColumn && <div className='column-header__wrapper'>
|
||||
<h1 className='column-header'>
|
||||
<button>
|
||||
<Icon id='bars' className='column-header__icon' fixedWidth />
|
||||
<FormattedMessage id='getting_started.heading' defaultMessage='Getting started' />
|
||||
</button>
|
||||
</h1>
|
||||
</div>}
|
||||
<Column>
|
||||
{(signedIn && !multiColumn) ? <NavigationContainer /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' multiColumn={multiColumn} />}
|
||||
|
||||
<div className='getting-started'>
|
||||
<div className='getting-started__wrapper' style={{ height }}>
|
||||
{!multiColumn && <NavigationContainer />}
|
||||
<div className='getting-started scrollable scrollable--flex'>
|
||||
<div className='getting-started__wrapper'>
|
||||
{navItems}
|
||||
</div>
|
||||
|
||||
{!multiColumn && <div className='flex-spacer' />}
|
||||
|
||||
<LinkFooter withHotkeys={multiColumn} />
|
||||
<LinkFooter />
|
||||
</div>
|
||||
|
||||
{multiColumn && showTrends && <TrendsContainer />}
|
||||
{(multiColumn && showTrends) && <TrendsContainer />}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.menu)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,49 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { connectHashtagStream } from '../../actions/streaming';
|
||||
import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { connectHashtagStream } from 'mastodon/actions/streaming';
|
||||
import { isEqual } from 'lodash';
|
||||
import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
||||
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||
tag: state.getIn(['tags', props.params.id]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class HashtagTimeline extends React.PureComponent {
|
||||
|
||||
disconnects = [];
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
hasUnread: PropTypes.bool,
|
||||
tag: ImmutablePropTypes.map,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
|
|
@ -39,7 +57,8 @@ class HashtagTimeline extends React.PureComponent {
|
|||
}
|
||||
|
||||
title = () => {
|
||||
let title = [this.props.params.id];
|
||||
const { id } = this.props.params;
|
||||
const title = [id];
|
||||
|
||||
if (this.additionalFor('any')) {
|
||||
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
|
||||
|
|
@ -76,6 +95,12 @@ class HashtagTimeline extends React.PureComponent {
|
|||
}
|
||||
|
||||
_subscribe (dispatch, id, tags = {}, local) {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let any = (tags.any || []).map(tag => tag.value);
|
||||
let all = (tags.all || []).map(tag => tag.value);
|
||||
let none = (tags.none || []).map(tag => tag.value);
|
||||
|
|
@ -95,23 +120,34 @@ class HashtagTimeline extends React.PureComponent {
|
|||
this.disconnects = [];
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
_unload () {
|
||||
const { dispatch } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
|
||||
this._unsubscribe();
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
}
|
||||
|
||||
_load() {
|
||||
const { dispatch } = this.props;
|
||||
const { id, tags, local } = this.props.params;
|
||||
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
dispatch(fetchHashtag(id));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const { dispatch, params } = this.props;
|
||||
const { id, tags, local } = nextProps.params;
|
||||
componentDidMount () {
|
||||
this._load();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params } = this.props;
|
||||
const { id, tags, local } = prevProps.params;
|
||||
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||
this._unsubscribe();
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
this._unload();
|
||||
this._load();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,14 +160,45 @@ class HashtagTimeline extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { id, tags, local } = this.props.params;
|
||||
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
|
||||
const { dispatch, params } = this.props;
|
||||
const { id, tags, local } = params;
|
||||
|
||||
dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
|
||||
}
|
||||
|
||||
handleFollow = () => {
|
||||
const { dispatch, params, tag } = this.props;
|
||||
const { id } = params;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag.get('following')) {
|
||||
dispatch(unfollowHashtag(id));
|
||||
} else {
|
||||
dispatch(followHashtag(id));
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { hasUnread, columnId, multiColumn } = this.props;
|
||||
const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
let followButton;
|
||||
|
||||
if (tag) {
|
||||
const following = tag.get('following');
|
||||
|
||||
followButton = (
|
||||
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
|
||||
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
|
||||
|
|
@ -144,6 +211,7 @@ class HashtagTimeline extends React.PureComponent {
|
|||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={followButton}
|
||||
showBackButton
|
||||
>
|
||||
{columnId && <ColumnSettingsContainer columnId={columnId} />}
|
||||
|
|
@ -157,6 +225,11 @@ class HashtagTimeline extends React.PureComponent {
|
|||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>#{id}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
|
|||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
import classNames from 'classnames';
|
||||
import IconWithBadge from 'mastodon/components/icon_with_badge';
|
||||
import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
|
|
@ -32,6 +34,10 @@ export default @connect(mapStateToProps)
|
|||
@injectIntl
|
||||
class HomeTimeline extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
|
@ -113,16 +119,17 @@ class HomeTimeline extends React.PureComponent {
|
|||
render () {
|
||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
let announcementsButton = null;
|
||||
|
||||
if (hasAnnouncements) {
|
||||
announcementsButton = (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('column-header__button', { 'active': showAnnouncements })}
|
||||
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||
aria-pressed={showAnnouncements ? 'true' : 'false'}
|
||||
onClick={this.handleToggleAnnouncementsClick}
|
||||
>
|
||||
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />
|
||||
|
|
@ -147,14 +154,21 @@ class HomeTimeline extends React.PureComponent {
|
|||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`home_timeline-${columnId}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
timelineId='home'
|
||||
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
{signedIn ? (
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`home_timeline-${columnId}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
timelineId='home'
|
||||
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
) : <NotSignedInIndicator />}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
161
app/javascript/mastodon/features/interaction_modal/index.js
Normal file
161
app/javascript/mastodon/features/interaction_modal/index.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { registrationsOpen } from 'mastodon/initial_state';
|
||||
import { connect } from 'react-redux';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onSignupClick() {
|
||||
dispatch(closeModal());
|
||||
dispatch(openModal('CLOSED_REGISTRATIONS'));
|
||||
},
|
||||
});
|
||||
|
||||
class Copypaste extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.input = c;
|
||||
}
|
||||
|
||||
handleInputClick = () => {
|
||||
this.setState({ copied: false });
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
this.input.setSelectionRange(0, this.input.value.length);
|
||||
}
|
||||
|
||||
handleButtonClick = () => {
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.input.blur();
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value } = this.props;
|
||||
const { copied } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('copypaste', { copied })}>
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setRef}
|
||||
value={value}
|
||||
readOnly
|
||||
onClick={this.handleInputClick}
|
||||
/>
|
||||
|
||||
<button className='button' onClick={this.handleButtonClick}>
|
||||
{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy' defaultMessage='Copy' />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
class InteractionModal extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
displayNameHtml: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
|
||||
onSignupClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleSignupClick = () => {
|
||||
this.props.onSignupClick();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { url, type, displayNameHtml } = this.props;
|
||||
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
|
||||
let title, actionDescription, icon;
|
||||
|
||||
switch(type) {
|
||||
case 'reply':
|
||||
icon = <Icon id='reply' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
|
||||
break;
|
||||
case 'reblog':
|
||||
icon = <Icon id='retweet' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />;
|
||||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favourite {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.' />;
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
|
||||
break;
|
||||
}
|
||||
|
||||
let signupButton;
|
||||
|
||||
if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href='/auth/sign_up' className='button button--block button-tertiary'>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='button button--block button-tertiary' onClick={this.handleSignupClick}>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
|
||||
<p>{actionDescription} <FormattedMessage id='interaction_modal.preamble' defaultMessage="Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one." /></p>
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices'>
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
||||
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||
{signupButton}
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
||||
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Copy and paste this URL into the search field of your favourite Mastodon app or the web interface of your Mastodon server.' /></p>
|
||||
<Copypaste value={url} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { closeOnboarding } from '../../actions/onboarding';
|
||||
import screenHello from '../../../images/screen_hello.svg';
|
||||
import screenFederation from '../../../images/screen_federation.svg';
|
||||
import screenInteractions from '../../../images/screen_interactions.svg';
|
||||
import logoTransparent from '../../../images/logo_transparent.svg';
|
||||
import { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
const FrameWelcome = ({ domain, onNext }) => (
|
||||
<div className='introduction__frame'>
|
||||
<div className='introduction__illustration' style={{ background: `url(${logoTransparent}) no-repeat center center / auto 80%` }}>
|
||||
<img src={screenHello} alt='' />
|
||||
</div>
|
||||
|
||||
<div className='introduction__text introduction__text--centered'>
|
||||
<h3><FormattedMessage id='introduction.welcome.headline' defaultMessage='First steps' /></h3>
|
||||
<p><FormattedMessage id='introduction.welcome.text' defaultMessage="Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name." values={{ domain: <code>{domain}</code> }} /></p>
|
||||
</div>
|
||||
|
||||
<div className='introduction__action'>
|
||||
<button className='button' onClick={onNext}><FormattedMessage id='introduction.welcome.action' defaultMessage="Let's go!" /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
FrameWelcome.propTypes = {
|
||||
domain: PropTypes.string.isRequired,
|
||||
onNext: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const FrameFederation = ({ onNext }) => (
|
||||
<div className='introduction__frame'>
|
||||
<div className='introduction__illustration'>
|
||||
<img src={screenFederation} alt='' />
|
||||
</div>
|
||||
|
||||
<div className='introduction__text introduction__text--columnized'>
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.federation.home.headline' defaultMessage='Home' /></h3>
|
||||
<p><FormattedMessage id='introduction.federation.home.text' defaultMessage='Posts from people you follow will appear in your home feed. You can follow anyone on any server!' /></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.federation.local.headline' defaultMessage='Local' /></h3>
|
||||
<p><FormattedMessage id='introduction.federation.local.text' defaultMessage='Public posts from people on the same server as you will appear in the local timeline.' /></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.federation.federated.headline' defaultMessage='Federated' /></h3>
|
||||
<p><FormattedMessage id='introduction.federation.federated.text' defaultMessage='Public posts from other servers of the fediverse will appear in the federated timeline.' /></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='introduction__action'>
|
||||
<button className='button' onClick={onNext}><FormattedMessage id='introduction.federation.action' defaultMessage='Next' /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
FrameFederation.propTypes = {
|
||||
onNext: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const FrameInteractions = ({ onNext }) => (
|
||||
<div className='introduction__frame'>
|
||||
<div className='introduction__illustration'>
|
||||
<img src={screenInteractions} alt='' />
|
||||
</div>
|
||||
|
||||
<div className='introduction__text introduction__text--columnized'>
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.interactions.reply.headline' defaultMessage='Reply' /></h3>
|
||||
<p><FormattedMessage id='introduction.interactions.reply.text' defaultMessage="You can reply to other people's and your own toots, which will chain them together in a conversation." /></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.interactions.reblog.headline' defaultMessage='Boost' /></h3>
|
||||
<p><FormattedMessage id='introduction.interactions.reblog.text' defaultMessage="You can share other people's toots with your followers by boosting them." /></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3><FormattedMessage id='introduction.interactions.favourite.headline' defaultMessage='Favourite' /></h3>
|
||||
<p><FormattedMessage id='introduction.interactions.favourite.text' defaultMessage='You can save a toot for later, and let the author know that you liked it, by favouriting it.' /></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='introduction__action'>
|
||||
<button className='button' onClick={onNext}><FormattedMessage id='introduction.interactions.action' defaultMessage='Finish toot-orial!' /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
FrameInteractions.propTypes = {
|
||||
onNext: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default @connect(state => ({ domain: state.getIn(['meta', 'domain']) }))
|
||||
class Introduction extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
domain: PropTypes.string.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentIndex: 0,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.pages = [
|
||||
<FrameWelcome domain={this.props.domain} onNext={this.handleNext} />,
|
||||
<FrameFederation onNext={this.handleNext} />,
|
||||
<FrameInteractions onNext={this.handleFinish} />,
|
||||
];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('keyup', this.handleKeyUp);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.addEventListener('keyup', this.handleKeyUp);
|
||||
}
|
||||
|
||||
handleDot = (e) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.setState({ currentIndex: i });
|
||||
}
|
||||
|
||||
handlePrev = () => {
|
||||
this.setState(({ currentIndex }) => ({
|
||||
currentIndex: Math.max(0, currentIndex - 1),
|
||||
}));
|
||||
}
|
||||
|
||||
handleNext = () => {
|
||||
const { pages } = this;
|
||||
|
||||
this.setState(({ currentIndex }) => ({
|
||||
currentIndex: Math.min(currentIndex + 1, pages.length - 1),
|
||||
}));
|
||||
}
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({ currentIndex: index });
|
||||
}
|
||||
|
||||
handleFinish = () => {
|
||||
this.props.dispatch(closeOnboarding());
|
||||
}
|
||||
|
||||
handleKeyUp = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
this.handlePrev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
this.handleNext();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { currentIndex } = this.state;
|
||||
const { pages } = this;
|
||||
|
||||
return (
|
||||
<div className='introduction'>
|
||||
<ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'>
|
||||
{pages.map((page, i) => (
|
||||
<div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div>
|
||||
))}
|
||||
</ReactSwipeableViews>
|
||||
|
||||
<div className='introduction__dots'>
|
||||
{pages.map((_, i) => (
|
||||
<div
|
||||
key={`dot-${i}`}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
data-index={i}
|
||||
onClick={this.handleDot}
|
||||
className={classNames('introduction__dot', { active: i === currentIndex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||
|
|
@ -21,8 +22,13 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
const { intl, multiColumn } = this.props;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='question' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<Column>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='question'
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='keyboard-shortcuts scrollable optionally-scrollable'>
|
||||
<table>
|
||||
<thead>
|
||||
|
|
@ -159,6 +165,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../../components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { connectListStream } from '../../actions/streaming';
|
||||
import { expandListTimeline } from '../../actions/timelines';
|
||||
import { fetchList, deleteList, updateList } from '../../actions/lists';
|
||||
import { openModal } from '../../actions/modal';
|
||||
import MissingIndicator from '../../components/missing_indicator';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { connect } from 'react-redux';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { connectListStream } from 'mastodon/actions/streaming';
|
||||
import { expandListTimeline } from 'mastodon/actions/timelines';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnBackButton from 'mastodon/components/column_back_button';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||
import RadioButton from 'mastodon/components/radio_button';
|
||||
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
|
||||
|
|
@ -177,11 +178,11 @@ class ListTimeline extends React.PureComponent {
|
|||
multiColumn={multiColumn}
|
||||
>
|
||||
<div className='column-settings__row column-header__links'>
|
||||
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}>
|
||||
<button type='button' className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}>
|
||||
<Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
|
||||
</button>
|
||||
|
||||
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}>
|
||||
<button type='button' className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}>
|
||||
<Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -208,6 +209,11 @@ class ListTimeline extends React.PureComponent {
|
|||
emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -65,10 +65,9 @@ class NewListForm extends React.PureComponent {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<IconButton
|
||||
<Button
|
||||
disabled={disabled || !value}
|
||||
icon='plus'
|
||||
title={title}
|
||||
text={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||
import { fetchLists } from '../../actions/lists';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ColumnLink from '../ui/components/column_link';
|
||||
import ColumnSubheading from '../ui/components/column_subheading';
|
||||
import NewListForm from './components/new_list_form';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ColumnLink from 'mastodon/features/ui/components/column_link';
|
||||
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
|
||||
import NewListForm from './components/new_list_form';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
|
|
@ -61,8 +62,8 @@ class Lists extends ImmutablePureComponent {
|
|||
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='list-ul' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='list-ul' multiColumn={multiColumn} showBackButton />
|
||||
|
||||
<NewListForm />
|
||||
|
||||
|
|
@ -76,6 +77,11 @@ class Lists extends ImmutablePureComponent {
|
|||
<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
|||
import AccountContainer from '../../containers/account_container';
|
||||
import { fetchMutes, expandMutes } from '../../actions/mutes';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
|
||||
|
|
@ -72,6 +73,10 @@ class Mutes extends ImmutablePureComponent {
|
|||
<AccountContainer key={id} id={id} defaultAction='mute' />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import { FormattedMessage } from 'react-intl';
|
|||
import ClearColumnButton from './clear_column_button';
|
||||
import GrantPermissionButton from './grant_permission_button';
|
||||
import SettingToggle from './setting_toggle';
|
||||
import { isStaff } from 'mastodon/initial_state';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
|
||||
|
||||
export default class ColumnSettings extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
pushSettings: ImmutablePropTypes.map.isRequired,
|
||||
|
|
@ -17,7 +21,7 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
onRequestNotificationPermission: PropTypes.func,
|
||||
alertsEnabled: PropTypes.bool,
|
||||
browserSupport: PropTypes.bool,
|
||||
browserPermission: PropTypes.bool,
|
||||
browserPermission: PropTypes.string,
|
||||
};
|
||||
|
||||
onPushChange = (path, checked) => {
|
||||
|
|
@ -166,7 +170,7 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isStaff && (
|
||||
{((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
|
||||
<div role='group' aria-labelledby='notifications-admin-sign-up'>
|
||||
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
|
||||
|
||||
|
|
@ -178,6 +182,19 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
|
||||
<div role='group' aria-labelledby='notifications-admin-report'>
|
||||
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import PropTypes from 'prop-types';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
|
@ -42,10 +42,10 @@ class FollowRequest extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
<div className='account__relationship'>
|
||||
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { me } from 'mastodon/initial_state';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import AccountContainer from 'mastodon/containers/account_container';
|
||||
import Report from './report';
|
||||
import FollowRequestContainer from '../containers/follow_request_container';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -21,6 +22,7 @@ const messages = defineMessages({
|
|||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
|
@ -367,11 +369,41 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderAdminReport (notification, account, link) {
|
||||
const { intl, unread, report } = this.props;
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetAccount = report.get('target_account');
|
||||
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
||||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='flag' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { notification } = this.props;
|
||||
const account = notification.get('account');
|
||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||
const link = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
|
||||
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
|
||||
|
||||
switch(notification.get('type')) {
|
||||
case 'follow':
|
||||
|
|
@ -392,6 +424,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderPoll(notification, account);
|
||||
case 'admin.sign_up':
|
||||
return this.renderAdminSignUp(notification, account, link);
|
||||
case 'admin.report':
|
||||
return this.renderAdminReport(notification, account, link);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import AvatarOverlay from 'mastodon/components/avatar_overlay';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
|
||||
other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
|
||||
spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
|
||||
violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Report extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
report: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, hidden, report, account } = this.props;
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{report.get('id')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification__report'>
|
||||
<div className='notification__report__avatar'>
|
||||
<AvatarOverlay account={report.get('target_account')} friend={account} />
|
||||
</div>
|
||||
|
||||
<div className='notification__report__details'>
|
||||
<div>
|
||||
<RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} />
|
||||
<br />
|
||||
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
||||
</div>
|
||||
|
||||
<div className='notification__report__actions'>
|
||||
<a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeGetNotification, makeGetStatus } from '../../../selectors';
|
||||
import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors';
|
||||
import Notification from '../components/notification';
|
||||
import { initBoostModal } from '../../../actions/boosts';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
|
|
@ -18,12 +18,14 @@ import { boostModal } from '../../../initial_state';
|
|||
const makeMapStateToProps = () => {
|
||||
const getNotification = makeGetNotification();
|
||||
const getStatus = makeGetStatus();
|
||||
const getReport = makeGetReport();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const notification = getNotification(state, props.notification, props.accountId);
|
||||
return {
|
||||
notification: notification,
|
||||
status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null,
|
||||
report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import LoadGap from '../../components/load_gap';
|
|||
import Icon from 'mastodon/components/icon';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import NotificationsPermissionBanner from './components/notifications_permission_banner';
|
||||
import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
|
|
@ -56,7 +58,7 @@ const getNotifications = createSelector([
|
|||
const mapStateToProps = state => ({
|
||||
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||
notifications: getNotifications(state),
|
||||
isLoading: state.getIn(['notifications', 'isLoading'], true),
|
||||
isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
|
||||
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
||||
|
|
@ -69,6 +71,10 @@ export default @connect(mapStateToProps)
|
|||
@injectIntl
|
||||
class Notifications extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
columnId: PropTypes.string,
|
||||
notifications: ImmutablePropTypes.list.isRequired,
|
||||
|
|
@ -178,10 +184,11 @@ class Notifications extends React.PureComponent {
|
|||
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
let scrollableContent = null;
|
||||
|
||||
const filterBarContainer = showFilterBar
|
||||
const filterBarContainer = (signedIn && showFilterBar)
|
||||
? (<FilterBarContainer />)
|
||||
: null;
|
||||
|
||||
|
|
@ -211,26 +218,32 @@ class Notifications extends React.PureComponent {
|
|||
|
||||
this.scrollableContent = scrollableContent;
|
||||
|
||||
const scrollContainer = (
|
||||
<ScrollableList
|
||||
scrollKey={`notifications-${columnId}`}
|
||||
trackScroll={!pinned}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
numPending={numPending}
|
||||
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
onLoadMore={this.handleLoadOlder}
|
||||
onLoadPending={this.handleLoadPending}
|
||||
onScrollToTop={this.handleScrollToTop}
|
||||
onScroll={this.handleScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
let scrollContainer;
|
||||
|
||||
if (signedIn) {
|
||||
scrollContainer = (
|
||||
<ScrollableList
|
||||
scrollKey={`notifications-${columnId}`}
|
||||
trackScroll={!pinned}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
numPending={numPending}
|
||||
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
onLoadMore={this.handleLoadOlder}
|
||||
onLoadPending={this.handleLoadPending}
|
||||
onScrollToTop={this.handleScrollToTop}
|
||||
onScroll={this.handleScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
} else {
|
||||
scrollContainer = <NotSignedInIndicator />;
|
||||
}
|
||||
|
||||
let extraButton = null;
|
||||
|
||||
|
|
@ -262,8 +275,14 @@ class Notifications extends React.PureComponent {
|
|||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
{filterBarContainer}
|
||||
{scrollContainer}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class Footer extends ImmutablePureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -67,26 +68,44 @@ class Footer extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleReplyClick = () => {
|
||||
const { dispatch, askReplyConfirmation, intl } = this.props;
|
||||
const { dispatch, askReplyConfirmation, status, intl } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onConfirm: this._performReply,
|
||||
}));
|
||||
if (signedIn) {
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onConfirm: this._performReply,
|
||||
}));
|
||||
} else {
|
||||
this._performReply();
|
||||
}
|
||||
} else {
|
||||
this._performReply();
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
const { dispatch, status } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
if (signedIn) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -97,13 +116,22 @@ class Footer extends ImmutablePureComponent {
|
|||
|
||||
handleReblogClick = e => {
|
||||
const { dispatch, status } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this._performReblog(status);
|
||||
if (signedIn) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this._performReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
|
||||
}
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -154,9 +182,9 @@ class Footer extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='picture-in-picture__footer'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
|||
import StatusList from '../../components/status_list';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
|
||||
|
|
@ -54,6 +55,9 @@ class PinnedStatuses extends ImmutablePureComponent {
|
|||
hasMore={hasMore}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
app/javascript/mastodon/features/privacy_policy/index.js
Normal file
61
app/javascript/mastodon/features/privacy_policy/index.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
|
||||
import Column from 'mastodon/components/column';
|
||||
import api from 'mastodon/api';
|
||||
import Skeleton from 'mastodon/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class PrivacyPolicy extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
content: null,
|
||||
lastUpdated: null,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
api().get('/api/v1/instance/privacy_policy').then(({ data }) => {
|
||||
this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, multiColumn } = this.props;
|
||||
const { isLoading, content, lastUpdated } = this.state;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3>
|
||||
<p><FormattedMessage id='privacy_policy.last_updated' defaultMessage='Last updated {date}' values={{ date: isLoading ? <Skeleton width='10ch' /> : <FormattedDate value={lastUpdated} year='numeric' month='short' day='2-digit' /> }} /></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='privacy-policy__body prose'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { expandPublicTimeline } from '../../actions/timelines';
|
|||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { connectPublicStream } from '../../actions/streaming';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.public', defaultMessage: 'Federated timeline' },
|
||||
|
|
@ -35,6 +37,7 @@ class PublicTimeline extends React.PureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -72,18 +75,30 @@ class PublicTimeline extends React.PureComponent {
|
|||
|
||||
componentDidMount () {
|
||||
const { dispatch, onlyMedia, onlyRemote } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote) {
|
||||
const { dispatch, onlyMedia, onlyRemote } = this.props;
|
||||
|
||||
this.disconnect();
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +138,10 @@ class PublicTimeline extends React.PureComponent {
|
|||
<ColumnSettingsContainer columnId={columnId} />
|
||||
</ColumnHeader>
|
||||
|
||||
<DismissableBanner id='public_timeline'>
|
||||
<FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
|
||||
</DismissableBanner>
|
||||
|
||||
<StatusListContainer
|
||||
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
|
@ -131,6 +150,11 @@ class PublicTimeline extends React.PureComponent {
|
|||
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Column from '../ui/components/column';
|
|||
import ScrollableList from '../../components/scrollable_list';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
|
|
@ -67,7 +68,7 @@ class Reblogs extends ImmutablePureComponent {
|
|||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
|
@ -80,6 +81,10 @@ class Reblogs extends ImmutablePureComponent {
|
|||
<AccountContainer key={id} id={id} withNote={false} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Button from 'mastodon/components/button';
|
||||
import Option from './components/option';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
|
||||
|
|
@ -20,7 +21,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
rules: state.get('rules'),
|
||||
rules: state.getIn(['server', 'server', 'rules'], ImmutableList()),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,25 @@ import DisplayName from 'mastodon/components/display_name';
|
|||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import Option from './option';
|
||||
import MediaAttachments from 'mastodon/components/media_attachments';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export default class StatusCheckBox extends React.PureComponent {
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class StatusCheckBox extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
checked: PropTypes.bool,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleStatusesToggle = (value, checked) => {
|
||||
|
|
@ -23,12 +34,21 @@ export default class StatusCheckBox extends React.PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { status, checked } = this.props;
|
||||
const { status, checked, intl } = this.props;
|
||||
|
||||
if (status.get('reblog')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
const labelComponent = (
|
||||
<div className='status-check-box__status poll__option__text'>
|
||||
<div className='detailed-status__display-name'>
|
||||
|
|
@ -37,7 +57,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
<DisplayName account={status.get('account')} /> · <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> <RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import Button from 'mastodon/components/button';
|
|||
import Option from './components/option';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
rules: state.get('rules'),
|
||||
rules: state.getIn(['server', 'server', 'rules']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { expandHashtagTimeline } from 'mastodon/actions/timelines';
|
||||
import Masonry from 'react-masonry-infinite';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
|
||||
import { debounce } from 'lodash';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
|
||||
const mapStateToProps = (state, { hashtag }) => ({
|
||||
statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
|
||||
isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
|
||||
hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class HashtagTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
hashtag: PropTypes.string.isRequired,
|
||||
local: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
local: false,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch, hashtag, local } = this.props;
|
||||
|
||||
dispatch(expandHashtagTimeline(hashtag, { local }));
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { dispatch, hashtag, local, statusIds } = this.props;
|
||||
const maxId = statusIds.last();
|
||||
|
||||
if (maxId) {
|
||||
dispatch(expandHashtagTimeline(hashtag, { maxId, local }));
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.masonry = c;
|
||||
}
|
||||
|
||||
handleHeightChange = debounce(() => {
|
||||
if (!this.masonry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masonry.forcePack();
|
||||
}, 50)
|
||||
|
||||
render () {
|
||||
const { statusIds, hasMore, isLoading } = this.props;
|
||||
|
||||
const sizes = [
|
||||
{ columns: 1, gutter: 0 },
|
||||
{ mq: '415px', columns: 1, gutter: 10 },
|
||||
{ mq: '640px', columns: 2, gutter: 10 },
|
||||
{ mq: '960px', columns: 3, gutter: 10 },
|
||||
{ mq: '1255px', columns: 3, gutter: 10 },
|
||||
];
|
||||
|
||||
const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
|
||||
|
||||
return (
|
||||
<Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
|
||||
{statusIds.map(statusId => (
|
||||
<div className='statuses-grid__item' key={statusId}>
|
||||
<DetailedStatusContainer
|
||||
id={statusId}
|
||||
compact
|
||||
measureHeight
|
||||
onHeightChange={this.handleHeightChange}
|
||||
/>
|
||||
</div>
|
||||
)).toArray()}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
|
||||
import Masonry from 'react-masonry-infinite';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
|
||||
import { debounce } from 'lodash';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
|
||||
const mapStateToProps = (state, { local }) => {
|
||||
const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
|
||||
|
||||
return {
|
||||
statusIds: timeline.get('items', ImmutableList()),
|
||||
isLoading: timeline.get('isLoading', false),
|
||||
hasMore: timeline.get('hasMore', false),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class PublicTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
local: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._connect();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.local !== this.props.local) {
|
||||
this._connect();
|
||||
}
|
||||
}
|
||||
|
||||
_connect () {
|
||||
const { dispatch, local } = this.props;
|
||||
|
||||
dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { dispatch, statusIds, local } = this.props;
|
||||
const maxId = statusIds.last();
|
||||
|
||||
if (maxId) {
|
||||
dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.masonry = c;
|
||||
}
|
||||
|
||||
handleHeightChange = debounce(() => {
|
||||
if (!this.masonry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masonry.forcePack();
|
||||
}, 50)
|
||||
|
||||
render () {
|
||||
const { statusIds, hasMore, isLoading } = this.props;
|
||||
|
||||
const sizes = [
|
||||
{ columns: 1, gutter: 0 },
|
||||
{ mq: '415px', columns: 1, gutter: 10 },
|
||||
{ mq: '640px', columns: 2, gutter: 10 },
|
||||
{ mq: '960px', columns: 3, gutter: 10 },
|
||||
{ mq: '1255px', columns: 3, gutter: 10 },
|
||||
];
|
||||
|
||||
const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
|
||||
|
||||
return (
|
||||
<Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
|
||||
{statusIds.map(statusId => (
|
||||
<div className='statuses-grid__item' key={statusId}>
|
||||
<DetailedStatusContainer
|
||||
id={statusId}
|
||||
compact
|
||||
measureHeight
|
||||
onHeightChange={this.handleHeightChange}
|
||||
/>
|
||||
</div>
|
||||
)).toArray()}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -5,8 +5,9 @@ import IconButton from '../../../components/icon_button';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { me, isStaff } from '../../../initial_state';
|
||||
import { me } from '../../../initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
|
@ -38,6 +39,7 @@ const messages = defineMessages({
|
|||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
|
|
@ -50,6 +52,7 @@ class ActionBar extends React.PureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -172,36 +175,28 @@ class ActionBar extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleCopy = () => {
|
||||
const url = this.props.status.get('url');
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.textContent = url;
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
const url = this.props.status.get('url');
|
||||
navigator.clipboard.writeText(url);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
|
||||
const mutingConversation = status.get('muted');
|
||||
const account = status.get('account');
|
||||
const writtenByMe = status.getIn(['account', 'id']) === me;
|
||||
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
||||
|
||||
let menu = [];
|
||||
|
||||
if (publicStatus) {
|
||||
if (isRemote) {
|
||||
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
menu.push(null);
|
||||
|
|
@ -215,7 +210,7 @@ class ActionBar extends React.PureComponent {
|
|||
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
// menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
|
||||
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
|
||||
} else {
|
||||
|
|
@ -248,10 +243,10 @@ class ActionBar extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
|
||||
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
|
||||
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,11 +279,12 @@ class ActionBar extends React.PureComponent {
|
|||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
{shareButton}
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer size={18} icon='ellipsis-h' status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
<DropdownMenuContainer size={18} icon='ellipsis-h' disabled={!signedIn} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export default class Card extends React.PureComponent {
|
|||
{revealed && (
|
||||
<div className='status-card__actions'>
|
||||
<div>
|
||||
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
|
||||
<button type='button' onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
|
||||
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
onToggleHidden: PropTypes.func.isRequired,
|
||||
onTranslate: PropTypes.func.isRequired,
|
||||
measureHeight: PropTypes.bool,
|
||||
onHeightChange: PropTypes.func,
|
||||
domain: PropTypes.string.isRequired,
|
||||
|
|
@ -103,6 +104,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
}
|
||||
|
||||
handleTranslate = () => {
|
||||
const { onTranslate, status } = this.props;
|
||||
onTranslate(status);
|
||||
}
|
||||
|
||||
render () {
|
||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
|
|
@ -138,7 +144,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={this.props.showMedia}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
|
|
@ -251,17 +261,22 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
|
||||
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
|
||||
<StatusContent
|
||||
status={status}
|
||||
expanded={!status.get('hidden')}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
/>
|
||||
|
||||
{media}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import { createSelector } from 'reselect';
|
||||
import { fetchStatus } from '../../actions/statuses';
|
||||
import MissingIndicator from '../../components/missing_indicator';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
import ActionBar from './components/action_bar';
|
||||
import Column from '../ui/components/column';
|
||||
|
|
@ -32,6 +33,8 @@ import {
|
|||
editStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
} from '../../actions/statuses';
|
||||
import {
|
||||
unblockAccount,
|
||||
|
|
@ -58,6 +61,7 @@ import { boostModal, deleteModal } from '../../initial_state';
|
|||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
|
|
@ -142,6 +146,7 @@ const makeMapStateToProps = () => {
|
|||
}
|
||||
|
||||
return {
|
||||
isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
|
||||
status,
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
|
|
@ -154,18 +159,37 @@ const makeMapStateToProps = () => {
|
|||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const truncate = (str, num) => {
|
||||
if (str.length > num) {
|
||||
return str.slice(0, num) + '…';
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const titleFromStatus = status => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
const username = status.getIn(['account', 'username']);
|
||||
const prefix = displayName.trim().length === 0 ? username : displayName;
|
||||
const text = status.get('search_index');
|
||||
|
||||
return `${prefix}: "${truncate(text, 30)}"`;
|
||||
};
|
||||
|
||||
export default @injectIntl
|
||||
@connect(makeMapStateToProps)
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
isLoading: PropTypes.bool,
|
||||
ancestorsIds: ImmutablePropTypes.list,
|
||||
descendantsIds: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
|
@ -208,10 +232,21 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleFavouriteClick = (status) => {
|
||||
if (status.get('favourited')) {
|
||||
this.props.dispatch(unfavourite(status));
|
||||
const { dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
} else {
|
||||
this.props.dispatch(favourite(status));
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -224,15 +259,25 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleReplyClick = (status) => {
|
||||
let { askReplyConfirmation, dispatch, intl } = this.props;
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
|
||||
}));
|
||||
const { askReplyConfirmation, dispatch, intl } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
|
||||
}));
|
||||
} else {
|
||||
dispatch(replyCompose(status, this.context.router.history));
|
||||
}
|
||||
} else {
|
||||
dispatch(replyCompose(status, this.context.router.history));
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,14 +286,25 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleReblogClick = (status, e) => {
|
||||
if (status.get('reblogged')) {
|
||||
this.props.dispatch(unreblog(status));
|
||||
} else {
|
||||
if ((e && e.shiftKey) || !boostModal) {
|
||||
this.handleModalReblog(status);
|
||||
const { dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
this.props.dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
|
||||
if ((e && e.shiftKey) || !boostModal) {
|
||||
this.handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal('INTERACTION', {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +395,16 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleTranslate = status => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (status.get('translation')) {
|
||||
dispatch(undoStatusTranslation(status.get('id')));
|
||||
} else {
|
||||
dispatch(translateStatus(status.get('id')));
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockClick = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
const account = status.get('account');
|
||||
|
|
@ -503,9 +569,17 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<Column>
|
||||
|
|
@ -523,6 +597,9 @@ class Status extends ImmutablePureComponent {
|
|||
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||
}
|
||||
|
||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
|
@ -542,7 +619,7 @@ class Status extends ImmutablePureComponent {
|
|||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
|
@ -558,6 +635,7 @@ class Status extends ImmutablePureComponent {
|
|||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
onToggleHidden={this.handleToggleHidden}
|
||||
onTranslate={this.handleTranslate}
|
||||
domain={domain}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
|
|
@ -592,6 +670,11 @@ class Status extends ImmutablePureComponent {
|
|||
{descendants}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromStatus(status)}</title>
|
||||
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable';
|
||||
import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
import Option from 'mastodon/features/report/components/option';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { followAccount } from 'mastodon/actions/accounts';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
const getAccountLanguages = createSelector([
|
||||
(state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
|
||||
state => state.get('statuses'),
|
||||
], (statusIds, statuses) =>
|
||||
new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
acct: state.getIn(['accounts', accountId, 'acct']),
|
||||
availableLanguages: getAccountLanguages(state, accountId),
|
||||
selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
|
||||
onSubmit (languages) {
|
||||
dispatch(followAccount(accountId, { languages }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class SubscribedLanguagesModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
acct: PropTypes.string.isRequired,
|
||||
availableLanguages: ImmutablePropTypes.setOf(PropTypes.string),
|
||||
selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
|
||||
intl: PropTypes.object.isRequired,
|
||||
submit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
languages: preloadedLanguages,
|
||||
};
|
||||
|
||||
state = {
|
||||
selectedLanguages: this.props.selectedLanguages,
|
||||
};
|
||||
|
||||
handleLanguageToggle = (value, checked) => {
|
||||
const { selectedLanguages } = this.state;
|
||||
|
||||
if (checked) {
|
||||
this.setState({ selectedLanguages: selectedLanguages.add(value) });
|
||||
} else {
|
||||
this.setState({ selectedLanguages: selectedLanguages.delete(value) });
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmit = () => {
|
||||
this.props.onSubmit(this.state.selectedLanguages.toArray());
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
renderItem (value) {
|
||||
const language = this.props.languages.find(language => language[0] === value);
|
||||
const checked = this.state.selectedLanguages.includes(value);
|
||||
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={value}
|
||||
name='languages'
|
||||
value={value}
|
||||
label={language[1]}
|
||||
checked={checked}
|
||||
onToggle={this.handleLanguageToggle}
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal report-dialog-modal'>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
|
||||
<FormattedMessage id='subscribed_languages.target' defaultMessage='Change subscribed languages for {target}' values={{ target: <strong>{acct}</strong> }} />
|
||||
</div>
|
||||
|
||||
<div className='report-dialog-modal__container'>
|
||||
<p className='report-dialog-modal__lead'><FormattedMessage id='subscribed_languages.lead' defaultMessage='Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.' /></p>
|
||||
|
||||
<div>
|
||||
{availableLanguages.union(selectedLanguages).delete(null).map(value => this.renderItem(value))}
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<div className='report-dialog-modal__actions'>
|
||||
<Button disabled={is(this.state.selectedLanguages, this.props.selectedLanguages)} onClick={this.handleSubmit}><FormattedMessage id='subscribed_languages.save' defaultMessage='Save changes' /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,10 +2,6 @@ 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';
|
||||
|
||||
|
|
@ -38,32 +34,8 @@ export default class ActionsModal extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
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 noreferrer'>
|
||||
<RelativeTimestamp timestamp={this.props.status.get('created_at')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href={this.props.status.getIn(['account', 'url'])} 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>
|
||||
|
|
|
|||
|
|
@ -97,14 +97,13 @@ class BoostModal extends ImmutablePureComponent {
|
|||
<div className='modal-root__modal boost-modal'>
|
||||
<div className='boost-modal__container'>
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
|
||||
<div className='boost-modal__status-header'>
|
||||
<div className='boost-modal__status-time'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
<div className='status__info'>
|
||||
<a href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</a>
|
||||
|
||||
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,162 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Column from 'mastodon/components/column';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
|
||||
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 {
|
||||
class GIF extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onRetry: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
staticSrc: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: autoPlayGif,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
const { animate } = this.props;
|
||||
|
||||
if (!animate) {
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.props.onRetry();
|
||||
handleMouseLeave = () => {
|
||||
const { animate } = this.props;
|
||||
|
||||
if (!animate) {
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl: { formatMessage } } = this.props;
|
||||
const { src, staticSrc, className, animate } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
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>
|
||||
<img
|
||||
className={className}
|
||||
src={(hovering || animate) ? src : staticSrc}
|
||||
alt=''
|
||||
role='presentation'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleColumnError);
|
||||
class CopyButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
const { copied } = this.state;
|
||||
|
||||
return (
|
||||
<Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @injectIntl
|
||||
class BundleColumnError extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
errorType: PropTypes.oneOf(['routing', 'network', 'error']),
|
||||
onRetry: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
stacktrace: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
errorType: 'routing',
|
||||
};
|
||||
|
||||
handleRetry = () => {
|
||||
const { onRetry } = this.props;
|
||||
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { errorType, multiColumn, stacktrace } = this.props;
|
||||
|
||||
let title, body;
|
||||
|
||||
switch(errorType) {
|
||||
case 'routing':
|
||||
title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
|
||||
body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
|
||||
break;
|
||||
case 'network':
|
||||
title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
|
||||
body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
|
||||
break;
|
||||
case 'error':
|
||||
title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
|
||||
body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<div className='error-column'>
|
||||
<GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
|
||||
|
||||
<div className='error-column__message'>
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
|
||||
<div className='error-column__message__actions'>
|
||||
{errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
|
||||
{errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
|
||||
<Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,41 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const ColumnLink = ({ icon, text, to, href, method, badge }) => {
|
||||
const ColumnLink = ({ icon, text, to, href, method, badge, transparent, ...other }) => {
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent });
|
||||
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
|
||||
const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className='column-link' data-method={method}>
|
||||
<Icon id={icon} fixedWidth className='column-link__icon' />
|
||||
{text}
|
||||
<a href={href} className={className} data-method={method} title={text} {...other}>
|
||||
{iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Link to={to} className='column-link'>
|
||||
<Icon id={icon} fixedWidth className='column-link__icon' />
|
||||
{text}
|
||||
<NavLink to={to} className={className} title={text} {...other}>
|
||||
{iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
</Link>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ColumnLink.propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
to: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
method: PropTypes.string,
|
||||
badge: PropTypes.node,
|
||||
transparent: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ColumnLink;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default class ColumnLoading extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
icon: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -18,10 +19,11 @@ export default class ColumnLoading extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render() {
|
||||
let { title, icon } = this.props;
|
||||
let { title, icon, multiColumn } = this.props;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder />
|
||||
<ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder />
|
||||
<div className='scrollable' />
|
||||
</Column>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
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 TabsBar, { links, getIndex, getLink } from './tabs_bar';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import ColumnLoading from './column_loading';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
|
|
@ -27,10 +19,8 @@ import {
|
|||
ListTimeline,
|
||||
Directory,
|
||||
} from '../../ui/util/async-components';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ComposePanel from './compose_panel';
|
||||
import NavigationPanel from './navigation_panel';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import { scrollRight } from '../../../scroll';
|
||||
|
||||
|
|
@ -49,41 +39,26 @@ const componentMap = {
|
|||
'DIRECTORY': Directory,
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
});
|
||||
|
||||
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/explore|^\/getting-started|^\/start/);
|
||||
|
||||
export default @(component => injectIntl(component, { withRef: true }))
|
||||
class ColumnsArea extends ImmutablePureComponent {
|
||||
export default class ColumnsArea extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
columns: ImmutablePropTypes.list.isRequired,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
singleColumn: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
// Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
|
||||
// Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
|
||||
|
||||
state = {
|
||||
shouldAnimate: false,
|
||||
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
||||
}
|
||||
|
||||
componentWillReceiveProps() {
|
||||
if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) {
|
||||
this.setState({ shouldAnimate: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
|
|
@ -98,10 +73,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
this.setState({ renderComposePanel: !this.mediaQuery.matches });
|
||||
}
|
||||
|
||||
this.lastIndex = getIndex(this.context.router.history.location.pathname);
|
||||
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
|
||||
|
||||
this.setState({ shouldAnimate: true });
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps) {
|
||||
|
|
@ -114,13 +86,6 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
}
|
||||
|
||||
const newIndex = getIndex(this.context.router.history.location.pathname);
|
||||
|
||||
if (this.lastIndex !== newIndex) {
|
||||
this.lastIndex = newIndex;
|
||||
this.setState({ shouldAnimate: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
|
|
@ -148,31 +113,6 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
this.setState({ renderComposePanel: !e.matches });
|
||||
}
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.pendingIndex = index;
|
||||
|
||||
const nextLinkTranslationId = links[index].props['data-preview-title-id'];
|
||||
const currentLinkSelector = '.tabs-bar__link.active';
|
||||
const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
|
||||
|
||||
// HACK: Remove the active class from the current link and set it to the next one
|
||||
// React-router does this for us, but too late, feeling laggy.
|
||||
document.querySelector(currentLinkSelector).classList.remove('active');
|
||||
document.querySelector(nextLinkSelector).classList.add('active');
|
||||
|
||||
if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
|
||||
this.context.router.history.push(getLink(this.pendingIndex));
|
||||
this.pendingIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleAnimationEnd = () => {
|
||||
if (typeof this.pendingIndex === 'number') {
|
||||
this.context.router.history.push(getLink(this.pendingIndex));
|
||||
this.pendingIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleWheel = () => {
|
||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
||||
return;
|
||||
|
|
@ -185,47 +125,19 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
this.node = node;
|
||||
}
|
||||
|
||||
renderView = (link, index) => {
|
||||
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
||||
const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
|
||||
const icon = link.props['data-preview-icon'];
|
||||
|
||||
const view = (index === columnIndex) ?
|
||||
React.cloneElement(this.props.children) :
|
||||
<ColumnLoading title={title} icon={icon} />;
|
||||
|
||||
return (
|
||||
<div className='columns-area columns-area--mobile' key={index}>
|
||||
{view}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoading = columnId => () => {
|
||||
return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
|
||||
return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading multiColumn />;
|
||||
}
|
||||
|
||||
renderError = (props) => {
|
||||
return <BundleColumnError {...props} />;
|
||||
return <BundleColumnError multiColumn errorType='network' {...props} />;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { columns, children, singleColumn, isModalOpen, intl } = this.props;
|
||||
const { shouldAnimate, renderComposePanel } = this.state;
|
||||
|
||||
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
||||
const { columns, children, singleColumn, isModalOpen } = this.props;
|
||||
const { renderComposePanel } = this.state;
|
||||
|
||||
if (singleColumn) {
|
||||
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
|
||||
|
||||
const content = columnIndex !== -1 ? (
|
||||
<ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
|
||||
{links.map(this.renderView)}
|
||||
</ReactSwipeableViews>
|
||||
) : (
|
||||
<div key='content' className='columns-area columns-area--mobile'>{children}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='columns-area__panels'>
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
|
||||
|
|
@ -234,9 +146,9 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`columns-area__panels__main ${floatingActionButton && 'with-fab'}`}>
|
||||
<TabsBar key='tabs' />
|
||||
{content}
|
||||
<div className='columns-area__panels__main'>
|
||||
<div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div>
|
||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
</div>
|
||||
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
|
||||
|
|
@ -244,8 +156,6 @@ class ColumnsArea extends ImmutablePureComponent {
|
|||
<NavigationPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{floatingActionButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,30 +5,62 @@ import SearchContainer from 'mastodon/features/compose/containers/search_contain
|
|||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
|
||||
import LinkFooter from './link_footer';
|
||||
import { changeComposing } from 'mastodon/actions/compose';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
|
||||
export default @connect()
|
||||
class ComposePanel extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
this.props.dispatch(changeComposing(true));
|
||||
const { dispatch } = this.props;
|
||||
dispatch(changeComposing(true));
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.props.dispatch(changeComposing(false));
|
||||
const { dispatch } = this.props;
|
||||
dispatch(changeComposing(false));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(mountCompose());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(unmountCompose());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
return (
|
||||
<div className='compose-panel' onFocus={this.onFocus}>
|
||||
<SearchContainer openInRoute />
|
||||
<NavigationContainer onClose={this.onBlur} />
|
||||
<ComposeFormContainer singleColumn />
|
||||
<LinkFooter withHotkeys />
|
||||
|
||||
{!signedIn && (
|
||||
<React.Fragment>
|
||||
<ServerBanner />
|
||||
<div className='flex-spacer' />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<React.Fragment>
|
||||
<NavigationContainer onClose={this.onBlur} />
|
||||
<ComposeFormContainer singleColumn />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<LinkFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { disabledAccountId, movedToAccountId, domain } from 'mastodon/initial_state';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
|
||||
const messages = defineMessages({
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
|
||||
movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
closeWhenConfirm: false,
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
class DisabledAccountBanner extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
disabledAcct: PropTypes.string.isRequired,
|
||||
movedToAcct: PropTypes.string,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogOutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { disabledAcct, movedToAcct } = this.props;
|
||||
|
||||
const disabledAccountLink = (
|
||||
<Link to={`/@${disabledAcct}`}>
|
||||
{disabledAcct}@{domain}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p>
|
||||
{movedToAcct ? (
|
||||
<FormattedMessage
|
||||
id='moved_to_account_banner.text'
|
||||
defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.'
|
||||
values={{
|
||||
disabledAccount: disabledAccountLink,
|
||||
movedToAccount: <Link to={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`}</Link>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='disabled_account_banner.text'
|
||||
defaultMessage='Your account {disabledAccount} is currently disabled.'
|
||||
values={{
|
||||
disabledAccount: disabledAccountLink,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<a href='/auth/edit' className='button button--block'>
|
||||
<FormattedMessage id='disabled_account_banner.account_settings' defaultMessage='Account settings' />
|
||||
</a>
|
||||
<button type='button' className='button button--block button-tertiary' onClick={this.handleLogOutClick}>
|
||||
<FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { title } from 'mastodon/initial_state';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
unread: state.getIn(['missed_updates', 'unread']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class DocumentTitle extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
unread: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._sideEffects();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._sideEffects();
|
||||
}
|
||||
|
||||
_sideEffects () {
|
||||
const { unread } = this.props;
|
||||
const future_preference = false;
|
||||
|
||||
if (future_preference === true && unread > 99) {
|
||||
document.title = `(*) ${title}`;
|
||||
} else if (future_preference ===true && unread > 0) {
|
||||
document.title = `(${unread}) ${title}`;
|
||||
} else {
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
134
app/javascript/mastodon/features/ui/components/filter_modal.js
Normal file
134
app/javascript/mastodon/features/ui/components/filter_modal.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchStatus } from 'mastodon/actions/statuses';
|
||||
import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import SelectFilter from 'mastodon/features/filters/select_filter';
|
||||
import AddedToFilter from 'mastodon/features/filters/added_to_filter';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export default @connect(undefined)
|
||||
@injectIntl
|
||||
class FilterModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
contextType: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
step: 'select',
|
||||
filterId: null,
|
||||
isSubmitting: false,
|
||||
isSubmitted: false,
|
||||
};
|
||||
|
||||
handleNewFilterSuccess = (result) => {
|
||||
this.handleSelectFilter(result.id);
|
||||
};
|
||||
|
||||
handleSuccess = () => {
|
||||
const { dispatch, statusId } = this.props;
|
||||
dispatch(fetchStatus(statusId, true));
|
||||
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
|
||||
};
|
||||
|
||||
handleFail = () => {
|
||||
this.setState({ isSubmitting: false });
|
||||
};
|
||||
|
||||
handleNextStep = step => {
|
||||
this.setState({ step });
|
||||
};
|
||||
|
||||
handleSelectFilter = (filterId) => {
|
||||
const { dispatch, statusId } = this.props;
|
||||
|
||||
this.setState({ isSubmitting: true, filterId });
|
||||
|
||||
dispatch(createFilterStatus({
|
||||
filter_id: filterId,
|
||||
status_id: statusId,
|
||||
}, this.handleSuccess, this.handleFail));
|
||||
};
|
||||
|
||||
handleNewFilter = (title) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
this.setState({ isSubmitting: true });
|
||||
|
||||
dispatch(createFilter({
|
||||
title,
|
||||
context: ['home', 'notifications', 'public', 'thread', 'account'],
|
||||
action: 'warn',
|
||||
}, this.handleNewFilterSuccess, this.handleFail));
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(fetchFilters());
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
intl,
|
||||
statusId,
|
||||
contextType,
|
||||
onClose,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
step,
|
||||
filterId,
|
||||
} = this.state;
|
||||
|
||||
let stepComponent;
|
||||
|
||||
switch(step) {
|
||||
case 'select':
|
||||
stepComponent = (
|
||||
<SelectFilter
|
||||
contextType={contextType}
|
||||
onSelectFilter={this.handleSelectFilter}
|
||||
onNewFilter={this.handleNewFilter}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'create':
|
||||
stepComponent = null;
|
||||
break;
|
||||
case 'submitted':
|
||||
stepComponent = (
|
||||
<AddedToFilter
|
||||
contextType={contextType}
|
||||
filterId={filterId}
|
||||
statusId={statusId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal report-dialog-modal'>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
|
||||
<FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
|
||||
</div>
|
||||
|
||||
<div className='report-dialog-modal__container'>
|
||||
{stepComponent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -176,14 +176,14 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onChangeDescription(e.target.value);
|
||||
this.handleSubmit();
|
||||
this.handleSubmit(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +313,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
</div>
|
||||
|
||||
<div className='report-modal__container'>
|
||||
<div className='report-modal__comment'>
|
||||
<form className='report-modal__comment' onSubmit={this.handleSubmit} >
|
||||
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
||||
|
||||
{thumbnailable && (
|
||||
|
|
@ -361,12 +361,23 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
</div>
|
||||
|
||||
<div className='setting-text__toolbar'>
|
||||
<button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
|
||||
<button
|
||||
type='button'
|
||||
disabled={detecting || media.get('type') !== 'image' || is_changing_upload}
|
||||
className='link-button'
|
||||
onClick={this.handleTextDetection}
|
||||
>
|
||||
<FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' />
|
||||
</button>
|
||||
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
||||
</div>
|
||||
|
||||
<Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} />
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload}
|
||||
text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className='focal-point-modal__content'>
|
||||
{focals && (
|
||||
|
|
|
|||
|
|
@ -2,22 +2,27 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
import ColumnLink from 'mastodon/features/ui/components/column_link';
|
||||
import IconWithBadge from 'mastodon/components/icon_with_badge';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
|
||||
});
|
||||
|
||||
export default @withRouter
|
||||
export default @injectIntl
|
||||
@connect(mapStateToProps)
|
||||
class FollowRequestsNavLink extends React.Component {
|
||||
class FollowRequestsColumnLink extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
|
|
@ -27,13 +32,20 @@ class FollowRequestsNavLink extends React.Component {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { count } = this.props;
|
||||
const { count, intl } = this.props;
|
||||
|
||||
if (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>;
|
||||
return (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/follow_requests'
|
||||
icon={<IconWithBadge className='column-link__icon' id='user-plus' count={count} />}
|
||||
text={intl.formatMessage(messages.text)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
62
app/javascript/mastodon/features/ui/components/header.js
Normal file
62
app/javascript/mastodon/features/ui/components/header.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import Logo from 'mastodon/components/logo';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { registrationsOpen, me } from 'mastodon/initial_state';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const Account = connect(state => ({
|
||||
account: state.getIn(['accounts', me]),
|
||||
}))(({ account }) => (
|
||||
<Link to={`/@${account.get('acct')}`} title={account.get('acct')}>
|
||||
<Avatar account={account} size={35} />
|
||||
</Link>
|
||||
));
|
||||
|
||||
export default @withRouter
|
||||
class Header extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { signedIn } = this.context.identity;
|
||||
const { location } = this.props;
|
||||
|
||||
let content;
|
||||
|
||||
if (signedIn) {
|
||||
content = (
|
||||
<>
|
||||
{location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish' defaultMessage='Publish' /></Link>}
|
||||
<Account />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||
<a href={registrationsOpen ? '/auth/sign_up' : 'https://joinmastodon.org/servers'} className='button button-tertiary'><FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /></a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='ui__header'>
|
||||
<Link to='/' className='ui__header__logo'><Logo /></Link>
|
||||
|
||||
<div className='ui__header__links'>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { LoadingBar } from 'react-redux-loading-bar';
|
||||
import ZoomableImage from './zoomable_image';
|
||||
|
||||
export default class ImageLoader extends React.PureComponent {
|
||||
export default class ImageLoader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
alt: PropTypes.string,
|
||||
|
|
@ -43,7 +43,7 @@ export default class ImageLoader extends React.PureComponent {
|
|||
this.loadImage(this.props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (this.props.src !== nextProps.src) {
|
||||
this.loadImage(nextProps);
|
||||
}
|
||||
|
|
@ -139,14 +139,18 @@ export default class ImageLoader extends React.PureComponent {
|
|||
|
||||
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}
|
||||
/>
|
||||
<>
|
||||
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||
<LoadingBar className='loading-bar' loading={1} />
|
||||
</div>
|
||||
<canvas
|
||||
className='image-loader__preview-canvas'
|
||||
ref={this.setCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import ImageLoader from './image_loader';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class ImageModal extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
navigationHidden: false,
|
||||
};
|
||||
|
||||
toggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, src, alt, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
|
||||
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} >
|
||||
<ImageLoader
|
||||
src={src}
|
||||
width={400}
|
||||
height={400}
|
||||
alt={alt}
|
||||
onClick={this.toggleNavigation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue