Merge tag 'v2.8.2'
This commit is contained in:
commit
fc50cffd44
727 changed files with 27044 additions and 9474 deletions
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'><i className='fa fa-link' /> {filename(displayUrl)}</a>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'><Icon id='link' /> {filename(displayUrl)}</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
@ -36,7 +37,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='attachment-list'>
|
||||
<div className='attachment-list__icon'>
|
||||
<i className='fa fa-link' />
|
||||
<Icon id='link' />
|
||||
</div>
|
||||
|
||||
<ul className='attachment-list__list'>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export default class ColumnBackButton extends React.PureComponent {
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export default class ColumnBackButton extends React.PureComponent {
|
|||
render () {
|
||||
return (
|
||||
<button onClick={this.handleClick} className='column-back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ColumnBackButton from './column_back_button';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export default class ColumnBackButtonSlim extends ColumnBackButton {
|
||||
|
||||
|
|
@ -8,7 +9,7 @@ export default class ColumnBackButtonSlim extends ColumnBackButton {
|
|||
return (
|
||||
<div className='column-back-button--slim'>
|
||||
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
|
|
@ -109,22 +110,22 @@ class ColumnHeader extends React.PureComponent {
|
|||
}
|
||||
|
||||
if (multiColumn && pinned) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
||||
|
||||
moveButtons = (
|
||||
<div key='move-buttons' className='column-header__setting-arrows'>
|
||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button>
|
||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button>
|
||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
|
||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
|
||||
</div>
|
||||
);
|
||||
} else if (multiColumn) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||
}
|
||||
|
||||
if (!pinned && (multiColumn || showBackButton)) {
|
||||
backButton = (
|
||||
<button onClick={this.handleBackClick} className='column-header__back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
|
|
@ -140,7 +141,7 @@ class ColumnHeader extends React.PureComponent {
|
|||
}
|
||||
|
||||
if (children || multiColumn) {
|
||||
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
|
||||
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
|
||||
}
|
||||
|
||||
const hasTitle = icon && title;
|
||||
|
|
@ -150,7 +151,7 @@ class ColumnHeader extends React.PureComponent {
|
|||
<h1 className={buttonClassName}>
|
||||
{hasTitle && (
|
||||
<button onClick={this.handleTitleClick}>
|
||||
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class DisplayName extends React.PureComponent {
|
|||
suffix = `+${others.size - 2}`;
|
||||
}
|
||||
} else {
|
||||
if (others) {
|
||||
if (others && others.size > 0) {
|
||||
account = others.first();
|
||||
} else {
|
||||
account = this.props.account;
|
||||
|
|
|
|||
39
app/javascript/mastodon/components/error_boundary.js
Normal file
39
app/javascript/mastodon/components/error_boundary.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import illustration from '../../images/elephant_ui_disappointed.svg';
|
||||
|
||||
export default class ErrorBoundary extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
hasError: false,
|
||||
stackTrace: undefined,
|
||||
componentStack: undefined,
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
stackTrace: error.stack,
|
||||
componentStack: info && info.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError } = this.state;
|
||||
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img src={illustration} alt='' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
21
app/javascript/mastodon/components/icon.js
Normal file
21
app/javascript/mastodon/components/icon.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Icon extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { id, className, fixedWidth, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import Motion from '../features/ui/util/optional_motion';
|
|||
import spring from 'react-motion/lib/spring';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export default class IconButton extends React.PureComponent {
|
||||
|
||||
|
|
@ -85,8 +86,9 @@ export default class IconButton extends React.PureComponent {
|
|||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -103,8 +105,9 @@ export default class IconButton extends React.PureComponent {
|
|||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
|
||||
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
)}
|
||||
</Motion>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
|
|
@ -25,7 +26,7 @@ class LoadGap extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
|
||||
<i className='fa fa-ellipsis-h' />
|
||||
<Icon id='ellipsis-h' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif, displayMedia } from '../initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
|
|
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
|
|||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayWidth: PropTypes.number,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
|
|||
size: 1,
|
||||
};
|
||||
|
||||
state = {
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
|
|
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
|
|||
e.stopPropagation();
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
if (pixels) {
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size, standalone, displayWidth } = this.props;
|
||||
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
|
|
@ -116,12 +154,20 @@ class Item extends React.PureComponent {
|
|||
|
||||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||
|
||||
|
|
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
|
|||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
|
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
{thumbnail}
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -242,7 +290,7 @@ class MediaGallery extends React.PureComponent {
|
|||
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
let children;
|
||||
let children, spoilerButton;
|
||||
|
||||
const style = {};
|
||||
|
||||
|
|
@ -256,35 +304,28 @@ class MediaGallery extends React.PureComponent {
|
|||
style.height = height;
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
let warning;
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
|
||||
}
|
||||
|
||||
children = (
|
||||
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
if (visible) {
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||
} else {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
|
|
|||
140
app/javascript/mastodon/components/poll.js
Normal file
140
app/javascript/mastodon/components/poll.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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 classNames from 'classnames';
|
||||
import { vote, fetchPoll } from 'mastodon/actions/polls';
|
||||
import Motion from 'mastodon/features/ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
|
||||
});
|
||||
|
||||
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
|
||||
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
export default @injectIntl
|
||||
class Poll extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
poll: ImmutablePropTypes.map,
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
selected: {},
|
||||
};
|
||||
|
||||
handleOptionChange = e => {
|
||||
const { target: { value } } = e;
|
||||
|
||||
if (this.props.poll.get('multiple')) {
|
||||
const tmp = { ...this.state.selected };
|
||||
if (tmp[value]) {
|
||||
delete tmp[value];
|
||||
} else {
|
||||
tmp[value] = true;
|
||||
}
|
||||
this.setState({ selected: tmp });
|
||||
} else {
|
||||
const tmp = {};
|
||||
tmp[value] = true;
|
||||
this.setState({ selected: tmp });
|
||||
}
|
||||
};
|
||||
|
||||
handleVote = () => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
|
||||
};
|
||||
|
||||
renderOption (option, optionIndex) {
|
||||
const { poll, disabled } = this.props;
|
||||
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
|
||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
|
||||
const active = !!this.state.selected[`${optionIndex}`];
|
||||
const showResults = poll.get('voted') || poll.get('expired');
|
||||
|
||||
let titleEmojified = option.get('title_emojified');
|
||||
if (!titleEmojified) {
|
||||
const emojiMap = makeEmojiMap(poll);
|
||||
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={option.get('title')}>
|
||||
{showResults && (
|
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ width }) =>
|
||||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
|
||||
}
|
||||
</Motion>
|
||||
)}
|
||||
|
||||
<label className={classNames('poll__text', { selectable: !showResults })}>
|
||||
<input
|
||||
name='vote-options'
|
||||
type={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
value={optionIndex}
|
||||
checked={active}
|
||||
onChange={this.handleOptionChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
|
||||
{showResults && <span className='poll__number'>{Math.round(percent)}%</span>}
|
||||
|
||||
<span dangerouslySetInnerHTML={{ __html: titleEmojified }} />
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { poll, intl } = this.props;
|
||||
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
||||
const showResults = poll.get('voted') || poll.get('expired');
|
||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
||||
|
||||
return (
|
||||
<div className='poll'>
|
||||
<ul>
|
||||
{poll.get('options').map((option, i) => this.renderOption(option, i))}
|
||||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
||||
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
|
||||
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />
|
||||
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ const messages = defineMessages({
|
|||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
|
||||
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
|
||||
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
|
||||
moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
|
||||
seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
|
||||
minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
|
||||
hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
|
||||
days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
|
|
@ -86,6 +91,26 @@ export const timeAgoString = (intl, date, now, year) => {
|
|||
return relativeTime;
|
||||
};
|
||||
|
||||
const timeRemainingString = (intl, date, now) => {
|
||||
const delta = date.getTime() - now;
|
||||
|
||||
let relativeTime;
|
||||
|
||||
if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(messages.moments_remaining);
|
||||
} else if (delta < MINUTE) {
|
||||
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
|
||||
} else if (delta < HOUR) {
|
||||
relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
|
||||
} else if (delta < DAY) {
|
||||
relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
|
||||
} else {
|
||||
relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
|
||||
}
|
||||
|
||||
return relativeTime;
|
||||
};
|
||||
|
||||
export default @injectIntl
|
||||
class RelativeTimestamp extends React.Component {
|
||||
|
||||
|
|
@ -93,6 +118,7 @@ class RelativeTimestamp extends React.Component {
|
|||
intl: PropTypes.object.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
futureDate: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
|
@ -145,10 +171,10 @@ class RelativeTimestamp extends React.Component {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { timestamp, intl, year } = this.props;
|
||||
const { timestamp, intl, year, futureDate } = this.props;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const relativeTime = timeAgoString(intl, date, this.state.now, year);
|
||||
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import PollContainer from 'mastodon/containers/poll_container';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
|
|
@ -249,7 +251,7 @@ class Status extends ImmutablePureComponent {
|
|||
if (featured) {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-thumb-tack status__prepend-icon' /></div>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -258,7 +260,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -269,8 +271,10 @@ class Status extends ImmutablePureComponent {
|
|||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
if (status.get('poll')) {
|
||||
media = <PollContainer pollId={status.get('poll')} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (this.props.muted) {
|
||||
media = (
|
||||
<AttachmentList
|
||||
compact
|
||||
|
|
@ -285,6 +289,7 @@ class Status extends ImmutablePureComponent {
|
|||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
width={this.props.cachedMediaWidth}
|
||||
|
|
@ -325,7 +330,7 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (otherAccounts) {
|
||||
if (otherAccounts && otherAccounts.size > 0) {
|
||||
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
|
||||
} else if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={48} />;
|
||||
|
|
@ -347,7 +352,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} ref={this.handleRef}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
{prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const messages = defineMessages({
|
|||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
|
||||
});
|
||||
|
||||
const obfuscatedCount = count => {
|
||||
|
|
@ -138,7 +139,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
this.props.onBlock(this.props.status.get('account'));
|
||||
this.props.onBlock(this.props.status);
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
|
|
@ -157,6 +158,25 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss } = this.props;
|
||||
|
||||
|
|
@ -172,6 +192,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
|
||||
if (publicStatus) {
|
||||
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +221,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { isRtl } from '../rtl';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import Permalink from './permalink';
|
||||
import classnames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||
|
||||
|
|
@ -160,7 +161,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
|
||||
const readMoreButton = (
|
||||
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><i className='fa fa-fw fa-angle-right' />
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
|
||||
</button>
|
||||
);
|
||||
|
||||
|
|
@ -182,14 +183,14 @@ export default class StatusContent extends React.PureComponent {
|
|||
return (
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
||||
<span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} />
|
||||
{' '}
|
||||
<button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
|
|
@ -201,6 +202,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
className={classNames}
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.get('language')}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
/>,
|
||||
|
|
@ -219,6 +221,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
className='status__content'
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.get('language')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
||||
}, 300, { leading: true })
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue