diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 96930bb4..ce965466 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -63,6 +63,7 @@ class Api::V1::StatusesController < Api::BaseController spoiler_text: status_params[:spoiler_text], visibility: status_params[:visibility], scheduled_at: status_params[:scheduled_at], + expires_at: status_params[:expires_at], application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], @@ -79,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController text: status_params[:status], markdown: markdown, media_ids: status_params[:media_ids], + expires_at: status_params[:expires_at], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], visibility: status_params[:visibility], @@ -117,6 +119,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :expires_at, :group_id, media_ids: [], poll: [ diff --git a/app/javascript/gabsocial/actions/compose.js b/app/javascript/gabsocial/actions/compose.js index ac562ac7..7870005a 100644 --- a/app/javascript/gabsocial/actions/compose.js +++ b/app/javascript/gabsocial/actions/compose.js @@ -15,6 +15,14 @@ import { updateTimeline, dequeueTimeline } from './timelines'; // import { showAlert, showAlertForError } from './alerts'; import { defineMessages } from 'react-intl'; import { openModal, closeModal } from './modal'; +import { + STATUS_EXPIRATION_OPTION_5_MINUTES, + STATUS_EXPIRATION_OPTION_60_MINUTES, + STATUS_EXPIRATION_OPTION_6_HOURS, + STATUS_EXPIRATION_OPTION_24_HOURS, + STATUS_EXPIRATION_OPTION_3_DAYS, + STATUS_EXPIRATION_OPTION_7_DAYS, +} from '../constants' import { me } from '../initial_state'; import { makeGetStatus } from '../selectors' @@ -67,6 +75,8 @@ export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; export const COMPOSE_SCHEDULED_AT_CHANGE = 'COMPOSE_SCHEDULED_AT_CHANGE'; +export const COMPOSE_EXPIRES_AT_CHANGE = 'COMPOSE_EXPIRES_AT_CHANGE' + export const COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY = 'COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY' export const COMPOSE_CLEAR = 'COMPOSE_CLEAR' @@ -299,6 +309,24 @@ export function submitCompose(groupId, replyToId = null, router, isStandalone, a let scheduled_at = getState().getIn(['compose', 'scheduled_at'], null); if (scheduled_at !== null) scheduled_at = moment.utc(scheduled_at).toDate(); + let expires_at = getState().getIn(['compose', 'expires_at'], null); + + if (expires_at) { + if (expires_at === STATUS_EXPIRATION_OPTION_5_MINUTES) { + expires_at = moment.utc().add('5', 'minute').toDate() + } else if (expires_at === STATUS_EXPIRATION_OPTION_60_MINUTES) { + expires_at = moment.utc().add('60', 'minute').toDate() + } else if (expires_at === STATUS_EXPIRATION_OPTION_6_HOURS) { + expires_at = moment.utc().add('6', 'hour').toDate() + } else if (expires_at === STATUS_EXPIRATION_OPTION_24_HOURS) { + expires_at = moment.utc().add('24', 'hour').toDate() + } else if (expires_at === STATUS_EXPIRATION_OPTION_3_DAYS) { + expires_at = moment.utc().add('3', 'day').toDate() + } else if (expires_at === STATUS_EXPIRATION_OPTION_7_DAYS) { + expires_at = moment.utc().add('7', 'day').toDate() + } + } + if (isMobile(window.innerWidth) && router && isStandalone) { router.history.goBack() } @@ -306,6 +334,7 @@ export function submitCompose(groupId, replyToId = null, router, isStandalone, a api(getState)[method](endpoint, { status, markdown, + expires_at, scheduled_at, autoJoinGroup, in_reply_to_id: inReplyToId, @@ -715,6 +744,13 @@ export function changeScheduledAt(date) { }; }; +export function changeExpiresAt(value) { + return { + type: COMPOSE_EXPIRES_AT_CHANGE, + value, + } +} + export function changeRichTextEditorControlsVisibility(open) { return { type: COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY, diff --git a/app/javascript/gabsocial/assets/stopwatch_icon.js b/app/javascript/gabsocial/assets/stopwatch_icon.js new file mode 100644 index 00000000..87d2f51f --- /dev/null +++ b/app/javascript/gabsocial/assets/stopwatch_icon.js @@ -0,0 +1,25 @@ +const StopwatchIcon = ({ + className = '', + size = '16px', + title = '', +}) => ( + + + + + + +) + +export default StopwatchIcon \ No newline at end of file diff --git a/app/javascript/gabsocial/components/icon.js b/app/javascript/gabsocial/components/icon.js index 80a2c522..fa0dff93 100644 --- a/app/javascript/gabsocial/components/icon.js +++ b/app/javascript/gabsocial/components/icon.js @@ -68,6 +68,7 @@ import ShareIcon from '../assets/share_icon' import ShopIcon from '../assets/shop_icon' import SortIcon from '../assets/sort_icon' import StarIcon from '../assets/star_icon' +import StopWatchIcon from '../assets/stopwatch_icon' import StrikethroughIcon from '../assets/strikethrough_icon' import SubtractIcon from '../assets/subtract_icon' import TextSizeIcon from '../assets/text_size_icon' @@ -150,6 +151,7 @@ const ICONS = { 'shop': ShopIcon, 'sort': SortIcon, 'star': StarIcon, + 'stopwatch': StopWatchIcon, 'strikethrough': StrikethroughIcon, 'subtract': SubtractIcon, 'text-size': TextSizeIcon, diff --git a/app/javascript/gabsocial/components/popover/popover_root.js b/app/javascript/gabsocial/components/popover/popover_root.js index 301a9201..08d8de46 100644 --- a/app/javascript/gabsocial/components/popover/popover_root.js +++ b/app/javascript/gabsocial/components/popover/popover_root.js @@ -10,6 +10,7 @@ import { POPOVER_SEARCH, POPOVER_SIDEBAR_MORE, POPOVER_STATUS_OPTIONS, + POPOVER_STATUS_EXPIRATION_OPTIONS, POPOVER_STATUS_VISIBILITY, POPOVER_USER_INFO, POPOVER_VIDEO_STATS, @@ -24,6 +25,7 @@ import { ProfileOptionsPopover, SearchPopover, SidebarMorePopover, + StatusExpirationOptionsPopover, StatusOptionsPopover, StatusVisibilityPopover, UserInfoPopover, @@ -51,6 +53,7 @@ POPOVER_COMPONENTS[POPOVER_PROFILE_OPTIONS] = ProfileOptionsPopover POPOVER_COMPONENTS[POPOVER_SEARCH] = SearchPopover POPOVER_COMPONENTS[POPOVER_SIDEBAR_MORE] = SidebarMorePopover POPOVER_COMPONENTS[POPOVER_STATUS_OPTIONS] = StatusOptionsPopover +POPOVER_COMPONENTS[POPOVER_STATUS_EXPIRATION_OPTIONS] = StatusExpirationOptionsPopover POPOVER_COMPONENTS[POPOVER_STATUS_VISIBILITY] = StatusVisibilityPopover POPOVER_COMPONENTS[POPOVER_USER_INFO] = UserInfoPopover POPOVER_COMPONENTS[POPOVER_VIDEO_STATS] = VideoStatsPopover diff --git a/app/javascript/gabsocial/components/popover/status_expiration_options_popover.js b/app/javascript/gabsocial/components/popover/status_expiration_options_popover.js new file mode 100644 index 00000000..1a1b82c9 --- /dev/null +++ b/app/javascript/gabsocial/components/popover/status_expiration_options_popover.js @@ -0,0 +1,126 @@ +import { defineMessages, injectIntl } from 'react-intl' +import { closePopover } from '../../actions/popover' +import { changeExpiresAt } from '../../actions/compose' +import { + STATUS_EXPIRATION_OPTION_5_MINUTES, + STATUS_EXPIRATION_OPTION_60_MINUTES, + STATUS_EXPIRATION_OPTION_6_HOURS, + STATUS_EXPIRATION_OPTION_24_HOURS, + STATUS_EXPIRATION_OPTION_3_DAYS, + STATUS_EXPIRATION_OPTION_7_DAYS, +} from '../../constants' +import PopoverLayout from './popover_layout' +import List from '../list' + +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}) + +const mapStateToProps = (state) => ({ + expiresAtValue: state.getIn(['compose', 'expires_at']), +}) + +const mapDispatchToProps = (dispatch) => ({ + onChangeExpiresAt(expiresAt) { + dispatch(changeExpiresAt(expiresAt)) + }, + onClosePopover() { + dispatch(closePopover()) + }, +}) + +export default +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +class StatusExpirationOptionsPopover extends PureComponent { + + static defaultProps = { + expiresAtValue: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, + isXS: PropTypes.bool, + onChangeExpiresAt: PropTypes.func.isRequired, + } + + handleOnSetStatusExpiration = (expiresAt) => { + this.props.onChangeExpiresAt(expiresAt) + this.handleOnClosePopover() + } + + handleOnClosePopover = () => { + this.props.onClosePopover() + } + + render() { + const { + expiresAtValue, + intl, + isXS, + } = this.props + + const listItems = [ + { + hideArrow: true, + title: intl.formatMessage(messages.minutes, { number: 5 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_5_MINUTES), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_5_MINUTES, + }, + { + hideArrow: true, + title: intl.formatMessage(messages.minutes, { number: 60 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_60_MINUTES), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_60_MINUTES, + }, + { + hideArrow: true, + title: '6 hours', + title: intl.formatMessage(messages.hours, { number: 6 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_6_HOURS), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_6_HOURS, + }, + { + hideArrow: true, + title: intl.formatMessage(messages.hours, { number: 24 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_24_HOURS), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_24_HOURS, + }, + { + hideArrow: true, + title: '3 days', + title: intl.formatMessage(messages.days, { number: 3 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_3_DAYS), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_3_DAYS, + }, + { + hideArrow: true, + title: intl.formatMessage(messages.days, { number: 7 }), + onClick: () => this.handleOnSetStatusExpiration(STATUS_EXPIRATION_OPTION_7_DAYS), + isActive: expiresAtValue === STATUS_EXPIRATION_OPTION_7_DAYS, + }, + ] + + if (expiresAtValue) { + listItems.unshift({ + hideArrow: true, + title: 'Remove expiration', + onClick: () => this.handleOnSetStatusExpiration(null), + },) + } + + return ( + + + + ) + } + +} \ No newline at end of file diff --git a/app/javascript/gabsocial/components/status_header.js b/app/javascript/gabsocial/components/status_header.js index 5b97be5a..0e35f6a3 100644 --- a/app/javascript/gabsocial/components/status_header.js +++ b/app/javascript/gabsocial/components/status_header.js @@ -4,6 +4,7 @@ import { NavLink } from 'react-router-dom' import ImmutablePropTypes from 'react-immutable-proptypes' import ImmutablePureComponent from 'react-immutable-pure-component' import classNames from 'classnames/bind' +import moment from 'moment-mini' import { openPopover } from '../actions/popover' import { openModal } from '../actions/modal' import { me } from '../initial_state' @@ -17,6 +18,7 @@ import Avatar from './avatar' const messages = defineMessages({ edited: { id: 'status.edited', defaultMessage: 'Edited' }, + expirationMessage: { id: 'status.expiration_message', defaultMessage: 'This status expires {time}' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for anyone on or off Gab' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, @@ -101,6 +103,12 @@ class StatusHeader extends ImmutablePureComponent { visibilityText = `${intl.formatMessage(messages.public_short)} - ${intl.formatMessage(messages.public_long)}` } + const expirationDate = status.get('expires_at') + let timeUntilExpiration + if (!!expirationDate) { + timeUntilExpiration = moment(expirationDate).fromNow() + } + return (
@@ -162,6 +170,18 @@ class StatusHeader extends ImmutablePureComponent { + { + !!status.get('expires_at') && + + + + + + + } + { !!status.get('group') && diff --git a/app/javascript/gabsocial/constants.js b/app/javascript/gabsocial/constants.js index a46848ee..b835fecf 100644 --- a/app/javascript/gabsocial/constants.js +++ b/app/javascript/gabsocial/constants.js @@ -30,6 +30,7 @@ export const POPOVER_PROFILE_OPTIONS = 'PROFILE_OPTIONS' export const POPOVER_SEARCH = 'SEARCH' export const POPOVER_SIDEBAR_MORE = 'SIDEBAR_MORE' export const POPOVER_STATUS_OPTIONS = 'STATUS_OPTIONS' +export const POPOVER_STATUS_EXPIRATION_OPTIONS = 'STATUS_EXPIRATION_OPTIONS' export const POPOVER_STATUS_VISIBILITY = 'STATUS_VISIBILITY' export const POPOVER_USER_INFO = 'USER_INFO' export const POPOVER_VIDEO_STATS = 'VIDEO_STATS' @@ -111,4 +112,11 @@ export const NOTIFICATION_FILTERS = [ export const GAB_COM_INTRODUCE_YOURSELF_GROUP_ID = '12' -export const MIN_ACCOUNT_CREATED_AT_ONBOARDING = 1594789200000 // 2020-07-15 \ No newline at end of file +export const MIN_ACCOUNT_CREATED_AT_ONBOARDING = 1594789200000 // 2020-07-15 + +export const STATUS_EXPIRATION_OPTION_5_MINUTES = '5-minutes' +export const STATUS_EXPIRATION_OPTION_60_MINUTES = '60-minutes' +export const STATUS_EXPIRATION_OPTION_6_HOURS = '6-hours' +export const STATUS_EXPIRATION_OPTION_24_HOURS = '24-hours' +export const STATUS_EXPIRATION_OPTION_3_DAYS = '3-days' +export const STATUS_EXPIRATION_OPTION_7_DAYS = '7-days' \ No newline at end of file diff --git a/app/javascript/gabsocial/features/compose/components/compose_form.js b/app/javascript/gabsocial/features/compose/components/compose_form.js index 30a36987..49e25ab1 100644 --- a/app/javascript/gabsocial/features/compose/components/compose_form.js +++ b/app/javascript/gabsocial/features/compose/components/compose_form.js @@ -21,6 +21,7 @@ import PollButton from './poll_button' import PollForm from './poll_form' import SchedulePostButton from './schedule_post_button' import SpoilerButton from './spoiler_button' +import ExpiresPostButton from './expires_post_button' import RichTextEditorButton from './rich_text_editor_button' import StatusContainer from '../../../containers/status_container' import StatusVisibilityButton from './status_visibility_button' @@ -484,10 +485,15 @@ class ComposeForm extends ImmutablePureComponent { { - !hidePro && + !hidePro && !edit && } + { + !hidePro && !edit && + + } + { !hidePro && diff --git a/app/javascript/gabsocial/features/compose/components/expires_post_button.js b/app/javascript/gabsocial/features/compose/components/expires_post_button.js new file mode 100644 index 00000000..71f0dc6b --- /dev/null +++ b/app/javascript/gabsocial/features/compose/components/expires_post_button.js @@ -0,0 +1,78 @@ +import { injectIntl, defineMessages } from 'react-intl' +import { openModal } from '../../../actions/modal' +import { openPopover } from '../../../actions/popover' +import { me } from '../../../initial_state' +import { POPOVER_STATUS_EXPIRATION_OPTIONS } from '../../../constants' +import ComposeExtraButton from './compose_extra_button' + +const messages = defineMessages({ + expires: { id: 'expiration.title', defaultMessage: 'Add status expiration' }, +}) + +const mapStateToProps = (state) => ({ + hasScheduledAt: !!state.getIn(['compose', 'scheduled_at']), + active: !!state.getIn(['compose', 'expires_at']) || state.getIn(['popover', 'popoverType']) === POPOVER_STATUS_EXPIRATION_OPTIONS, + isPro: state.getIn(['accounts', me, 'is_pro']), +}) + +const mapDispatchToProps = (dispatch) => ({ + onOpenExpirationPopover(targetRef) { + dispatch(openPopover(POPOVER_STATUS_EXPIRATION_OPTIONS, { + targetRef, + })) + }, + onOpenProUpgradeModal() { + dispatch(openModal('PRO_UPGRADE')) + }, +}) + +export default +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +class ExpiresPostButton extends PureComponent { + + static propTypes = { + active: PropTypes.bool.isRequired, + intl: PropTypes.object.isRequired, + isPro: PropTypes.bool, + hasScheduledAt: PropTypes.bool, + onOpenProUpgradeModal: PropTypes.func.isRequired, + onOpenExpirationPopover: PropTypes.func.isRequired, + small: PropTypes.bool, + } + + handleToggle = () => { + if (!this.props.isPro) { + this.props.onOpenProUpgradeModal() + } else { + this.props.onOpenExpirationPopover(this.button) + } + } + + setButton = (n) => { + this.button = n + } + + render () { + const { + active, + intl, + hasScheduledAt, + small, + } = this.props + + if (hasScheduledAt) return null + + return ( + + ) + } + +} diff --git a/app/javascript/gabsocial/features/compose/components/schedule_post_button.js b/app/javascript/gabsocial/features/compose/components/schedule_post_button.js index 410dbc04..4a1936ac 100644 --- a/app/javascript/gabsocial/features/compose/components/schedule_post_button.js +++ b/app/javascript/gabsocial/features/compose/components/schedule_post_button.js @@ -11,6 +11,7 @@ const messages = defineMessages({ }) const mapStateToProps = (state) => ({ + hasExpiresAt: !!state.getIn(['compose', 'expires_at']), active: !!state.getIn(['compose', 'scheduled_at']) || state.getIn(['popover', 'popoverType']) === 'DATE_PICKER', isPro: state.getIn(['accounts', me, 'is_pro']), }) @@ -34,12 +35,13 @@ const mapDispatchToProps = (dispatch) => ({ export default @injectIntl @connect(mapStateToProps, mapDispatchToProps) -class SchedulePostDropdown extends PureComponent { +class SchedulePostButton extends PureComponent { static propTypes = { active: PropTypes.bool.isRequired, intl: PropTypes.object.isRequired, isPro: PropTypes.bool, + hasExpiresAt: PropTypes.bool, onOpenProUpgradeModal: PropTypes.func.isRequired, onOpenDatePickerPopover: PropTypes.func.isRequired, onCloseDatePickerPopover: PropTypes.func.isRequired, @@ -62,9 +64,12 @@ class SchedulePostDropdown extends PureComponent { const { active, intl, + hasExpiresAt, small, } = this.props + if (hasExpiresAt) return null + return ( poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); case COMPOSE_SCHEDULED_AT_CHANGE: return state.set('scheduled_at', action.date); + case COMPOSE_EXPIRES_AT_CHANGE: + return state.set('expires_at', action.value); case COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY: return state.withMutations(map => { map.set('rte_controls_visible', action.open || !state.get('rte_controls_visible')); diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index ca8f669b..6e86dd9b 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attributes :id, :created_at, :revised_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility, :language, :uri, :url, :replies_count, :reblogs_count, - :favourites_count, :quote_of_id + :favourites_count, :quote_of_id, :expires_at attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 52e1cf73..e35b11a1 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -16,6 +16,7 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [String] :expires_at # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application @@ -55,6 +56,7 @@ class PostStatusService < BaseService @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility == :public && @account.silenced? + @expires_at = @options[:expires_at]&.to_datetime if @account.is_pro @scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = nil if scheduled_in_the_past? rescue ArgumentError @@ -98,6 +100,7 @@ class PostStatusService < BaseService DistributionWorker.perform_async(@status.id) # Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id) # ActivityPub::DistributionWorker.perform_async(@status.id) + ExpiringStatusWorker.perform_at(@status.expires_at, @status.id) if @status.expires_at && @account.is_pro PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end @@ -177,6 +180,7 @@ class PostStatusService < BaseService { text: @text, markdown: @markdown, + expires_at: @expires_at, group_id: @options[:group_id], quote_of_id: @options[:quote_of_id], media_attachments: @media || [], diff --git a/app/workers/expiring_status_worker.rb b/app/workers/expiring_status_worker.rb new file mode 100644 index 00000000..28c618c0 --- /dev/null +++ b/app/workers/expiring_status_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ExpiringStatusWorker + include Sidekiq::Worker + + sidekiq_options unique: :until_executed + + def perform(status_id) + status = Status.find(status_id) + RemovalWorker.perform_async(status.id) + rescue ActiveRecord::RecordNotFound + true + end +end