]> cat aescling's git repositories - mastodon.git/commitdiff
Merge tootsuite/master at 30237259367a0ef2b20908518b86bbeb358999b5
authorSurinna Curtis <ekiru.0@gmail.com>
Thu, 16 Nov 2017 07:21:16 +0000 (01:21 -0600)
committerSurinna Curtis <ekiru.0@gmail.com>
Thu, 16 Nov 2017 07:21:16 +0000 (01:21 -0600)
60 files changed:
1  2 
app/controllers/api/v1/search_controller.rb
app/javascript/glitch/components/account/header.js
app/javascript/glitch/components/status/action_bar.js
app/javascript/glitch/components/status/container.js
app/javascript/glitch/components/status/index.js
app/javascript/mastodon/actions/streaming.js
app/javascript/mastodon/components/account.js
app/javascript/mastodon/components/icon_button.js
app/javascript/mastodon/components/media_gallery.js
app/javascript/mastodon/components/status.js
app/javascript/mastodon/components/status_action_bar.js
app/javascript/mastodon/containers/status_container.js
app/javascript/mastodon/features/account/components/action_bar.js
app/javascript/mastodon/features/account/components/header.js
app/javascript/mastodon/features/account_timeline/components/header.js
app/javascript/mastodon/features/account_timeline/containers/header_container.js
app/javascript/mastodon/features/account_timeline/index.js
app/javascript/mastodon/features/blocks/index.js
app/javascript/mastodon/features/compose/components/compose_form.js
app/javascript/mastodon/features/compose/containers/compose_form_container.js
app/javascript/mastodon/features/follow_requests/index.js
app/javascript/mastodon/features/getting_started/index.js
app/javascript/mastodon/features/mutes/index.js
app/javascript/mastodon/features/status/components/action_bar.js
app/javascript/mastodon/features/status/components/detailed_status.js
app/javascript/mastodon/features/status/index.js
app/javascript/mastodon/features/ui/components/onboarding_modal.js
app/javascript/mastodon/features/ui/index.js
app/javascript/mastodon/initial_state.js
app/javascript/mastodon/reducers/compose.js
app/javascript/styles/mastodon/components.scss
app/models/account.rb
app/models/concerns/account_interactions.rb
app/models/follow.rb
app/models/follow_request.rb
app/models/media_attachment.rb
app/models/mute.rb
app/models/status.rb
app/models/stream_entry.rb
app/policies/status_policy.rb
app/serializers/initial_state_serializer.rb
app/services/notify_service.rb
app/services/post_status_service.rb
app/views/accounts/_header.html.haml
app/views/home/index.html.haml
app/views/layouts/application.html.haml
config/locales/en.yml
config/navigation.rb
config/routes.rb
config/settings.yml
config/webpack/shared.js
db/schema.rb
package.json
spec/models/account_spec.rb
spec/models/concerns/account_interactions_spec.rb
spec/models/follow_request_spec.rb
spec/models/status_spec.rb
spec/services/notify_service_spec.rb
streaming/index.js
yarn.lock

index 997eed6e2c5418de7d543fabb8bd033ec187bc53,e183a71d708636d40835edc36adc243d2f485215..d1b4e040220ae4c9649b513eee9d15050ee6814f
@@@ -1,9 -1,7 +1,9 @@@
  # frozen_string_literal: true
  
  class Api::V1::SearchController < Api::BaseController
-   RESULTS_LIMIT = 5
 +  include Authorization
 +
+   RESULTS_LIMIT = 10
  
    before_action -> { doorkeeper_authorize! :read }
    before_action :require_user!
index 0000000000000000000000000000000000000000,c94fb08518cc42c00c5be763fcc36a1bd4699899..7bc1a2189bbf8e74bb830477f6fb7ff4bce2c223
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,227 +1,227 @@@
 -    me       : PropTypes.string.isRequired,
+ /*
+ `<AccountHeader>`
+ =================
+ >   For more information on the contents of this file, please contact:
+ >
+ >   - kibigo! [@kibi@glitch.social]
+ Original file by @gargron@mastodon.social et al as part of
+ tootsuite/mastodon. We've expanded it in order to handle user bio
+ frontmatter.
+ The `<AccountHeader>` component provides the header for account
+ timelines. It is a fairly simple component which mostly just consists
+ of a `render()` method.
+ __Props:__
+  -  __`account` (`ImmutablePropTypes.map`) :__
+     The account to render a header for.
+  -  __`me` (`PropTypes.number.isRequired`) :__
+     The id of the currently-signed-in account.
+  -  __`onFollow` (`PropTypes.func.isRequired`) :__
+     The function to call when the user clicks the "follow" button.
+  -  __`intl` (`PropTypes.object.isRequired`) :__
+     Our internationalization object, inserted by `@injectIntl`.
+ */
+ //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ /*
+ Imports:
+ --------
+ */
+ //  Package imports  //
+ import React from 'react';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import PropTypes from 'prop-types';
+ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+ //  Mastodon imports  //
+ import emojify from '../../../mastodon/features/emoji/emoji';
+ import IconButton from '../../../mastodon/components/icon_button';
+ import Avatar from '../../../mastodon/components/avatar';
++import { me } from '../../../mastodon/initial_state';
+ //  Our imports  //
+ import { processBio } from '../../util/bio_metadata';
+ //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ /*
+ Inital setup:
+ -------------
+ The `messages` constant is used to define any messages that we need
+ from inside props. In our case, these are the `unfollow`, `follow`, and
+ `requested` messages used in the `title` of our buttons.
+ */
+ const messages = defineMessages({
+   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+   follow: { id: 'account.follow', defaultMessage: 'Follow' },
+   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ });
+ //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ /*
+ Implementation:
+ ---------------
+ */
+ @injectIntl
+ export default class AccountHeader extends ImmutablePureComponent {
+   static propTypes = {
+     account  : ImmutablePropTypes.map,
 -    const { account, me, intl } = this.props;
+     onFollow : PropTypes.func.isRequired,
+     intl     : PropTypes.object.isRequired,
+   };
+ /*
+ ###  `render()`
+ The `render()` function is used to render our component.
+ */
+   render () {
++    const { account, intl } = this.props;
+ /*
+ If no `account` is provided, then we can't render a header. Otherwise,
+ we get the `displayName` for the account, if available. If it's blank,
+ then we set the `displayName` to just be the `username` of the account.
+ */
+     if (!account) {
+       return null;
+     }
+     let displayName = account.get('display_name_html');
+     let info        = '';
+     let actionBtn   = '';
+     let following   = false;
+ /*
+ Next, we handle the account relationships. If the account follows the
+ user, then we add an `info` message. If the user has requested a
+ follow, then we disable the `actionBtn` and display an hourglass.
+ Otherwise, if the account isn't blocked, we set the `actionBtn` to the
+ appropriate icon.
+ */
+     if (me !== account.get('id')) {
+       if (account.getIn(['relationship', 'followed_by'])) {
+         info = (
+           <span className='account--follows-info'>
+             <FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
+           </span>
+         );
+       }
+       if (account.getIn(['relationship', 'requested'])) {
+         actionBtn = (
+           <div className='account--action-button'>
+             <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
+           </div>
+         );
+       } else if (!account.getIn(['relationship', 'blocking'])) {
+         following = account.getIn(['relationship', 'following']);
+         actionBtn = (
+           <div className='account--action-button'>
+             <IconButton
+               size={26}
+               icon={following ? 'user-times' : 'user-plus'}
+               active={following ? true : false}
+               title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
+               onClick={this.props.onFollow}
+             />
+           </div>
+         );
+       }
+     }
+ /*
+  we extract the `text` and
+ `metadata` from our account's `note` using `processBio()`.
+ */
+     const { text, metadata } = processBio(account.get('note'));
+ /*
+ Here, we render our component using all the things we've defined above.
+ */
+     return (
+       <div className='account__header__wrapper'>
+         <div
+           className='account__header'
+           style={{ backgroundImage: `url(${account.get('header')})` }}
+         >
+           <div>
+             <a href={account.get('url')} target='_blank' rel='noopener'>
+               <span className='account__header__avatar'>
+                 <Avatar account={account} size={90} />
+               </span>
+               <span
+                 className='account__header__display-name'
+                 dangerouslySetInnerHTML={{ __html: displayName }}
+               />
+             </a>
+             <span className='account__header__username'>
+               @{account.get('acct')}
+               {account.get('locked') ? <i className='fa fa-lock' /> : null}
+             </span>
+             <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
+             {info}
+             {actionBtn}
+           </div>
+         </div>
+         {metadata.length && (
+           <table className='account__metadata'>
+             <tbody>
+               {(() => {
+                 let data = [];
+                 for (let i = 0; i < metadata.length; i++) {
+                   data.push(
+                     <tr key={i}>
+                       <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
+                       <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
+                     </tr>
+                   );
+                 }
+                 return data;
+               })()}
+             </tbody>
+           </table>
+         ) || null}
+       </div>
+     );
+   }
+ }
index 0000000000000000000000000000000000000000,f4450d31bfbf5bbd887ce65e0652a369e828a58c..34588b008339a63f9b1530e16e8b4cefd0713e59
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,188 +1,187 @@@
 -    me: PropTypes.string,
