Statuses redux!

- Better unified reblogs, statuses, and notifications
- Polished up collapsed toots greatly
- Apologies to bea if this makes everything more difficult
This commit is contained in:
kibigo! 2017-07-05 18:51:03 -07:00
parent 4cbbea5881
commit bba75c15f1
8 changed files with 1234 additions and 338 deletions

View File

@ -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 React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Avatar from './avatar'; import ImmutablePropTypes from 'react-immutable-proptypes';
import AvatarOverlay from './avatar_overlay';
import DisplayName from './display_name'; // `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 MediaGallery from './media_gallery';
import VideoPlayer from './video_player'; 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 StatusContent from './status_content';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import IconButton from './icon_button';
import { defineMessages, FormattedMessage } from 'react-intl'; // This is used to schedule tasks at the browser's convenience:
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 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 = { The `<Status>` component:
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,
};
// Avoid checking props that are functions (and whose equality will always The `<Status>` component is a container for statuses. It consists of a
// evaluate to false. See react-immutable-pure-component for usage. few parts:
updateOnProps = [
'status',
'account',
'settings',
'wrapped',
'me',
'boostModal',
'autoPlayGif',
'muted',
'collapse',
]
render () { - The `<StatusPrepend>`, which contains tangential information about
// Exclude intersectionObserverWrapper from `other` variable the status, such as who reblogged it.
// because intersection is managed in here. - The `<StatusHeader>`, which contains the avatar and username of the
const { status, account, ...other } = this.props; 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) { ### Context
return null;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - __`router` (`PropTypes.object`) :__
let displayName = status.getIn(['account', 'display_name']); We need to get our router from the surrounding React context.
if (displayName.length === 0) { ### Props
displayName = status.getIn(['account', 'username']);
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; - __`id` (`PropTypes.number`) :__
The id of the status.
return ( - __`status` (`ImmutablePropTypes.map`) :__
<div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} > The status object, straight from the store.
<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} status={status.get('reblog')} account={status.get('account')} wrapped /> - __`account` (`ImmutablePropTypes.map`) :__
</div> Don't be confused by this one! This is **not** the account which
); posted the status, but the associated account with any further
} else return <Status {...this.props} />; 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.
class Status extends ImmutablePureComponent { - __`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.
### 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 = { static contextTypes = {
router : PropTypes.object, router : PropTypes.object,
}; };
static propTypes = { static propTypes = {
id : PropTypes.number,
status : ImmutablePropTypes.map, status : ImmutablePropTypes.map,
account : ImmutablePropTypes.map, account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map, settings : ImmutablePropTypes.map,
wrapped: PropTypes.bool, me : PropTypes.number,
onReply: PropTypes.func,
onFavourite : PropTypes.func, onFavourite : PropTypes.func,
onReblog : PropTypes.func, onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
onDelete : PropTypes.func, onDelete : PropTypes.func,
onMention : PropTypes.func,
onMute : PropTypes.func,
onMuteConversation : PropTypes.func,
onBlock : PropTypes.func,
onReport : PropTypes.func,
onOpenMedia : PropTypes.func, onOpenMedia : PropTypes.func,
onOpenVideo : PropTypes.func, onOpenVideo : PropTypes.func,
onBlock: PropTypes.func, reblogModal : PropTypes.bool,
me: PropTypes.number, deleteModal : PropTypes.bool,
boostModal: PropTypes.bool,
autoPlayGif : PropTypes.bool, autoPlayGif : PropTypes.bool,
muted : PropTypes.bool, muted : PropTypes.bool,
collapse : PropTypes.bool, collapse : PropTypes.bool,
prepend : PropTypes.string,
withDismiss : PropTypes.bool,
intersectionObserverWrapper : PropTypes.object, intersectionObserverWrapper : PropTypes.object,
intl: PropTypes.object.isRequired,
}; };
state = { state = {
isExpanded: false, isExpanded : null,
isIntersecting: true, // assume intersecting until told otherwise isIntersecting : true,
isHidden: false, // set to true in requestIdleCallback to trigger un-render isHidden : false,
isCollapsed: 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 = [ updateOnProps = [
'status', 'status',
'account', 'account',
'settings', 'settings',
'wrapped', 'prepend',
'me', 'me',
'boostModal', 'boostModal',
'autoPlayGif', 'autoPlayGif',
@ -140,74 +219,191 @@ class Status extends ImmutablePureComponent {
updateOnStates = [ updateOnStates = [
'isExpanded', '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) { componentWillReceiveProps (nextProps) {
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) this.collapse(false); if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
else if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.collapse(this.props.collapse); this.setExpansion(false);
} else if (
nextProps.collapse !== this.props.collapse &&
nextProps.collapse !== undefined
) this.setExpansion(nextProps.collapse ? false : null);
} }
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);
}
componentDidUpdate () { #### `componentDidMount()`.
if (this.state.isIntersecting || !this.state.isHidden) this.saveHeight();
} 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 () { componentDidMount () {
const node = this.node; const { node, handleIntersection } = this;
const {
status,
settings,
collapse,
muted,
id,
intersectionObserverWrapper,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
const { collapse, settings, status } = this.props; 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 (collapse !== undefined) this.collapse(collapse); if (!intersectionObserverWrapper) return;
else if (settings.getIn(['collapsed', 'auto', 'all'])) this.collapse(); else intersectionObserverWrapper.observe(
else if (settings.getIn(['collapsed', 'auto', 'lengthy']) && node.clientHeight > (status.get('media_attachments').size > 0 && !this.props.muted ? 650 : 400)) this.collapse(); id,
else if (settings.getIn(['collapsed', 'auto', 'replies']) && status.get('in_reply_to_id', null) !== null) this.collapse(); node,
else if (settings.getIn(['collapsed', 'auto', 'media']) && !(status.get('spoiler_text').length > 0) && status.get('media_attachments').size > 0) this.collapse(); handleIntersection
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; 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) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
return this.state.isIntersecting || !this.state.isHidden;
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
return super.shouldComponentUpdate(nextProps, nextState);
}
}
/*
#### `componentDidUpdate()`.
If our component is being rendered for any reason and an update has
triggered, this will save its height.
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.
*/
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 () { componentWillUnmount () {
this.componentMounted = false; 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) => { handleIntersection = (entry) => {
// Edge 15 doesn't support isIntersecting, but we can infer it const isIntersecting = (
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ typeof entry.isIntersecting === 'boolean' ?
// https://github.com/WICG/IntersectionObserver/issues/211 entry.isIntersecting :
const isIntersecting = (typeof entry.isIntersecting === 'boolean') ? entry.intersectionRect.height > 0
entry.isIntersecting : entry.intersectionRect.height > 0; );
this.setState((prevState) => { this.setState(
(prevState) => {
if (prevState.isIntersecting && !isIntersecting) { if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting); scheduleIdleTask(this.hideIfNotIntersecting);
} }
@ -215,155 +411,311 @@ class Status extends ImmutablePureComponent {
isIntersecting : isIntersecting, isIntersecting : isIntersecting,
isHidden : false, isHidden : false,
}; };
});
} }
);
}
/*
#### `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.
*/
hideIfNotIntersecting = () => { hideIfNotIntersecting = () => {
if (!this.componentMounted) { if (!this.componentMounted) return;
return; this.setState(
(prevState) => ({ isHidden: !prevState.isIntersecting })
);
} }
// 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. #### `saveHeight()`.
// See: https://github.com/tootsuite/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); `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 = () => { saveHeight = () => {
if (this.node && this.node.children.length !== 0) { if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height; 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) => { handleRef = (node) => {
this.node = node; this.node = node;
this.saveHeight(); this.saveHeight();
} }
handleClick = () => { /*
#### `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 { status } = this.props;
const { isCollapsed } = this.state; const { isExpanded } = this.state;
if (isCollapsed) this.handleCollapsedClick(); if (destination === undefined) {
else this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
} }
handleAccountClick = (e) => {
if (e.button === 0) { 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(); 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 = () => { #### `render()`.
this.collapse(!this.state.isCollapsed);
this.setState({ isExpanded: false }); `render()` actually puts our element on the screen. The particulars of
} this operation are further explained in the code below.
*/
render () { 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 media = null;
let mediaIcon = 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) { if (status === null) {
return 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) { if (!isIntersecting && isHidden) {
return ( return (
<div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> <div
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} 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')} {status.get('content')}
</div> </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') { If user backgrounds for collapsed statuses are enabled, then we
media = ( 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 <VideoPlayer
media={status.getIn(['media_attachments', 0])} media={attachments.get(0)}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])} letterbox={settings.getIn(['media', 'letterbox'])}
height={250} height={250}
onOpenVideo={this.props.onOpenVideo} onOpenVideo={onOpenVideo}
/> />
); );
mediaIcon = 'video-camera'; mediaIcon = 'video-camera';
} else { } else { // Media type is 'image' or 'gifv'
media = ( media = (
<MediaGallery <MediaGallery
media={status.get('media_attachments')} media={attachments}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])} letterbox={settings.getIn(['media', 'letterbox'])}
height={250} height={250}
onOpenMedia={this.props.onOpenMedia} onOpenMedia={onOpenMedia}
autoPlayGif={this.props.autoPlayGif} autoPlayGif={autoPlayGif}
/> />
); );
mediaIcon = 'picture-o'; 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 (
!status.get('sensitive') &&
!(status.get('spoiler_text').length > 0) &&
settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
) background = attachments.getIn([0, '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')} />; 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.
*/
return ( 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' }}> <article
<div className='status__info'> className={
`status${
<div className='status__info__icons'> muted ? ' muted' : ''
{mediaIcon ? <i className={`fa fa-fw fa-${mediaIcon}`} aria-hidden='true' /> : null} } status-${status.get('visibility')}${
{settings.getIn(['collapsed', 'enabled']) ? <IconButton isExpanded === false ? ' collapsed' : ''
className='status__collapse-button' }${
animate flip isExpanded === false && background ? ' has-background' : ''
active={isCollapsed} }`
title={isCollapsed ? intl.formatMessage(messages.uncollapse) : intl.formatMessage(messages.collapse)} }
icon='angle-double-up' style={{
onClick={this.handleCollapsedClick} backgroundImage: (
/> : null} isExpanded === false && background ?
</div> `url(${background})` :
'none'
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> ),
<div className='status__avatar'> }}
{statusAvatar} ref={handleRef}
</div> >
{prepend && account ? (
<DisplayName account={status.get('account')} /> <StatusPrepend
</a> type={prepend}
account={account}
</div> parseClick={parseClick}
/>
<StatusContent status={status} mediaIcon={mediaIcon} onClick={this.handleClick} expanded={isExpanded} collapsed={isCollapsed} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight}> ) : null}
<StatusHeader
{isCollapsed ? null : media} account={status.get('account')}
friend={account}
</StatusContent> mediaIcon={mediaIcon}
collapsible={settings.getIn(['collapsed', 'enabled'])}
{isCollapsed ? null : <StatusActionBar status={status} account={account} {...other} />} collapsed={isExpanded === false}
</div> 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>
); );
} }
} }

