Add an expiry to status

This commit is contained in:
noellabo 2020-06-16 07:56:38 +09:00
parent b1812e519d
commit 9d69b53bb5
33 changed files with 396 additions and 57 deletions

View file

@ -39,11 +39,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def permitted_account_statuses
@account.statuses.permitted_for(@account, current_account)
@account.permitted_statuses(current_account)
end
def only_media_scope
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
Status.include_expired.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
end
def pinned_scope
@ -53,18 +53,18 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def no_replies_scope
Status.without_replies
Status.include_expired.without_replies
end
def no_reblogs_scope
Status.without_reblogs
Status.include_expired.without_reblogs
end
def hashtag_scope
tag = Tag.find_normalized(params[:tagged])
if tag
Status.tagged_with(tag.id)
Status.include_expired.tagged_with(tag.id)
else
Status.none
end

View file

@ -18,11 +18,11 @@ class Api::V1::BookmarksController < Api::BaseController
end
def cached_bookmarks
cache_collection(results.map(&:status), Status)
cache_collection(Status.where(id: results.pluck(:status_id)), Status)
end
def results
@_results ||= account_bookmarks.eager_load(:status).to_a_paginated_by_id(
@_results ||= account_bookmarks.joins('INNER JOIN statuses ON statuses.deleted_at IS NULL AND statuses.id = bookmarks.status_id').to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View file

@ -18,11 +18,11 @@ class Api::V1::FavouritesController < Api::BaseController
end
def cached_favourites
cache_collection(results.map(&:status), Status)
cache_collection(Status.where(id: results.pluck(:status_id)), Status)
end
def results
@_results ||= account_favourites.eager_load(:status).to_a_paginated_by_id(
@_results ||= account_favourites.joins('INNER JOIN statuses ON statuses.deleted_at IS NULL AND statuses.id = favourites.status_id').to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View file

@ -32,7 +32,7 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController
private
def set_status
@status = Status.find(params[:status_id])
@status = Status.include_expired(current_account).find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found

View file

@ -65,7 +65,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
end
def set_status
@status = Status.find(params[:status_id])
@status = Status.include_expired(current_account).find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found

View file

@ -31,7 +31,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
private
def set_status
@status = Status.find(params[:status_id])
@status = Status.include_expired(current_account).find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found

View file

@ -61,7 +61,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
end
def set_status
@status = Status.find(params[:status_id])
@status = Status.include_expired(current_account).find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found

View file

@ -44,6 +44,8 @@ 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],
expires_action: status_params[:expires_action],
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
@ -54,7 +56,7 @@ class Api::V1::StatusesController < Api::BaseController
end
def destroy
@status = Status.where(account_id: current_user.account).find(params[:id])
@status = Status.include_expired.where(account_id: current_account.id).find(params[:id])
authorize @status, :destroy?
@status.discard
@ -67,7 +69,7 @@ class Api::V1::StatusesController < Api::BaseController
private
def set_status
@status = Status.find(params[:id])
@status = Status.include_expired(current_account).find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
@ -88,6 +90,8 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:scheduled_at,
:quote_id,
:expires_at,
:expires_action,
media_ids: [],
poll: [
:multiple,

View file

@ -40,7 +40,7 @@ module CacheConcern
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
unless uncached_ids.empty?
uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id)
uncached = klass.unscoped.where(id: uncached_ids).with_includes.index_by(&:id)
uncached.each_value do |item|
Rails.cache.write(item, item)

View file

@ -84,6 +84,15 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
const dateFormatOptions = {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
};
export default @connect(mapStateToProps)
@injectIntl
class Status extends ImmutablePureComponent {
@ -654,19 +663,22 @@ class Status extends ImmutablePureComponent {
);
}
const expires_at = status.get('expires_at')
const expires_date = expires_at && new Date(expires_at)
const expired = expires_date && expires_date.getTime() < intl.now()
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted, 'status__wrapper-with-expiration': expires_date, 'status__wrapper-expired': expired })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired })} data-id={status.get('id')}>
<AccountActionBar account={status.get('account')} {...other} />
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className="fa fa-clock-o" aria-hidden="true"></i></time></span>}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
@ -682,7 +694,7 @@ class Status extends ImmutablePureComponent {
{quote}
{media}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
<StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} />
</div>
</div>
</HotKeys>

