Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack * Add react-intl-translations-manager * Do not minify in development, add offline-plugin for ServiceWorker background cache updates * Adjust tests and dependencies * Fix production deployments * Fix tests * More optimizations * Improve travis cache for npm stuff * Re-run travis * Add back support for custom.scss as before * Remove offline-plugin and babili * Fix issue with Immutable.List().unshift(...values) not working as expected * Make travis load schema instead of running all migrations in sequence * Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in <UI /> * Add react definitions to places that use JSX * Add Procfile.dev for running rails, webpack and streaming API at the same time
This commit is contained in:
parent
26bc591572
commit
f5bf5ebb82
343 changed files with 5299 additions and 2081 deletions
93
app/javascript/mastodon/components/account.js
Normal file
93
app/javascript/mastodon/components/account.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import Permalink from './permalink';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }
|
||||
});
|
||||
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleFollow = this.handleFollow.bind(this);
|
||||
this.handleBlock = this.handleBlock.bind(this);
|
||||
this.handleMute = this.handleMute.bind(this);
|
||||
}
|
||||
|
||||
handleFollow () {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock () {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute () {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, me, intl } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
||||
} else {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Account.propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: PropTypes.number.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
export default injectIntl(Account);
|
33
app/javascript/mastodon/components/attachment_list.js
Normal file
33
app/javascript/mastodon/components/attachment_list.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
|
||||
|
||||
class AttachmentList extends React.PureComponent {
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
|
||||
return (
|
||||
<div className='attachment-list'>
|
||||
<div className='attachment-list__icon'>
|
||||
<i className='fa fa-link' />
|
||||
</div>
|
||||
|
||||
<ul className='attachment-list__list'>
|
||||
{media.map(attachment =>
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentList.propTypes = {
|
||||
media: ImmutablePropTypes.list.isRequired
|
||||
};
|
||||
|
||||
export default AttachmentList;
|
213
app/javascript/mastodon/components/autosuggest_textarea.js
Normal file
213
app/javascript/mastodon/components/autosuggest_textarea.js
Normal file
|
@ -0,0 +1,213 @@
|
|||
import React from 'react';
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from '../rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
suggestionsHidden: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0
|
||||
};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
this.onSuggestionClick = this.onSuggestionClick.bind(this);
|
||||
this.setTextarea = this.setTextarea.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
}
|
||||
|
||||
onChange (e) {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
// auto-resize textarea
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown (e) {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (!suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur () {
|
||||
// If we hide the suggestions immediately, then this will prevent the
|
||||
// onClick for the suggestions themselves from firing.
|
||||
// Setting a short window for that to take place before hiding the
|
||||
// suggestions ensures that can't happen.
|
||||
setTimeout(() => {
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onSuggestionClick (suggestion, e) {
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea (c) {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
onPaste (e) {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files)
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.textarea.style.height = 'auto';
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
|
||||
const { suggestionsHidden, selectedSuggestion } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<textarea
|
||||
ref={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={true}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={suggestion}
|
||||
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
|
||||
onClick={this.onSuggestionClick.bind(this, suggestion)}>
|
||||
<AutosuggestAccountContainer id={suggestion} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
AutosuggestTextarea.propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AutosuggestTextarea;
|
68
app/javascript/mastodon/components/avatar.js
Normal file
68
app/javascript/mastodon/components/avatar.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Avatar extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
hovering: false
|
||||
};
|
||||
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
}
|
||||
|
||||
handleMouseEnter () {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
|
||||
handleMouseLeave () {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { src, size, staticSrc, animate } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
const style = {
|
||||
...this.props.style,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundSize: `${size}px ${size}px`
|
||||
};
|
||||
|
||||
if (hovering || animate) {
|
||||
style.backgroundImage = `url(${src})`;
|
||||
} else {
|
||||
style.backgroundImage = `url(${staticSrc})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='account__avatar'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Avatar.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
staticSrc: PropTypes.string,
|
||||
size: PropTypes.number.isRequired,
|
||||
style: PropTypes.object,
|
||||
animate: PropTypes.bool
|
||||
};
|
||||
|
||||
Avatar.defaultProps = {
|
||||
animate: false
|
||||
};
|
||||
|
||||
export default Avatar;
|
50
app/javascript/mastodon/components/button.js
Normal file
50
app/javascript/mastodon/components/button.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Button extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick (e) {
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
display: this.props.block ? 'block' : 'inline-block',
|
||||
width: this.props.block ? '100%' : 'auto',
|
||||
padding: `0 ${this.props.size / 2.25}px`,
|
||||
height: `${this.props.size}px`,
|
||||
lineHeight: `${this.props.size}px`
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
text: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
style: PropTypes.object,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
size: 36
|
||||
};
|
||||
|
||||
export default Button;
|
21
app/javascript/mastodon/components/collapsable.js
Normal file
21
app/javascript/mastodon/components/collapsable.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Collapsable = ({ fullHeight, isVisible, children }) => (
|
||||
<Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
|
||||
{({ opacity, height }) =>
|
||||
<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
|
||||
Collapsable.propTypes = {
|
||||
fullHeight: PropTypes.number.isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Collapsable;
|
32
app/javascript/mastodon/components/column_back_button.js
Normal file
32
app/javascript/mastodon/components/column_back_button.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class ColumnBackButton extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
if (window.history && window.history.length === 1) this.context.router.push("/");
|
||||
else this.context.router.goBack();
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon'/>
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
ColumnBackButton.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
export default ColumnBackButton;
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class ColumnBackButtonSlim extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
this.context.router.push('/');
|
||||
}
|
||||
|
||||
render () {
|
||||
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' />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnBackButtonSlim.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
export default ColumnBackButtonSlim;
|
57
app/javascript/mastodon/components/column_collapsable.js
Normal file
57
app/javascript/mastodon/components/column_collapsable.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class ColumnCollapsable extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
collapsed: true
|
||||
};
|
||||
|
||||
this.handleToggleCollapsed = this.handleToggleCollapsed.bind(this);
|
||||
}
|
||||
|
||||
handleToggleCollapsed () {
|
||||
const currentState = this.state.collapsed;
|
||||
|
||||
this.setState({ collapsed: !currentState });
|
||||
|
||||
if (!currentState && this.props.onCollapse) {
|
||||
this.props.onCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, title, fullHeight, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
|
||||
|
||||
return (
|
||||
<div className='column-collapsable'>
|
||||
<div role='button' tabIndex='0' title={`${title}`} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}>
|
||||
<i className={`fa fa-${icon}`} />
|
||||
</div>
|
||||
|
||||
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
|
||||
{({ opacity, height }) =>
|
||||
<div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnCollapsable.propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
fullHeight: PropTypes.number.isRequired,
|
||||
children: PropTypes.node,
|
||||
onCollapse: PropTypes.func
|
||||
};
|
||||
|
||||
export default ColumnCollapsable;
|
25
app/javascript/mastodon/components/display_name.js
Normal file
25
app/javascript/mastodon/components/display_name.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import emojify from '../emoji';
|
||||
|
||||
class DisplayName extends React.PureComponent {
|
||||
|
||||
render () {
|
||||
const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<span className='display-name'>
|
||||
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
DisplayName.propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired
|
||||
}
|
||||
|
||||
export default DisplayName;
|
79
app/javascript/mastodon/components/dropdown_menu.js
Normal file
79
app/javascript/mastodon/components/dropdown_menu.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class DropdownMenu extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
direction: 'left'
|
||||
};
|
||||
this.setRef = this.setRef.bind(this);
|
||||
this.renderItem = this.renderItem.bind(this);
|
||||
}
|
||||
|
||||
setRef (c) {
|
||||
this.dropdown = c;
|
||||
}
|
||||
|
||||
handleClick (i, e) {
|
||||
const { action } = this.props.items[i];
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
this.dropdown.hide();
|
||||
}
|
||||
}
|
||||
|
||||
renderItem (item, i) {
|
||||
if (item === null) {
|
||||
return <li key={ 'sep' + i } className='dropdown__sep' />;
|
||||
}
|
||||
|
||||
const { text, action, href = '#' } = item;
|
||||
|
||||
return (
|
||||
<li className='dropdown__content-list-item' key={ text + i }>
|
||||
<a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)} className='dropdown__content-list-link'>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, direction, ariaLabel } = this.props;
|
||||
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
|
||||
|
||||
return (
|
||||
<Dropdown ref={this.setRef}>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
|
||||
<i className={ `fa fa-fw fa-${icon} dropdown__icon` } aria-hidden={true} />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent className={directionClass}>
|
||||
<ul className='dropdown__content-list'>
|
||||
{items.map(this.renderItem)}
|
||||
</ul>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DropdownMenu.propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
direction: PropTypes.string,
|
||||
ariaLabel: PropTypes.string
|
||||
};
|
||||
|
||||
DropdownMenu.defaultProps = {
|
||||
ariaLabel: "Menu"
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
54
app/javascript/mastodon/components/extended_video_player.js
Normal file
54
app/javascript/mastodon/components/extended_video_player.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class ExtendedVideoPlayer extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleLoadedData = this.handleLoadedData.bind(this);
|
||||
this.setRef = this.setRef.bind(this);
|
||||
}
|
||||
|
||||
handleLoadedData () {
|
||||
if (this.props.time) {
|
||||
this.video.currentTime = this.props.time;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
setRef (c) {
|
||||
this.video = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className='extended-video-player'>
|
||||
<video
|
||||
ref={this.setRef}
|
||||
src={this.props.src}
|
||||
autoPlay
|
||||
muted={this.props.muted}
|
||||
controls={this.props.controls}
|
||||
loop={!this.props.controls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ExtendedVideoPlayer.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
time: PropTypes.number,
|
||||
controls: PropTypes.bool.isRequired,
|
||||
muted: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ExtendedVideoPlayer;
|
96
app/javascript/mastodon/components/icon_button.js
Normal file
96
app/javascript/mastodon/components/icon_button.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class IconButton extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
let style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style
|
||||
};
|
||||
|
||||
if (this.props.active) {
|
||||
style = { ...style, ...this.props.activeStyle };
|
||||
}
|
||||
|
||||
const classes = ['icon-button'];
|
||||
|
||||
if (this.props.active) {
|
||||
classes.push('active');
|
||||
}
|
||||
|
||||
if (this.props.disabled) {
|
||||
classes.push('disabled');
|
||||
}
|
||||
|
||||
if (this.props.inverted) {
|
||||
classes.push('inverted');
|
||||
}
|
||||
|
||||
if (this.props.overlay) {
|
||||
classes.push('overlayed');
|
||||
}
|
||||
|
||||
if (this.props.className) {
|
||||
classes.push(this.props.className)
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) =>
|
||||
<button
|
||||
aria-label={this.props.title}
|
||||
title={this.props.title}
|
||||
className={classes.join(' ')}
|
||||
onClick={this.handleClick}
|
||||
style={style}>
|
||||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
|
||||
</button>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
IconButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
activeStyle: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
inverted: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
overlay: PropTypes.bool
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false
|
||||
};
|
||||
|
||||
export default IconButton;
|
15
app/javascript/mastodon/components/load_more.js
Normal file
15
app/javascript/mastodon/components/load_more.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const LoadMore = ({ onClick }) => (
|
||||
<a href="#" className='load-more' role='button' onClick={onClick}>
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
</a>
|
||||
);
|
||||
|
||||
LoadMore.propTypes = {
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
export default LoadMore;
|
10
app/javascript/mastodon/components/loading_indicator.js
Normal file
10
app/javascript/mastodon/components/loading_indicator.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<div className='loading-indicator'>
|
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
196
app/javascript/mastodon/components/media_gallery.js
Normal file
196
app/javascript/mastodon/components/media_gallery.js
Normal file
|
@ -0,0 +1,196 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
|
||||
});
|
||||
|
||||
class Item extends React.PureComponent {
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick (e) {
|
||||
const { index, onClick } = this.props;
|
||||
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
onClick(index);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
let top = 'auto';
|
||||
let left = 'auto';
|
||||
let bottom = 'auto';
|
||||
let right = 'auto';
|
||||
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
if (size === 4 || (size === 3 && index > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
|
||||
if (size === 2) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else {
|
||||
left = '2px';
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else if (index > 0) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index === 1) {
|
||||
bottom = '2px';
|
||||
} else if (index > 1) {
|
||||
top = '2px';
|
||||
}
|
||||
} else if (size === 4) {
|
||||
if (index === 0 || index === 2) {
|
||||
right = '2px';
|
||||
}
|
||||
|
||||
if (index === 1 || index === 3) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index < 2) {
|
||||
bottom = '2px';
|
||||
} else {
|
||||
top = '2px';
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
thumbnail = (
|
||||
<a
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={attachment.get('remote_url') || attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
|
||||
/>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
const autoPlay = !isIOS() && this.props.autoPlayGif;
|
||||
|
||||
thumbnail = (
|
||||
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
autoPlay={autoPlay}
|
||||
loop={true}
|
||||
muted={true}
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Item.propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
autoPlayGif: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
class MediaGallery extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
visible: !props.sensitive
|
||||
};
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleOpen (e) {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
}
|
||||
|
||||
handleClick (index) {
|
||||
this.props.onOpenMedia(this.props.media, index);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, sensitive } = this.props;
|
||||
|
||||
let children;
|
||||
|
||||
if (!this.state.visible) {
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
children = (
|
||||
<div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={{ height: `${this.props.height}px` }}>
|
||||
<div className='spoiler-button' style={{ display: !this.state.visible ? 'none' : 'block' }}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MediaGallery.propTypes = {
|
||||
sensitive: PropTypes.bool,
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
height: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
autoPlayGif: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(MediaGallery);
|
12
app/javascript/mastodon/components/missing_indicator.js
Normal file
12
app/javascript/mastodon/components/missing_indicator.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const MissingIndicator = () => (
|
||||
<div className='missing-indicator'>
|
||||
<div>
|
||||
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MissingIndicator;
|
41
app/javascript/mastodon/components/permalink.js
Normal file
41
app/javascript/mastodon/components/permalink.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Permalink extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick (e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(this.props.to);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { href, children, className, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Permalink.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
Permalink.propTypes = {
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default Permalink;
|
20
app/javascript/mastodon/components/relative_timestamp.js
Normal file
20
app/javascript/mastodon/components/relative_timestamp.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import { injectIntl, FormattedRelative } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RelativeTimestamp = ({ intl, timestamp }) => {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
|
||||
<FormattedRelative value={date} />
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
RelativeTimestamp.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
timestamp: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(RelativeTimestamp);
|
123
app/javascript/mastodon/components/status.js
Normal file
123
app/javascript/mastodon/components/status.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from './avatar';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import DisplayName from './display_name';
|
||||
import MediaGallery from './media_gallery';
|
||||
import VideoPlayer from './video_player';
|
||||
import AttachmentList from './attachment_list';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import emojify from '../emoji';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleAccountClick = this.handleAccountClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
const { status } = this.props;
|
||||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||
}
|
||||
|
||||
handleAccountClick (id, e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
let media = '';
|
||||
const { status, ...other } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
let displayName = status.getIn(['account', 'display_name']);
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = status.getIn(['account', 'username']);
|
||||
}
|
||||
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<div className='status__wrapper'>
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
|
||||
</div>
|
||||
|
||||
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
|
||||
} else {
|
||||
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={this.props.muted ? 'status muted' : 'status'}>
|
||||
<div className='status__info'>
|
||||
<div className='status__info-time'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
<Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} />
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar {...this.props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Status.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
Status.propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
wrapped: PropTypes.bool,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onOpenMedia: PropTypes.func,
|
||||
onOpenVideo: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
me: PropTypes.number,
|
||||
boostModal: PropTypes.bool,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
muted: PropTypes.bool
|
||||
};
|
||||
|
||||
export default Status;
|
138
app/javascript/mastodon/components/status_action_bar.js
Normal file
138
app/javascript/mastodon/components/status_action_bar.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' }
|
||||
});
|
||||
|
||||
class StatusActionBar extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleReplyClick = this.handleReplyClick.bind(this);
|
||||
this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
|
||||
this.handleReblogClick = this.handleReblogClick.bind(this);
|
||||
this.handleDeleteClick = this.handleDeleteClick.bind(this);
|
||||
this.handleMentionClick = this.handleMentionClick.bind(this);
|
||||
this.handleMuteClick = this.handleMuteClick.bind(this);
|
||||
this.handleBlockClick = this.handleBlockClick.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleReport = this.handleReport.bind(this);
|
||||
}
|
||||
|
||||
handleReplyClick () {
|
||||
this.props.onReply(this.props.status, this.context.router);
|
||||
}
|
||||
|
||||
handleFavouriteClick () {
|
||||
this.props.onFavourite(this.props.status);
|
||||
}
|
||||
|
||||
handleReblogClick (e) {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleDeleteClick () {
|
||||
this.props.onDelete(this.props.status);
|
||||
}
|
||||
|
||||
handleMentionClick () {
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router);
|
||||
}
|
||||
|
||||
handleMuteClick () {
|
||||
this.props.onMute(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleBlockClick () {
|
||||
this.props.onBlock(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleOpen () {
|
||||
this.context.router.push(`/statuses/${this.props.status.get('id')}`);
|
||||
}
|
||||
|
||||
handleReport () {
|
||||
this.props.onReport(this.props.status);
|
||||
this.context.router.push('/report');
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, me, intl } = this.props;
|
||||
const reblog_disabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
|
||||
let menu = [];
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
menu.push(null);
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||
menu.push(null);
|
||||
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 });
|
||||
}
|
||||
|
||||
let reblogIcon = 'retweet';
|
||||
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
|
||||
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
|
||||
let reply_icon;
|
||||
let reply_title;
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
reply_icon = "reply";
|
||||
reply_title = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
reply_icon = "reply-all";
|
||||
reply_title = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton title={reply_title} icon={reply_icon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div>
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
StatusActionBar.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
StatusActionBar.propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onReport: PropTypes.func,
|
||||
me: PropTypes.number.isRequired,
|
||||
intl: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(StatusActionBar);
|
165
app/javascript/mastodon/components/status_content.js
Normal file
165
app/javascript/mastodon/components/status_content.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import PropTypes from 'prop-types';
|
||||
import emojify from '../emoji';
|
||||
import { isRtl } from '../rtl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Permalink from './permalink';
|
||||
|
||||
class StatusContent extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
hidden: true
|
||||
};
|
||||
this.onMentionClick = this.onMentionClick.bind(this);
|
||||
this.onHashtagClick = this.onHashtagClick.bind(this);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this)
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
this.handleSpoilerClick = this.handleSpoilerClick.bind(this);
|
||||
this.setRef = this.setRef.bind(this);
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const node = this.node;
|
||||
const links = node.querySelectorAll('a');
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
let media = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_url')));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
link.setAttribute('title', mention.get('acct'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else if (media) {
|
||||
link.innerHTML = '<i class="fa fa-fw fa-photo"></i>';
|
||||
} else {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.setAttribute('title', link.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMentionClick (mention, e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
}
|
||||
|
||||
onHashtagClick (hashtag, e) {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown (e) {
|
||||
this.startXY = [e.clientX, e.clientY];
|
||||
}
|
||||
|
||||
handleMouseUp (e) {
|
||||
const [ startX, startY ] = this.startXY;
|
||||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
|
||||
|
||||
if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0) {
|
||||
this.props.onClick();
|
||||
}
|
||||
|
||||
this.startXY = null;
|
||||
}
|
||||
|
||||
handleSpoilerClick (e) {
|
||||
e.preventDefault();
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
|
||||
setRef (c) {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
const { hidden } = this.state;
|
||||
|
||||
const content = { __html: emojify(status.get('content')) };
|
||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
|
||||
const directionStyle = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(status.get('content'))) {
|
||||
directionStyle.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], [])
|
||||
|
||||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__content' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
|
||||
<span dangerouslySetInnerHTML={spoilerContent} /> <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div ref={this.setRef} style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status__content'
|
||||
style={{ ...directionStyle }}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status__content status__content--no-action'
|
||||
style={{ ...directionStyle }}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
StatusContent.contextTypes = {
|
||||
router: PropTypes.object
|
||||
};
|
||||
|
||||
StatusContent.propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
export default StatusContent;
|
130
app/javascript/mastodon/components/status_list.js
Normal file
130
app/javascript/mastodon/components/status_list.js
Normal file
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import Status from './status';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import PropTypes from 'prop-types';
|
||||
import StatusContainer from '../containers/status_container';
|
||||
import LoadMore from './load_more';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
class StatusList extends ImmutablePureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
this.setRef = this.setRef.bind(this);
|
||||
this.handleLoadMore = this.handleLoadMore.bind(this);
|
||||
}
|
||||
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||
|
||||
if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
|
||||
this.props.onScrollToBottom();
|
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
||||
this.props.onScrollToTop();
|
||||
} else if (this.props.onScroll) {
|
||||
this.props.onScroll();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.attachScrollListener();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
|
||||
this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.detachScrollListener();
|
||||
}
|
||||
|
||||
attachScrollListener () {
|
||||
this.node.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
detachScrollListener () {
|
||||
this.node.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
setRef (c) {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleLoadMore (e) {
|
||||
e.preventDefault();
|
||||
this.props.onScrollToBottom();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
|
||||
|
||||
let loadMore = '';
|
||||
let scrollableArea = '';
|
||||
let unread = '';
|
||||
|
||||
if (!isLoading && statusIds.size > 0 && hasMore) {
|
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||
}
|
||||
|
||||
if (isUnread) {
|
||||
unread = <div className='status-list__unread-indicator' />;
|
||||
}
|
||||
|
||||
if (isLoading || statusIds.size > 0 || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className='scrollable' ref={this.setRef}>
|
||||
{unread}
|
||||
|
||||
<div className='status-list'>
|
||||
{prepend}
|
||||
|
||||
{statusIds.map((statusId) => {
|
||||
return <StatusContainer key={statusId} id={statusId} />;
|
||||
})}
|
||||
|
||||
{loadMore}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
scrollableArea = (
|
||||
<div className='empty-column-indicator' ref={this.setRef}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
StatusList.propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
onScrollToBottom: PropTypes.func,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
isUnread: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
prepend: PropTypes.node,
|
||||
emptyMessage: PropTypes.node
|
||||
};
|
||||
|
||||
StatusList.defaultProps = {
|
||||
trackScroll: true
|
||||
};
|
||||
|
||||
export default StatusList;
|
210
app/javascript/mastodon/components/video_player.js
Normal file
210
app/javascript/mastodon/components/video_player.js
Normal file
|
@ -0,0 +1,210 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
|
||||
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
|
||||
expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' }
|
||||
});
|
||||
|
||||
class VideoPlayer extends React.PureComponent {
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
visible: !this.props.sensitive,
|
||||
preview: true,
|
||||
muted: true,
|
||||
hasAudio: true,
|
||||
videoError: false
|
||||
};
|
||||
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleVideoClick = this.handleVideoClick.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleVisibility = this.handleVisibility.bind(this);
|
||||
this.handleExpand = this.handleExpand.bind(this);
|
||||
this.setRef = this.setRef.bind(this);
|
||||
this.handleLoadedData = this.handleLoadedData.bind(this);
|
||||
this.handleVideoError = this.handleVideoError.bind(this);
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
this.setState({ muted: !this.state.muted });
|
||||
}
|
||||
|
||||
handleVideoClick (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
const node = this.video;
|
||||
|
||||
if (node.paused) {
|
||||
node.play();
|
||||
} else {
|
||||
node.pause();
|
||||
}
|
||||
}
|
||||
|
||||
handleOpen () {
|
||||
this.setState({ preview: !this.state.preview });
|
||||
}
|
||||
|
||||
handleVisibility () {
|
||||
this.setState({
|
||||
visible: !this.state.visible,
|
||||
preview: true
|
||||
});
|
||||
}
|
||||
|
||||
handleExpand () {
|
||||
this.video.pause();
|
||||
this.props.onOpenVideo(this.props.media, this.video.currentTime);
|
||||
}
|
||||
|
||||
setRef (c) {
|
||||
this.video = c;
|
||||
}
|
||||
|
||||
handleLoadedData () {
|
||||
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
|
||||
this.setState({ hasAudio: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleVideoError () {
|
||||
this.setState({ videoError: true });
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
this.video.addEventListener('error', this.handleVideoError);
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
this.video.addEventListener('error', this.handleVideoError);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData);
|
||||
this.video.removeEventListener('error', this.handleVideoError);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, width, height, sensitive, autoplay } = this.props;
|
||||
|
||||
let spoilerButton = (
|
||||
<div className='status__video-player-spoiler' style={{ display: !this.state.visible ? 'none' : 'block' }} >
|
||||
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
|
||||
</div>
|
||||
);
|
||||
|
||||
let expandButton = (
|
||||
<div className='status__video-player-expand'>
|
||||
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
|
||||
</div>
|
||||
);
|
||||
|
||||
let muteButton = '';
|
||||
|
||||
if (this.state.hasAudio) {
|
||||
muteButton = (
|
||||
<div className='status__video-player-mute'>
|
||||
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.visible) {
|
||||
if (sensitive) {
|
||||
return (
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
{spoilerButton}
|
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
{spoilerButton}
|
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.preview && !autoplay) {
|
||||
return (
|
||||
<div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center` }} onClick={this.handleOpen}>
|
||||
{spoilerButton}
|
||||
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.videoError) {
|
||||
return (
|
||||
<div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
|
||||
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
|
||||
{spoilerButton}
|
||||
{muteButton}
|
||||
{expandButton}
|
||||
|
||||
<video
|
||||
className='status__video-player-video'
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
ref={this.setRef}
|
||||
src={media.get('url')}
|
||||
autoPlay={!isIOS()}
|
||||
loop={true}
|
||||
muted={this.state.muted}
|
||||
onClick={this.handleVideoClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
VideoPlayer.propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
autoplay: PropTypes.bool,
|
||||
onOpenVideo: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
VideoPlayer.defaultProps = {
|
||||
width: 239,
|
||||
height: 110
|
||||
};
|
||||
|
||||
export default injectIntl(VideoPlayer);
|
Loading…
Add table
Add a link
Reference in a new issue