about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-07-19 18:50:24 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:43:59 -0500
commit21438b54bdaf3c557ec9ebbc482a2c418d8c64f8 (patch)
treee577d047af196823227e675dea52b2fc2fa842c6 /app
parent8c8ad0ac0ed0d3e67f3e521068b59edd4054f1e9 (diff)
[Feature] Add manual publishing option
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/statuses/publishing_controller.rb33
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js43
-rw-r--r--app/javascript/flavours/glitch/components/status.js2
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js10
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js17
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js12
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js10
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js3
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/status.scss23
-rw-r--r--app/javascript/mastodon/locales/en-MP.json4
-rw-r--r--app/lib/user_settings_decorator.rb6
-rw-r--r--app/models/status.rb13
-rw-r--r--app/models/user.rb1
-rw-r--r--app/policies/status_policy.rb5
-rw-r--r--app/serializers/activitypub/note_serializer.rb1
-rw-r--r--app/serializers/rest/preferences_serializer.rb6
-rw-r--r--app/serializers/rest/status_serializer.rb2
-rw-r--r--app/services/fan_out_on_write_service.rb3
-rw-r--r--app/services/post_status_service.rb11
-rw-r--r--app/services/process_mentions_service.rb5
-rw-r--r--app/services/update_status_service.rb8
-rw-r--r--app/views/settings/preferences/other/show.html.haml3
-rw-r--r--app/workers/activitypub/distribution_worker.rb2
-rw-r--r--app/workers/distribution_worker.rb5
-rw-r--r--app/workers/link_crawl_worker.rb3
29 files changed, 238 insertions, 22 deletions
diff --git a/app/controllers/api/v1/statuses/publishing_controller.rb b/app/controllers/api/v1/statuses/publishing_controller.rb
new file mode 100644
index 000000000..5124b1009
--- /dev/null
+++ b/app/controllers/api/v1/statuses/publishing_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::PublishingController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write, :'write:statuses:publish' }
+  before_action :require_user!
+  before_action :set_status
+
+  def create
+    @status.update!(published: true)
+
+    LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
+    DistributionWorker.perform_async(@status.id)
+    ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
+
+    mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil })
+    mentions.each { |mention| LocalNotificationWorker.perform_async(mention.account.id, mention.id, mention.class.name) }
+
+    render json: @status,
+           serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer),
+           source_requested: truthy_param?(:source)
+  end
+
+  private
+
+  def set_status
+    @status = Status.unpublished.find(params[:status_id])
+    authorize @status, :destroy?
+  rescue Mastodon::NotPermittedError
+    not_found
+  end
+end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 75c3e2495..3d659af4c 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -61,6 +61,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_use_pending_items,
       :setting_trends,
       :setting_crop_images,
+      :setting_manual_publish,
       notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
       interactions: %i(must_be_follower must_be_following must_be_following_dm)
     )
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 72e8f14d8..9b33ed09f 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -12,6 +12,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
 export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
 export const STATUS_DELETE_FAIL    = 'STATUS_DELETE_FAIL';
 
+export const STATUS_PUBLISH_REQUEST = 'STATUS_PUBLISH_REQUEST';
+export const STATUS_PUBLISH_SUCCESS = 'STATUS_PUBLISH_SUCCESS';
+export const STATUS_PUBLISH_FAIL    = 'STATUS_PUBLISH_FAIL';
+
 export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
 export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
 export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
@@ -34,9 +38,9 @@ export function fetchStatusRequest(id, skipLoading) {
   };
 };
 
