|
|
|
@ -1,136 +1,215 @@ |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
`<Status>` |
|
|
|
|
========== |
|
|
|
|
|
|
|
|
|
Original file by @gargron@mastodon.social et al as part of |
|
|
|
|
tootsuite/mastodon. *Heavily* rewritten (and documented!) by |
|
|
|
|
@kibi@glitch.social as a part of glitch-soc/mastodon. The following |
|
|
|
|
features have been added: |
|
|
|
|
|
|
|
|
|
- Better separating the "guts" of statuses from their wrapper(s) |
|
|
|
|
- Collapsing statuses |
|
|
|
|
- Moving images inside of CWs |
|
|
|
|
|
|
|
|
|
A number of aspects of this original file have been split off into |
|
|
|
|
their own components for better maintainance; for these, see: |
|
|
|
|
|
|
|
|
|
- <StatusHeader> |
|
|
|
|
- <StatusPrepend> |
|
|
|
|
|
|
|
|
|
…And, of course, the other <Status>-related components as well. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
/* * * * */ |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
Imports: |
|
|
|
|
-------- |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
// Our standard React imports:
|
|
|
|
|
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 DisplayName from './display_name'; |
|
|
|
|
import ImmutablePropTypes from 'react-immutable-proptypes'; |
|
|
|
|
|
|
|
|
|
// `ImmutablePureComponent` gives us `updateOnProps` and
|
|
|
|
|
// `updateOnStates`:
|
|
|
|
|
import ImmutablePureComponent from 'react-immutable-pure-component'; |
|
|
|
|
|
|
|
|
|
// These are our various media types:
|
|
|
|
|
import MediaGallery from './media_gallery'; |
|
|
|
|
import VideoPlayer from './video_player'; |
|
|
|
|
|
|
|
|
|
// These are our core status components:
|
|
|
|
|
import StatusPrepend from './status_prepend'; |
|
|
|
|
import StatusHeader from './status_header'; |
|
|
|
|
import StatusContent from './status_content'; |
|
|
|
|
import StatusActionBar from './status_action_bar'; |
|
|
|
|
import IconButton from './icon_button'; |
|
|
|
|
import { defineMessages, FormattedMessage } from 'react-intl'; |
|
|
|
|
import emojify from '../emoji'; |
|
|
|
|
import escapeTextContentForBrowser from 'escape-html'; |
|
|
|
|
import ImmutablePureComponent from 'react-immutable-pure-component'; |
|
|
|
|
|
|
|
|
|
// This is used to schedule tasks at the browser's convenience:
|
|
|
|
|
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; |
|
|
|
|
|
|
|
|
|
const messages = defineMessages({ |
|
|
|
|
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, |
|
|
|
|
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, |
|
|
|
|
}); |
|
|
|
|
/* * * * */ |
|
|
|
|
|
|
|
|
|
export default class StatusOrReblog extends ImmutablePureComponent { |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
static propTypes = { |
|
|
|
|
status: ImmutablePropTypes.map, |
|
|
|
|
account: ImmutablePropTypes.map, |
|
|
|
|
settings: 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, |
|
|
|
|
collapse: PropTypes.bool, |
|
|
|
|
intersectionObserverWrapper: PropTypes.object, |
|
|
|
|
intl: PropTypes.object.isRequired, |
|
|
|
|
}; |
|
|
|
|
The `<Status>` component: |
|
|
|
|
------------------------- |
|
|
|
|
|
|
|
|
|
// Avoid checking props that are functions (and whose equality will always
|
|
|
|
|
// evaluate to false. See react-immutable-pure-component for usage.
|
|
|
|
|
updateOnProps = [ |
|
|
|
|
'status', |
|
|
|
|
'account', |
|
|
|
|
'settings', |
|
|
|
|
'wrapped', |
|
|
|
|
'me', |
|
|
|
|
'boostModal', |
|
|
|
|
'autoPlayGif', |
|
|
|
|
'muted', |
|
|
|
|
'collapse', |
|
|
|
|
] |
|
|
|
|
The `<Status>` component is a container for statuses. It consists of a |
|
|
|
|
few parts: |
|
|
|
|
|
|
|
|
|
render () { |
|
|
|
|
// Exclude intersectionObserverWrapper from `other` variable
|
|
|
|
|
// because intersection is managed in here.
|
|
|
|
|
const { status, account, ...other } = this.props; |
|
|
|
|
- The `<StatusPrepend>`, which contains tangential information about |
|
|
|
|
the status, such as who reblogged it. |
|
|
|
|
- The `<StatusHeader>`, which contains the avatar and username of the |
|
|
|
|
status author, as well as a media icon and the "collapse" toggle. |
|
|
|
|
- The `<StatusContent>`, which contains the content of the status. |
|
|
|
|
- The `<StatusActionBar>`, which provides actions to be performed |
|
|
|
|
on statuses, like reblogging or sending a reply. |
|
|
|
|
|
|
|
|
|
if (status === null) { |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
### Context |
|
|
|
|
|
|
|
|
|
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |
|
|
|
|
let displayName = status.getIn(['account', 'display_name']); |
|
|
|
|
- __`router` (`PropTypes.object`) :__ |
|
|
|
|
We need to get our router from the surrounding React context. |
|
|
|
|
|
|
|
|
|
if (displayName.length === 0) { |
|
|
|
|
displayName = status.getIn(['account', 'username']); |
|
|
|
|
} |
|
|
|
|
### Props |
|
|
|
|
|
|
|
|
|
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |
|
|
|
|
- __`id` (`PropTypes.number`) :__ |
|
|
|
|
The id of the status. |
|
|
|
|
|
|
|
|
|
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` (`ImmutablePropTypes.map`) :__ |
|
|
|
|
The status object, straight from the store. |
|
|
|
|
|
|
|
|
|
<Status {...other} status={status.get('reblog')} account={status.get('account')} wrapped /> |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} else return <Status {...this.props} />; |
|
|
|
|
} |
|
|
|
|
- __`account` (`ImmutablePropTypes.map`) :__ |
|
|
|
|
Don't be confused by this one! This is **not** the account which |
|
|
|
|
posted the status, but the associated account with any further |
|
|
|
|
action (eg, a reblog or a favourite). |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
- __`settings` (`ImmutablePropTypes.map`) :__ |
|
|
|
|
These are our local settings, fetched from our store. We need this |
|
|
|
|
to determine how best to collapse our statuses, among other things. |
|
|
|
|
|
|
|
|
|
- __`me` (`PropTypes.number`) :__ |
|
|
|
|
This is the id of the currently-signed-in user. |
|
|
|
|
|
|
|
|
|
- __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`, |
|
|
|
|
`onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
|
|
|
|
|
`onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__ |
|
|
|
|
These are all functions passed through from the |
|
|
|
|
`<StatusContainer>`. We don't deal with them directly here. |
|
|
|
|
|
|
|
|
|
- __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__ |
|
|
|
|
These tell whether or not the user has modals activated for |
|
|
|
|
reblogging and deleting statuses. They are used by the `onReblog` |
|
|
|
|
and `onDelete` functions, but we don't deal with them here. |
|
|
|
|
|
|
|
|
|
- __`autoPlayGif` (`PropTypes.bool`) :__ |
|
|
|
|
This tells the frontend whether or not to autoplay gifs! |
|
|
|
|
|
|
|
|
|
- __`muted` (`PropTypes.bool`) :__ |
|
|
|
|
This has nothing to do with a user or conversation mute! "Muted" is |
|
|
|
|
what Mastodon internally calls the subdued look of statuses in the |
|
|
|
|
notifications column. This should be `true` for notifications, and |
|
|
|
|
`false` otherwise. |
|
|
|
|
|
|
|
|
|
- __`collapse` (`PropTypes.bool`) :__ |
|
|
|
|
This prop signals a directive from a higher power to (un)collapse |
|
|
|
|
a status. Most of the time it should be `undefined`, in which case |
|
|
|
|
we do nothing. |
|
|
|
|
|
|
|
|
|
- __`prepend` (`PropTypes.string`) :__ |
|
|
|
|
The type of prepend: `'reblogged_by'`, `'reblog'`, or |
|
|
|
|
`'favourite'`. |
|
|
|
|
|
|
|
|
|
- __`withDismiss` (`PropTypes.bool`) :__ |
|
|
|
|
Whether or not the status can be dismissed. Used for notifications. |
|
|
|
|
|
|
|
|
|
- __`intersectionObserverWrapper` (`PropTypes.object`) :__ |
|
|
|
|
This holds our intersection observer. In Mastodon parlance, |
|
|
|
|
an "intersection" is just when the status is viewable onscreen. |
|
|
|
|
|
|
|
|
|
class Status extends ImmutablePureComponent { |
|
|
|
|
### State |
|
|
|
|
|
|
|
|
|
- __`isExpanded` :__ |
|
|
|
|
Should be either `true`, `false`, or `null`. The meanings of |
|
|
|
|
these values are as follows: |
|
|
|
|
|
|
|
|
|
- __`true` :__ The status contains a CW and the CW is expanded. |
|
|
|
|
- __`false` :__ The status is collapsed. |
|
|
|
|
- __`null` :__ The status is not collapsed or expanded. |
|
|
|
|
|
|
|
|
|
- __`isIntersecting` :__ |
|
|
|
|
This boolean tells us whether or not the status is currently |
|
|
|
|
onscreen. |
|
|
|
|
|
|
|
|
|
- __`isHidden` :__ |
|
|
|
|
This boolean tells us if the status has been unrendered to save |
|
|
|
|
CPUs. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
export default class Status extends ImmutablePureComponent { |
|
|
|
|
|
|
|
|
|
static contextTypes = { |
|
|
|
|
router: PropTypes.object, |
|
|
|
|
router : PropTypes.object, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
static propTypes = { |
|
|
|
|
status: ImmutablePropTypes.map, |
|
|
|
|
account: ImmutablePropTypes.map, |
|
|
|
|
settings: 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, |
|
|
|
|
collapse: PropTypes.bool, |
|
|
|
|
intersectionObserverWrapper: PropTypes.object, |
|
|
|
|
intl: PropTypes.object.isRequired, |
|
|
|
|
id : PropTypes.number, |
|
|
|
|
status : ImmutablePropTypes.map, |
|
|
|
|
account : ImmutablePropTypes.map, |
|
|
|
|
settings : ImmutablePropTypes.map, |
|
|
|
|
me : PropTypes.number, |
|
|
|
|
onFavourite : PropTypes.func, |
|
|
|
|
onReblog : PropTypes.func, |
|
|
|
|
onModalReblog : PropTypes.func, |
|
|
|
|
onDelete : PropTypes.func, |
|
|
|
|
onMention : PropTypes.func, |
|
|
|
|
onMute : PropTypes.func, |
|
|
|
|
onMuteConversation : PropTypes.func, |
|
|
|
|
onBlock : PropTypes.func, |
|
|
|
|
onReport : PropTypes.func, |
|
|
|
|
onOpenMedia : PropTypes.func, |
|
|
|
|
onOpenVideo : PropTypes.func, |
|
|
|
|
reblogModal : PropTypes.bool, |
|
|
|
|
deleteModal : PropTypes.bool, |
|
|
|
|
autoPlayGif : PropTypes.bool, |
|
|
|
|
muted : PropTypes.bool, |
|
|
|
|
collapse : PropTypes.bool, |
|
|
|
|
prepend : PropTypes.string, |
|
|
|
|
withDismiss : 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
|
|
|
|
|
isCollapsed: false, |
|
|
|
|
isExpanded : null, |
|
|
|
|
isIntersecting : true, |
|
|
|
|
isHidden : false, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Avoid checking props that are functions (and whose equality will always
|
|
|
|
|
// evaluate to false. See react-immutable-pure-component for usage.
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
### Implementation |
|
|
|
|
|
|
|
|
|
#### `updateOnProps` and `updateOnStates`. |
|
|
|
|
|
|
|
|
|
`updateOnProps` and `updateOnStates` tell the component when to update. |
|
|
|
|
We specify them explicitly because some of our props are dynamically= |
|
|
|
|
generated functions, which would otherwise always trigger an update. |
|
|
|
|
Of course, this means that if we add an important prop, we will need |
|
|
|
|
to remember to specify it here. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
updateOnProps = [ |
|
|
|
|
'status', |
|
|
|
|
'account', |
|
|
|
|
'settings', |
|
|
|
|
'wrapped', |
|
|
|
|
'prepend', |
|
|
|
|
'me', |
|
|
|
|
'boostModal', |
|
|
|
|
'autoPlayGif', |
|
|
|
@ -140,230 +219,503 @@ class Status extends ImmutablePureComponent { |
|
|
|
|
|
|
|
|
|
updateOnStates = [ |
|
|
|
|
'isExpanded', |
|
|
|
|
'isCollapsed', |
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `componentWillReceiveProps()`. |
|
|
|
|
|
|
|
|
|
If our settings have changed to disable collapsed statuses, then we |
|
|
|
|
need to make sure that we uncollapse every one. We do that by watching |
|
|
|
|
for changes to `settings.collapsed.enabled` in |
|
|
|
|
`componentWillReceiveProps()`. |
|
|
|
|
|
|
|
|
|
We also need to watch for changes on the `collapse` prop---if this |
|
|
|
|
changes to anything other than `undefined`, then we need to collapse or |
|
|
|
|
uncollapse our status accordingly. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
componentWillReceiveProps (nextProps) { |
|
|
|
|
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) this.collapse(false); |
|
|
|
|
else if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.collapse(this.props.collapse); |
|
|
|
|
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { |
|
|
|
|
this.setExpansion(false); |
|
|
|
|
} else if ( |
|
|
|
|
nextProps.collapse !== this.props.collapse && |
|
|
|
|
nextProps.collapse !== undefined |
|
|
|
|
) this.setExpansion(nextProps.collapse ? false : null); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `componentDidMount()`. |
|
|
|
|
|
|
|
|
|
When mounting, we just check to see if our status should be collapsed, |
|
|
|
|
and collapse it if so. We don't need to worry about whether collapsing |
|
|
|
|
is enabled here, because `setExpansion()` already takes that into |
|
|
|
|
account. |
|
|
|
|
|
|
|
|
|
The cases where a status should be collapsed are: |
|
|
|
|
|
|
|
|
|
- The `collapse` prop has been set to `true` |
|
|
|
|
- The user has decided in local settings to collapse all statuses. |
|
|
|
|
- The user has decided to collapse all notifications ('muted' |
|
|
|
|
statuses). |
|
|
|
|
- The user has decided to collapse long statuses and the status is |
|
|
|
|
over 400px (without media, or 650px with). |
|
|
|
|
- The status is a reply and the user has decided to collapse all |
|
|
|
|
replies. |
|
|
|
|
- The status contains media and the user has decided to collapse all |
|
|
|
|
statuses with media. |
|
|
|
|
|
|
|
|
|
We also start up our intersection observer to monitor our statuses. |
|
|
|
|
`componentMounted` lets us know that everything has been set up |
|
|
|
|
properly and our intersection observer is good to go. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
componentDidMount () { |
|
|
|
|
const { node, handleIntersection } = this; |
|
|
|
|
const { |
|
|
|
|
status, |
|
|
|
|
settings, |
|
|
|
|
collapse, |
|
|
|
|
muted, |
|
|
|
|
id, |
|
|
|
|
intersectionObserverWrapper, |
|
|
|
|
} = this.props; |
|
|
|
|
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); |
|
|
|
|
|
|
|
|
|
if ( |
|
|
|
|
collapse || |
|
|
|
|
autoCollapseSettings.get('all') || ( |
|
|
|
|
autoCollapseSettings.get('notifications') && muted |
|
|
|
|
) || ( |
|
|
|
|
autoCollapseSettings.get('lengthy') && |
|
|
|
|
node.clientHeight > ( |
|
|
|
|
status.get('media_attachments').size && !muted ? 650 : 400 |
|
|
|
|
) |
|
|
|
|
) || ( |
|
|
|
|
autoCollapseSettings.get('replies') && |
|
|
|
|
status.get('in_reply_to_id', null) !== null |
|
|
|
|
) || ( |
|
|
|
|
autoCollapseSettings.get('media') && |
|
|
|
|
!(status.get('spoiler_text').length) && |
|
|
|
|
status.get('media_attachments').size |
|
|
|
|
) |
|
|
|
|
) this.setExpansion(false); |
|
|
|
|
|
|
|
|
|
if (!intersectionObserverWrapper) return; |
|
|
|
|
else intersectionObserverWrapper.observe( |
|
|
|
|
id, |
|
|
|
|
node, |
|
|
|
|
handleIntersection |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
this.componentMounted = true; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `shouldComponentUpdate()`. |
|
|
|
|
|
|
|
|
|
If the status is about to be both offscreen (not intersecting) and |
|
|
|
|
hidden, then we only need to update it if it's not that way currently. |
|
|
|
|
If the status is moving from offscreen to onscreen, then we *have* to |
|
|
|
|
re-render, so that we can unhide the element if necessary. |
|
|
|
|
|
|
|
|
|
If neither of these cases are true, we can leave it up to our |
|
|
|
|
`updateOnProps` and `updateOnStates` arrays. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
switch (true) { |
|
|
|
|
case !nextState.isIntersecting && nextState.isHidden: |
|
|
|
|
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
|
|
|
|
|
case nextState.isIntersecting && !this.state.isIntersecting: |
|
|
|
|
return true; |
|
|
|
|
default: |
|
|
|
|
return super.shouldComponentUpdate(nextProps, nextState); |
|
|
|
|
} |
|
|
|
|
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
|
|
|
|
|
return super.shouldComponentUpdate(nextProps, nextState); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
componentDidUpdate () { |
|
|
|
|
if (this.state.isIntersecting || !this.state.isHidden) this.saveHeight(); |
|
|
|
|
} |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
componentDidMount () { |
|
|
|
|
const node = this.node; |
|
|
|
|
#### `componentDidUpdate()`. |
|
|
|
|
|
|
|
|
|
const { collapse, settings, status } = this.props; |
|
|
|
|
If our component is being rendered for any reason and an update has |
|
|
|
|
triggered, this will save its height. |
|
|
|
|
|
|
|
|
|
if (collapse !== undefined) this.collapse(collapse); |
|
|
|
|
else if (settings.getIn(['collapsed', 'auto', 'all'])) this.collapse(); |
|
|
|
|
else if (settings.getIn(['collapsed', 'auto', 'lengthy']) && node.clientHeight > (status.get('media_attachments').size > 0 && !this.props.muted ? 650 : 400)) this.collapse(); |
|
|
|
|
else if (settings.getIn(['collapsed', 'auto', 'replies']) && status.get('in_reply_to_id', null) !== null) this.collapse(); |
|
|
|
|
else if (settings.getIn(['collapsed', 'auto', 'media']) && !(status.get('spoiler_text').length > 0) && status.get('media_attachments').size > 0) this.collapse(); |
|
|
|
|
This is, frankly, a bit overkill, as the only instance when we |
|
|
|
|
actually *need* to update the height right now should be when the |
|
|
|
|
value of `isExpanded` has changed. But it makes for more readable |
|
|
|
|
code and prevents bugs in the future where the height isn't set |
|
|
|
|
properly after some change. |
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
componentDidUpdate () { |
|
|
|
|
if ( |
|
|
|
|
this.state.isIntersecting || !this.state.isHidden |
|
|
|
|
) this.saveHeight(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `componentWillUnmount()`. |
|
|
|
|
|
|
|
|
|
If our component is about to unmount, then we'd better unset |
|
|
|
|
`this.componentMounted`. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
componentWillUnmount () { |
|
|
|
|
this.componentMounted = false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
collapse = (collapsedOrNot) => { |
|
|
|
|
if (collapsedOrNot === undefined) collapsedOrNot = true; |
|
|
|
|
if (this.props.settings.getIn(['collapsed', 'enabled'])) this.setState({ isCollapsed: !!collapsedOrNot }); |
|
|
|
|
} |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `handleIntersection()`. |
|
|
|
|
|
|
|
|
|
`handleIntersection()` either hides the status (if it is offscreen) or |
|
|
|
|
unhides it (if it is onscreen). It's called by |
|
|
|
|
`intersectionObserverWrapper.observe()`. |
|
|
|
|
|
|
|
|
|
If our status isn't intersecting, we schedule an idle task (using the |
|
|
|
|
aptly-named `scheduleIdleTask()`) to hide the status at the next |
|
|
|
|
available opportunity. |
|
|
|
|
|
|
|
|
|
tootsuite/mastodon left us with the following enlightening comment |
|
|
|
|
regarding this function: |
|
|
|
|
|
|
|
|
|
> Edge 15 doesn't support isIntersecting, but we can infer it |
|
|
|
|
|
|
|
|
|
It then implements a polyfill (intersectionRect.height > 0) which isn't |
|
|
|
|
actually sufficient. The short answer is, this behaviour isn't really |
|
|
|
|
supported on Edge but we can get kinda close. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
handleIntersection = (entry) => { |
|
|
|
|
// 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); |
|
|
|
|
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, |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
return { |
|
|
|
|
isIntersecting: isIntersecting, |
|
|
|
|
isHidden: false, |
|
|
|
|
}; |
|
|
|
|
}); |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
hideIfNotIntersecting = () => { |
|
|
|
|
if (!this.componentMounted) { |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `hideIfNotIntersecting()`. |
|
|
|
|
|
|
|
|
|
This function will hide the status if we're still not intersecting. |
|
|
|
|
Hiding the status means that it will just render an empty div instead |
|
|
|
|
of actual content, which saves RAMS and CPUs or some such. |
|
|
|
|
|
|
|
|
|
// 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 })); |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
hideIfNotIntersecting = () => { |
|
|
|
|
if (!this.componentMounted) return; |
|
|
|
|
this.setState( |
|
|
|
|
(prevState) => ({ isHidden: !prevState.isIntersecting }) |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `saveHeight()`. |
|
|
|
|
|
|
|
|
|
`saveHeight()` saves the height of our status so that when whe hide it |
|
|
|
|
we preserve its dimensions. We only want to store our height, though, |
|
|
|
|
if our status has content (otherwise, it would imply that it is |
|
|
|
|
already hidden). |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
saveHeight = () => { |
|
|
|
|
if (this.node && this.node.children.length !== 0) { |
|
|
|
|
if (this.node && this.node.children.length) { |
|
|
|
|
this.height = this.node.getBoundingClientRect().height; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `setExpansion()`. |
|
|
|
|
|
|
|
|
|
`setExpansion()` sets the value of `isExpanded` in our state. It takes |
|
|
|
|
one argument, `value`, which gives the desired value for `isExpanded`. |
|
|
|
|
The default for this argument is `null`. |
|
|
|
|
|
|
|
|
|
`setExpansion()` automatically checks for us whether toot collapsing |
|
|
|
|
is enabled, so we don't have to. |
|
|
|
|
|
|
|
|
|
We use a `switch` statement to simplify our code. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
setExpansion = (value) => { |
|
|
|
|
switch (true) { |
|
|
|
|
case value === undefined || value === null: |
|
|
|
|
this.setState({ isExpanded: null }); |
|
|
|
|
break; |
|
|
|
|
case !value && this.props.settings.getIn(['collapsed', 'enabled']): |
|
|
|
|
this.setState({ isExpanded: false }); |
|
|
|
|
break; |
|
|
|
|
case !!value: |
|
|
|
|
this.setState({ isExpanded: true }); |
|
|
|
|
break; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
#### `handleRef()`. |
|
|
|
|
|
|
|
|
|
`handleRef()` just saves a reference to our status node to `this.node`. |
|
|
|
|
It also saves our height, in case the height of our node has changed. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
handleRef = (node) => { |
|
|
|
|
this.node = node; |
|
|
|
|
this.saveHeight(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
handleClick = () => { |
|
|
|
|
const { status } = this.props; |
|
|
|
|
const { isCollapsed } = this.state; |
|
|
|
|
if (isCollapsed) this.handleCollapsedClick(); |
|
|
|
|
else this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); |
|
|
|
|
} |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
handleAccountClick = (e) => { |
|
|
|
|
#### `parseClick()`. |
|
|
|
|
|
|
|
|
|
`parseClick()` takes a click event and responds appropriately. |
|
|
|
|
If our status is collapsed, then clicking on it should uncollapse it. |
|
|
|
|
If `Shift` is held, then clicking on it should collapse it. |
|
|
|
|
Otherwise, we open the url handed to us in `destination`, if |
|
|
|
|
applicable. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
parseClick = (e, destination) => { |
|
|
|
|
const { router } = this.context; |
|
|
|
|
const { status } = this.props; |
|
|
|
|
const { isExpanded } = this.state; |
|
|
|
|
if (destination === undefined) { |
|
|
|
|
destination = `/statuses/${ |
|
|
|
|
status.getIn(['reblog', 'id'], status.get('id')) |
|
|
|
|
}`;
|
|
|
|
|
} |
|
|
|
|
if (e.button === 0) { |
|
|
|
|
const id = Number(e.currentTarget.getAttribute('data-id')); |
|
|
|
|
if (isExpanded === false) this.setExpansion(null); |
|
|
|
|
else if (e.shiftKey) { |
|
|
|
|
this.setExpansion(false); |
|
|
|
|
document.getSelection().removeAllRanges(); |
|
|
|
|
} else router.history.push(destination); |
|
|
|
|
e.preventDefault(); |
|
|
|
|
if (this.state.isCollapsed) this.handleCollapsedClick(); |
|
|
|
|
else this.context.router.history.push(`/accounts/${id}`); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
handleExpandedToggle = () => { |
|
|
|
|
this.setState({ isExpanded: !this.state.isExpanded, isCollapsed: false }); |
|
|
|
|
}; |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
handleCollapsedClick = () => { |
|
|
|
|
this.collapse(!this.state.isCollapsed); |
|
|
|
|
this.setState({ isExpanded: false }); |
|
|
|
|
} |
|
|
|
|
#### `render()`. |
|
|
|
|
|
|
|
|
|
`render()` actually puts our element on the screen. The particulars of |
|
|
|
|
this operation are further explained in the code below. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
render () { |
|
|
|
|
const { parseClick, setExpansion, handleRef } = this; |
|
|
|
|
const { |
|
|
|
|
status, |
|
|
|
|
account, |
|
|
|
|
settings, |
|
|
|
|
collapsed, |
|
|
|
|
muted, |
|
|
|
|
prepend, |
|
|
|
|
intersectionObserverWrapper, |
|
|
|
|
onOpenVideo, |
|
|
|
|
onOpenMedia, |
|
|
|
|
autoPlayGif, |
|
|
|
|
...other |
|
|
|
|
} = this.props; |
|
|
|
|
const { isExpanded, isIntersecting, isHidden } = this.state; |
|
|
|
|
let background = null; |
|
|
|
|
let attachments = null; |
|
|
|
|
let media = null; |
|
|
|
|
let mediaIcon = null; |
|
|
|
|
let statusAvatar; |
|
|
|
|
|
|
|
|
|
// Exclude intersectionObserverWrapper from `other` variable
|
|
|
|
|
// because intersection is managed in here.
|
|
|
|
|
const { status, account, settings, intersectionObserverWrapper, intl, ...other } = this.props; |
|
|
|
|
const { isExpanded, isIntersecting, isHidden, isCollapsed } = this.state; |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
If we don't have a status, then we don't render anything. |
|
|
|
|
|
|
|
|
|
let background = settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds']) ? status.getIn(['account', 'header']) : null; |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
if (status === null) { |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
If our status is offscreen and hidden, then we render an empty <div> in |
|
|
|
|
its place. We fill it with "content" but note that opacity is set to 0. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
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'])} |
|
|
|
|
<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('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 = ( |
|
|
|
|
If user backgrounds for collapsed statuses are enabled, then we |
|
|
|
|
initialize our background accordingly. This will only be rendered if |
|
|
|
|
the status is collapsed. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
if ( |
|
|
|
|
settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds']) |
|
|
|
|
) background = status.getIn(['account', 'header']); |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
This handles our media attachments. Note that we don't show media on |
|
|
|
|
muted (notification) statuses. If the media type is unknown, then we |
|
|
|
|
simply ignore it. |
|
|
|
|
|
|
|
|
|
After we have generated our appropriate media element and stored it in |
|
|
|
|
`media`, we snatch the thumbnail to use as our `background` if media |
|
|
|
|
backgrounds for collapsed statuses are enabled. |
|
|
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
attachments = status.get('media_attachments'); |
|
|
|
|
if (attachments.size && !muted) { |
|
|
|
|
if (attachments.some((item) => item.get('type') === 'unknown')) { |
|
|
|
|
|
|
|
|
|
} else if ( |
|
|
|
|
attachments.getIn([0, 'type']) === 'video' |
|
|
|
|
) { |
|
|
|
|
media = ( // Media type is 'video'
|
|
|
|
|
<VideoPlayer |
|
|
|
|
media={status.getIn(['media_attachments', 0])} |
|
|
|
|
media={attachments.get(0)} |
|
|
|
|
sensitive={status.get('sensitive')} |
|
|
|
|
letterbox={settings.getIn(['media', 'letterbox'])} |
|
|
|
|
height={250} |
|
|
|
|
onOpenVideo={this.props.onOpenVideo} |
|
|
|
|
onOpenVideo={onOpenVideo} |
|
|
|
|
/> |
|
|
|
|
); |
|
|
|
|
mediaIcon = 'video-camera'; |
|
|
|
|
} else { |
|
|
|
|
} else { // Media type is 'image' or 'gifv'
|
|
|
|
|
media = ( |
|
|
|
|
<MediaGallery |
|
|
|
|
media={status.get('media_attachments')} |
|
|
|
|
media={attachments} |
|
|
|
|
sensitive={status.get('sensitive')} |
|
|
|
|
letterbox={settings.getIn(['media', 'letterbox'])} |
|
|
|
|
height={250} |
|
|
|
|
onOpenMedia={this.props.onOpenMedia} |
|
|
|
|
autoPlayGif={this.props.autoPlayGif} |
|
|
|
|
onOpenMedia={onOpenMedia} |
|
|
|
|
autoPlayGif={autoPlayGif} |
|
|
|
|
/> |
|
|
|
|
); |
|
|
|
|
mediaIcon = 'picture-o'; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) background = status.getIn(['media_attachments', 0]).get('preview_url'); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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')} />; |
|
|
|
|
if ( |
|
|
|
|
!status.get('sensitive') && |
|
|
|
|
!(status.get('spoiler_text').length > 0) && |
|
|
|
|
settings.getIn(['collapsed', 'backgrounds', 'preview_images']) |
|
|
|
|
) background = attachments.getIn([0, 'preview_url']); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')} ${isCollapsed ? 'status-collapsed' : ''}`} data-id={status.get('id')} ref={this.handleRef} style={{ backgroundImage: background && isCollapsed ? 'url(' + background + ')' : 'none' }}> |
|
|
|
|
<div className='status__info'> |
|
|
|
|
|
|
|
|
|
<div className='status__info__icons'> |
|
|
|
|
{mediaIcon ? <i className={`fa fa-fw fa-${mediaIcon}`} aria-hidden='true' /> : null} |
|
|
|
|
{settings.getIn(['collapsed', 'enabled']) ? <IconButton |
|
|
|
|
className='status__collapse-button' |
|
|
|
|
animate flip |
|
|
|
|
active={isCollapsed} |
|
|
|
|
title={isCollapsed ? intl.formatMessage(messages.uncollapse) : intl.formatMessage(messages.collapse)} |
|
|
|
|
icon='angle-double-up' |
|
|
|
|
onClick={this.handleCollapsedClick} |
|
|
|
|
/> : null} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<a onClick={this.handleAccountClick} 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} mediaIcon={mediaIcon} onClick={this.handleClick} expanded={isExpanded} collapsed={isCollapsed} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight}> |
|
|
|
|
/* |
|
|
|
|
|
|
|
|
|
{isCollapsed ? null : media} |
|
|
|
|
Finally, we can render our status. We just put the pieces together |
|
|
|
|
from above. We only render the action bar if the status isn't |
|
|
|
|
collapsed. |
|
|
|
|
|
|
|
|
|
</StatusContent> |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
{isCollapsed ? null : <StatusActionBar status={status} account={account} {...other} />} |
|
|
|
|
</div> |
|
|
|
|
return ( |
|
|
|
|
<article |
|
|
|
|
className={ |
|
|
|
|
`status${ |
|
|
|
|
muted ? ' muted' : '' |
|
|
|
|
} status-${status.get('visibility')}${ |
|
|
|
|
isExpanded === false ? ' collapsed' : '' |
|
|
|
|
}${ |
|
|
|
|
isExpanded === false && background ? ' has-background' : '' |
|
|
|
|
}` |
|
|
|
|
} |
|
|
|
|
style={{ |
|
|
|
|
backgroundImage: ( |
|
|
|
|
isExpanded === false && background ? |
|
|
|
|
`url(${background})` : |
|
|
|
|
'none' |
|
|
|
|
), |
|
|
|
|
}} |
|
|
|
|
ref={handleRef} |
|
|
|
|
> |
|
|
|
|
{prepend && account ? ( |
|
|
|
|
<StatusPrepend |
|
|
|
|
type={prepend} |
|
|
|
|
account={account} |
|
|
|
|
parseClick={parseClick} |
|
|
|
|
/> |
|
|
|
|
) : null} |
|
|
|
|
<StatusHeader |
|
|
|
|
account={status.get('account')} |
|
|
|
|
friend={account} |
|
|
|
|
mediaIcon={mediaIcon} |
|
|
|
|
collapsible={settings.getIn(['collapsed', 'enabled'])} |
|
|
|
|
collapsed={isExpanded === false} |
|
|
|
|
parseClick={parseClick} |
|
|
|
|
setExpansion={setExpansion} |
|
|
|
|
/> |
|
|
|
|
<StatusContent |
|
|
|
|
status={status} |
|
|
|
|
media={media} |
|
|
|
|
mediaIcon={mediaIcon} |
|
|
|
|
expanded={isExpanded} |
|
|
|
|
setExpansion={this.setExpansion} |
|
|
|
|
onHeightUpdate={this.saveHeight} |
|
|
|
|
parseClick={parseClick} |
|
|
|
|
/> |
|
|
|
|
{isExpanded !== false ? ( |
|
|
|
|
<StatusActionBar |
|
|
|
|
{...other} |
|
|
|
|
status={status} |
|
|
|
|
account={status.get('account')} |
|
|
|
|
/> |
|
|
|
|
) : null} |
|
|
|
|
</article> |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|