mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-14 07:05:11 +01:00
049c9a3b97
Cache width shared by Video player, MediaGallery and Cards at the ScrollableList level, pass it down through StatusList and Notifications.
599 lines
19 KiB
JavaScript
599 lines
19 KiB
JavaScript
import React from 'react';
|
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
import PropTypes from 'prop-types';
|
|
import StatusPrepend from './status_prepend';
|
|
import StatusHeader from './status_header';
|
|
import StatusIcons from './status_icons';
|
|
import StatusContent from './status_content';
|
|
import StatusActionBar from './status_action_bar';
|
|
import AttachmentList from './attachment_list';
|
|
import Card from '../features/status/components/card';
|
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
|
|
import { HotKeys } from 'react-hotkeys';
|
|
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
|
|
import classNames from 'classnames';
|
|
import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
|
|
|
|
// 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';
|
|
|
|
export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => {
|
|
const displayName = status.getIn(['account', 'display_name']);
|
|
|
|
const values = [
|
|
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
|
status.get('spoiler_text') && !expanded ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
|
|
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
|
status.getIn(['account', 'acct']),
|
|
];
|
|
|
|
if (rebloggedByText) {
|
|
values.push(rebloggedByText);
|
|
}
|
|
|
|
return values.join(', ');
|
|
};
|
|
|
|
@injectIntl
|
|
export default class Status extends ImmutablePureComponent {
|
|
|
|
static contextTypes = {
|
|
router: PropTypes.object,
|
|
};
|
|
|
|
static propTypes = {
|
|
containerId: PropTypes.string,
|
|
id: PropTypes.string,
|
|
status: ImmutablePropTypes.map,
|
|
account: ImmutablePropTypes.map,
|
|
onReply: PropTypes.func,
|
|
onFavourite: PropTypes.func,
|
|
onReblog: PropTypes.func,
|
|
onDelete: PropTypes.func,
|
|
onDirect: PropTypes.func,
|
|
onMention: PropTypes.func,
|
|
onPin: PropTypes.func,
|
|
onOpenMedia: PropTypes.func,
|
|
onOpenVideo: PropTypes.func,
|
|
onBlock: PropTypes.func,
|
|
onEmbed: PropTypes.func,
|
|
onHeightChange: PropTypes.func,
|
|
muted: PropTypes.bool,
|
|
collapse: PropTypes.bool,
|
|
hidden: PropTypes.bool,
|
|
prepend: PropTypes.string,
|
|
withDismiss: PropTypes.bool,
|
|
onMoveUp: PropTypes.func,
|
|
onMoveDown: PropTypes.func,
|
|
getScrollPosition: PropTypes.func,
|
|
updateScrollBottom: PropTypes.func,
|
|
expanded: PropTypes.bool,
|
|
intl: PropTypes.object.isRequired,
|
|
cacheMediaWidth: PropTypes.func,
|
|
cachedMediaWidth: PropTypes.number,
|
|
};
|
|
|
|
state = {
|
|
isCollapsed: false,
|
|
autoCollapsed: false,
|
|
isExpanded: undefined,
|
|
}
|
|
|
|
// 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',
|
|
'prepend',
|
|
'boostModal',
|
|
'favouriteModal',
|
|
'muted',
|
|
'collapse',
|
|
'notification',
|
|
'hidden',
|
|
'expanded',
|
|
]
|
|
|
|
updateOnStates = [
|
|
'isExpanded',
|
|
'isCollapsed',
|
|
]
|
|
|
|
// 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
|
|
// `getderivedStateFromProps()`.
|
|
|
|
// 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.
|
|
static getDerivedStateFromProps(nextProps, prevState) {
|
|
let update = {};
|
|
let updated = false;
|
|
|
|
// Make sure the state mirrors props we track…
|
|
if (nextProps.collapse !== prevState.collapseProp) {
|
|
update.collapseProp = nextProps.collapse;
|
|
updated = true;
|
|
}
|
|
if (nextProps.expanded !== prevState.expandedProp) {
|
|
update.expandedProp = nextProps.expanded;
|
|
updated = true;
|
|
}
|
|
|
|
// Update state based on new props
|
|
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
|
|
if (prevState.isCollapsed) {
|
|
update.isCollapsed = false;
|
|
updated = true;
|
|
}
|
|
} else if (
|
|
nextProps.collapse !== prevState.collapseProp &&
|
|
nextProps.collapse !== undefined
|
|
) {
|
|
update.isCollapsed = nextProps.collapse;
|
|
if (nextProps.collapse) update.isExpanded = false;
|
|
updated = true;
|
|
}
|
|
if (nextProps.expanded !== prevState.expandedProp &&
|
|
nextProps.expanded !== undefined
|
|
) {
|
|
update.isExpanded = nextProps.expanded;
|
|
if (nextProps.expanded) update.isCollapsed = false;
|
|
updated = true;
|
|
}
|
|
|
|
if (nextProps.expanded === undefined &&
|
|
prevState.isExpanded === undefined &&
|
|
update.isExpanded === undefined
|
|
) {
|
|
const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
|
|
if (isExpanded !== undefined) {
|
|
update.isExpanded = isExpanded;
|
|
updated = true;
|
|
}
|
|
}
|
|
|
|
return updated ? update : null;
|
|
}
|
|
|
|
// 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 `setCollapsed()` 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.
|
|
// - The status is a reblog the user has decided to collapse all
|
|
// statuses which are reblogs.
|
|
componentDidMount () {
|
|
const { node } = this;
|
|
const {
|
|
status,
|
|
settings,
|
|
collapse,
|
|
muted,
|
|
prepend,
|
|
} = this.props;
|
|
|
|
// Prevent a crash when node is undefined. Not completely sure why this
|
|
// happens, might be because status === null.
|
|
if (node === undefined) return;
|
|
|
|
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
|
|
|
|
if (function () {
|
|
switch (true) {
|
|
case !!collapse:
|
|
case !!autoCollapseSettings.get('all'):
|
|
case autoCollapseSettings.get('notifications') && !!muted:
|
|
case autoCollapseSettings.get('lengthy') && node.clientHeight > (
|
|
status.get('media_attachments').size && !muted ? 650 : 400
|
|
):
|
|
case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
|
|
case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
|
|
case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}()) {
|
|
this.setCollapsed(true);
|
|
// Hack to fix timeline jumps on second rendering when auto-collapsing
|
|
this.setState({ autoCollapsed: true });
|
|
}
|
|
}
|
|
|
|
getSnapshotBeforeUpdate (prevProps, prevState) {
|
|
if (this.props.getScrollPosition) {
|
|
return this.props.getScrollPosition();
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Hack to fix timeline jumps on second rendering when auto-collapsing
|
|
componentDidUpdate (prevProps, prevState, snapshot) {
|
|
if (this.state.autoCollapsed) {
|
|
this.setState({ autoCollapsed: false });
|
|
if (snapshot !== null && this.props.updateScrollBottom) {
|
|
if (this.node.offsetTop < snapshot.top) {
|
|
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// `setCollapsed()` sets the value of `isCollapsed` in our state, that is,
|
|
// whether the toot is collapsed or not.
|
|
|
|
// `setCollapsed()` automatically checks for us whether toot collapsing
|
|
// is enabled, so we don't have to.
|
|
setCollapsed = (value) => {
|
|
if (this.props.settings.getIn(['collapsed', 'enabled'])) {
|
|
this.setState({ isCollapsed: value });
|
|
if (value) {
|
|
this.setExpansion(false);
|
|
}
|
|
} else {
|
|
this.setState({ isCollapsed: false });
|
|
}
|
|
}
|
|
|
|
setExpansion = (value) => {
|
|
this.setState({ isExpanded: value });
|
|
if (value) {
|
|
this.setCollapsed(false);
|
|
}
|
|
}
|
|
|
|
// `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 { isCollapsed } = this.state;
|
|
if (!router) return;
|
|
if (destination === undefined) {
|
|
destination = `/statuses/${
|
|
status.getIn(['reblog', 'id'], status.get('id'))
|
|
}`;
|
|
}
|
|
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
|
if (isCollapsed) this.setCollapsed(false);
|
|
else if (e.shiftKey) {
|
|
this.setCollapsed(true);
|
|
document.getSelection().removeAllRanges();
|
|
} else router.history.push(destination);
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
handleAccountClick = (e) => {
|
|
if (this.context.router && e.button === 0) {
|
|
const id = e.currentTarget.getAttribute('data-id');
|
|
e.preventDefault();
|
|
this.context.router.history.push(`/accounts/${id}`);
|
|
}
|
|
}
|
|
|
|
handleExpandedToggle = () => {
|
|
if (this.props.status.get('spoiler_text')) {
|
|
this.setExpansion(!this.state.isExpanded);
|
|
}
|
|
};
|
|
|
|
handleOpenVideo = (media, startTime) => {
|
|
this.props.onOpenVideo(media, startTime);
|
|
}
|
|
|
|
handleHotkeyReply = e => {
|
|
e.preventDefault();
|
|
this.props.onReply(this.props.status, this.context.router.history);
|
|
}
|
|
|
|
handleHotkeyFavourite = (e) => {
|
|
this.props.onFavourite(this.props.status, e);
|
|
}
|
|
|
|
handleHotkeyBoost = e => {
|
|
this.props.onReblog(this.props.status, e);
|
|
}
|
|
|
|
handleHotkeyMention = e => {
|
|
e.preventDefault();
|
|
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
|
}
|
|
|
|
handleHotkeyOpen = () => {
|
|
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
|
}
|
|
|
|
handleHotkeyOpenProfile = () => {
|
|
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
|
}
|
|
|
|
handleHotkeyMoveUp = e => {
|
|
this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
|
|
}
|
|
|
|
handleHotkeyMoveDown = e => {
|
|
this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
|
|
}
|
|
|
|
handleRef = c => {
|
|
this.node = c;
|
|
}
|
|
|
|
renderLoadingMediaGallery () {
|
|
return <div className='media_gallery' style={{ height: '110px' }} />;
|
|
}
|
|
|
|
renderLoadingVideoPlayer () {
|
|
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
|
}
|
|
|
|
render () {
|
|
const {
|
|
handleRef,
|
|
parseClick,
|
|
setExpansion,
|
|
setCollapsed,
|
|
} = this;
|
|
const { router } = this.context;
|
|
const {
|
|
intl,
|
|
status,
|
|
account,
|
|
settings,
|
|
collapsed,
|
|
muted,
|
|
prepend,
|
|
intersectionObserverWrapper,
|
|
onOpenVideo,
|
|
onOpenMedia,
|
|
notification,
|
|
hidden,
|
|
featured,
|
|
...other
|
|
} = this.props;
|
|
const { isExpanded, isCollapsed } = this.state;
|
|
let background = null;
|
|
let attachments = null;
|
|
let media = null;
|
|
let mediaIcon = null;
|
|
|
|
if (status === null) {
|
|
return null;
|
|
}
|
|
|
|
if (hidden) {
|
|
return (
|
|
<div ref={this.handleRef}>
|
|
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
|
{' '}
|
|
{status.get('content')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
|
const minHandlers = this.props.muted ? {} : {
|
|
moveUp: this.handleHotkeyMoveUp,
|
|
moveDown: this.handleHotkeyMoveDown,
|
|
};
|
|
|
|
return (
|
|
<HotKeys handlers={minHandlers}>
|
|
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'>
|
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
|
</div>
|
|
</HotKeys>
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
// If a media file is of unknwon type or if the status is muted
|
|
// (notification), we show a list of links instead of embedded media.
|
|
|
|
// 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 > 0) {
|
|
if (muted || attachments.some(item => item.get('type') === 'unknown')) {
|
|
media = (
|
|
<AttachmentList
|
|
compact
|
|
media={status.get('media_attachments')}
|
|
/>
|
|
);
|
|
} else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
|
|
const video = status.getIn(['media_attachments', 0]);
|
|
|
|
media = (
|
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
|
{Component => (<Component
|
|
preview={video.get('preview_url')}
|
|
src={video.get('url')}
|
|
alt={video.get('description')}
|
|
inline
|
|
sensitive={status.get('sensitive')}
|
|
letterbox={settings.getIn(['media', 'letterbox'])}
|
|
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
|
preventPlayback={isCollapsed || !isExpanded}
|
|
onOpenVideo={this.handleOpenVideo}
|
|
width={this.props.cachedMediaWidth}
|
|
cacheWidth={this.props.cacheMediaWidth}
|
|
/>)}
|
|
</Bundle>
|
|
);
|
|
mediaIcon = 'video-camera';
|
|
} else { // Media type is 'image' or 'gifv'
|
|
media = (
|
|
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
|
{Component => (
|
|
<Component
|
|
media={attachments}
|
|
sensitive={status.get('sensitive')}
|
|
letterbox={settings.getIn(['media', 'letterbox'])}
|
|
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
|
hidden={isCollapsed || !isExpanded}
|
|
onOpenMedia={this.props.onOpenMedia}
|
|
cacheWidth={this.props.cacheMediaWidth}
|
|
defaultWidth={this.props.cachedMediaWidth}
|
|
/>
|
|
)}
|
|
</Bundle>
|
|
);
|
|
mediaIcon = 'picture-o';
|
|
}
|
|
|
|
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
|
|
background = attachments.getIn([0, 'preview_url']);
|
|
}
|
|
} else if (status.get('card') && settings.get('inline_preview_cards')) {
|
|
media = (
|
|
<Card
|
|
onOpenMedia={this.props.onOpenMedia}
|
|
card={status.get('card')}
|
|
compact
|
|
cacheWidth={this.props.cacheMediaWidth}
|
|
defaultWidth={this.props.cachedMediaWidth}
|
|
/>
|
|
);
|
|
mediaIcon = 'link';
|
|
}
|
|
|
|
// Here we prepare extra data-* attributes for CSS selectors.
|
|
// Users can use those for theming, hiding avatars etc via UserStyle
|
|
const selectorAttribs = {
|
|
'data-status-by': `@${status.getIn(['account', 'acct'])}`,
|
|
};
|
|
|
|
if (prepend && account) {
|
|
const notifKind = {
|
|
favourite: 'favourited',
|
|
reblog: 'boosted',
|
|
reblogged_by: 'boosted',
|
|
}[prepend];
|
|
|
|
selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
|
|
}
|
|
|
|
let rebloggedByText;
|
|
|
|
if (prepend === 'reblog') {
|
|
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
|
|
}
|
|
|
|
const handlers = {
|
|
reply: this.handleHotkeyReply,
|
|
favourite: this.handleHotkeyFavourite,
|
|
boost: this.handleHotkeyBoost,
|
|
mention: this.handleHotkeyMention,
|
|
open: this.handleHotkeyOpen,
|
|
openProfile: this.handleHotkeyOpenProfile,
|
|
moveUp: this.handleHotkeyMoveUp,
|
|
moveDown: this.handleHotkeyMoveDown,
|
|
toggleSpoiler: this.handleExpandedToggle,
|
|
};
|
|
|
|
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
|
|
collapsed: isCollapsed,
|
|
'has-background': isCollapsed && background,
|
|
'status__wrapper-reply': !!status.get('in_reply_to_id'),
|
|
muted,
|
|
}, 'focusable');
|
|
|
|
return (
|
|
<HotKeys handlers={handlers}>
|
|
<div
|
|
className={computedClass}
|
|
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
|
{...selectorAttribs}
|
|
ref={handleRef}
|
|
tabIndex='0'
|
|
data-featured={featured ? 'true' : null}
|
|
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
|
|
>
|
|
<header className='status__info'>
|
|
<span>
|
|
{prepend && account ? (
|
|
<StatusPrepend
|
|
type={prepend}
|
|
account={account}
|
|
parseClick={parseClick}
|
|
notificationId={this.props.notificationId}
|
|
/>
|
|
) : null}
|
|
{!muted || !isCollapsed ? (
|
|
<StatusHeader
|
|
status={status}
|
|
friend={account}
|
|
collapsed={isCollapsed}
|
|
parseClick={parseClick}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
<StatusIcons
|
|
status={status}
|
|
mediaIcon={mediaIcon}
|
|
collapsible={settings.getIn(['collapsed', 'enabled'])}
|
|
collapsed={isCollapsed}
|
|
setCollapsed={setCollapsed}
|
|
/>
|
|
</header>
|
|
<StatusContent
|
|
status={status}
|
|
media={media}
|
|
mediaIcon={mediaIcon}
|
|
expanded={isExpanded}
|
|
onExpandedToggle={this.handleExpandedToggle}
|
|
parseClick={parseClick}
|
|
disabled={!router}
|
|
/>
|
|
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
|
|
<StatusActionBar
|
|
{...other}
|
|
status={status}
|
|
account={status.get('account')}
|
|
showReplyCount={settings.get('show_reply_count')}
|
|
/>
|
|
) : null}
|
|
{notification ? (
|
|
<NotificationOverlayContainer
|
|
notification={notification}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</HotKeys>
|
|
);
|
|
}
|
|
|
|
}
|