-export function fetchStatus(id) {
+export function fetchStatus(id, skipLoading = null) {
   return (dispatch, getState) => {
-    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+    skipLoading = skipLoading === null ? getState().getIn(['statuses', id], null) !== null : skipLoading;
 
     dispatch(fetchContext(id));
 
@@ -73,6 +77,41 @@ export function editStatus(status, routerHistory) {
   };
 };
 
+export function publishStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(publishStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/publish`).then(() => {
+      dispatch(publishStatusSuccess(id));
+      dispatch(fetchStatus(id, false));
+    }).catch(error => {
+      dispatch(publishStatusFail(id, error));
+    });
+  };
+};
+
+export function publishStatusRequest(id) {
+  return {
+    type: STATUS_PUBLISH_REQUEST,
+    id: id,
+  };
+};
+
+export function publishStatusSuccess(id) {
+  return {
+    type: STATUS_PUBLISH_SUCCESS,
+    id: id,
+  };
+};
+
+export function publishStatusFail(id, error) {
+  return {
+    type: STATUS_PUBLISH_FAIL,
+    id: id,
+    error: error,
+  };
+};
+
 export function fetchStatusSuccess(skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 3a6029b96..4626d1cd8 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -74,6 +74,7 @@ class Status extends ImmutablePureComponent {
     onBookmark: PropTypes.func,
     onDelete: PropTypes.func,
     onEdit: PropTypes.func,
+    onPublish: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onPin: PropTypes.func,
@@ -695,6 +696,7 @@ class Status extends ImmutablePureComponent {
 
     const computedClass = classNames('status', `status-${status.get('visibility')}`, {
       collapsed: isCollapsed,
+      unpublished: status.get('published') === false,
       'has-background': isCollapsed && background,
       'status__wrapper-reply': !!status.get('in_reply_to_id'),
       read: unread === false,
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 6902103c5..e941fb994 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -13,6 +13,7 @@ const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
   edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  publish: { id: 'status.publish', defaultMessage: 'Publish' },
   direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
@@ -63,6 +64,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
     onEdit: PropTypes.func,
+    onPublish: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
@@ -139,6 +141,10 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onEdit(this.props.status, this.context.router.history);
   }
 
+  handlePublishClick = () => {
+    this.props.onPublish(this.props.status);
+  }
+
   handlePinClick = () => {
     this.props.onPin(this.props.status);
   }
@@ -238,6 +244,10 @@ class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
       menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+
+      if (status.get('published') === false) {
+        menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick });
+      }
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
       menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index b353b028b..171aff097 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -278,6 +278,7 @@ export default class StatusContent extends React.PureComponent {
 
     const edited = (status.get('edited') === 0) ? null : (
       <div className='status__edit-notice'>
+        <Icon id='pencil-square-o' />
         <FormattedMessage
           id='status.edited'
           defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}'
@@ -290,6 +291,17 @@ export default class StatusContent extends React.PureComponent {
       </div>
     );
 
+    const unpublished = (status.get('published') === false) && (
+      <div className='status__unpublished-notice'>
+        <Icon id='chain-broken' />
+        <FormattedMessage
+          id='status.unpublished'
+          defaultMessage='Unpublished'
+          key={`unpublished-${status.get('id')}`}
+        />
+      </div>
+    );
+
     const content = { __html: status.get('contentHtml') };
     const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
@@ -345,6 +357,8 @@ export default class StatusContent extends React.PureComponent {
 
       return (
         <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}>
+          {unpublished}
+          {edited}
           <p
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
           >
@@ -355,7 +369,6 @@ export default class StatusContent extends React.PureComponent {
             </button>
           </p>
 
-          {edited}
           {mentionsPlaceholder}
 
           <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
@@ -382,6 +395,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {unpublished}
           {edited}
           <div
             ref={this.setContentsRef}
@@ -401,6 +415,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {unpublished}
           {edited}
           <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' />
           {media}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 9e011ac6b..bccaba92d 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -17,7 +17,7 @@ import {
   pin,
   unpin,
 } from 'flavours/glitch/actions/interactions';
-import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
@@ -38,6 +38,8 @@ const messages = defineMessages({
   redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
   replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
   replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
   author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
   matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
@@ -170,6 +172,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     dispatch(editStatus(status, history));
   },
 
+  onPublish (status) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
index c4f510184..e76d9634b 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -11,6 +11,7 @@ const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
   edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  publish: { id: 'status.publish', defaultMessage: 'Publish' },
   direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
@@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent {
     onBlock: PropTypes.func,
     onDelete: PropTypes.func.isRequired,
     onEdit: PropTypes.func.isRequired,
+    onPublish: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
@@ -88,6 +90,10 @@ class ActionBar extends React.PureComponent {
     this.props.onEdit(this.props.status, this.context.router.history);
   }
 
+  handlePublishClick = () => {
+    this.props.onPublish(this.props.status);
+  }
+
   handleDirectClick = () => {
     this.props.onDirect(this.props.status.get('account'), this.context.router.history);
   }
@@ -171,6 +177,10 @@ class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
       menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+
+      if (status.get('published') === false) {
+        menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick });
+      }
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
       menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 24e614a8d..0db0316cc 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -259,7 +259,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     return (
       <div style={outerStyle}>
-        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} {...selectorAttribs}>
+        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, unpublished: status.get('published') === false })} {...selectorAttribs}>
           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
             <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
             <DisplayName account={status.get('account')} localDomain={this.props.domain} />
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
index eeafc0b08..124de903a 100644
--- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -19,6 +19,7 @@ import {
   unmuteStatus,
   editStatus,
   deleteStatus,
+  publishStatus,
   hideStatus,
   revealStatus,
 } from 'flavours/glitch/actions/statuses';
@@ -35,6 +36,8 @@ const messages = defineMessages({
   deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
   redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
   redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
   replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
 });
@@ -123,6 +126,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(editStatus(status, history));
   },
 
+  onPublish (status) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index beea64341..3a6847e8d 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -26,7 +26,7 @@ import {
   directCompose,
 } from 'flavours/glitch/actions/compose';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
@@ -50,6 +50,8 @@ const messages = defineMessages({
   deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
   redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
   redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
   hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
   detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
@@ -308,6 +310,16 @@ class Status extends ImmutablePureComponent {
     this.props.dispatch(editStatus(status, history));
   }
 
+  handlePublishClick = (status) => {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  }
+
   handleDirectClick = (account, router) => {
     this.props.dispatch(directCompose(account, router));
   }
@@ -593,6 +605,7 @@ class Status extends ImmutablePureComponent {
                   onBookmark={this.handleBookmarkClick}
                   onDelete={this.handleDeleteClick}
                   onEdit={this.handleEditClick}
+                  onPublish={this.handlePublishClick}
                   onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
index 5db766b96..20822b4cb 100644
--- a/app/javascript/flavours/glitch/reducers/statuses.js
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -10,6 +10,7 @@ import {
 import {
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
+  STATUS_PUBLISH_SUCCESS,
 } from 'flavours/glitch/actions/statuses';
 import {
   TIMELINE_DELETE,
@@ -56,6 +57,8 @@ export default function statuses(state = initialState, action) {
     return state.setIn([action.id, 'muted'], true);
   case STATUS_UNMUTE_SUCCESS:
     return state.setIn([action.id, 'muted'], false);
+  case STATUS_PUBLISH_SUCCESS:
+    return state.setIn([action.id, 'published'], true);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
index fb2cfabce..33601b8bf 100644
--- a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
@@ -1,9 +1,28 @@
-.status__edit-notice {
+.status__edit-notice, .status__unpublished-notice {
+  margin-bottom: 1em;
+
   & > span {
     color: $dark-text-color;
     line-height: normal;
     font-style: italic;
     font-size: 12px;
+    padding-left: 8px;
+    position: relative;
+    bottom: 0.25em;
+  }
+
+  & > i {
+    color: lighten($dark-text-color, 4%);
+  }
+}
+
+.status, .detailed-status {
+  &.unpublished {
+    background: darken($ui-base-color, 4%);
+
+    &:focus {
+      background: lighten($ui-base-color, 4%);
+    }
   }
 }
 
@@ -15,4 +34,4 @@
 
 div[data-nest-deep="true"] {
   border-left: 75px dashed darken($ui-base-color, 8%);
-}
\ No newline at end of file
+}
diff --git a/app/javascript/mastodon/locales/en-MP.json b/app/javascript/mastodon/locales/en-MP.json
index ba5b276b2..8544a0d1b 100644
--- a/app/javascript/mastodon/locales/en-MP.json
+++ b/app/javascript/mastodon/locales/en-MP.json
@@ -27,6 +27,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this roar?",
   "confirmations.mute.explanation": "This will hide roars from them and roars mentioning them, but it will still allow them to see your roars and follow you.",
   "confirmations.redraft.message": "Are you sure you want to delete and redraft this roar? Admirations and boosts will be lost, and replies to the original roar will be orphaned.",
+  "confirmations.publish.confirm": "Publish",
+  "confirmations.publish.message": "Are you ready to publish your roar?",
   "content-type.change": "Content type",
   "directory.federated": "From world",
   "embed.instructions": "Embed this roar on your website by copying the code below.",
@@ -121,12 +123,14 @@
   "status.is_poll": "This roar is a poll",
   "status.open": "Open this roar",
   "status.pinned": "Pinned",
+  "status.publish": "Publish",
   "status.reblogs.empty": "No one has boosted this roar yet. When someone does, they will show up here.",
   "status.show_less_all": "Hide all",
   "status.show_less": "Hide",
   "status.show_more_all": "Reveal all",
   "status.show_more": "Reveal",
   "status.show_thread": "Reveal thread",
+  "status.unpublished": "Unpublished",
   "tabs_bar.federated_timeline": "World",
   "timeline_hint.resources.statuses": "Older roars",
   "trends.counter_by_accounts": "{count, plural, one {{counter} creature} other {{counter} creatures}} talking",  
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 2f9cfe3ad..8106b976c 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -43,6 +43,8 @@ class UserSettingsDecorator
     user.settings['use_pending_items']   = use_pending_items_preference if change?('setting_use_pending_items')
     user.settings['trends']              = trends_preference if change?('setting_trends')
     user.settings['crop_images']         = crop_images_preference if change?('setting_crop_images')
+
+    user.settings['manual_publish']      = manual_publish_preference if change?('setting_manual_publish')
   end
 
   def merged_notification_emails
@@ -157,6 +159,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_crop_images'
   end
 
+  def manual_publish_preference
+    boolean_cast_setting 'setting_manual_publish'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
diff --git a/app/models/status.rb b/app/models/status.rb
index 54023c24c..b94aad633 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -27,6 +27,7 @@
 #  deleted_at             :datetime
 #  edited                 :integer          default(0), not null
 #  nest_level             :integer          default(0), not null
+#  published              :boolean          default(TRUE), not null
 #
 
 class Status < ApplicationRecord
@@ -94,8 +95,8 @@ class Status < ApplicationRecord
   scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
-  scope :with_public_visibility, -> { where(visibility: :public) }
-  scope :distributable, -> { where(visibility: [:public, :unlisted]) }
+  scope :with_public_visibility, -> { where(visibility: :public, published: true) }
+  scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
@@ -115,6 +116,10 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
+  scope :including_unpublished, -> { unscope(where: :published) }
+  scope :unpublished, -> { rewhere(published: false) }
+  scope :published, -> { where(published: true) }
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -394,7 +399,7 @@ class Status < ApplicationRecord
       visibility = user_signed_in || target_account.show_unlisted? ? [:public, :unlisted] : :public
 
       if account.nil?
-        where(visibility: visibility).not_local_only
+        where(visibility: visibility).not_local_only.published
       elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps
         none
       elsif account.id == target_account.id # author can see own stuff
@@ -404,7 +409,7 @@ class Status < ApplicationRecord
         # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
         visibility.push(:private) if account.following?(target_account) && (user_signed_in || target_account.show_unlisted?)
 
-        scope = left_outer_joins(:reblog)
+        scope = left_outer_joins(:reblog).published
 
         scope.where(visibility: visibility)
              .or(scope.where(id: account.mentions.select(:status_id)))
diff --git a/app/models/user.rb b/app/models/user.rb
index a05d98d88..8fe694ba1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -114,6 +114,7 @@ class User < ApplicationRecord
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
            :default_content_type, :system_emoji_font,
+           :manual_publish,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code, :sign_in_token_attempt
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index fa5c0dd9c..9c98b0688 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -13,6 +13,7 @@ class StatusPolicy < ApplicationPolicy
 
   def show?
     return false if local_only? && (current_account.nil? || !current_account.local?)
+    return false unless published? || owned?
 
     if requires_mention?
       owned? || mention_exists?
@@ -96,4 +97,8 @@ class StatusPolicy < ApplicationPolicy
   def local_only?
     record.local_only?
   end
+
+  def published?
+    record.published?
+  end
 end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 431a0faa4..87d7f9db0 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -33,6 +33,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 
   def id
     raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only]
+    raise Mastodon::NotPermittedError, 'Unpublished statuses should not be serialized' unless object.published? || instance_options[:allow_local_only]
 
     ActivityPub::TagManager.instance.uri_for(object)
   end
diff --git a/app/serializers/rest/preferences_serializer.rb b/app/serializers/rest/preferences_serializer.rb
index 119f0e06d..5220aa034 100644
--- a/app/serializers/rest/preferences_serializer.rb
+++ b/app/serializers/rest/preferences_serializer.rb
@@ -8,6 +8,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer
   attribute :reading_default_sensitive_media, key: 'reading:expand:media'
   attribute :reading_default_sensitive_text, key: 'reading:expand:spoilers'
 
+  attribute :posting_default_manual_publish, key: 'posting:default:manual_publish'
+
   def posting_default_privacy
     object.user.setting_default_privacy
   end
@@ -27,4 +29,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer
   def reading_default_sensitive_text
     object.user.setting_expand_spoilers
   end
+
+  def posting_default_manual_publish
+    object.user.setting_manual_publish
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index b5650544f..7a2dd6db9 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -20,6 +20,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :text, if: :source_requested?
   attribute :content_type, if: :source_requested?
 
+  attribute :published if :local?
+
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
   belongs_to :account, serializer: REST::AccountSerializer
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index dd9c1264d..f16b799bd 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -3,10 +3,11 @@
 class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
-  def call(status)
+  def call(status, only_to_self: false)
     raise Mastodon::RaceConditionError if status.visibility.nil?
 
     deliver_to_self(status) if status.account.local?
+    return if only_to_self
 
     render_anonymous_payload(status)
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index c52ca4a9b..5ddc1aeeb 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -23,6 +23,7 @@ class PostStatusService < BaseService
   # @option [Status] :status Edit an existing status
   # @option [Enumerable] :mentions Optional array of Mentions to include
   # @option [Enumerable] :tags Option array of tag names to include
+  # @option [Boolean] :publish If true, status will be published
   # @return [Status]
   def call(account, options = {})
     @account     = account
@@ -30,6 +31,8 @@ class PostStatusService < BaseService
     @text        = @options[:text] || ''
     @in_reply_to = @options[:thread]
 
+    @options[:publish] ||= !account.user&.setting_manual_publish
+
     raise Mastodon::NotPermittedError if different_author?
 
     @tag_names   = (@options[:tags] || []).select { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
@@ -47,7 +50,7 @@ class PostStatusService < BaseService
     else
       process_status!
       postprocess_status!
-      bump_potential_friendship!
+      bump_potential_friendship! if @options[:publish]
     end
 
     redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
@@ -86,7 +89,7 @@ class PostStatusService < BaseService
     end
 
     process_hashtags_service.call(@status, nil, @tag_names)
-    process_mentions_service.call(@status, mentions: @mentions)
+    process_mentions_service.call(@status, mentions: @mentions, deliver: @options[:publish])
   end
 
   def schedule_status!
@@ -109,6 +112,9 @@ class PostStatusService < BaseService
   def postprocess_status!
     LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
     DistributionWorker.perform_async(@status.id)
+
+    return unless @options[:publish]
+
     ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only?
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
   end
@@ -188,6 +194,7 @@ class PostStatusService < BaseService
       visibility: @visibility,
       language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
       application: @options[:application],
+      published: @options[:publish],
       content_type: @options[:content_type] || @account.user&.setting_default_content_type,
       rate_limit: @options[:with_rate_limit],
     }.compact
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 2cc376a75..8b4b11617 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -9,13 +9,16 @@ class ProcessMentionsService < BaseService
   # @param [Status] status
   # @option [Enumerable] :mentions Mentions to include
   # @option [Boolean] :reveal_implicit_mentions Append implicit mentions to text
-  def call(status, mentions: [], reveal_implicit_mentions: true)
+  # @option [Boolean] :deliver Deliver mention notifications
+  def call(status, mentions: [], reveal_implicit_mentions: true, deliver: true)
     return unless status.local?
 
     @status = status
     @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions, reveal_implicit_mentions: reveal_implicit_mentions)
     @status.save!
 
+    return unless deliver
+
     check_for_spam(status)
 
     mentions.each { |mention| create_notification(mention) }
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
index b393f13bb..440b99ce7 100644
--- a/app/services/update_status_service.rb
+++ b/app/services/update_status_service.rb
@@ -32,7 +32,8 @@ class UpdateStatusService < BaseService
     @tags                   = (tags.nil? ? @status.tags : (tags || [])).to_set
 
     @params[:text]        ||= ''
-    @params[:edited]      ||= 1 + @status.edited
+    @params[:published]     = true if @status.published?
+    (@params[:edited]     ||= 1 + @status.edited) if @params[:published].presence || @status.published?
 
     update_tags if @status.local?
     filter_tags
@@ -54,7 +55,7 @@ class UpdateStatusService < BaseService
     prune_attachments
     reset_status_caches
 
-    SpamCheck.perform(@status)
+    SpamCheck.perform(@status) if @status.published?
     distribute
 
     @status
@@ -132,6 +133,9 @@ class UpdateStatusService < BaseService
   def distribute
     LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
     DistributionWorker.perform_async(@status.id)
+
+    return unless @status.published?
+
     ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
 
     mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil })
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 3b5c7016d..efe8cd0db 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -30,6 +30,9 @@
       = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false, hint: false
 
   .fields-group
+    = f.input :setting_manual_publish, as: :boolean, wrapper: :with_label
+
+  .fields-group
     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
 
   .fields-group
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index b0b1756d0..ec1b1f20e 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -24,7 +24,7 @@ class ActivityPub::DistributionWorker
   private
 
   def skip_distribution?
-    @status.direct_visibility? || @status.limited_visibility?
+    !@status.published? || @status.direct_visibility? || @status.limited_visibility?
   end
 
   def relayable?
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index 4e20ef31b..765cd76c8 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -3,10 +3,11 @@
 class DistributionWorker
   include Sidekiq::Worker
 
-  def perform(status_id)
+  def perform(status_id, only_to_self = false)
     RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}") do |lock|
       if lock.acquired?
-        FanOutOnWriteService.new.call(Status.find(status_id))
+        status = Status.find(status_id)
+        FanOutOnWriteService.new.call(status, only_to_self: !status.published? || only_to_self)
       else
         raise Mastodon::RaceConditionError
       end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
index b3d8aa264..32e51537d 100644
--- a/app/workers/link_crawl_worker.rb
+++ b/app/workers/link_crawl_worker.rb
@@ -6,7 +6,8 @@ class LinkCrawlWorker
   sidekiq_options queue: 'pull', retry: 0
 
   def perform(status_id)
-    FetchLinkCardService.new.call(Status.find(status_id))
+    status = Status.find(status_id)
+    FetchLinkCardService.new.call(status) if status.published?
   rescue ActiveRecord::RecordNotFound
     true
   end