View file

@ -60,6 +60,7 @@ class StatusActionBar extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expired: PropTypes.bool,
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
@ -84,6 +85,10 @@ class StatusActionBar extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
};
static defaultProps = {
expired: false,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
@ -236,7 +241,7 @@ class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
const { status, relationship, intl, withDismiss, scrollKey, expired } = this.props;
const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -248,20 +253,20 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
if (publicStatus && !expired) {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
menu.push(null);
if (writtenByMe && publicStatus) {
if (writtenByMe && publicStatus && !expired) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push(null);
if (writtenByMe || withDismiss) {
if ((writtenByMe || withDismiss) && !expired) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
@ -332,17 +337,17 @@ class StatusActionBar extends ImmutablePureComponent {
}
const shareButton = ('share' in navigator) && publicStatus && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
<IconButton className='status__action-bar-button' disabled={expired} title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar-button' disabled={expired} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={!me && expired} active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
<IconButton className='status__action-bar-button bookmark-icon' disabled={!me && expired} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer

View file

@ -207,22 +207,28 @@ class ActionBar extends React.PureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const expires_at = status.get('expires_at')
const expires_date = expires_at && new Date(expires_at)
const expired = expires_date && expires_date.getTime() < intl.now()
let menu = [];
if (publicStatus) {
if (publicStatus && !expired) {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);
}
if (writtenByMe) {
if (publicStatus) {
if (publicStatus && !expired) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
if (!expired) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else {
@ -265,7 +271,7 @@ class ActionBar extends React.PureComponent {
}
const shareButton = ('share' in navigator) && publicStatus && (
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
<div className='detailed-status__button'><IconButton disabled={expired} title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
);
let replyIcon;
@ -290,10 +296,10 @@ class ActionBar extends React.PureComponent {
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton disabled={expired} title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View file

@ -48,6 +48,15 @@ const mapStateToProps = (state, props) => {
};
};
const dateFormatOptions = {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
};
export default @connect(mapStateToProps)
@injectIntl
class DetailedStatus extends ImmutablePureComponent {
@ -376,9 +385,13 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
const expires_at = status.get('expires_at');
const expires_date = expires_at && new Date(expires_at);
const expired = expires_date && expires_date.getTime() < intl.now();
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, 'detailed-status-with-expiration': expires_date, 'detailed-status-expired': expired })}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
@ -392,7 +405,15 @@ class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</a>
{status.get('expires_at') &&
<span className='detailed-status__expiration-time'>
<time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}>
<i className='fa fa-clock-o' aria-hidden='true' />
</time>
</span>
}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div>
</div>
</div>

View file

@ -99,10 +99,9 @@ class BoostModal extends ImmutablePureComponent {
<div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
<div className='boost-modal__status-header'>
<div className='boost-modal__status-time'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
<div className='status__avatar'>

View file

@ -1147,6 +1147,10 @@
color: $light-text-color;
}
.status__expiration-time {
color: $light-text-color;
}
.status__display-name {
color: $inverted-text-color;
}
@ -1179,6 +1183,8 @@
}
.status__relative-time,
.status__visibility-icon,
.status__expiration-time,
.notification__relative_time {
color: $dark-text-color;
float: right;
@ -1190,6 +1196,14 @@
padding: 0 4px;
}
.status__expiration-time {
margin-left: 4px;
}
.status-expired .status__expiration-time {
color: red;
}
.status__display-name {
color: $dark-text-color;
}
@ -1355,6 +1369,15 @@
margin-left: 6px;
}
.detailed-status__expiration-time {
margin-left: 4px;
margin-right: 4px;
}
.detailed-status-expired .detailed-status__expiration-time {
color: red;
}
.reply-indicator__content,
.quote-indicator__content {
color: $inverted-text-color;
@ -7083,6 +7106,12 @@ noscript {
padding-left: 15px;
}
&__expiration-time {
font-size: 15px;
color: $darker-text-color;
padding-left: 15px;
}
&__names {
color: $darker-text-color;
font-size: 15px;

View file

@ -114,6 +114,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
quote: quote,
expires_at: @object['expiry'],
}
end
end

View file

@ -25,6 +25,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
expiry: { 'toot' => 'http://joinmastodon.org/ns#', 'expiry' => 'toot:expiry' },
}.freeze
def self.default_key_transform

View file

@ -383,6 +383,14 @@ class Account < ApplicationRecord
@synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
end
def permitted_statuses(account)
if account.class.name == 'Account' && account.id == id
Status.include_expired.where(account_id: id)
else
statuses
end.permitted_for(self, account)
end
class Field < ActiveModelSerializers::Model
attributes :name, :value, :verified_at, :account

View file

@ -22,8 +22,10 @@
# application_id :bigint(8)
# in_reply_to_account_id :bigint(8)
# poll_id :bigint(8)
# deleted_at :datetime
# quote_id :bigint(8)
# deleted_at :datetime
# expires_at :datetime
# expires_action :integer default("delete"), not null
#
class Status < ApplicationRecord
@ -34,6 +36,7 @@ class Status < ApplicationRecord
include Cacheable
include StatusThreadingConcern
include RateLimitable
include Expireable
rate_limit by: :account, family: :statuses
@ -46,6 +49,7 @@ class Status < ApplicationRecord
update_index('statuses#status', :proper)
enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
enum expires_action: [:delete, :hint], _prefix: :expires
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@ -81,14 +85,46 @@ class Status < ApplicationRecord
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
validates_with ExpiresValidator, on: :create, if: :local?
accepts_nested_attributes_for :poll
default_scope { recent.kept }
default_scope { recent.kept.not_expired }
scope :recent, -> { reorder(id: :desc) }
scope :remote, -> { where(local: false).where.not(uri: nil) }
scope :local, -> { where(local: true).or(where(uri: nil)) }
scope :not_expired, -> { where(statuses: {expires_at: nil} ).or(where('statuses.expires_at >= ?', Time.now.utc)) }
scope :include_expired, ->(account = nil) {
if account.nil?
unscoped.recent.kept
else
unscoped.recent.kept.where(<<-SQL, account_id: account.id, current_utc: Time.now.utc)
(
statuses.expires_at IS NULL
) OR
NOT (
statuses.account_id != :account_id
AND NOT EXISTS (
SELECT *
FROM bookmarks b
WHERE b.status_id = statuses.id
AND b.account_id = :account_id
)
AND NOT EXISTS (
SELECT *
FROM favourites f
WHERE f.status_id = statuses.id
AND f.account_id = :account_id
)
AND statuses.expires_at IS NOT NULL
AND statuses.expires_at < :current_utc
)
SQL
end
}
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') }

50
app/models/time_limit.rb Normal file
View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
class TimeLimit
TIME_LIMIT_RE = /^exp(?<value>\d+)(?<unit>[mhd])$/
VALID_DURATION = (1.minute..7.days)
def self.from_tags(tags, created_at = Time.now.utc)
return unless tags
tags.map { |tag| new(tag.name, created_at) }.find(&:valid?)
end
def self.from_status(status)
return unless status
status = status.reblog if status.reblog?
return unless status.local?
from_tags(status.tags, status.created_at)
end
def initialize(name, created_at)
@name = name
@created_at = created_at
end
def valid?
VALID_DURATION.include?(to_duration)
end
def to_duration
matched = @name.match(TIME_LIMIT_RE)
return 0 unless matched
case matched[:unit]
when 'm'
matched[:value].to_i.minutes
when 'h'
matched[:value].to_i.hours
when 'd'
matched[:value].to_i.days
else
0
end
end
def to_datetime
@created_at + to_duration
end
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ActivityPub::NoteSerializer < ActivityPub::Serializer
context_extensions :atom_uri, :conversation, :sensitive, :voters_count
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :expiry
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@ -15,6 +15,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
attribute :content
attribute :content_map, if: :language?
attribute :expiry, if: -> { object.expires? }
has_many :media_attachments, key: :attachment
has_many :virtual_tags, key: :tag
@ -82,6 +84,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.created_at.iso8601
end
def expiry
object.expires_at.iso8601
end
def url
ActivityPub::TagManager.instance.url_for(object)
end

View file

@ -17,6 +17,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :quote_id, if: -> { object.quote? }
attribute :expires_at, if: -> { object.expires? }
belongs_to :reblog, serializer: REST::StatusSerializer
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer

View file

@ -15,6 +15,8 @@ class PostStatusService < BaseService
# @option [String] :spoiler_text
# @option [String] :language
# @option [String] :scheduled_at
# @option [String] :expires_at
# @option [String] :expires_action
# @option [Hash] :poll Optional poll to attach
# @option [Enumerable] :media_ids Optional array of media IDs to attach
# @option [Doorkeeper::Application] :application
@ -63,12 +65,14 @@ class PostStatusService < BaseService
end
def preprocess_attributes!
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
@scheduled_at = @options[:scheduled_at]&.to_datetime
@expires_at = @options[:expires_at]&.to_datetime
@expires_action = @options[:expires_action]
@scheduled_at = nil if scheduled_in_the_past?
if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
@quote_id = quote_from_url(md[1])&.id
@text.sub!(/QT:\s*\[.*?\]/, '')
@ -118,6 +122,7 @@ class PostStatusService < BaseService
DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id)
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
DeleteExpiredStatusWorker.perform_at(@status.expires_at, @status.id) if !@status.expires_at.nil? && @status.expires_delete?
end
def validate_media!
@ -191,6 +196,8 @@ class PostStatusService < BaseService
application: @options[:application],
rate_limit: @options[:with_rate_limit],
quote_id: @quote_id,
expires_at: @expires_at,
expires_action: @expires_action,
}.compact
end