+ //  Package imports  //
+ import React from 'react';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import PropTypes from 'prop-types';
+ import { defineMessages, injectIntl } from 'react-intl';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+ //  Mastodon imports  //
+ import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
+ import IconButton from '../../../mastodon/components/icon_button';
+ import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
++import { me } from '../../../mastodon/initial_state';
+ const messages = defineMessages({
+   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+   reply: { id: 'status.reply', defaultMessage: 'Reply' },
+   share: { id: 'status.share', defaultMessage: 'Share' },
+   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+   open: { id: 'status.open', defaultMessage: 'Expand this status' },
+   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+   pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+   unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+   embed: { id: 'status.embed', defaultMessage: 'Embed' },
+ });
+ @injectIntl
+ export default class StatusActionBar extends ImmutablePureComponent {
+   static contextTypes = {
+     router: PropTypes.object,
+   };
+   static propTypes = {
+     status: ImmutablePropTypes.map.isRequired,
+     onReply: PropTypes.func,
+     onFavourite: PropTypes.func,
+     onReblog: PropTypes.func,
+     onDelete: PropTypes.func,
+     onMention: PropTypes.func,
+     onMute: PropTypes.func,
+     onBlock: PropTypes.func,
+     onReport: PropTypes.func,
+     onEmbed: PropTypes.func,
+     onMuteConversation: PropTypes.func,
+     onPin: PropTypes.func,
 -    'me',
+     withDismiss: PropTypes.bool,
+     intl: PropTypes.object.isRequired,
+   };
+   // Avoid checking props that are functions (and whose equality will always
+   // evaluate to false. See react-immutable-pure-component for usage.
+   updateOnProps = [
+     'status',
 -    const { status, me, intl, withDismiss } = this.props;
+     'withDismiss',
+   ]
+   handleReplyClick = () => {
+     this.props.onReply(this.props.status, this.context.router.history);
+   }
+   handleShareClick = () => {
+     navigator.share({
+       text: this.props.status.get('search_index'),
+       url: this.props.status.get('url'),
+     });
+   }
+   handleFavouriteClick = () => {
+     this.props.onFavourite(this.props.status);
+   }
+   handleReblogClick = (e) => {
+     this.props.onReblog(this.props.status, e);
+   }
+   handleDeleteClick = () => {
+     this.props.onDelete(this.props.status);
+   }
+   handlePinClick = () => {
+     this.props.onPin(this.props.status);
+   }
+   handleMentionClick = () => {
+     this.props.onMention(this.props.status.get('account'), this.context.router.history);
+   }
+   handleMuteClick = () => {
+     this.props.onMute(this.props.status.get('account'));
+   }
+   handleBlockClick = () => {
+     this.props.onBlock(this.props.status.get('account'));
+   }
+   handleOpen = () => {
+     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+   }
+   handleEmbed = () => {
+     this.props.onEmbed(this.props.status);
+   }
+   handleReport = () => {
+     this.props.onReport(this.props.status);
+   }
+   handleConversationMuteClick = () => {
+     this.props.onMuteConversation(this.props.status);
+   }
+   render () {
++    const { status, intl, withDismiss } = this.props;
+     const mutingConversation = status.get('muted');
+     const anonymousAccess = !me;
+     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+     let menu = [];
+     let reblogIcon = 'retweet';
+     let replyIcon;
+     let replyTitle;
+     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+     if (publicStatus) {
+       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+     }
+     menu.push(null);
+     if (status.getIn(['account', 'id']) === me || withDismiss) {
+       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+       menu.push(null);
+     }
+     if (status.getIn(['account', 'id']) === me) {
+       if (publicStatus) {
+         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+       }
+       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+     } else {
+       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+       menu.push(null);
+       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+     }
+     if (status.get('in_reply_to_id', null) === null) {
+       replyIcon = 'reply';
+       replyTitle = intl.formatMessage(messages.reply);
+     } else {
+       replyIcon = 'reply-all';
+       replyTitle = intl.formatMessage(messages.replyAll);
+     }
+     const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+       <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
+     );
+     return (
+       <div className='status__action-bar'>
+         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
+         <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+         {shareButton}
+         <div className='status__action-bar-dropdown'>
+           <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+         </div>
+         <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+       </div>
+     );
+   }
+ }
index 0000000000000000000000000000000000000000,24261e7633eddee5d495461ac4bef2449a14c476..0054abd1499b32200fa43ff20a761bc34410791a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,265 +1,263 @@@
 -      me          : state.getIn(['meta', 'me']),
