Add an expiry to status
This commit is contained in:
parent
b1812e519d
commit
9d69b53bb5
33 changed files with 396 additions and 57 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
50
app/models/time_limit.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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|
|
||||
|
|
13
app/validators/expires_validator.rb
Normal file
13
app/validators/expires_validator.rb
Normal 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
|
|
@ -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
|
||||
|
|
14
app/workers/delete_expired_status_worker.rb
Normal file
14
app/workers/delete_expired_status_worker.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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で開く
|
||||
|
|
5
db/migrate/20200615103117_add_expires_at_to_statuses.rb
Normal file
5
db/migrate/20200615103117_add_expires_at_to_statuses.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddExpiresAtToStatuses < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :statuses, :expires_at, :datetime
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddExpiresActionToStatuses < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :statuses, :expires_action, :integer, default: 0, null: false
|
||||
end
|
||||
end
|
|
@ -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)))"
|
||||
|
|
104
spec/models/time_limit_spec.rb
Normal file
104
spec/models/time_limit_spec.rb
Normal 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
|
Loading…
Reference in a new issue