parent
297921fce5
commit
bcf7ee48e9
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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} |
||||
/> |
||||
); |
||||
} |
||||
} |
||||
|
||||
} |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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)); |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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; |
||||
} |
||||
|
||||
} |
@ -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); |
Loading…
Reference in new issue