diff options
9 files changed, 1407 insertions, 0 deletions
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js new file mode 100644 index 000000000..89a358e38 --- /dev/null +++ b/app/javascript/mastodon/components/media_gallery.js @@ -0,0 +1,230 @@ +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 { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool, + }; + + static defaultProps = { + autoPlayGif: false, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment, autoPlayGif } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (this.context.router && 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') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; + + const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; + const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + > + <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> + </a> + ); + } 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} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + + <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> + ); + } + +} + +@injectIntl +export default class MediaGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + height: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool, + }; + + static defaultProps = { + autoPlayGif: false, + }; + + state = { + visible: !this.props.sensitive, + }; + + handleOpen = () => { + 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 ${this.state.visible ? 'spoiler-button--visible' : ''}`}> + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + </div> + + {children} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js new file mode 100644 index 000000000..6b9fdd2af --- /dev/null +++ b/app/javascript/mastodon/components/status.js @@ -0,0 +1,261 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import AvatarOverlay from './avatar_overlay'; +import RelativeTimestamp from './relative_timestamp'; +import DisplayName from './display_name'; +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'; +import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; +import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; + +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; +import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; + +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + account: 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, + intersectionObserverWrapper: PropTypes.object, + }; + + state = { + isExpanded: false, + isIntersecting: true, // assume intersecting until told otherwise + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'wrapped', + 'me', + 'boostModal', + 'autoPlayGif', + 'muted', + ] + + updateOnStates = ['isExpanded'] + + shouldComponentUpdate (nextProps, nextState) { + if (!nextState.isIntersecting && nextState.isHidden) { + // It's only if we're not intersecting (i.e. offscreen) and isHidden is true + // that either "isIntersecting" or "isHidden" matter, and then they're + // the only things that matter. + return this.state.isIntersecting || !this.state.isHidden; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { + // If we're going from a non-intersecting state to an intersecting state, + // (i.e. offscreen to onscreen), then we definitely need to re-render + return true; + } + // Otherwise, diff based on "updateOnProps" and "updateOnStates" + return super.shouldComponentUpdate(nextProps, nextState); + } + + componentDidMount () { + if (!this.props.intersectionObserverWrapper) { + // TODO: enable IntersectionObserver optimization for notification statuses. + // These are managed in notifications/index.js rather than status_list.js + return; + } + this.props.intersectionObserverWrapper.observe( + this.props.id, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + if (this.props.intersectionObserverWrapper) { + this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); + } + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + if (this.node && this.node.children.length !== 0) { + // save the height of the fully-rendered element + this.height = getRectFromEntry(entry).height; + } + + // Edge 15 doesn't support isIntersecting, but we can infer it + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ + // https://github.com/WICG/IntersectionObserver/issues/211 + const isIntersecting = (typeof entry.isIntersecting === 'boolean') ? + entry.isIntersecting : entry.intersectionRect.height > 0; + this.setState((prevState) => { + if (prevState.isIntersecting && !isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: isIntersecting, + isHidden: false, + }; + }); + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + handleClick = () => { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + } + + handleAccountClick = (e) => { + if (this.context.router && e.button === 0) { + const id = Number(e.currentTarget.getAttribute('data-id')); + e.preventDefault(); + this.context.router.history.push(`/accounts/${id}`); + } + } + + handleExpandedToggle = () => { + this.setState({ isExpanded: !this.state.isExpanded }); + }; + + renderLoadingMediaGallery () { + return <div className='media_gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='media-spoiler-video' style={{ height: '110px' }} />; + } + + render () { + let media = null; + let statusAvatar; + + // Exclude intersectionObserverWrapper from `other` variable + // because intersection is managed in here. + const { status, account, intersectionObserverWrapper, ...other } = this.props; + const { isExpanded, isIntersecting, isHidden } = this.state; + + if (status === null) { + return null; + } + + if (!isIntersecting && isHidden) { + return ( + <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} + </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' ref={this.handleRef} data-id={status.get('id')} > + <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} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> + </div> + + <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> + </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 = ( + <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} > + {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />} + </Bundle> + ); + } else { + media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > + {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />} + </Bundle> + ); + } + } + + if (account === undefined || account === null) { + statusAvatar = <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />; + }else{ + statusAvatar = <AvatarOverlay staticSrc={status.getIn(['account', 'avatar_static'])} overlaySrc={account.get('avatar_static')} />; + } + + return ( + <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> + <div className='status__info'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + + <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + {statusAvatar} + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> + + {media} + + <StatusActionBar {...this.props} /> + </div> + ); + } + +} 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..7bb394e71 --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -0,0 +1,152 @@ +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'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +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}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, +}); + +@injectIntl +export default class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static 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, + onMuteConversation: PropTypes.func, + me: PropTypes.number, + withDismiss: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'me', + 'withDismiss', + ] + + handleReplyClick = () => { + this.props.onReply(this.props.status, this.context.router.history); + } + + 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.history); + } + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + } + + handleBlockClick = () => { + this.props.onBlock(this.props.status.get('account')); + } + + handleOpen = () => { + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + render () { + const { status, me, intl, withDismiss } = this.props; + const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + menu.push(null); + + if (withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + 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 }); + } + + if (status.get('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + return ( + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js new file mode 100644 index 000000000..1b803a22e --- /dev/null +++ b/app/javascript/mastodon/components/status_content.js @@ -0,0 +1,184 @@ +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'; +import classnames from 'classnames'; + +export default class StatusContent extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.bool, + onExpandedToggle: PropTypes.func, + onClick: PropTypes.func, + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.node; + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + if (link.classList.contains('status-link')) { + continue; + } + link.classList.add('status-link'); + + let mention = this.props.status.get('mentions').find(item => link.href === item.get('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 { + link.setAttribute('title', link.href); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + } + + componentDidMount () { + this._updateStatusLinks(); + } + + componentDidUpdate () { + this._updateStatusLinks(); + } + + onMentionClick = (mention, e) => { + if (this.context.router && e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (this.context.router && e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/timelines/tag/${hashtag}`); + } + } + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp = (e) => { + if (!this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { + this.props.onClick(); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.onExpandedToggle) { + // The parent manages the state + this.props.onExpandedToggle(); + } else { + this.setState({ hidden: !this.state.hidden }); + } + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { status } = this.props; + + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + + const content = { __html: emojify(status.get('content')) }; + const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; + const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': this.props.onClick && this.context.router, + }); + + if (isRtl(status.get('search_index'))) { + 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={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> + <span dangerouslySetInnerHTML={spoilerContent} /> + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> + </div> + ); + } else if (this.props.onClick) { + return ( + <div + ref={this.setRef} + className={classNames} + style={directionStyle} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + ); + } else { + return ( + <div + ref={this.setRef} + className='status__content' + style={directionStyle} + dangerouslySetInnerHTML={content} + /> + ); + } + } + +} diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js new file mode 100644 index 000000000..999cf42d9 --- /dev/null +++ b/app/javascript/mastodon/components/video_player.js @@ -0,0 +1,204 @@ +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' }, +}); + +@injectIntl +export default class VideoPlayer extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static 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, + }; + + static defaultProps = { + width: 239, + height: 110, + }; + + state = { + visible: !this.props.sensitive, + preview: true, + muted: true, + hasAudio: true, + videoError: false, + }; + + 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 ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> + <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + let expandButton = ''; + + if (this.context.router) { + 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`, marginTop: '8px' }} 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`, marginTop: '8px' }} 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`, backgroundImage: `url(${media.get('preview_url')})` }} 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 + muted={this.state.muted} + onClick={this.handleVideoClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js new file mode 100644 index 000000000..438ecfe43 --- /dev/null +++ b/app/javascript/mastodon/containers/status_container.js @@ -0,0 +1,129 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Status from '../components/status'; +import { makeGetStatus } from '../selectors'; +import { + replyCompose, + mentionCompose, +} from '../actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, +} from '../actions/interactions'; +import { + blockAccount, + muteAccount, +} from '../actions/accounts'; +import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; +import { initReport } from '../actions/reports'; +import { openModal } from '../actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props.id), + me: state.getIn(['meta', 'me']), + boostModal: state.getIn(['meta', 'boost_modal']), + deleteModal: state.getIn(['meta', 'delete_modal']), + autoPlayGif: state.getIn(['meta', 'auto_play_gif']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch(replyCompose(status, router)); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !this.boostModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + } + }, + + onFavourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + + onDelete (status) { + if (!this.deleteModal) { + dispatch(deleteStatus(status.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))), + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (account) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))), + })); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))), + })); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js new file mode 100644 index 000000000..3239b1085 --- /dev/null +++ b/app/javascript/mastodon/features/account/components/header.js @@ -0,0 +1,144 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, +}); + +const makeMapStateToProps = () => { + const mapStateToProps = state => ({ + autoPlayGif: state.getIn(['meta', 'auto_play_gif']), + }); + + return mapStateToProps; +}; + +class Avatar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + state = { + isHovered: false, + }; + + handleMouseOver = () => { + if (this.state.isHovered) return; + this.setState({ isHovered: true }); + } + + handleMouseOut = () => { + if (!this.state.isHovered) return; + this.setState({ isHovered: false }); + } + + render () { + const { account, autoPlayGif } = this.props; + const { isHovered } = this.state; + + return ( + <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> + {({ radius }) => + <a // eslint-disable-line jsx-a11y/anchor-has-content + href={account.get('url')} + className='account__header__avatar' + target='_blank' + rel='noopener' + style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} + onMouseOver={this.handleMouseOver} + onMouseOut={this.handleMouseOut} + onFocus={this.handleMouseOver} + onBlur={this.handleMouseOut} + /> + } + </Motion> + ); + } + +} + +@connect(makeMapStateToProps) +@injectIntl +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + render () { + const { account, me, intl } = this.props; + + if (!account) { + return null; + } + + let displayName = account.get('display_name'); + let info = ''; + let actionBtn = ''; + let lockedIcon = ''; + + if (displayName.length === 0) { + displayName = account.get('username'); + } + + if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { + info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; + } + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> + </div> + ); + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> + </div> + ); + } + } + + if (account.get('locked')) { + lockedIcon = <i className='fa fa-lock' />; + } + + const content = { __html: emojify(account.get('note')) }; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( + <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> + <div> + <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> + + <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> + <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> + <div className='account__header__content' dangerouslySetInnerHTML={content} /> + + {info} + {actionBtn} + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js new file mode 100644 index 000000000..9d631644a --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -0,0 +1,88 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusContainer from '../../../containers/status_container'; +import AccountContainer from '../../../containers/account_container'; +import { FormattedMessage } from 'react-intl'; +import Permalink from '../../../components/permalink'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + }; + + renderFollow (account, link) { + return ( + <div className='notification notification-follow'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> + </div> + + <AccountContainer id={account.get('id')} withNote={false} /> + </div> + ); + } + + renderMention (notification) { + return <StatusContainer id={notification.get('status')} withDismiss />; + } + + renderFavourite (notification, link) { + return ( + <div className='notification notification-favourite'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-star star-icon' /> + </div> + <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + </div> + ); + } + + renderReblog (notification, link) { + return ( + <div className='notification notification-reblog'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-retweet' /> + </div> + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + </div> + ); + } + + render () { + const { notification } = this.props; + const account = notification.get('account'); + const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(account, link); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification, link); + case 'reblog': + return this.renderReblog(notification, link); + } + + return null; + } + +} diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..786222967 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeGetNotification } from '../../../selectors'; +import Notification from '../components/notification'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(Notification); |