+ /*
+ `<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:
+ --------
+ */
+ //  Package imports  //
+ import React from 'react';
+ import { connect } from 'react-redux';
+ import {
+   defineMessages,
+   injectIntl,
+   FormattedMessage,
+ } from 'react-intl';
+ //  Mastodon imports  //
+ import { makeGetStatus } from '../../../mastodon/selectors';
+ import {
+   replyCompose,
+   mentionCompose,
+ } from '../../../mastodon/actions/compose';
+ import {
+   reblog,
+   favourite,
+   unreblog,
+   unfavourite,
+   pin,
+   unpin,
+ } from '../../../mastodon/actions/interactions';
+ import { blockAccount } from '../../../mastodon/actions/accounts';
+ import { initMuteModal } from '../../../mastodon/actions/mutes';
+ import {
+   muteStatus,
+   unmuteStatus,
+   deleteStatus,
+ } from '../../../mastodon/actions/statuses';
+ import { initReport } from '../../../mastodon/actions/reports';
+ import { openModal } from '../../../mastodon/actions/modal';
+ //  Our imports  //
+ import Status from '.';
+                             /* * * * */
+ /*
+ 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({
+   deleteConfirm : {
+     id             : 'confirmations.delete.confirm',
+     defaultMessage : 'Delete',
+   },
+   deleteMessage : {
+     id             : 'confirmations.delete.message',
+     defaultMessage : 'Are you sure you want to delete this status?',
+   },
+   blockConfirm  : {
+     id             : 'confirmations.block.confirm',
+     defaultMessage : 'Block',
+   },
+ });
+                             /* * * * */
+ /*
+ 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 getStatus = makeGetStatus();
+   const mapStateToProps = (state, ownProps) => {
+     let status = getStatus(state, ownProps.id);
+     if(status === null) {
+       console.error(`ERROR! NULL STATUS! ${ownProps.id}`);
+       // work-around: find first good status
+       for (let k of state.get('statuses').keys()) {
+         status = getStatus(state, k);
+         if (status !== null) break;
+       }
+     }
+     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,
 -      autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
+       settings    : state.get('local_settings'),
+       prepend     : prepend || ownProps.prepend,
+       reblogModal : state.getIn(['meta', 'boost_modal']),
+       deleteModal : state.getIn(['meta', 'delete_modal']),
+     };
+   };
+   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 }) => ({
+   onReply (status, router) {
+     dispatch(replyCompose(status, router));
+   },
+   onModalReblog (status) {
+     dispatch(reblog(status));
+   },
+   onReblog (status, e) {
+     if (status.get('reblogged')) {
+       dispatch(unreblog(status));
+     } else {
+       if (e.shiftKey || !this.reblogModal) {
+         this.onModalReblog(status);
+       } else {
+         dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+       }
+     }
+   },
+   onFavourite (status) {
+     if (status.get('favourited')) {
+       dispatch(unfavourite(status));
+     } else {
+       dispatch(favourite(status));
+     }
+   },
+   onPin (status) {
+     if (status.get('pinned')) {
+       dispatch(unpin(status));
+     } else {
+       dispatch(pin(status));
+     }
+   },
+   onEmbed (status) {
+     dispatch(openModal('EMBED', { url: status.get('url') }));
+   },
+   onDelete (status) {
+     if (!this.deleteModal) {
+       dispatch(deleteStatus(status.get('id')));
+     } else {
+       dispatch(openModal('CONFIRM', {
+         message: intl.formatMessage(messages.deleteMessage),
+         confirm: intl.formatMessage(messages.deleteConfirm),
+         onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+       }));
+     }
+   },
+   onMention (account, router) {
+     dispatch(mentionCompose(account, router));
+   },
+   onOpenMedia (media, index) {
+     dispatch(openModal('MEDIA', { media, index }));
+   },
+   onOpenVideo (media, time) {
+     dispatch(openModal('VIDEO', { media, time }));
+   },
+   onBlock (account) {
+     dispatch(openModal('CONFIRM', {
+       message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+       confirm: intl.formatMessage(messages.blockConfirm),
+       onConfirm: () => dispatch(blockAccount(account.get('id'))),
+     }));
+   },
+   onReport (status) {
+     dispatch(initReport(status.get('account'), status));
+   },
+   onMute (account) {
+     dispatch(initMuteModal(account));
+   },
+   onMuteConversation (status) {
+     if (status.get('muted')) {
+       dispatch(unmuteStatus(status.get('id')));
+     } else {
+       dispatch(muteStatus(status.get('id')));
+     }
+   },
+ });
+ export default injectIntl(
+   connect(makeMapStateToProps, mapDispatchToProps)(Status)
+ );
index 0000000000000000000000000000000000000000,6bd95b05195d0b671dd52d610779bf56ae891d94..33a9730e5accf9855f43ebea659157c11521d01d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,770 +1,760 @@@
 - -  __`me` (`PropTypes.number`) :__
 -    This is the id of the currently-signed-in user.
 -
+ /*
+ `<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:
+ --------
+ */
+ //  Package imports  //
+ import React from 'react';
+ import PropTypes from 'prop-types';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+ //  Mastodon imports  //
+ import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
++import { autoPlayGif } from '../../../mastodon/initial_state';
+ //  Our imports  //
+ import StatusPrepend from './prepend';
+ import StatusHeader from './header';
+ import StatusContent from './content';
+ import StatusActionBar from './action_bar';
+ import StatusGallery from './gallery';
+ import StatusPlayer from './player';
+ import NotificationOverlayContainer from '../notification/overlay/container';
+                             /* * * * */
+ /*
+ The `<Status>` component:
+ -------------------------
+ The `<Status>` component is a container for statuses. It consists of a
+ few parts:
+  -  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.
+ ###  Context
+  -  __`router` (`PropTypes.object`) :__
+     We need to get our router from the surrounding React context.
+ ###  Props
+  -  __`id` (`PropTypes.number`) :__
+     The id of the status.
+  -  __`status` (`ImmutablePropTypes.map`) :__
+     The status object, straight from the store.
+  -  __`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.
 - -  __`autoPlayGif` (`PropTypes.bool`) :__
 -    This tells the frontend whether or not to autoplay gifs!
 -
+  -  __`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.
 -    me                          : PropTypes.string,
+  -  __`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 = {
+     router                      : PropTypes.object,
+   };
+   static propTypes = {
+     id                          : PropTypes.string,
+     status                      : ImmutablePropTypes.map,
+     account                     : ImmutablePropTypes.map,
+     settings                    : ImmutablePropTypes.map,
+     notification                : ImmutablePropTypes.map,
 -    autoPlayGif                 : PropTypes.bool,
+     onFavourite                 : PropTypes.func,
+     onReblog                    : PropTypes.func,
+     onModalReblog               : PropTypes.func,
+     onDelete                    : PropTypes.func,
+     onPin                       : PropTypes.func,
+     onMention                   : PropTypes.func,
+     onMute                      : PropTypes.func,
+     onMuteConversation          : PropTypes.func,
+     onBlock                     : PropTypes.func,
+     onEmbed                     : PropTypes.func,
+     onHeightChange              : PropTypes.func,
+     onReport                    : PropTypes.func,
+     onOpenMedia                 : PropTypes.func,
+     onOpenVideo                 : PropTypes.func,
+     reblogModal                 : PropTypes.bool,
+     deleteModal                 : PropTypes.bool,
 -    'me',
+     muted                       : PropTypes.bool,
+     collapse                    : PropTypes.bool,
+     prepend                     : PropTypes.string,
+     withDismiss                 : PropTypes.bool,
+     intersectionObserverWrapper : PropTypes.object,
+   };
+   state = {
+     isExpanded                  : null,
+     isIntersecting              : true,
+     isHidden                    : false,
+     markedForDelete             : false,
+   }
+ /*
+ ###  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',
+     'prepend',
 -    'autoPlayGif',
+     'boostModal',
 -      autoPlayGif,
+     'muted',
+     'collapse',
+     'notification',
+   ]
+   updateOnStates = [
+     'isExpanded',
+     'markedForDelete',
+   ]
+ /*
+ ####  `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'])) {
+       if (this.state.isExpanded === false) {
+         this.setExpansion(null);
+       }
+     } 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,
+       prepend,
+     } = 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('reblogs') &&
+         prepend === 'reblogged_by'
+       ) || (
+         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) {
+     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 () {
+     this.componentMounted = false;
+   }
+ /*
+ ####  `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) => {
+     const isIntersecting = (
+       typeof entry.isIntersecting === 'boolean' ?
+       entry.isIntersecting :
+       entry.intersectionRect.height > 0
+     );
+     this.setState(
+       (prevState) => {
+         if (prevState.isIntersecting && !isIntersecting) {
+           scheduleIdleTask(this.hideIfNotIntersecting);
+         }
+         return {
+           isIntersecting : isIntersecting,
+           isHidden       : false,
+         };
+       }
+     );
+   }
+ /*
+ ####  `hideIfNotIntersecting()`.
+ 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 = () => {
+     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) {
+       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();
+   }
+ /*
+ ####  `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 (!router) return;
+     if (destination === undefined) {
+       destination = `/statuses/${
+         status.getIn(['reblog', 'id'], status.get('id'))
+       }`;
+     }
+     if (e.button === 0) {
+       if (isExpanded === false) this.setExpansion(null);
+       else if (e.shiftKey) {
+         this.setExpansion(false);
+         document.getSelection().removeAllRanges();
+       } else router.history.push(destination);
+       e.preventDefault();
+     }
+   }
+ /*
+ ####  `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,
+       saveHeight,
+       handleRef,
+     } = this;
+     const { router } = this.context;
+     const {
+       status,
+       account,
+       settings,
+       collapsed,
+       muted,
+       prepend,
+       intersectionObserverWrapper,
+       onOpenVideo,
+       onOpenMedia,
+       notification,
+       ...other
+     } = this.props;
+     const { isExpanded, isIntersecting, isHidden } = this.state;
+     let background = null;
+     let attachments = null;
+     let media = null;
+     let mediaIcon = null;
+ /*
+ If we don't have a status, then we don't render anything.
+ */
+     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'])
+           }
+           {status.get('content')}
+         </div>
+       );
+     }
+ /*
+ 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'
+           <StatusPlayer
+             media={attachments.get(0)}
+             sensitive={status.get('sensitive')}
+             letterbox={settings.getIn(['media', 'letterbox'])}
+             fullwidth={settings.getIn(['media', 'fullwidth'])}
+             height={250}
+             onOpenVideo={onOpenVideo}
+           />
+         );
+         mediaIcon = 'video-camera';
+       } else {  //  Media type is 'image' or 'gifv'
+         media = (
+           <StatusGallery
+             media={attachments}
+             sensitive={status.get('sensitive')}
+             letterbox={settings.getIn(['media', 'letterbox'])}
+             fullwidth={settings.getIn(['media', 'fullwidth'])}
+             height={250}
+             onOpenMedia={onOpenMedia}
+             autoPlayGif={autoPlayGif}
+           />
+         );
+         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']);
+     }
+ /*
+ 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')}`;
+     }
+ /*
+ 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 (
+       <article
+         className={
+           `status${
+             muted ? ' muted' : ''
+           } status-${status.get('visibility')}${
+             isExpanded === false ? ' collapsed' : ''
+           }${
+             isExpanded === false && background ? ' has-background' : ''
+           }${
+             this.state.markedForDelete ? ' marked-for-delete' : ''
+           }`
+         }
+         style={{
+           backgroundImage: (
+             isExpanded === false && background ?
+             `url(${background})` :
+             'none'
+           ),
+         }}
+         ref={handleRef}
+         {...selectorAttribs}
+       >
+         {prepend && account ? (
+           <StatusPrepend
+             type={prepend}
+             account={account}
+             parseClick={parseClick}
+             notificationId={this.props.notificationId}
+           />
+         ) : null}
+         <StatusHeader
+           status={status}
+           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={setExpansion}
+           onHeightUpdate={saveHeight}
+           parseClick={parseClick}
+           disabled={!router}
+         />
+         {isExpanded !== false ? (
+           <StatusActionBar
+             {...other}
+             status={status}
+             account={status.get('account')}
+           />
+         ) : null}
+         {notification ? (
+           <NotificationOverlayContainer
+             notification={notification}
+           />
+         ) : null}
+       </article>
+     );
+   }
+ }
index 06f53841d2362cbe28ec60c6e7b54ede58f41ce8,76b0da12fef2bbc325b6d9eb24adfbafbb61f44b..d0c1b049f89f2737071e83b56dc9db9dcd9e8eae
@@@ -59,6 -65,6 +65,7 @@@ export default class IconButton extend
        expanded,
        icon,
        inverted,
++      flip,
        overlay,
        pressed,
        tabIndex,
        overlayed: overlay,
      });
  
 -    const flipDeg = this.props.flip ? -180 : -360;
 -    const rotateDeg = this.props.active ? flipDeg : 0;
++    const flipDeg = flip ? -180 : -360;
++    const rotateDeg = active ? flipDeg : 0;
+     const motionDefaultStyle = {
+       rotate: rotateDeg,
+     };
+     const springOpts = {
+       stiffness: this.props.flip ? 60 : 120,
+       damping: 7,
+     };
+     const motionStyle = {
+       rotate: animate ? spring(rotateDeg, springOpts) : 0,
+     };
 +    if (!animate) {
 +      // Perf optimization: avoid unnecessary <Motion> components unless
 +      // we actually need to animate.
 +      return (
 +        <button
 +          aria-label={title}
 +          aria-pressed={pressed}
 +          aria-expanded={expanded}
 +          title={title}
 +          className={classes}
 +          onClick={this.handleClick}
 +          style={style}
 +          tabIndex={tabIndex}
 +        >
 +          <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
 +        </button>
 +      );
 +    }
 +
      return (
-       <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+       <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
          {({ rotate }) =>
            <button
              aria-label={title}
index 8cf7b92ca2a0f42c326a74075ecff8ffb0151062,b33df282fcd068a96b70f80a0658162deb5bc2f4..9a087e922ae3ffc868434efdfec98a2c1d18df68
@@@ -75,8 -82,10 +80,9 @@@ export default class Header extends Imm
  
          <ActionBar
            account={account}
 -          me={me}
            onBlock={this.handleBlock}
            onMention={this.handleMention}
+           onReblogToggle={this.handleReblogToggle}
            onReport={this.handleReport}
            onMute={this.handleMute}
            onBlockDomain={this.handleBlockDomain}
index 5f5509dbea580d215c5a6febc3af08318ed1dedb,ffa0a3442f08fc93da00d7346cb973beec07856a..dfe8241c6d9025b2324602670747ae4337adacb9
@@@ -22,7 -23,10 +23,9 @@@ const mapStateToProps = state => (
    preselectDate: state.getIn(['compose', 'preselectDate']),
    is_submitting: state.getIn(['compose', 'is_submitting']),
    is_uploading: state.getIn(['compose', 'is_uploading']),
 -  me: state.getIn(['compose', 'me']),
    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+   settings: state.get('local_settings'),
+   filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
  });
  
  const mapDispatchToProps = (dispatch) => ({
index 4b4ae6947038b96f17e4e8580a07e1a9dd7ebddf,9b94b983088caf51f92a05720378214970b643cd..2f7d9281edb8dfc6809b5e986e0f24069ad17144
@@@ -38,13 -41,23 +42,23 @@@ export default class GettingStarted ext
  
    static propTypes = {
      intl: PropTypes.object.isRequired,
 -    me: ImmutablePropTypes.map.isRequired,
 +    myAccount: ImmutablePropTypes.map.isRequired,
      columns: ImmutablePropTypes.list,
      multiColumn: PropTypes.bool,
+     dispatch: PropTypes.func.isRequired,
    };
  
+   openSettings = () => {
+     this.props.dispatch(openModal('SETTINGS', {}));
+   }
+   openOnboardingModal = (e) => {
+     e.preventDefault();
+     this.props.dispatch(openModal('ONBOARDING'));
+   }
    render () {
 -    const { intl, me, columns, multiColumn } = this.props;
 +    const { intl, myAccount, columns, multiColumn } = this.props;
  
      let navItems = [];
  
        }
      }
  
+     if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+       navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+     }
      navItems = navItems.concat([
-       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
-       <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
+       <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
+       <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
      ]);
  
 -    if (me.get('locked')) {
 +    if (myAccount.get('locked')) {
-       navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+       navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
      }
  
      navItems = navItems.concat([
index 81f71749b118aaa567a96e2f9f596018200d11c5,d8547db36868979d459ee1627aae1d238859a138..85a030ea8e8884933c9f57c21dfc15b6d029668b
@@@ -20,8 -22,10 +22,9 @@@ export default class DetailedStatus ext
  
    static propTypes = {
      status: ImmutablePropTypes.map.isRequired,
+     settings: ImmutablePropTypes.map.isRequired,
      onOpenMedia: PropTypes.func.isRequired,
      onOpenVideo: PropTypes.func.isRequired,
 -    autoPlayGif: PropTypes.bool,
    };
  
    handleAccountClick = (e) => {
        if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
          media = <AttachmentList media={status.get('media_attachments')} />;
        } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-         const video = status.getIn(['media_attachments', 0]);
          media = (
-           <Video
-             preview={video.get('preview_url')}
-             src={video.get('url')}
-             width={300}
-             height={150}
-             onOpenVideo={this.handleOpenVideo}
+           <StatusPlayer
              sensitive={status.get('sensitive')}
+             media={status.getIn(['media_attachments', 0])}
+             letterbox={settings.getIn(['media', 'letterbox'])}
+             fullwidth={settings.getIn(['media', 'fullwidth'])}
+             height={250}
+             onOpenVideo={this.props.onOpenVideo}
+             autoplay
            />
          );
+         mediaIcon = 'video-camera';
        } else {
          media = (
-           <MediaGallery
-             standalone
+           <StatusGallery
              sensitive={status.get('sensitive')}
              media={status.get('media_attachments')}
-             height={300}
+             letterbox={settings.getIn(['media', 'letterbox'])}
+             fullwidth={settings.getIn(['media', 'fullwidth'])}
+             height={250}
              onOpenMedia={this.props.onOpenMedia}
 -            autoPlayGif={this.props.autoPlayGif}
            />
          );
+         mediaIcon = 'picture-o';
        }
-     } else if (status.get('spoiler_text').length === 0) {
-       media = <CardContainer statusId={status.get('id')} />;
-     }
+     } else media = <CardContainer statusId={status.get('id')} />;
  
      if (status.get('application')) {
        applicationLink = <span> Â· <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
index cc28ff5fcadbc07ca01d8a475b56080b8dfa9d3d,c40630a0a1e1217b70110a77d9ab4754fd6e45ed..e7ea046dd5ea97e5e13c3c334e79b9b0277d780c
@@@ -23,9 -22,9 +23,9 @@@ import 
  import { deleteStatus } from '../../actions/statuses';
  import { initReport } from '../../actions/reports';
  import { makeGetStatus } from '../../selectors';
 -import { ScrollContainer } from 'react-router-scroll';
 +import { ScrollContainer } from 'react-router-scroll-4';
  import ColumnBackButton from '../../components/column_back_button';
- import StatusContainer from '../../containers/status_container';
+ import StatusContainer from '../../../glitch/components/status/container';
  import { openModal } from '../../actions/modal';
  import { defineMessages, injectIntl } from 'react-intl';
  import ImmutablePureComponent from 'react-immutable-pure-component';
@@@ -43,8 -40,13 +43,9 @@@ const makeMapStateToProps = () => 
  
    const mapStateToProps = (state, props) => ({
      status: getStatus(state, props.params.statusId),
+     settings: state.get('local_settings'),
      ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
      descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
 -    me: state.getIn(['meta', 'me']),
 -    boostModal: state.getIn(['meta', 'boost_modal']),
 -    deleteModal: state.getIn(['meta', 'delete_modal']),
 -    autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
    });
  
    return mapStateToProps;
@@@ -62,8 -64,13 +63,9 @@@ export default class Status extends Imm
      params: PropTypes.object.isRequired,
      dispatch: PropTypes.func.isRequired,
      status: ImmutablePropTypes.map,
+     settings: ImmutablePropTypes.map.isRequired,
      ancestorsIds: ImmutablePropTypes.list,
      descendantsIds: ImmutablePropTypes.list,
 -    me: PropTypes.string,
 -    boostModal: PropTypes.bool,
 -    deleteModal: PropTypes.bool,
 -    autoPlayGif: PropTypes.bool,
      intl: PropTypes.object.isRequired,
    };
  
      }
    }
  
 +  componentWillUnmount () {
 +    detachFullscreenListener(this.onFullScreenChange);
 +  }
 +
 +  onFullScreenChange = () => {
 +    this.setState({ fullscreen: isFullscreen() });
 +  }
 +
    render () {
      let ancestors, descendants;
-     const { status, ancestorsIds, descendantsIds } = this.props;
 -    const { status, settings, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
++    const { status, settings, ancestorsIds, descendantsIds } = this.props;
 +    const { fullscreen } = this.state;
  
      if (status === null) {
        return (
                <div className='focusable' tabIndex='0'>
                  <DetailedStatus
                    status={status}
 -                  autoPlayGif={autoPlayGif}
 -                  me={me}
+                   settings={settings}
                    onOpenVideo={this.handleOpenVideo}
                    onOpenMedia={this.handleOpenMedia}
                  />
index 54673e223e5de094defff176dabd6c695ecde2c7,daf6b485ce4bc719cf8c4f57ed5c3efe951f2d65..1f9f0cd03b21c0df51512c9498379e11bc6ce6e3
@@@ -10,8 -10,10 +10,11 @@@ import ComposeForm from '../../compose/
  import Search from '../../compose/components/search';
  import NavigationBar from '../../compose/components/navigation_bar';
  import ColumnHeader from './column_header';
- import { List as ImmutableList } from 'immutable';
+ import {
+   List as ImmutableList,
+   Map as ImmutableMap,
+ } from 'immutable';
 +import { me } from '../../../initial_state';
  
  const noop = () => { };
  
@@@ -45,7 -47,7 +48,7 @@@ const PageTwo = ({ myAccount }) => 
    <div className='onboarding-modal__page onboarding-modal__page-two'>
      <div className='figure non-interactive'>
        <div className='pseudo-drawer'>
-         <NavigationBar account={myAccount} />
 -        <NavigationBar onClose={noop} account={me} />
++        <NavigationBar onClose={noop} account={myAccount} />
        </div>
        <ComposeForm
          text='Awoo! #introductions'
@@@ -84,7 -88,7 +89,7 @@@ const PageThree = ({ myAccount }) => 
        />
  
        <div className='pseudo-drawer'>
-         <NavigationBar account={myAccount} />
 -        <NavigationBar onClose={noop} account={me} />
++        <NavigationBar onClose={noop} account={myAccount} />
        </div>
      </div>
  
index f28b3709926a0971335f14991d566bf3a8e1de7b,9f77ab5aa96d1c0f80e43d0495bf6dcb66ba9079..fc51df163c44162c067590fc5434026f16ca4596
@@@ -43,15 -43,15 +45,15 @@@ import { defineMessages, injectIntl } f
  
  // Dummy import, to make sure that <Status /> ends up in the application bundle.
  // Without this it ends up in ~8 very commonly used bundles.
- import '../../components/status';
+ import '../../../glitch/components/status';
  
 +const messages = defineMessages({
 +  beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
 +});
 +
  const mapStateToProps = state => ({
 -  systemFontUi: state.getIn(['meta', 'system_font_ui']),
 -  layout: state.getIn(['local_settings', 'layout']),
 -  isWide: state.getIn(['local_settings', 'stretch']),
 -  navbarUnder: state.getIn(['local_settings', 'navbar_under']),
 -  me: state.getIn(['meta', 'me']),
    isComposing: state.getIn(['compose', 'is_composing']),
 +  hasComposingText: state.getIn(['compose', 'text']) !== '',
  });
  
  const keyMap = {
@@@ -92,10 -92,13 +95,14 @@@ export default class UI extends React.C
    static propTypes = {
      dispatch: PropTypes.func.isRequired,
      children: PropTypes.node,
+     layout: PropTypes.string,
+     isWide: PropTypes.bool,
+     systemFontUi: PropTypes.bool,
+     navbarUnder: PropTypes.bool,
      isComposing: PropTypes.bool,
 -    me: PropTypes.string,
 +    hasComposingText: PropTypes.bool,
      location: PropTypes.object,
 +    intl: PropTypes.object.isRequired,
    };
  
    state = {
index 3fc45077d4cf197a79f6c7296d3cbd335722c2ba,0000000000000000000000000000000000000000..ef5d8b0efcc22eeb73d893409fc0bf34b504374c
mode 100644,000000..100644
--- /dev/null
@@@ -1,13 -1,0 +1,21 @@@
- const initialState = element && JSON.parse(element.textContent);
 +const element = document.getElementById('initial-state');
++const initialState = element && function () {
++  const result = JSON.parse(element.textContent);
++  try {
++    result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
++  } catch (e) {
++    result.local_settings = {};
++  }
++  return result;
++}();
 +
 +const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
 +
 +export const reduceMotion = getMeta('reduce_motion');
 +export const autoPlayGif = getMeta('auto_play_gif');
 +export const unfollowModal = getMeta('unfollow_modal');
 +export const boostModal = getMeta('boost_modal');
 +export const deleteModal = getMeta('delete_modal');
 +export const me = getMeta('me');
 +
 +export default initialState;
index c709fb88c9d663ddcdeda37b52623527fc4962d0,251a4014403b686276539c14824412697784d580..5d0acbd60d98202fe13c33d294579659aa6c3838
@@@ -50,6 -54,10 +55,9 @@@ const initialState = ImmutableMap(
    media_attachments: ImmutableList(),
    suggestion_token: null,
    suggestions: ImmutableList(),
 -  me: null,
+   default_advanced_options: ImmutableMap({
+     do_not_federate: false,
+   }),
    default_privacy: 'public',
    default_sensitive: false,
    resetFileKey: Math.floor((Math.random() * 0x10000)),
index 0ded6f159673f4fc0c1cb08980f678fbb9eec755,2506bbe620c4c1ddbfa35a4c5772f434059666ca..6a6d1bdca79f246632b93b53ac9074011d604b22
    cursor: pointer;
  }
  
+ .status-check-box {
+   .status__content,
+   .reply-indicator__content {
+     color: #3a3a3a;
+     a {
+       color: #005aa9;
+     }
+   }
+ }
  .status__content,
  .reply-indicator__content {
+   position: relative;
+   margin: 10px 0;
+   padding: 0 12px;
    font-size: 15px;
    line-height: 20px;
+   color: $primary-text-color;
    word-wrap: break-word;
    font-weight: 400;
-   overflow: hidden;
+   overflow: visible;
    white-space: pre-wrap;
 +  padding-top: 5px;
  
    &.status__content--with-spoiler {
      white-space: normal;
Simple merge
index 3d5447fb1f050aeb649c1bc3a9f56e1943b7e2fa,a8ddcb7f0549d4aa2ae24e6e17276b99ebb1cb9b..ea00a377a23d2493779fd413b3b96234af9d001e
@@@ -5,9 -5,10 +5,10 @@@
  #
  #  created_at        :datetime         not null
  #  updated_at        :datetime         not null
- #  account_id        :bigint           not null
- #  id                :bigint           not null, primary key
- #  target_account_id :bigint           not null
 -#  account_id        :integer          not null
 -#  id                :integer          not null, primary key
 -#  target_account_id :integer          not null
++#  account_id        :bigint          not null
++#  id                :bigint          not null, primary key
++#  target_account_id :bigint          not null
+ #  show_reblogs      :boolean          default(TRUE), not null
  #
  
  class Follow < ApplicationRecord
index ce27fc92149c81d879560c2ff3444bc5e37b6ed0,1a1c52382ca17d3f881b15ffb17128f0e2fd78cb..962b61411308bca2426cf39bbbbf185f46b4f8db
@@@ -5,9 -5,10 +5,10 @@@
  #
  #  created_at        :datetime         not null
  #  updated_at        :datetime         not null
- #  account_id        :bigint           not null
- #  id                :bigint           not null, primary key
- #  target_account_id :bigint           not null
 -#  account_id        :integer          not null
 -#  id                :integer          not null, primary key
 -#  target_account_id :integer          not null
++#  account_id        :bigint          not null
++#  id                :bigint          not null, primary key
++#  target_account_id :bigint          not null
+ #  show_reblogs      :boolean          default(TRUE), not null
  #
  
  class FollowRequest < ApplicationRecord
Simple merge
index 105696da63aecc49edbe07ac7ef67224425f50b3,bcd3d247c8f97cee8b1590aa299a6145629cf30c..74b445c0b9233920a9ac07e158191cb980e3cdd2
@@@ -3,11 -3,11 +3,11 @@@
  #
  # Table name: mutes
  #
- #  id                 :integer          not null, primary key
++#  id                 :bigint          not null, primary key
  #  created_at         :datetime         not null
  #  updated_at         :datetime         not null
--#  account_id         :integer          not null
 -#  id                 :integer          not null, primary key
--#  target_account_id  :integer          not null
++#  account_id         :bigint          not null
++#  target_account_id  :bigint          not null
  #  hide_notifications :boolean          default(TRUE), not null
  #
  
Simple merge
Simple merge
index 0373fdf04fc9492fc68e57593abde261e49b4a0e,f4a5e7c6c01791d49424ca26f648aaefb8f0bd7d..8e0c1eef18fe4f791d9201dd98ab649d70a44218
@@@ -6,12 -9,14 +6,14 @@@ class StatusPolicy < ApplicationPolic
    end
  
    def show?
+     return false if local_only? && account.nil?
      if direct?
 -      owned? || status.mentions.where(account: account).exists?
 +      owned? || record.mentions.where(account: current_account).exists?
      elsif private?
 -      owned? || account&.following?(status.account) || status.mentions.where(account: account).exists?
 +      owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists?
      else
 -      account.nil? || !status.account.blocking?(account)
 +      current_account.nil? || !author.blocking?(current_account)
      end
    end
  
    end
  
    def private?
 -    status.private_visibility?
 +    record.private_visibility?
 +  end
 +
 +  def author
 +    record.account
    end
 -    status.local_only?
+   
+   def local_only?
++    record.local_only?
+   end
  end
Simple merge
Simple merge
index 5530fcc200aac2f85586fa631951bef43a57b8c2,5b504912d39164a346c6bd1748483d7f843f5d95..47ee02cf04904495449132b887759b84a281933e
@@@ -1,22 -1,22 +1,23 @@@
+ - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
  .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
    .card__illustration
 -    - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
 -      .controls
 -        - if current_account.following?(account)
 -          = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
 -            = fa_icon 'user-times'
 -            = t('accounts.unfollow')
 -        - else
 -          = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
 -            = fa_icon 'user-plus'
 -            = t('accounts.follow')
 -    - elsif !user_signed_in?
 -      .controls
 -        .remote-follow
 -          = link_to account_remote_follow_path(account), class: 'icon-button' do
 -            = fa_icon 'user-plus'
 -            = t('accounts.remote_follow')
 +    - unless account.memorial?
 +      - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
 +        .controls
 +          - if current_account.following?(account)
 +            = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
 +              = fa_icon 'user-times'
 +              = t('accounts.unfollow')
 +          - else
 +            = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
 +              = fa_icon 'user-plus'
 +              = t('accounts.follow')
 +      - elsif !user_signed_in?
 +        .controls
 +          .remote-follow
 +            = link_to account_remote_follow_path(account), class: 'icon-button' do
 +              = fa_icon 'user-plus'
 +              = t('accounts.remote_follow')
  
      .avatar= image_tag account.avatar.url(:original), class: 'u-photo'
  
Simple merge
index e941653172bba89f14818364b62d41d04c3fcc39,d5c46470c08a8130563a5275c8949883242feb30..a590b189f722094f84f90c88d7b45fb2d6483c2a
@@@ -392,7 -377,14 +392,15 @@@ en
        following: Following list
        muting: Muting list
      upload: Upload
 +  in_memoriam_html: In Memoriam.
+   keyword_mutes:
+     add_keyword: Add keyword
+     edit: Edit
+     edit_keyword: Edit keyword
+     keyword: Keyword
+     match_whole_word: Match whole word
+     remove: Remove
+     remove_all: Remove all
    landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
    landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
    media_attachments:
Simple merge
Simple merge
Simple merge
index 50fa4817594cd2d7fe08cc724b8dd2838d699d38,e3a1fc379a2da76e619eae620d888bd29e434040..5d176db4eb3cc39cdd9dc9dd5187af3e468ed7bf
@@@ -15,21 -15,26 +15,24 @@@ const packPaths = sync(join(entryPath, 
  
  module.exports = {
    entry: Object.assign(
 -    entryPacks.reduce(
 -      (map, entry) => {
 -        const localMap = map;
 -        let namespace = relative(join(entryPath), dirname(entry));
 -        if (namespace === join('..', '..', '..', 'tmp', 'packs')) {
 -          namespace = ''; // generated by generateLocalePacks.js
 -        }
 -        localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
 -        return localMap;
 +    packPaths.reduce((map, entry) => {
 +      const localMap = map;
 +      const namespace = relative(join(entryPath), dirname(entry));
 +      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
 +      return localMap;
 +    }, {}),
 +    localePackPaths.reduce((map, entry) => {
 +      const localMap = map;
 +      localMap[basename(entry, extname(entry, extname(entry)))] = resolve(entry);
 +      return localMap;
 +    }, {}),
-     Object.keys(themes).reduce((themePaths, name) => {
-       themePaths[name] = resolve(join(settings.source_path, themes[name]));
-       return themePaths;
-     }, {})
++    Object.keys(themes).reduce(
++      (themePaths, name) => {
++        const themeData = themes[name];
++        themePaths[`themes/${name}`] = resolve(themeData.pack_directory, themeData.pack);
++        return themePaths;
+       }, {}
 -    ), themePaths
++    )
    ),
  
    output: {
          resource.request = resource.request.replace(/^history/, 'history/es');
        }
      ),
-     new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css'),
+     new ExtractTextPlugin({
 -      filename: env.NODE_ENV === 'production' ? '[name]-[hash].css' : '[name].css',
++      filename: env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css',
+       allChunks: true,
+     }),
      new ManifestPlugin({
        publicPath: output.publicPath,
        writeToFileEmit: true,
diff --cc db/schema.rb
index 2d763e2f4fc03d1211e66548dbdd002a303962ec,93505f9a09d267cb8f9741d116b8fd651633dfd6..0691c4220b614ec06316d967a340bc14ceb3fa9a
@@@ -201,9 -210,9 +212,10 @@@ ActiveRecord::Schema.define(version: 20
    create_table "mutes", force: :cascade do |t|
      t.datetime "created_at", null: false
      t.datetime "updated_at", null: false
+     t.boolean "hide_notifications", default: true, null: false
      t.bigint "account_id", null: false
      t.bigint "target_account_id", null: false
 +    t.boolean "hide_notifications", default: true, null: false
      t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
    end
  
diff --cc package.json
index cd088e5c0c4cbae67f7b2ac0aecaa53676c074e4,8d24c78506c4a07f04efc4049b37c3134934f195..159181030bbb71aee31dac2ce73adbe7162fce7e
@@@ -19,8 -19,9 +19,9 @@@
    "private": true,
    "dependencies": {
      "array-includes": "^3.0.3",
 -    "autoprefixer": "^7.1.2",
 -    "axios": "^0.16.2",
+     "atrament": "^0.2.3",
 +    "autoprefixer": "^7.1.6",
 +    "axios": "~0.16.2",
      "babel-core": "^6.25.0",
      "babel-loader": "^7.1.1",
      "babel-plugin-lodash": "^3.2.11",
Simple merge
index a468549d831ce301ea6b1ec2b8f604285df10843,f47d9d057950239995efe4e7d3e9135e19f1c99e..1e238e27c30c4b428f83932af2e37c0ac6afc005
@@@ -31,7 -33,44 +31,44 @@@ describe AccountInteractions d
        end
  
        it 'does mute notifications' do
-         expect(me.muting_notifications?(you)).to be true
 -        expect(@me.muting_notifications?(@you)).to be(true)
++        expect(me.muting_notifications?(you)).to be true 
+       end
+     end
+   end
+   describe 'ignoring reblogs from an account' do
+     before do
+       @me = Fabricate(:account, username: 'Me')
+       @you = Fabricate(:account, username: 'You')
+     end
+     context 'with the reblogs option unspecified' do
+       before do
+         @me.follow!(@you)
+       end
+       it 'defaults to showing reblogs' do
+         expect(@me.muting_reblogs?(@you)).to be(false)
+       end
+     end
+     context 'with the reblogs option set to false' do
+       before do
+         @me.follow!(@you, reblogs: false)
+       end
+       it 'does mute reblogs' do
+         expect(@me.muting_reblogs?(@you)).to be(true)
+       end
+     end
+     context 'with the reblogs option set to true' do
+       before do
+         @me.follow!(@you, reblogs: true)
+       end
+       it 'does not mute reblogs' do
+         expect(@me.muting_reblogs?(@you)).to be(false)
        end
      end
    end
index 1436501e99d0b33be9e643a7105d582d90f5c42b,62bd724d7ffa06b8d9d594f38aa30f849485253e..7bc93a2aae87a5065d5e7dde2ac2d580308651e5
@@@ -2,15 -2,46 +2,36 @@@ require 'rails_helper
  
  RSpec.describe FollowRequest, type: :model do
    describe '#authorize!' do
-       expect(account).to        receive(:follow!).with(target_account)
 +    let(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) }
 +    let(:account)        { Fabricate(:account) }
 +    let(:target_account) { Fabricate(:account) }
 +
 +    it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
++      expect(account).to        receive(:follow!).with(target_account, reblogs: true)
 +      expect(MergeWorker).to    receive(:perform_async).with(target_account.id, account.id)
 +      expect(follow_request).to receive(:destroy!)
 +      follow_request.authorize!
 +    end
++
+     it 'generates a Follow' do
+       follow_request = Fabricate.create(:follow_request)
+       follow_request.authorize!
+       target = follow_request.target_account
+       expect(follow_request.account.following?(target)).to be true
+     end
+     it 'correctly passes show_reblogs when true' do
+       follow_request = Fabricate.create(:follow_request, show_reblogs: true)
+       follow_request.authorize!
+       target = follow_request.target_account
+       expect(follow_request.account.muting_reblogs?(target)).to be false
+     end
+     it 'correctly passes show_reblogs when false' do
+       follow_request = Fabricate.create(:follow_request, show_reblogs: false)
+       follow_request.authorize!
+       target = follow_request.target_account
+       expect(follow_request.account.muting_reblogs?(target)).to be true
+     end
    end
 -
 -  describe '#reject!'
 -
 -  describe 'validations' do
 -    it 'has a valid fabricator' do
 -      follow_request = Fabricate.build(:follow_request)
 -      expect(follow_request).to be_valid
 -    end
 -
 -    it 'is invalid without an account' do
 -      follow_request = Fabricate.build(:follow_request, account: nil)
 -      follow_request.valid?
 -      expect(follow_request).to model_have_error_on_field(:account)
 -    end
 -
 -    it 'is invalid without a target account' do
 -      follow_request = Fabricate.build(:follow_request, target_account: nil)
 -      follow_request.valid?
 -      expect(follow_request).to model_have_error_on_field(:target_account)      
 -    end
 -  end
  end
Simple merge
index fad0dd36955b461acb23aa4627fb951e582f7bbb,250a880a2627ba01fc8e4cd97cb3a168271ca8d1..a8ebc16b8323de3993930a35c8d7ac4edb448d84
@@@ -48,38 -48,25 +48,58 @@@ RSpec.describe NotifyService d
      is_expected.to_not change(Notification, :count)
    end
  
+   describe 'reblogs' do
+     let(:status)   { Fabricate(:status, account: Fabricate(:account)) }
+     let(:activity) { Fabricate(:status, account: sender, reblog: status) }
+     it 'shows reblogs by default' do
+       recipient.follow!(sender)
+       is_expected.to change(Notification, :count)
+     end
+     it 'shows reblogs when explicitly enabled' do
+       recipient.follow!(sender, reblogs: true)
+       is_expected.to change(Notification, :count)
+     end
+     it 'hides reblogs when disabled' do
+       recipient.follow!(sender, reblogs: false)
+       is_expected.to_not change(Notification, :count)
+     end
+   end
++  
 +  context 'for direct messages' do
 +    let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
 +
 +    before do
 +      user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled)
 +    end
 +
 +    context 'if recipient is supposed to be following sender' do
 +      let(:enabled) { true }
 +
 +      it 'does not notify' do
 +        is_expected.to_not change(Notification, :count)
 +      end
 +
 +      context 'if the message chain initiated by recipient' do
 +        let(:reply_to) { Fabricate(:status, account: recipient) }
 +        let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
 +
 +        it 'does notify' do
 +          is_expected.to change(Notification, :count)
 +        end
 +      end
 +    end
 +
 +    context 'if recipient is NOT supposed to be following sender' do
 +      let(:enabled) { false }
 +
 +      it 'does notify' do
 +        is_expected.to change(Notification, :count)
 +      end
 +    end
 +  end
  
    context do
      let(:asshole)  { Fabricate(:account, username: 'asshole') }
Simple merge
diff --cc yarn.lock
Simple merge