View file

@ -11,6 +11,11 @@ class ProcessHashtagsService < BaseService
tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility?
end
time_limit = TimeLimit.from_status(status)
if (time_limit.present?)
status.update(expires_at: time_limit.to_datetime, expires_action: :delete)
end
return unless status.distributable?
status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ExpiresValidator < ActiveModel::Validator
MAX_EXPIRATION = 1.month.freeze
MIN_EXPIRATION = 1.minutes.freeze
def validate(status)
current_time = Time.now.utc
status.errors.add(:expires_at, I18n.t('statuses.errors.duration_too_long')) if status.expires_at.present? && status.expires_at - current_time > MAX_EXPIRATION
status.errors.add(:expires_at, I18n.t('statuses.errors.duration_too_short')) if status.expires_at.present? && (status.expires_at - current_time).ceil < MIN_EXPIRATION
end
end

View file

@ -4,10 +4,10 @@
.status{ class: "status-#{status.visibility}" }
.status__info
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
%span.status__visibility-icon><
= visibility_icon status
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
%data.dt-published{ value: status.created_at.to_time.iso8601 }
%span.status__visibility-icon><
= visibility_icon status
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class DeleteExpiredStatusWorker
include Sidekiq::Worker
sidekiq_options retry: 0, dead: false
def perform(status_id)
@status = Status.include_expired.find(status_id)
RemoveStatusService.new.call(@status, redraft: false)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -1329,6 +1329,8 @@ en:
one: 'contained a disallowed hashtag: %{tags}'
other: 'contained the disallowed hashtags: %{tags}'
errors:
duration_too_long: is too far into the future
duration_too_short: is too soon
in_reply_not_found: The post you are trying to reply to does not appear to exist.
language_detection: Automatically detect language
open_in_web: Open in web

