From 57c12e4fad6a1d221e38ca2aa70805258797bd49 Mon Sep 17 00:00:00 2001 From: KMY Date: Sun, 19 Mar 2023 13:23:20 +0900 Subject: [PATCH 1/7] Change menu order of favourites/bookmarks --- .../mastodon/features/compose/components/action_bar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx index 714071c875..71208e57f7 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx @@ -44,9 +44,9 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); + menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.emoji_reactions), to: '/emoji_reactions' }); - menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); menu.push(null); From 16079b4db56f3b88c671ae4d4b363189dae211e3 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 11:12:33 +0900 Subject: [PATCH 2/7] Add status expiration --- app/models/concerns/account_associations.rb | 1 + app/models/scheduled_expiration_status.rb | 36 +++++++++++++++++++ app/models/status.rb | 1 + app/services/delete_account_service.rb | 2 ++ app/services/post_status_service.rb | 1 + .../update_status_expiration_service.rb | 19 ++++++++++ app/services/update_status_service.rb | 5 +++ app/workers/remove_expired_status_worker.rb | 16 +++++++++ .../scheduler/scheduled_statuses_scheduler.rb | 11 ++++++ ...18_create_scheduled_expiration_statuses.rb | 14 ++++++++ db/schema.rb | 15 +++++++- 11 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/models/scheduled_expiration_status.rb create mode 100644 app/services/update_status_expiration_service.rb create mode 100644 app/workers/remove_expired_status_worker.rb create mode 100644 db/migrate/20230320234918_create_scheduled_expiration_statuses.rb diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 3435f7a9e5..b76892a9d6 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -19,6 +19,7 @@ module AccountAssociations has_many :notifications, inverse_of: :account, dependent: :destroy has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account has_many :scheduled_statuses, inverse_of: :account, dependent: :destroy + has_many :scheduled_expiration_statuses, inverse_of: :account, dependent: :destroy # Pinned statuses has_many :status_pins, inverse_of: :account, dependent: :destroy diff --git a/app/models/scheduled_expiration_status.rb b/app/models/scheduled_expiration_status.rb new file mode 100644 index 0000000000..aff27765a8 --- /dev/null +++ b/app/models/scheduled_expiration_status.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: scheduled_expiration_statuses +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# status_id :bigint(8) not null +# scheduled_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# + +class ScheduledExpirationStatus < ApplicationRecord + include Paginable + + TOTAL_LIMIT = 300 + DAILY_LIMIT = 25 + + belongs_to :account, inverse_of: :scheduled_expiration_statuses + belongs_to :status, inverse_of: :scheduled_expiration_status + + validate :validate_total_limit + validate :validate_daily_limit + + private + + def validate_total_limit + errors.add(:base, I18n.t('scheduled_expiration_statuses.over_total_limit', limit: TOTAL_LIMIT)) if account.scheduled_expiration_statuses.count >= TOTAL_LIMIT + end + + def validate_daily_limit + errors.add(:base, I18n.t('scheduled_expiration_statuses.over_daily_limit', limit: DAILY_LIMIT)) if account.scheduled_expiration_statuses.where('scheduled_at::date = ?::date', scheduled_at).count >= DAILY_LIMIT + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 05791b9f7d..f2f71f5415 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -81,6 +81,7 @@ class Status < ApplicationRecord has_one :status_stat, inverse_of: :status has_one :poll, inverse_of: :status, dependent: :destroy has_one :trend, class_name: 'StatusTrend', inverse_of: :status + has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index 035d5d5079..bd606b2afa 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -26,6 +26,7 @@ class DeleteAccountService < BaseService passive_relationships report_notes scheduled_statuses + scheduled_expiration_statuses status_pins ).freeze @@ -51,6 +52,7 @@ class DeleteAccountService < BaseService notifications owned_lists scheduled_statuses + scheduled_expiration_statuses status_pins ) diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index f9349670d9..370992304f 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -116,6 +116,7 @@ class PostStatusService < BaseService end def postprocess_status! + UpdateStatusExpirationService.new.call(@status) process_hashtags_service.call(@status) Trends.tags.register(@status) LinkCrawlWorker.perform_async(@status.id) diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb new file mode 100644 index 0000000000..ab0f2735f0 --- /dev/null +++ b/app/services/update_status_expiration_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UpdateStatusExpirationService < BaseService + SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dms]+)/ + + def call(status) + existing_expiration = ScheduledExpirationStatus.find_by(status: status) + existing_expiration.destroy! if existing_expiration + + expiration = status.text.scan(SCAN_EXPIRATION_RE).first + return if !expiration + + expiration_num = expiration[0].to_f + expiration_option = expiration[1] + + expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) + ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at) + end +end diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 2022d73932..d461d60abf 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -30,6 +30,7 @@ class UpdateStatusService < BaseService update_media_attachments! if @options.key?(:media_ids) update_poll! if @options.key?(:poll) update_immediate_attributes! + update_expiration! create_edit! unless @options[:no_history] end @@ -122,6 +123,10 @@ class UpdateStatusService < BaseService @status.save! end + def update_expiration! + UpdateStatusExpirationService.new.call(@status) + end + def reset_preview_card! return unless @status.text_previously_changed? diff --git a/app/workers/remove_expired_status_worker.rb b/app/workers/remove_expired_status_worker.rb new file mode 100644 index 0000000000..b86a4b65e2 --- /dev/null +++ b/app/workers/remove_expired_status_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class RemoveExpiredStatusWorker + include Sidekiq::Worker + + sidekiq_options lock: :until_executed + + def perform(scheduled_expiration_status_id) + scheduled_expiration_status = ScheduledExpirationStatus.find(scheduled_expiration_status_id) + scheduled_expiration_status.destroy! + + RemoveStatusService.new.call(scheduled_expiration_status.status) + rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid + true + end +end diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb index 3bf6300b3c..fddf964e4c 100644 --- a/app/workers/scheduler/scheduled_statuses_scheduler.rb +++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb @@ -7,6 +7,7 @@ class Scheduler::ScheduledStatusesScheduler def perform publish_scheduled_statuses! + unpublish_expired_statuses! publish_scheduled_announcements! unpublish_expired_announcements! end @@ -19,10 +20,20 @@ class Scheduler::ScheduledStatusesScheduler end end + def unpublish_expired_statuses! + expired_statuses.find_each do |expired_status| + RemoveExpiredStatusWorker.perform_at(expired_status.scheduled_at, expired_status.id) + end + end + def due_statuses ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET) end + def expired_statuses + ScheduledExpirationStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET) + end + def publish_scheduled_announcements! due_announcements.find_each do |announcement| PublishScheduledAnnouncementWorker.perform_at(announcement.scheduled_at, announcement.id) diff --git a/db/migrate/20230320234918_create_scheduled_expiration_statuses.rb b/db/migrate/20230320234918_create_scheduled_expiration_statuses.rb new file mode 100644 index 0000000000..9fa1e634f8 --- /dev/null +++ b/db/migrate/20230320234918_create_scheduled_expiration_statuses.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateScheduledExpirationStatuses < ActiveRecord::Migration[6.1] + def change + create_table :scheduled_expiration_statuses do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.datetime :scheduled_at, index: true + + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index df73b75825..fa8a541745 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: 2023_03_14_121142) do +ActiveRecord::Schema.define(version: 2023_03_20_234918) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -844,6 +844,17 @@ ActiveRecord::Schema.define(version: 2023_03_14_121142) do t.datetime "updated_at", null: false end + create_table "scheduled_expiration_statuses", force: :cascade do |t| + t.bigint "account_id" + t.bigint "status_id", null: false + t.datetime "scheduled_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_scheduled_expiration_statuses_on_account_id" + t.index ["scheduled_at"], name: "index_scheduled_expiration_statuses_on_scheduled_at" + t.index ["status_id"], name: "index_scheduled_expiration_statuses_on_status_id" + end + create_table "scheduled_statuses", force: :cascade do |t| t.bigint "account_id" t.datetime "scheduled_at" @@ -1222,6 +1233,8 @@ ActiveRecord::Schema.define(version: 2023_03_14_121142) do add_foreign_key "reports", "accounts", column: "assigned_account_id", on_delete: :nullify add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade + add_foreign_key "scheduled_expiration_statuses", "accounts", on_delete: :cascade + add_foreign_key "scheduled_expiration_statuses", "statuses", on_delete: :cascade add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade From d9123e21ba682b0cad3f17fcd13d44ff2ef566a2 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 12:14:31 +0900 Subject: [PATCH 3/7] Add choose expiration form --- app/javascript/mastodon/actions/compose.js | 9 + .../compose/components/compose_form.jsx | 10 + .../components/expiration_dropdown.jsx | 279 ++++++++++++++++++ .../containers/compose_form_container.js | 5 + .../expiration_dropdown_container.js | 25 ++ app/javascript/mastodon/reducers/compose.js | 14 + .../update_status_expiration_service.rb | 4 +- 7 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx create mode 100644 app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 3756a975b7..6cf44df086 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -57,6 +57,7 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; +export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT'; export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; @@ -742,6 +743,14 @@ export function insertEmojiCompose(position, emoji, needsSpace) { }; } +export function insertExpirationCompose(position, data) { + return { + type: COMPOSE_EXPIRATION_INSERT, + position, + data, + }; +} + export function changeComposing(value) { return { type: COMPOSE_COMPOSING_CHANGE, diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index d5da8141c6..edd0c0257b 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -11,6 +11,7 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import ExpirationDropdownContainer from '../containers/expiration_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -60,6 +61,7 @@ class ComposeForm extends ImmutablePureComponent { onChangeSpoilerText: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired, + onPickExpiration: PropTypes.func.isRequired, autoFocus: PropTypes.bool, anyMedia: PropTypes.bool, isInReply: PropTypes.bool, @@ -206,6 +208,13 @@ class ComposeForm extends ImmutablePureComponent { this.props.onPickEmoji(position, data, needsSpace); }; + handleExpirationPick = (data) => { + const { text } = this.props; + const position = this.autosuggestTextarea.textarea.selectionStart; + + this.props.onPickExpiration(position, data); + }; + render () { const { intl, onPaste, autoFocus } = this.props; const disabled = this.props.isSubmitting; @@ -277,6 +286,7 @@ class ComposeForm extends ImmutablePureComponent { +
diff --git a/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx new file mode 100644 index 0000000000..470d5deae5 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx @@ -0,0 +1,279 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/Overlay'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' }, + public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, + public_unlisted_long: { id: 'privacy.public_unlisted.long', defaultMessage: 'Visible for all without GTL' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, + add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class ExpirationDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + }; + + handleKeyDown = e => { + const { items } = this.props; + const value = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => { + return (item.value === value); + }); + let element = null; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1] || this.node.firstChild; + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1] || this.node.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + break; + case 'Home': + element = this.node.firstChild; + break; + case 'End': + element = this.node.lastChild; + break; + } + + if (element) { + element.focus(); + this.props.onChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + }; + + handleClick = e => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + }; + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + }; + + setFocusRef = c => { + this.focusedItem = c; + }; + + render () { + const { style, items, value } = this.props; + + return ( +
+ {items.map(item => ( +
+
+ {item.text} +
+
+ ))} +
+ ); + } + +} + +export default @injectIntl +class ExpirationDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + noDirect: PropTypes.bool, + container: PropTypes.func, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + placement: 'bottom', + }; + + handleToggle = () => { + if (this.props.isUserTouching && this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: !this.state.open }); + } + }; + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + }; + + handleKeyDown = e => { + switch(e.key) { + case 'Escape': + this.handleClose(); + break; + } + }; + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + }; + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + }; + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: false }); + }; + + handleChange = value => { + this.props.onChange(value); + }; + + componentWillMount () { + this.options = [ + { value: '#exp5m', text: '#exp5m (5 minutes)' }, + { value: '#exp30m', text: '#exp30m (30 minutes)' }, + { value: '#exp1h', text: '#exp1h (1 hour)' }, + { value: '#exp3h', text: '#exp3h (3 hours)' }, + { value: '#exp12h', text: '#exp12h (12 hours)' }, + { value: '#exp1d', text: '#exp1d (1 day)' }, + { value: '#exp7d', text: '#exp7d (7 days)' }, + ]; + } + + setTargetRef = c => { + this.target = c; + }; + + findTarget = () => { + return this.target; + }; + + handleOverlayEnter = (state) => { + this.setState({ placement: state.placement }); + }; + + render () { + const { value, container, disabled, intl } = this.props; + const { open, placement } = this.state; + + return ( +
+
+ +
+ + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 2b76422376..132546dbc7 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -8,6 +8,7 @@ import { selectComposeSuggestion, changeComposeSpoilerText, insertEmojiCompose, + insertExpirationCompose, uploadCompose, } from '../../../actions/compose'; @@ -63,6 +64,10 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(insertEmojiCompose(position, data, needsSpace)); }, + onPickExpiration (position, data) { + dispatch(insertExpirationCompose(position, data)); + }, + }); export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js new file mode 100644 index 0000000000..2243aa70be --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import ExpirationDropdown from '../components/expiration_dropdown'; +import { changeComposeVisibility } from '../../../actions/compose'; +import { openModal, closeModal } from '../../../actions/modal'; +import { isUserTouching } from '../../../is_mobile'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']), +}); + +const mapDispatchToProps = (dispatch, { onPickExpiration }) => ({ + + onChange (value) { + if (onPickExpiration) { + onPickExpiration(value); + } + }, + + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ExpirationDropdown); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 9cd81a7bf0..1483f1c2e4 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -32,6 +32,7 @@ import { COMPOSE_LANGUAGE_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, + COMPOSE_EXPIRATION_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_FAIL, @@ -217,6 +218,17 @@ const insertEmoji = (state, position, emojiData, needsSpace) => { }); }; +const insertExpiration = (state, position, data) => { + const oldText = state.get('text'); + + return state.merge({ + text: `${oldText.slice(0, position)} ${data} ${oldText.slice(position)}`, + focusDate: new Date(), + caretPosition: position + data.length + 1, + idempotencyKey: uuid(), + }); +}; + const privacyPreference = (a, b) => { const order = ['public', 'public_unlisted', 'unlisted', 'private', 'direct']; return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; @@ -443,6 +455,8 @@ export default function compose(state = initialState, action) { } case COMPOSE_EMOJI_INSERT: return insertEmoji(state, action.position, action.emoji, action.needsSpace); + case COMPOSE_EXPIRATION_INSERT: + return insertExpiration(state, action.position, action.data); case COMPOSE_UPLOAD_CHANGE_SUCCESS: return state .set('is_changing_upload', false) diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb index ab0f2735f0..374999f2e8 100644 --- a/app/services/update_status_expiration_service.rb +++ b/app/services/update_status_expiration_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UpdateStatusExpirationService < BaseService - SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dms]+)/ + SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dhms]+)/ def call(status) existing_expiration = ScheduledExpirationStatus.find_by(status: status) @@ -13,7 +13,7 @@ class UpdateStatusExpirationService < BaseService expiration_num = expiration[0].to_f expiration_option = expiration[1] - expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) + expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at) end end From 16965de9457d9674ce48aea25416cae3c8eb18c6 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 12:23:53 +0900 Subject: [PATCH 4/7] Fix editing status expiration --- .../compose/components/expiration_dropdown.jsx | 11 ----------- app/services/update_status_expiration_service.rb | 3 ++- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx index 470d5deae5..ce45363a75 100644 --- a/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/expiration_dropdown.jsx @@ -8,17 +8,6 @@ import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; const messages = defineMessages({ - public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, - public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' }, - unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, - unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' }, - public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, - public_unlisted_long: { id: 'privacy.public_unlisted.long', defaultMessage: 'Visible for all without GTL' }, - private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, - private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, - direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, - direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, - change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' }, }); diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb index 374999f2e8..df151a8f1c 100644 --- a/app/services/update_status_expiration_service.rb +++ b/app/services/update_status_expiration_service.rb @@ -12,8 +12,9 @@ class UpdateStatusExpirationService < BaseService expiration_num = expiration[0].to_f expiration_option = expiration[1] + base_time = status.created_at || Time.now.utc - expired_at = Time.now.utc + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) + expired_at = base_time + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at) end end From a1284940eb2a9e16e9a4daf19419aff3d63288f2 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 13:02:22 +0900 Subject: [PATCH 5/7] Fix expiration unit bug --- app/services/post_status_service.rb | 3 ++- app/services/update_status_expiration_service.rb | 6 ++++-- app/services/update_status_service.rb | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 370992304f..73d92752b1 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -76,6 +76,8 @@ class PostStatusService < BaseService @status = @account.statuses.new(status_attributes) process_mentions_service.call(@status, save_records: false) safeguard_mentions!(@status) + + UpdateStatusExpirationService.new.call(@status) # The following transaction block is needed to wrap the UPDATEs to # the media attachments when the status is created @@ -116,7 +118,6 @@ class PostStatusService < BaseService end def postprocess_status! - UpdateStatusExpirationService.new.call(@status) process_hashtags_service.call(@status) Trends.tags.register(@status) LinkCrawlWorker.perform_async(@status.id) diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb index df151a8f1c..21068bbcea 100644 --- a/app/services/update_status_expiration_service.rb +++ b/app/services/update_status_expiration_service.rb @@ -10,10 +10,12 @@ class UpdateStatusExpirationService < BaseService expiration = status.text.scan(SCAN_EXPIRATION_RE).first return if !expiration - expiration_num = expiration[0].to_f - expiration_option = expiration[1] + expiration_num = expiration[1].to_f + expiration_option = expiration[2] base_time = status.created_at || Time.now.utc + raise Mastodon::ValidationError, 'Too many expiration value' if expiration_num >= 10000 + expired_at = base_time + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at) end diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index d461d60abf..9cdef9b326 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -30,7 +30,6 @@ class UpdateStatusService < BaseService update_media_attachments! if @options.key?(:media_ids) update_poll! if @options.key?(:poll) update_immediate_attributes! - update_expiration! create_edit! unless @options[:no_history] end @@ -118,6 +117,8 @@ class UpdateStatusService < BaseService # We raise here to rollback the entire transaction raise NoChangesSubmittedError unless significant_changes? + + update_expiration! @status.edited_at = Time.now.utc @status.save! From fc6819016d33d16018c64c375aaea9b21742f69d Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 13:44:10 +0900 Subject: [PATCH 6/7] Fix multi digits expiration --- app/services/update_status_expiration_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb index 21068bbcea..468d009050 100644 --- a/app/services/update_status_expiration_service.rb +++ b/app/services/update_status_expiration_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UpdateStatusExpirationService < BaseService - SCAN_EXPIRATION_RE = /#exp((\d.\d|\d)+)([dhms]+)/ + SCAN_EXPIRATION_RE = /#exp((\d+.\d+|\d+))([dhms]+)/ def call(status) existing_expiration = ScheduledExpirationStatus.find_by(status: status) From e73706c82ecb596670fe8b115b9d018ac0eb39e2 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 21 Mar 2023 15:05:36 +0900 Subject: [PATCH 7/7] Fix exp hashtag digit max --- app/services/update_status_expiration_service.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/services/update_status_expiration_service.rb b/app/services/update_status_expiration_service.rb index 468d009050..02ea106dbf 100644 --- a/app/services/update_status_expiration_service.rb +++ b/app/services/update_status_expiration_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UpdateStatusExpirationService < BaseService - SCAN_EXPIRATION_RE = /#exp((\d+.\d+|\d+))([dhms]+)/ + SCAN_EXPIRATION_RE = /#exp((\d{1,4}\.\d{1,2}|\d{1,4}))(d|h|m|s)/ def call(status) existing_expiration = ScheduledExpirationStatus.find_by(status: status) @@ -14,8 +14,6 @@ class UpdateStatusExpirationService < BaseService expiration_option = expiration[2] base_time = status.created_at || Time.now.utc - raise Mastodon::ValidationError, 'Too many expiration value' if expiration_num >= 10000 - expired_at = base_time + (expiration_option == 'd' ? expiration_num.days : expiration_option == 'h' ? expiration_num.hours : expiration_option == 's' ? expiration_num.seconds : expiration_num.minutes) ScheduledExpirationStatus.create!(account: status.account, status: status, scheduled_at: expired_at) end