From 21438b54bdaf3c557ec9ebbc482a2c418d8c64f8 Mon Sep 17 00:00:00 2001 From: Fire Demon Date: Sun, 19 Jul 2020 18:50:24 -0500 Subject: [Feature] Add manual publishing option --- .../api/v1/statuses/publishing_controller.rb | 33 +++++++++++++++++ app/controllers/settings/preferences_controller.rb | 1 + app/javascript/flavours/glitch/actions/statuses.js | 43 +++++++++++++++++++++- .../flavours/glitch/components/status.js | 2 + .../glitch/components/status_action_bar.js | 10 +++++ .../flavours/glitch/components/status_content.js | 17 ++++++++- .../flavours/glitch/containers/status_container.js | 12 +++++- .../features/status/components/action_bar.js | 10 +++++ .../features/status/components/detailed_status.js | 2 +- .../status/containers/detailed_status_container.js | 11 ++++++ .../flavours/glitch/features/status/index.js | 15 +++++++- .../flavours/glitch/reducers/statuses.js | 3 ++ .../styles/monsterfork/components/status.scss | 23 +++++++++++- app/javascript/mastodon/locales/en-MP.json | 4 ++ app/lib/user_settings_decorator.rb | 6 +++ app/models/status.rb | 13 +++++-- app/models/user.rb | 1 + app/policies/status_policy.rb | 5 +++ app/serializers/activitypub/note_serializer.rb | 1 + app/serializers/rest/preferences_serializer.rb | 6 +++ app/serializers/rest/status_serializer.rb | 2 + app/services/fan_out_on_write_service.rb | 3 +- app/services/post_status_service.rb | 11 +++++- app/services/process_mentions_service.rb | 5 ++- app/services/update_status_service.rb | 8 +++- .../settings/preferences/other/show.html.haml | 3 ++ app/workers/activitypub/distribution_worker.rb | 2 +- app/workers/distribution_worker.rb | 5 ++- app/workers/link_crawl_worker.rb | 3 +- config/initializers/doorkeeper.rb | 1 + config/initializers/inflections.rb | 2 + config/locales/simple_form.en-MP.yml | 2 + config/routes.rb | 2 + .../20200719181947_add_published_to_statuses.rb | 7 ++++ ...0719184152_add_unpublished_index_to_statuses.rb | 7 ++++ db/schema.rb | 4 +- 36 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 app/controllers/api/v1/statuses/publishing_controller.rb create mode 100644 db/migrate/20200719181947_add_published_to_statuses.rb create mode 100644 db/migrate/20200719184152_add_unpublished_index_to_statuses.rb 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 : (
+ ); + const unpublished = (status.get('published') === false) && ( +
+ + +
+ ); + 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 (
+ {unpublished} + {edited} - {edited} {mentionsPlaceholder}
@@ -382,6 +395,7 @@ export default class StatusContent extends React.PureComponent { tabIndex='0' ref={this.setRef} > + {unpublished} {edited}
+ {unpublished} {edited}
{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 (
-
+
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 @@ -29,6 +29,9 @@ .fields-group.fields-row__column.fields-row__column-6 = 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 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 diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 0eee547ee..4394444bb 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -76,6 +76,7 @@ Doorkeeper.configure do :'write:notifications', :'write:reports', :'write:statuses', + :'write:statuses:publish', :read, :'read:accounts', :'read:blocks', diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ebb7541eb..4170b69ba 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -22,4 +22,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'Ed25519' inflect.singular 'data', 'data' + + inflect.irregular 'publish', 'publishing' end diff --git a/config/locales/simple_form.en-MP.yml b/config/locales/simple_form.en-MP.yml index 376ea7f9d..6d448c04d 100644 --- a/config/locales/simple_form.en-MP.yml +++ b/config/locales/simple_form.en-MP.yml @@ -24,6 +24,7 @@ en-MP: setting_default_content_type_console_html: Plain-text console formatting. setting_default_content_type_bbcode_html: "[b]Bold[/b], [u]Underline[/u], [i]Italic[/i], [code]Console[/code], ..." setting_default_language: The language of your roars can be detected automatically, but it's not always accurate + setting_manual_publish: This allows you to draft, proofread, and edit your roars before publishing them. You can publish a roar from its action menu (the three dots). setting_show_application: The application you use to toot will be displayed in the detailed view of your roars setting_skin: Reskins the selected UI flavour show_replies: Disable if you'd prefer your replies not be a part of your public profile @@ -46,6 +47,7 @@ en-MP: setting_display_media_show_all: Reveal all setting_expand_spoilers: Always expand roars marked with content warnings setting_favourite_modal: Show confirmation dialog before admiring (applies to Glitch flavour only) + setting_manual_publish: Manually publish roars setting_show_application: Disclose application used to send roars setting_use_pending_items: Relax mode show_replies: Show replies on profile diff --git a/config/routes.rb b/config/routes.rb index b0d064c35..6df812090 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,8 @@ Rails.application.routes.draw do resource :pin, only: :create post :unpin, to: 'pins#destroy' + + resource :publish, only: :create end member do diff --git a/db/migrate/20200719181947_add_published_to_statuses.rb b/db/migrate/20200719181947_add_published_to_statuses.rb new file mode 100644 index 000000000..129840a0c --- /dev/null +++ b/db/migrate/20200719181947_add_published_to_statuses.rb @@ -0,0 +1,7 @@ +class AddPublishedToStatuses < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :statuses, :published, :boolean, default: true, null: false + end + end +end diff --git a/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb new file mode 100644 index 000000000..ee6d3e942 --- /dev/null +++ b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb @@ -0,0 +1,7 @@ +class AddUnpublishedIndexToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :statuses, [:account_id, :id], where: '(deleted_at IS NULL) AND (published = FALSE)', order: { id: :desc }, algorithm: :concurrently, name: :index_unpublished_statuses + end +end diff --git a/db/schema.rb b/db/schema.rb index ff878d47e..57783dc3a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_07_19_114344) do +ActiveRecord::Schema.define(version: 2020_07_19_184152) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -791,7 +791,9 @@ ActiveRecord::Schema.define(version: 2020_07_19_114344) do t.datetime "deleted_at" t.integer "edited", default: 0, null: false t.integer "nest_level", limit: 2, default: 0, null: false + t.boolean "published", default: true, null: false t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" + t.index ["account_id", "id"], name: "index_unpublished_statuses", order: { id: :desc }, where: "((deleted_at IS NULL) AND (published = false))" t.index ["conversation_id"], name: "index_statuses_on_conversation_id", where: "(deleted_at IS NULL)" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_on_id_and_account_id" -- cgit