View file

@ -1285,6 +1285,8 @@ ja:
disallowed_hashtags:
other: '許可されていないハッシュタグが含まれています: %{tags}'
errors:
duration_too_long: が長過ぎます
duration_too_short: が短過ぎます
in_reply_not_found: あなたが返信しようとしている投稿は存在しないようです。
language_detection: 自動検出
open_in_web: Webで開く

View file

@ -0,0 +1,5 @@
class AddExpiresAtToStatuses < ActiveRecord::Migration[5.2]
def change
add_column :statuses, :expires_at, :datetime
end
end

View file

@ -0,0 +1,5 @@
class AddExpiresActionToStatuses < ActiveRecord::Migration[5.2]
def change
add_column :statuses, :expires_action, :integer, default: 0, null: false
end
end

View file

@ -928,6 +928,8 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
t.bigint "poll_id"
t.bigint "quote_id"
t.datetime "deleted_at"
t.datetime "expires_at"
t.integer "expires_action", default: 0, null: false
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, 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_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"

View file

@ -0,0 +1,104 @@
require 'rails_helper'
describe TimeLimit do
describe '.from_tags' do
it 'returns true' do
tags = [
Fabricate(:tag, name: "hoge"),
Fabricate(:tag, name: "exp1m"),
Fabricate(:tag, name: "fuga"),
Fabricate(:tag, name: "exp10m"),
]
result = TimeLimit.from_tags(tags)
expect(result.to_duration).to eq(1.minute)
end
end
describe '.from_status' do
subject { TimeLimit.from_status(target_status)&.to_duration }
let(:tag) { Fabricate(:tag, name: "exp1m") }
let(:local_status) { Fabricate(:status, tags: [tag]) }
let(:remote_status) { Fabricate(:status, tags: [tag], local: false, account: Fabricate(:account, domain: 'pawoo.net')) }
context 'when status is local' do
let(:target_status) { local_status }
it { is_expected.to eq(1.minute) }
end
context 'when status is remote' do
let(:target_status) { remote_status }
it { is_expected.to be_nil }
end
context 'when status is reblog' do
let(:target_status) { Fabricate(:status, tags: [tag], reblog: reblog_target) }
context 'reblog target is local status' do
let(:reblog_target) { local_status }
it { is_expected.to eq(1.minute) }
end
context 'when status is remote' do
let(:reblog_target) { remote_status }
it { is_expected.to be_nil }
end
end
end
describe '#valid?' do
context 'valid tag_name' do
it 'returns true' do
result = TimeLimit.new('exp1m').valid?
expect(result).to be true
end
end
context 'invalid tag_name' do
it 'returns false' do
result = TimeLimit.new('10m').valid?
expect(result).to be false
end
it 'returns false' do
result = TimeLimit.new('exp10s').valid?
expect(result).to be false
end
end
context 'invalid time' do
it 'returns false' do
result = TimeLimit.new('exp8d').valid?
expect(result).to be false
end
it 'returns false' do
result = TimeLimit.new("exp#{24 * 8}h").valid?
expect(result).to be false
end
end
end
describe '#to_duration' do
context 'valid tag_name' do
it 'returns positive numeric' do
result = TimeLimit.new('exp1m').to_duration
expect(result.positive?).to be true
end
end
context 'invalid tag_name' do
it 'returns 0' do
result = TimeLimit.new('10m').to_duration
expect(result).to be 0
end
it 'returns 0' do
result = TimeLimit.new('exp10s').to_duration
expect(result).to be 0
end
end
end
end