diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-05-03 02:04:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-03 02:04:16 +0200 |
commit | f5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch) | |
tree | 92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon/components | |
parent | 26bc5915727e0a0173c03cb49f5193dd612fb888 (diff) |
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
Diffstat (limited to 'app/javascript/mastodon/components')
24 files changed, 1913 insertions, 0 deletions
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js new file mode 100644 index 000000000..9016bedb6 --- /dev/null +++ b/app/javascript/mastodon/components/account.js @@ -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); diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js new file mode 100644 index 000000000..6df578b77 --- /dev/null +++ b/app/javascript/mastodon/components/attachment_list.js @@ -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; diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js new file mode 100644 index 000000000..6d8d3b2a3 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -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; diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js new file mode 100644 index 000000000..47f2715c7 --- /dev/null +++ b/app/javascript/mastodon/components/avatar.js @@ -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; diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js new file mode 100644 index 000000000..1063e0289 --- /dev/null +++ b/app/javascript/mastodon/components/button.js @@ -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; diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js new file mode 100644 index 000000000..a61f67d8e --- /dev/null +++ b/app/javascript/mastodon/components/collapsable.js @@ -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; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js new file mode 100644 index 000000000..bedc417fd --- /dev/null +++ b/app/javascript/mastodon/components/column_back_button.js @@ -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; diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js new file mode 100644 index 000000000..9aa7e92c2 --- /dev/null +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -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; diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js new file mode 100644 index 000000000..797946859 --- /dev/null +++ b/app/javascript/mastodon/components/column_collapsable.js @@ -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; diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js new file mode 100644 index 000000000..6bdd06db7 --- /dev/null +++ b/app/javascript/mastodon/components/display_name.js @@ -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; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js new file mode 100644 index 000000000..aed0757b1 --- /dev/null +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -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; diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js new file mode 100644 index 000000000..34ede66fd --- /dev/null +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -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; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js new file mode 100644 index 000000000..87324b6c8 --- /dev/null +++ b/app/javascript/mastodon/components/icon_button.js @@ -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; diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js new file mode 100644 index 000000000..36dae79af --- /dev/null +++ b/app/javascript/mastodon/components/load_more.js @@ -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; diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js new file mode 100644 index 000000000..c09244834 --- /dev/null +++ b/app/javascript/mastodon/components/loading_indicator.js @@ -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; diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js new file mode 100644 index 000000000..dc08c457d --- /dev/null +++ b/app/javascript/mastodon/components/media_gallery.js @@ -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); diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js new file mode 100644 index 000000000..87df7f61c --- /dev/null +++ b/app/javascript/mastodon/components/missing_indicator.js @@ -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; diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js new file mode 100644 index 000000000..26444f27c --- /dev/null +++ b/app/javascript/mastodon/components/permalink.js @@ -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; diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js new file mode 100644 index 000000000..9c7a8121e --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -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); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js new file mode 100644 index 000000000..39ed6dd4f --- /dev/null +++ b/app/javascript/mastodon/components/status.js @@ -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; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js new file mode 100644 index 000000000..dc4466d6c --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -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); diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js new file mode 100644 index 000000000..1d462103b --- /dev/null +++ b/app/javascript/mastodon/components/status_content.js @@ -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; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js new file mode 100644 index 000000000..9abf1fbfe --- /dev/null +++ b/app/javascript/mastodon/components/status_list.js @@ -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; diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js new file mode 100644 index 000000000..0c8aea3a9 --- /dev/null +++ b/app/javascript/mastodon/components/video_player.js @@ -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); |