View File

@ -15,13 +15,12 @@ export default class StatusContent extends React.PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.bool, expanded: PropTypes.oneOf([true, false, null]),
collapsed: PropTypes.bool, setExpansion: PropTypes.func,
onExpandedToggle: PropTypes.func,
onHeightUpdate: PropTypes.func, onHeightUpdate: PropTypes.func,
onClick: PropTypes.func, media: PropTypes.element,
mediaIcon: PropTypes.string, mediaIcon: PropTypes.string,
children: PropTypes.element, parseClick: PropTypes.func,
}; };
state = { state = {
@ -57,27 +56,22 @@ export default class StatusContent extends React.PureComponent {
} }
onLinkClick = (e) => { onLinkClick = (e) => {
if (e.button === 0 && this.props.collapsed) { if (this.props.expanded === false) {
e.preventDefault(); if (this.props.parseClick) this.props.parseClick(e);
if (this.props.onClick) this.props.onClick();
} }
} }
onMentionClick = (mention, e) => { onMentionClick = (mention, e) => {
if (e.button === 0) { if (this.props.parseClick) {
e.preventDefault(); this.props.parseClick(e, `/accounts/${mention.get('id')}`);
if (!this.props.collapsed) this.context.router.history.push(`/accounts/${mention.get('id')}`);
else if (this.props.onClick) this.props.onClick();
} }
} }
onHashtagClick = (hashtag, e) => { onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase(); hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (e.button === 0) { if (this.props.parseClick) {
e.preventDefault(); this.props.parseClick(e, `/timelines/tag/${hashtag}`);
if (!this.props.collapsed) this.context.router.history.push(`/timelines/tag/${hashtag}`);
else if (this.props.onClick) this.props.onClick();
} }
} }
@ -86,6 +80,8 @@ export default class StatusContent extends React.PureComponent {
} }
handleMouseUp = (e) => { handleMouseUp = (e) => {
const { parseClick } = this.props;
if (!this.startXY) { if (!this.startXY) {
return; return;
} }
@ -97,8 +93,8 @@ export default class StatusContent extends React.PureComponent {
return; return;
} }
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
this.props.onClick(); parseClick(e);
} }
this.startXY = null; this.startXY = null;
@ -107,9 +103,8 @@ export default class StatusContent extends React.PureComponent {
handleSpoilerClick = (e) => { handleSpoilerClick = (e) => {
e.preventDefault(); e.preventDefault();
if (this.props.onExpandedToggle) { if (this.props.setExpansion) {
// The parent manages the state this.props.setExpansion(this.props.expanded ? null : true);
this.props.onExpandedToggle();
} else { } else {
this.setState({ hidden: !this.state.hidden }); this.setState({ hidden: !this.state.hidden });
} }
@ -120,12 +115,20 @@ export default class StatusContent extends React.PureComponent {
} }
render () { render () {
const { status, children, mediaIcon } = this.props; const { status, media, mediaIcon } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const hidden = (
this.props.setExpansion ?
!this.props.expanded :
this.state.hidden
);
const content = { __html: emojify(status.get('content')) }; const content = { __html: emojify(status.get('content')) };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; const spoilerContent = {
__html: emojify(escapeTextContentForBrowser(
status.get('spoiler_text', '')
)),
};
const directionStyle = { direction: 'ltr' }; const directionStyle = { direction: 'ltr' };
if (isRtl(status.get('search_index'))) { if (isRtl(status.get('search_index'))) {
@ -136,12 +139,38 @@ export default class StatusContent extends React.PureComponent {
let mentionsPlaceholder = ''; let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => ( const mentionLinks = status.get('mentions').map(item => (
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'> <Permalink
to={`/accounts/${item.get('id')}`}
href={item.get('url')}
key={item.get('id')}
className='mention'
>
@<span>{item.get('username')}</span> @<span>{item.get('username')}</span>
</Permalink> </Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? [<FormattedMessage id='status.show_more' defaultMessage='Show more' key='0' />, mediaIcon ? <i className={`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`} aria-hidden='true' key='1' /> : null] : [<FormattedMessage id='status.show_less' defaultMessage='Show less' key='0' />]; const toggleText = hidden ? [
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/>,
mediaIcon ? (
<i
className={
`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
}
aria-hidden='true'
key='1'
/>
) : null,
] : [
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/>,
];
if (hidden) { if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>; mentionsPlaceholder = <div>{mentionLinks}</div>;
@ -170,12 +199,12 @@ export default class StatusContent extends React.PureComponent {
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
/> />
{children} {media}
</div> </div>
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.parseClick) {
return ( return (
<div <div
ref={this.setRef} ref={this.setRef}
@ -187,7 +216,7 @@ export default class StatusContent extends React.PureComponent {
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
/> />
{children} {media}
</div> </div>
); );
} else { } else {
@ -198,7 +227,7 @@ export default class StatusContent extends React.PureComponent {
style={directionStyle} style={directionStyle}
> >
<div dangerouslySetInnerHTML={content} /> <div dangerouslySetInnerHTML={content} />
{children} {media}
</div> </div>
); );
} }

View File

@ -0,0 +1,229 @@
/*
`<StatusHeader>`
================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Our standard React imports:
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// We will need internationalization in this component:
import { defineMessages, injectIntl } from 'react-intl';
// The various components used when constructing our header:
import Avatar from './avatar';
import AvatarOverlay from './avatar_overlay';
import DisplayName from './display_name';
import IconButton from './icon_button';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `collapse` and
`uncollapse` messages used with our collapse/uncollapse buttons.
*/
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
});
/* * * * */
/*
The `<StatusHeader>` component:
-------------------------------
The `<StatusHeader>` component wraps together the header information
(avatar, display name) and upper buttons and icons (collapsing, media
icons) into a single `<header>` element.
### Props
- __`account`, `friend` (`ImmutablePropTypes.map`) :__
These give the accounts associated with the status. `account` is
the author of the post; `friend` will have their avatar appear
in the overlay if provided.
- __`mediaIcon` (`PropTypes.string`) :__
If a mediaIcon should be placed in the header, this string
specifies it.
- __`collapsible`, `collapsed` (`PropTypes.bool`) :__
These props tell whether a post can be, and is, collapsed.
- __`parseClick` (`PropTypes.func`) :__
This function will be called when the user clicks inside the header
information.
- __`setExpansion` (`PropTypes.func`) :__
This function is used to set the expansion state of the post.
- __`intl` (`PropTypes.object`) :__
This is our internationalization object, provided by
`injectIntl()`.
*/
@injectIntl
export default class StatusHeader extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map,
mediaIcon: PropTypes.string,
collapsible: PropTypes.bool,
collapsed: PropTypes.bool,
parseClick: PropTypes.func.isRequired,
setExpansion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
/*
### Implementation
#### `handleCollapsedClick()`.
`handleCollapsedClick()` is just a simple callback for our collapsing
button. It calls `setExpansion` to set the collapsed state of the
status.
*/
handleCollapsedClick = (e) => {
const { collapsed, setExpansion } = this.props;
if (e.button === 0) {
setExpansion(collapsed ? null : false);
e.preventDefault();
}
}
/*
#### `handleAccountClick()`.
`handleAccountClick()` handles any clicks on the header info. It calls
`parseClick()` with our `account` as the anticipatory `destination`.
*/
handleAccountClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `render()`.
`render()` actually puts our element on the screen. `<StatusHeader>`
has a very straightforward rendering process.
*/
render () {
const {
account,
friend,
mediaIcon,
collapsible,
collapsed,
intl,
} = this.props;
return (
<header className='status__info'>
{
/*
We have to include the status icons before the header content because
it is rendered as a float.
*/
}
<div className='status__info__icons'>
{mediaIcon ? (
<i
className={`fa fa-fw fa-${mediaIcon}`}
aria-hidden='true'
/>
) : null}
{collapsible ? (
<IconButton
className='status__collapse-button'
animate flip
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
) : null}
</div>
{
/*
This begins our header content. It is all wrapped inside of a link
which gets handled by `handleAccountClick`. We use an `<AvatarOverlay>`
if we have a `friend` and a normal `<Avatar>` if we don't.
*/
}
<a
href={account.get('url')}
className='status__display-name'
onClick={this.handleAccountClick}
>
<div className='status__avatar'>{
friend ? (
<AvatarOverlay
staticSrc={account.get('avatar_static')}
overlaySrc={friend.get('avatar_static')}
/>
) : (
<Avatar
src={account.get('avatar')}
staticSrc={account.get('avatar_static')}
size={48}
/>
)
}</div>
<DisplayName account={account} />
</a>
</header>
);
}
}

View File

@ -0,0 +1,164 @@
/*
`<StatusPrepend>`
=================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Our standard React imports:
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// This helps us process our text:
import emojify from '../emoji';
import escapeTextContentForBrowser from 'escape-html';
import { FormattedMessage } from 'react-intl';
/* * * * */
/*
The `<StatusPrepend>` component:
--------------------------------
The `<StatusPrepend>` component holds a status's prepend, ie the text
that says X reblogged this, etc. It is represented by an `<aside>`
element.
### Props
- __`type` (`PropTypes.string`) :__
The type of prepend. One of `'reblogged_by'`, `'reblog'`,
`'favourite'`.
- __`account` (`ImmutablePropTypes.map`) :__
The account associated with the prepend.
- __`parseClick` (`PropTypes.func.isRequired`) :__
Our click parsing function.
*/
export default class StatusPrepend extends React.PureComponent {
static propTypes = {
type: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired,
parseClick: PropTypes.func.isRequired,
};
/*
### Implementation
#### `handleClick()`.
This is just a small wrapper for `parseClick()` that gets fired when
an account link is clicked.
*/
handleClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `<Message>`.
`<Message>` is a quick functional React component which renders the
actual prepend message based on our provided `type`. First we create a
`link` for the account's name, and then use `<FormattedMessage>` to
generate the message.
*/
Message = () => {
const { type, account } = this.props;
let link = (
<a
onClick={this.handleClick}
href={account.get('url')}
className='status__display-name'
>
<b
dangerouslySetInnerHTML={{
__html : emojify(escapeTextContentForBrowser(
account.get('display_name') || account.get('username')
)),
}}
/>
</a>
);
switch (type) {
case 'reblogged_by':
return (
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} boosted'
values={{ name : link }}
/>
);
case 'favourite':
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favourited your status'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name : link }}
/>
);
}
return null;
}
/*
#### `render()`.
Our `render()` is incredibly simple; we just render the icon and then
the `<Message>` inside of an <aside>.
*/
render () {
const { Message } = this;
const { type } = this.props;
return !type ? null : (
<aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<i
className={`fa fa-fw fa-${
type === 'favourite' ? 'star star-icon' : 'retweet'
} status__prepend-icon`}
/>
</div>
<Message />
</aside>
);
}
}

View File

@ -1,7 +1,34 @@
/*
`<StatusContainer>`
===================
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
detecting reblogs has been moved here from <Status>.
*/
/* * * * */
/*
Imports:
--------
*/
// Our standard React/Redux imports:
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
// Our `<Status>`:
import Status from '../components/status'; import Status from '../components/status';
// This selector helps us get our status from the store:
import { makeGetStatus } from '../selectors'; import { makeGetStatus } from '../selectors';
// These are our various `<Status>`-related actions:
import { import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
@ -16,33 +43,130 @@ import {
blockAccount, blockAccount,
muteAccount, muteAccount,
} from '../actions/accounts'; } from '../actions/accounts';
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; import {
muteStatus,
unmuteStatus,
deleteStatus,
} from '../actions/statuses';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// We will need internationalization in this component:
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we will
need in our component. In our case, these are the various confirmation
messages used with statuses.
*/
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm : {
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, id : 'confirmations.delete.confirm',
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, defaultMessage : 'Delete',
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, },
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',
},
}); });
/* * * * */
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in a `makeMapStateToProps()`
function to give us closure and preserve `getStatus()` across function
calls.
*/
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, ownProps) => {
status: getStatus(state, props.id),
let status = getStatus(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let account = undefined;
let prepend = undefined;
/*
Here we process reblogs. If our status is a reblog, then we create a
`prependMessage` to pass along to our `<Status>` along with the
reblogger's `account`, and set `coreStatus` (the one we will actually
render) to the status which has been reblogged.
*/
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';
}
/*
Here are the props we pass to `<Status>`.
*/
return {
status : status,
account : account || ownProps.account,
me : state.getIn(['meta', 'me']), me : state.getIn(['meta', 'me']),
settings : state.get('local_settings'), settings : state.get('local_settings'),
boostModal: state.getIn(['meta', 'boost_modal']), prepend : prepend || ownProps.prepend,
reblogModal : state.getIn(['meta', 'boost_modal']),
deleteModal : state.getIn(['meta', 'delete_modal']), deleteModal : state.getIn(['meta', 'delete_modal']),
autoPlayGif : state.getIn(['meta', 'auto_play_gif']), autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
}); };
};
return mapStateToProps; return mapStateToProps;
}; };
/* * * * */
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We need to provide dispatches for all
of the things you can do with a status: reply, reblog, favourite, et
cetera.
For a few of these dispatches, we open up confirmation modals; the rest
just immediately execute their corresponding actions.
*/
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) { onReply (status, router) {
@ -57,7 +181,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
if (e.shiftKey || !this.boostModal) { if (e.shiftKey || !this.reblogModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
@ -127,4 +251,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(
connect(makeMapStateToProps, mapDispatchToProps)(Status)
);

View File

@ -15,7 +15,11 @@ export default class Notification extends ImmutablePureComponent {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
}; };
renderFollow (account, link) { renderFollow (notification) {
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} />;
return ( return (
<div className='notification notification-follow'> <div className='notification notification-follow'>
<div className='notification__message'> <div className='notification__message'>
@ -32,55 +36,50 @@ export default class Notification extends ImmutablePureComponent {
} }
renderMention (notification) { renderMention (notification) {
return <StatusContainer id={notification.get('status')} withDismiss />;
}
renderFavourite (notification, settings, link) {
return ( return (
<div className='notification notification-favourite'> <StatusContainer
<div className='notification__message'> id={notification.get('status')}
<div className='notification__favourite-icon-wrapper'> withDismiss
<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 collapse={settings.getIn(['collapsed', 'auto', 'notifications'])} withDismiss />
</div>
); );
} }
renderReblog (notification, settings, link) { renderFavourite (notification) {
return ( return (
<div className='notification notification-reblog'> <StatusContainer
<div className='notification__message'> id={notification.get('status')}
<div className='notification__favourite-icon-wrapper'> account={notification.get('account')}
<i className='fa fa-fw fa-retweet' /> prepend='favourite'
</div> muted
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> withDismiss
</div> />
);
}
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse={settings.getIn(['collapsed', 'auto', 'notifications'])} withDismiss /> renderReblog (notification) {
</div> return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='reblog'
muted
withDismiss
/>
); );
} }
render () { render () {
const { notification, settings } = this.props; 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')) { switch(notification.get('type')) {
case 'follow': case 'follow':
return this.renderFollow(account, link); return this.renderFollow(notification);
case 'mention': case 'mention':
return this.renderMention(notification); return this.renderMention(notification);
case 'favourite': case 'favourite':
return this.renderFavourite(notification, settings, link); return this.renderFavourite(notification);
case 'reblog': case 'reblog':
return this.renderReblog(notification, settings, link); return this.renderReblog(notification);
} }
return null; return null;

View File

@ -84,7 +84,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>
<StatusContent status={status} mediaIcon={mediaIcon}>{media}</StatusContent> <StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
/>
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>

View File

@ -577,19 +577,19 @@
} }
} }
&.status-collapsed { &.collapsed {
height: 48px;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
user-select: none;
&::before { &.has-background::before {
display: block; display: block;
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
background-image: linear-gradient(to bottom, transparentize($ui-base-color, .15), transparentize($ui-base-color, .3) 24px, transparentize($ui-base-color, .35)); background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));
content: ""; content: "";
} }
@ -601,6 +601,10 @@
height: 20px; height: 20px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
a:hover {
text-decoration: none;
}
} }
} }
} }
@ -673,10 +677,9 @@
} }
.status__prepend { .status__prepend {
margin-left: 68px; margin: -10px 0 10px;
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
padding: 8px 0; padding: 8px 0 2px;
padding-bottom: 2px;
font-size: 14px; font-size: 14px;
position: relative; position: relative;
@ -1072,12 +1075,6 @@
strong { strong {
color: $primary-text-color; color: $primary-text-color;
} }
&.muted {
.emojione {
opacity: 0.5;
}
}
} }
.status__display-name, .status__display-name,
@ -1122,10 +1119,9 @@
} }
.status__avatar { .status__avatar {
height: 48px;
left: 10px;
position: absolute; position: absolute;
top: 10px; margin-left: -58px;
height: 48px;
width: 48px; width: 48px;
} }
@ -1139,7 +1135,7 @@
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
} }
.status__avatar { .status__avatar, .emojione {
opacity: 0.5; opacity: 0.5;
} }
@ -1155,7 +1151,7 @@
} }
.notification__message { .notification__message {
margin-left: 68px; margin: -10px 0 10px;
padding: 8px 0; padding: 8px 0;
padding-bottom: 0; padding-bottom: 0;
cursor: default; cursor: default;
@ -2314,9 +2310,6 @@ button.icon-button.active i.fa-retweet {
position: relative; position: relative;
text-align: center; text-align: center;
z-index: 100; z-index: 100;
margin-top: 15px;
margin-left:-68px;
width: calc(100% + 80px);
} }
.media-spoiler__warning { .media-spoiler__warning {