Merge commit 'eaa1f9e450' into kb_migration

This commit is contained in:
KMY 2023-07-07 07:20:37 +09:00
commit 2a813d517d
73 changed files with 987 additions and 72 deletions

View file

@ -12,7 +12,7 @@ class Admin::Reports::ActionsController < Admin::BaseController
authorize @report, :show?
case action_from_button
when 'delete', 'mark_as_sensitive'
when 'delete', 'mark_as_sensitive', 'force_cw'
status_batch_action = Admin::StatusBatchAction.new(
type: action_from_button,
status_ids: @report.status_ids,
@ -52,6 +52,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
'delete'
elsif params[:mark_as_sensitive]
'mark_as_sensitive'
elsif params[:force_cw]
'force_cw'
elsif params[:silence]
'silence'
elsif params[:suspend]

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
class Api::V1::Statuses::ReferredByStatusesController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
before_action :set_status
after_action :insert_pagination_headers
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_references
end
def cached_references
cache_collection(results, Status)
end
def results
@results ||= Status.where(id: @status.referenced_by_status_objects.select(:status_id), visibility: [:public, :public_unlisted, :unlisted, :login]).paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_status_referred_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_referred_by_index_url pagination_params(min_id: pagination_since_id) unless results.empty?
end
def pagination_max_id
results.last.id
end
def pagination_since_id
results.first.id
end
def records_continue?
results.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View file

@ -44,11 +44,13 @@ class Api::V1::StatusesController < Api::BaseController
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
references_results = @status.references
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
loaded_references = cache_collection(references_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references)
statuses = [@status] + @context.ancestors + @context.descendants + @context.references
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end

View file

@ -58,6 +58,10 @@ module FormattingHelper
end
def account_field_value_format(field, with_rel_me: true)
html_aware_format(field.value, field.account.local?, markdown: false, with_rel_me: with_rel_me, with_domains: true, multiline: false)
if field.verified? && !field.account.local?
TextFormatter.shortened_link(field.value_for_verification)
else
html_aware_format(field.value, field.account.local?, markdown: false, with_rel_me: with_rel_me, with_domains: true, multiline: false)
end
end
end

View file

@ -62,6 +62,7 @@ export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT';
export const COMPOSE_REFERENCE_INSERT = 'COMPOSE_REFERENCE_INSERT';
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
@ -770,6 +771,14 @@ export function insertExpirationCompose(position, data) {
};
}
export function insertReferenceCompose(position, url) {
return {
type: COMPOSE_REFERENCE_INSERT,
position,
url,
};
}
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,

View file

@ -1,6 +1,6 @@
import api from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
@ -34,6 +34,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const STATUS_REFERENCES_FETCH_REQUEST = 'STATUS_REFERENCES_FETCH_REQUEST';
export const STATUS_REFERENCES_FETCH_SUCCESS = 'STATUS_REFERENCES_FETCH_SUCCESS';
export const STATUS_REFERENCES_FETCH_FAIL = 'STATUS_REFERENCES_FETCH_FAIL';
export const EMOJI_REACTIONS_FETCH_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST';
export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS';
export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL';
@ -470,6 +474,41 @@ export function fetchEmojiReactionsFail(id, error) {
};
}
export function fetchStatusReferences(id) {
return (dispatch, getState) => {
dispatch(fetchStatusReferencesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/referred_by`).then(response => {
dispatch(importFetchedStatuses(response.data));
dispatch(fetchStatusReferencesSuccess(id, response.data));
}).catch(error => {
dispatch(fetchStatusReferencesFail(id, error));
});
};
}
export function fetchStatusReferencesRequest(id) {
return {
type: STATUS_REFERENCES_FETCH_REQUEST,
id,
};
}
export function fetchStatusReferencesSuccess(id, statuses) {
return {
type: STATUS_REFERENCES_FETCH_SUCCESS,
id,
statuses,
};
}
export function fetchStatusReferencesFail(id, error) {
return {
type: STATUS_REFERENCES_FETCH_FAIL,
error,
};
}
export function pin(status) {
return (dispatch, getState) => {
dispatch(pinRequest(status));

View file

@ -143,6 +143,7 @@ const excludeTypesFromFilter = filter => {
'favourite',
'emoji_reaction',
'reblog',
'status_reference',
'mention',
'poll',
'status',

View file

@ -181,8 +181,8 @@ export function fetchContext(id) {
dispatch(fetchContextRequest(id));
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants).concat(response.data.references)));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references));
}).catch(error => {
if (error.response && error.response.status === 404) {
@ -201,12 +201,13 @@ export function fetchContextRequest(id) {
};
}
export function fetchContextSuccess(id, ancestors, descendants) {
export function fetchContextSuccess(id, ancestors, descendants, references) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
references,
statuses: ancestors.concat(descendants),
};
}

View file

@ -49,6 +49,7 @@ const messages = defineMessages({
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
reference: { id: 'status.reference', defaultMessage: 'Add reference' },
hide: { id: 'status.hide', defaultMessage: 'Hide post' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
@ -251,6 +252,10 @@ class StatusActionBar extends ImmutablePureComponent {
navigator.clipboard.writeText(url);
};
handleReference = () => {
this.props.onReference(this.props.status);
};
handleHideClick = () => {
this.props.onFilter();
};
@ -289,6 +294,11 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push(null);
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancelReblog : messages.reblog), action: this.handleReblogForceModalClick });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
}
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
if (writtenByMe && pinnableStatus) {

View file

@ -13,6 +13,7 @@ import {
replyCompose,
mentionCompose,
directCompose,
insertReferenceCompose,
} from '../actions/compose';
import {
blockDomain,
@ -192,6 +193,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
});
},
onReference (status) {
dispatch(insertReferenceCompose(0, status.get('url')));
},
onTranslate (status) {
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));

View file

@ -165,8 +165,11 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onAddToAntenna (account) {
dispatch(openModal('ANTENNA_ADDER', {
accountId: account.get('id'),
dispatch(openModal({
modalType: 'ANTENNA_ADDER',
modalProps: {
accountId: account.get('id'),
},
}));
},

View file

@ -152,6 +152,17 @@ export default class ColumnSettings extends PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-status_reference'>
<span id='notifications-status_reference' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status_reference' defaultMessage='References:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status_reference']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status_reference']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status_reference']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status_reference']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>

View file

@ -10,6 +10,7 @@ const tooltips = defineMessages({
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
status_references: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
@ -90,6 +91,13 @@ class FilterBar extends PureComponent {
>
<Icon id='retweet' fixedWidth />
</button>
<button
className={selectedFilter === 'status_reference' ? 'active' : ''}
onClick={this.onClick('status_reference')}
title={intl.formatMessage(tooltips.status_references)}
>
<Icon id='link' fixedWidth />
</button>
<button
className={selectedFilter === 'poll' ? 'active' : ''}
onClick={this.onClick('poll')}

View file

@ -28,6 +28,7 @@ const messages = defineMessages({
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
statusReference: { id: 'notification.status_reference', defaultMessage: '{name} refered' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
@ -288,6 +289,40 @@ class Notification extends ImmutablePureComponent {
);
}
renderStatusReference (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-status_reference focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.statusReference, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='link' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.status_reference' defaultMessage='{name} referenced your status' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
unread={this.props.unread}
/>
</div>
</HotKeys>
);
}
renderStatus (notification, link) {
const { intl, unread, status } = this.props;
@ -479,6 +514,8 @@ class Notification extends ImmutablePureComponent {
return this.renderEmojiReaction(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'status_reference':
return this.renderStatusReference(notification, link);
case 'status':
return this.renderStatus(notification, link);
case 'update':

View file

@ -42,6 +42,7 @@ const messages = defineMessages({
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
reference: { id: 'status.reference', defaultMessage: 'Add reference' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@ -69,6 +70,7 @@ class ActionBar extends PureComponent {
onReblogForceModal: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onEmojiReact: PropTypes.func.isRequired,
onReference: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@ -190,6 +192,10 @@ class ActionBar extends PureComponent {
navigator.clipboard.writeText(url);
};
handleReference = () => {
this.props.onReference(this.props.status);
};
handleEmojiPick = (data) => {
this.props.onEmojiReact(this.props.status, data);
};
@ -227,6 +233,11 @@ class ActionBar extends PureComponent {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.reblog), action: this.handleReblogForceModalClick });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
}
menu.push(null);
}

View file

@ -137,6 +137,7 @@ class DetailedStatus extends ImmutablePureComponent {
let reblogIcon = 'retweet';
let favouriteLink = '';
let emojiReactionsLink = '';
let statusReferencesLink = '';
let edited = '';
if (this.props.measureHeight) {
@ -310,6 +311,26 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
if (this.context.router) {
statusReferencesLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/references`} className='detailed-status__link'>
<Icon id='link' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('status_referred_by_count')} />
</span>
</Link>
);
} else {
statusReferencesLink = (
<a href={`/interact/${status.get('id')}?type=references`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='link' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('status_referred_by_count')} />
</span>
</a>
);
}
if (status.get('edited_at')) {
edited = (
<>
@ -347,7 +368,7 @@ class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} 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>{edited}{visibilityLink}{searchabilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
</a>{edited}{visibilityLink}{searchabilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink} - {statusReferencesLink}
</div>
</div>
</div>

View file

@ -28,6 +28,7 @@ import {
replyCompose,
mentionCompose,
directCompose,
insertReferenceCompose,
} from '../../actions/compose';
import {
blockDomain,
@ -88,6 +89,12 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const getReferenceIds = createSelector([
(state, { id }) => state.getIn(['contexts', 'references', id]),
], (references) => {
return references;
});
const getAncestorsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
@ -147,10 +154,12 @@ const makeMapStateToProps = () => {
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
let referenceIds = Immutable.List();
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
referenceIds = getReferenceIds(state, { id: status.get('id') });
}
return {
@ -158,6 +167,7 @@ const makeMapStateToProps = () => {
status,
ancestorsIds,
descendantsIds,
referenceIds,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
@ -200,6 +210,7 @@ class Status extends ImmutablePureComponent {
isLoading: PropTypes.bool,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
referenceIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
@ -356,6 +367,10 @@ class Status extends ImmutablePureComponent {
this.handleReblogClick(status, e, true);
};
handleReference = (status) => {
this.props.dispatch(insertReferenceCompose(0, status.get('url')));
};
handleBookmarkClick = (status) => {
if (status.get('bookmarked')) {
this.props.dispatch(unbookmark(status));
@ -442,8 +457,8 @@ class Status extends ImmutablePureComponent {
};
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
const { status, ancestorsIds, descendantsIds, referenceIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS(), referenceIds.toJS());
if (status.get('hidden')) {
this.props.dispatch(revealStatus(statusIds));
@ -636,8 +651,8 @@ class Status extends ImmutablePureComponent {
};
render () {
let ancestors, descendants;
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
let ancestors, descendants, references;
const { isLoading, status, ancestorsIds, descendantsIds, referenceIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { fullscreen } = this.state;
if (isLoading) {
@ -654,6 +669,10 @@ class Status extends ImmutablePureComponent {
);
}
if (referenceIds && referenceIds.size > 0) {
references = <>{this.renderChildren(referenceIds, true)}</>;
}
if (ancestorsIds && ancestorsIds.size > 0) {
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
}
@ -690,6 +709,7 @@ class Status extends ImmutablePureComponent {
<ScrollContainer scrollKey='thread'>
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
{references}
{ancestors}
<HotKeys handlers={handlers}>
@ -717,6 +737,7 @@ class Status extends ImmutablePureComponent {
onEmojiReact={this.handleEmojiReact}
onReblog={this.handleReblogClick}
onReblogForceModal={this.handleReblogForceModalClick}
onReference={this.handleReference}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}

View file

@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { fetchStatusReferences } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import StatusContainer from 'mastodon/containers/status_container';
import Column from 'mastodon/features/ui/components/column';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'referred_by', props.params.statusId]),
});
class StatusReferences extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
UNSAFE_componentWillMount () {
if (!this.props.accountIds) {
this.props.dispatch(fetchStatusReferences(this.props.params.statusId));
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchStatusReferences(nextProps.params.statusId));
}
}
handleRefresh = () => {
this.props.dispatch(fetchStatusReferences(this.props.params.statusId));
};
render () {
const { intl, accountIds, multiColumn } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.status_references' defaultMessage='No one has referred this post yet. When someone does, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
)}
/>
<ScrollableList
scrollKey='references'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<StatusContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(StatusReferences));

View file

@ -45,6 +45,7 @@ import {
Reblogs,
Favourites,
EmojiReactions,
StatusReferences,
DirectTimeline,
HashtagTimeline,
Notifications,
@ -223,6 +224,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/@:acct/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path='/@:acct/:statusId/references' component={StatusReferences} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
@ -231,6 +233,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path='/statuses/:statusId/references' component={StatusReferences} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />

View file

@ -86,6 +86,10 @@ export function EmojiReactions () {
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
}
export function StatusReferences () {
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
}
export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
}

View file

@ -427,6 +427,7 @@
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your post",
"notification.status": "{name} just posted",
"notification.status_reference": "{name} refered your post",
"notification.update": "{name} edited a post",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",

View file

@ -415,6 +415,7 @@
"notification.poll": "アンケートが終了しました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.status": "{name}さんが投稿しました",
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
"notification.update": "{name}さんが投稿を編集しました",
"notifications.clear": "通知を消去",
"notifications.clear_confirmation": "本当に通知を消去しますか?",

View file

@ -36,6 +36,7 @@ import {
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_EXPIRATION_INSERT,
COMPOSE_REFERENCE_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
@ -238,6 +239,41 @@ const insertExpiration = (state, position, data) => {
});
};
const insertReference = (state, url) => {
const oldText = state.get('text');
if (oldText.indexOf(`BT ${url}`) >= 0) {
return state;
}
let newLine = '\n\n';
if (oldText.length === 0) newLine = '';
else if (oldText[oldText.length - 1] === '\n') {
if (oldText.length === 1 || oldText[oldText.length - 2] === '\n') {
newLine = '';
} else {
newLine = '\n';
}
}
if (oldText.length > 0) {
const lastLine = oldText.slice(oldText.lastIndexOf('\n') + 1, oldText.length - 1);
if (lastLine.startsWith('BT ')) {
newLine = '\n';
}
}
const referenceText = `${newLine}BT ${url}`;
const text = `${oldText}${referenceText}`;
return state.merge({
text,
focusDate: new Date(),
caretPosition: text.length - referenceText.length,
idempotencyKey: uuid(),
});
};
const privacyPreference = (a, b) => {
const order = ['public', 'public_unlisted', 'unlisted', 'login', 'private', 'direct'];
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
@ -476,6 +512,8 @@ export default function compose(state = initialState, action) {
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
case COMPOSE_EXPIRATION_INSERT:
return insertExpiration(state, action.position, action.data);
case COMPOSE_REFERENCE_INSERT:
return insertReference(state, action.url);
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state
.set('is_changing_upload', false)

View file

@ -11,33 +11,38 @@ import { compareId } from '../compare_id';
const initialState = ImmutableMap({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
references: ImmutableMap(),
});
const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
const normalizeContext = (immutableState, id, ancestors, descendants, references) => immutableState.withMutations(state => {
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id && !inReplyTos.has(id)) {
state.update('references', immutableReferences => immutableReferences.withMutations(referencePosts => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id && !inReplyTos.has(id)) {
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, id);
});
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, id);
});
inReplyTos.set(id, in_reply_to_id);
inReplyTos.set(id, in_reply_to_id);
}
}
}
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
ancestors.forEach(addReply);
ancestors.forEach(addReply);
if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
descendants.forEach(addReply);
descendants.forEach(addReply);
referencePosts.set(id, ImmutableList(references.map((r) => r.id)));
}));
}));
}));
});
@ -96,7 +101,7 @@ export default function replies(state = initialState, action) {
case ACCOUNT_MUTE_SUCCESS:
return filterContexts(state, action.relationship, action.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
return normalizeContext(state, action.id, action.ancestors, action.descendants, action.references);
case TIMELINE_DELETE:
return deleteFromContexts(state, [action.id]);
case TIMELINE_UPDATE:

View file

@ -48,6 +48,7 @@ import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS,
EMOJI_REACTIONS_FETCH_SUCCESS,
STATUS_REFERENCES_FETCH_SUCCESS,
} from '../actions/interactions';
import {
MUTES_FETCH_REQUEST,
@ -75,6 +76,7 @@ const initialState = ImmutableMap({
reblogged_by: initialListState,
favourited_by: initialListState,
emoji_reactioned_by: initialListState,
referred_by: initialListState,
follow_requests: initialListState,
blocks: initialListState,
mutes: initialListState,
@ -141,6 +143,8 @@ export default function userLists(state = initialState, action) {
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case EMOJI_REACTIONS_FETCH_SUCCESS:
return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts));
case STATUS_REFERENCES_FETCH_SUCCESS:
return state.setIn(['referred_by', action.id], ImmutableList(action.statuses.map(item => item.id)));
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:

View file

@ -6,6 +6,9 @@ $classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8;
$classic-highlight-color: #6364ff;
$emoji-reaction-color: #42485a !default;
$emoji-reaction-selected-color: #617ed5 !default;
$ui-base-color: $classic-base-color !default;
$ui-primary-color: $classic-primary-color !default;
$ui-secondary-color: $classic-secondary-color !default;

View file

@ -13,6 +13,9 @@ $blurple-300: #858afa; // Faded Blue
$grey-600: #4e4c5a; // Trout
$grey-100: #dadaf3; // Topaz
$emoji-reaction-color: #dfe5f5 !default;
$emoji-reaction-selected-color: #9ac1f2 !default;
// Differences
$success-green: lighten(#3c754d, 8%);

View file

@ -1326,7 +1326,7 @@ body > [data-popper-placement] {
margin: 12px 0 2px 4px;
.emoji-reactions-bar__button {
background: lighten($ui-base-color, 12%);
background: $emoji-reaction-color;
border: 0;
cursor: pointer;
display: flex;
@ -1335,7 +1335,7 @@ body > [data-popper-placement] {
height: 24px;
&.toggled {
background: darken($ui-primary-color, 16%);
background: $emoji-reaction-selected-color;
}
> .emoji {
@ -2128,7 +2128,7 @@ a.account__display-name {
font: inherit;
display: block;
width: 100%;
padding: 10px 14px;
padding: 8px 10px;
border: 0;
margin: 0;
background: transparent;

View file

@ -23,6 +23,10 @@ $classic-primary-color: #9baec8; // Echo Blue
$classic-secondary-color: #d9e1e8; // Pattens Blue
$classic-highlight-color: #6364ff; // Brand purple
// Values for kmyblue original functions
$emoji-reaction-color: #42485a !default;
$emoji-reaction-selected-color: #617ed5 !default;
// Variables for defaults in UI
$base-shadow-color: $black !default;
$base-overlay-background: $black !default;

View file

@ -97,6 +97,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
fetch_replies(@status)
distribute
forward_for_reply
process_references!
join_group!
end
@ -471,6 +472,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
retry
end
def process_references!
references = @json['references'].nil? ? [] : ActivityPub::FetchReferencesService(@json['references'])
ProcessReferencesWorker.perform_async(@status.id, [], references)
end
def join_group!
GroupReblogService.new.call(@status)
end

View file

@ -7,11 +7,48 @@ require 'resolv'
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
# around the Socket#open method, since we use our own timeout blocks inside
# that method
#
# Also changes how the read timeout behaves so that it is cumulative (closer
# to HTTP::Timeout::Global, but still having distinct timeouts for other
# operation types)
class HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
# Reset deadline when the connection is re-used for different requests
def reset_counter
@deadline = nil
end
# Read data from the socket
def readpartial(size, buffer = nil)
@deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
timeout = false
loop do
result = @socket.read_nonblock(size, buffer, exception: false)
return :eof if result.nil?
remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
return result if result != :wait_readable
# marking the socket for timeout. Why is this not being raised immediately?
# it seems there is some race-condition on the network level between calling
# #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
# for reads, and when waiting for x seconds, it returns nil suddenly without completing
# the x seconds. In a normal case this would be a timeout on wait/read, but it can
# also mean that the socket has been closed by the server. Therefore we "mark" the
# socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
# timeout. Else, the first timeout was a proper timeout.
# This hack has to be done because io/wait#wait_readable doesn't provide a value for when
# the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
timeout = true unless @socket.to_io.wait_readable(remaining_time)
end
end
end
class Request

View file

@ -53,6 +53,26 @@ class TextFormatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
class << self
include ERB::Util
def shortened_link(url, rel_me: false)
url = Addressable::URI.parse(url).to_s
rel = rel_me ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
prefix = url.match(URL_PREFIX_REGEX).to_s
display_url = url[prefix.length, 30]
suffix = url[prefix.length + 30..-1]
cutoff = url[prefix.length..-1].length > 30
<<~HTML.squish
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
HTML
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(url)
end
end
private
def rewrite
@ -75,19 +95,7 @@ class TextFormatter
end
def link_to_url(entity)
url = Addressable::URI.parse(entity[:url]).to_s
rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
prefix = url.match(URL_PREFIX_REGEX).to_s
display_url = url[prefix.length, 30]
suffix = url[prefix.length + 30..-1]
cutoff = url[prefix.length..-1].length > 30
<<~HTML.squish
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
HTML
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(entity[:url])
TextFormatter.shortened_link(entity[:url], rel_me: with_rel_me?)
end
def link_to_hashtag(entity)

View file

@ -20,6 +20,7 @@ class AccountWarning < ApplicationRecord
enum action: {
none: 0,
disable: 1_000,
force_cw: 1_200,
mark_statuses_as_sensitive: 1_250,
delete_statuses: 1_500,
sensitive: 2_000,

View file

@ -33,6 +33,8 @@ class Admin::StatusBatchAction
handle_delete!
when 'mark_as_sensitive'
handle_mark_as_sensitive!
when 'force_cw'
handle_force_cw!
when 'report'
handle_report!
when 'remove_from_report'
@ -104,6 +106,42 @@ class Admin::StatusBatchAction
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
end
def handle_force_cw!
representative_account = Account.representative
# Can't use a transaction here because UpdateStatusService queues
# Sidekiq jobs
statuses.find_each do |status|
authorize([:admin, status], :update?)
status_text = status.text
status_text = "#{status.spoiler_text}\n\n#{status_text}" if status.spoiler_text
if target_account.local?
UpdateStatusService.new.call(status, representative_account.id, spoiler_text: 'CW', text: status_text)
else
status.update(spoiler_text: 'CW', text: status_text)
end
log_action(:update, status)
if with_report?
report.resolve!(current_account)
log_action(:resolve, report)
end
end
@warning = target_account.strikes.create!(
action: :force_cw,
account: current_account,
report: report,
status_ids: status_ids,
text: text
)
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
end
def handle_report!
@report = Report.new(report_params) unless with_report?
@report.status_ids = (@report.status_ids + allowed_status_ids).uniq

View file

@ -22,15 +22,14 @@ module Attachmentable
included do
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
options = { validate_media_type: false }.merge(options)
super(name, options)
send(:"before_#{name}_post_process") do
send(:"before_#{name}_validate") do
attachment = send(name)
check_image_dimension(attachment)
set_file_content_type(attachment)
obfuscate_file_name(attachment)
set_file_extension(attachment)
Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(attributes: [name]).validate(self)
end
end
end

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
class Context < ActiveModelSerializers::Model
attributes :ancestors, :descendants
attributes :ancestors, :descendants, :references
end

View file

@ -26,6 +26,7 @@ class Notification < ApplicationRecord
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'EmojiReaction' => :emoji_reaction,
'StatusReference' => :status_reference,
'Poll' => :poll,
}.freeze
@ -33,6 +34,7 @@ class Notification < ApplicationRecord
mention
status
reblog
status_reference
follow
follow_request
favourite
@ -47,6 +49,7 @@ class Notification < ApplicationRecord
TARGET_STATUS_INCLUDES_BY_TYPE = {
status: :status,
reblog: [status: :reblog],
status_reference: [status_reference: :status],
mention: [mention: :status],
favourite: [favourite: :status],
emoji_reaction: [emoji_reaction: :status],
@ -67,6 +70,7 @@ class Notification < ApplicationRecord
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :emoji_reaction, inverse_of: :notification
belongs_to :status_reference, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
end
@ -85,6 +89,8 @@ class Notification < ApplicationRecord
status
when :reblog
status&.reblog
when :status_reference
status_reference&.status
when :favourite
favourite&.status
when :emoji_reaction, :reaction
@ -136,6 +142,8 @@ class Notification < ApplicationRecord
notification.status = cached_status
when :reblog
notification.status.reblog = cached_status
when :status_reference
notification.status_reference.status = cached_status
when :favourite
notification.favourite.status = cached_status
when :emoji_reaction, :reaction
@ -162,7 +170,7 @@ class Notification < ApplicationRecord
case activity_type
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
when 'Mention', 'StatusReference'
self.from_account_id = activity&.status&.account_id
when 'Account'
self.from_account_id = activity&.id

View file

@ -75,6 +75,10 @@ class Status < ApplicationRecord
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :reference_objects, class_name: 'StatusReference', inverse_of: :status, dependent: :destroy
has_many :references, through: :reference_objects, class_name: 'Status', source: :target_status
has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status, dependent: :destroy
has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@ -332,6 +336,10 @@ class Status < ApplicationRecord
status_stat&.emoji_reaction_accounts_count || 0
end
def status_referred_by_count
status_stat&.status_referred_by_count || 0
end
def increment_count!(key)
update_status_stat!(key => public_send(key) + 1)
end
@ -340,6 +348,10 @@ class Status < ApplicationRecord
update_status_stat!(key => [public_send(key) - 1, 0].max)
end
def add_status_referred_by_count!(diff)
update_status_stat!(status_referred_by_count: [public_send(:status_referred_by_count) + diff, 0].max)
end
def emoji_reactions_grouped_by_name(account = nil)
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
if account.present?

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_references
#
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# target_status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusReference < ApplicationRecord
belongs_to :status
belongs_to :target_status, class_name: 'Status'
has_one :notification, as: :activity, dependent: :destroy
validate :validate_status_visibilities
def validate_status_visibilities
raise Mastodon::ValidationError, I18n.t('status_references.errors.invalid_status_visibilities') if [:public, :public_unlisted, :unlisted, :login].exclude?(target_status.visibility.to_sym)
end
end

View file

@ -15,6 +15,7 @@
# emoji_reactions_count :integer default(0), not null
# test :integer default(0), not null
# emoji_reaction_accounts_count :integer default(0), not null
# status_referred_by_count :integer default(0), not null
#
class StatusStat < ApplicationRecord
@ -46,6 +47,10 @@ class StatusStat < ApplicationRecord
[attributes['emoji_reaction_accounts_count'], 0].max
end
def status_referred_by_count
[attributes['status_referred_by_count'] || 0, 0].max
end
private
def reset_parent_cache

View file

@ -3,4 +3,5 @@
class REST::ContextSerializer < ActiveModel::Serializer
has_many :ancestors, serializer: REST::StatusSerializer
has_many :descendants, serializer: REST::StatusSerializer
has_many :references, serializer: REST::StatusSerializer
end

View file

@ -117,6 +117,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:kmyblue_markdown,
:kmyblue_reaction_deck,
:kmyblue_visibility_login,
:status_reference,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :emoji_reaction, :reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :emoji_reaction, :reaction, :reblog, :status_reference, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?

View file

@ -11,4 +11,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
def image
object.image? ? full_asset_url(object.image.url(:original)) : nil
end
def html
Sanitize.fragment(object.html, Sanitize::Config::MASTODON_OEMBED)
end
end

View file

@ -6,6 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :visibility_ex, :language,
:uri, :url, :replies_count, :reblogs_count, :searchability, :markdown,
:status_reference_ids, :status_references_count, :status_referred_by_count,
:favourites_count, :emoji_reactions, :emoji_reactions_count, :reactions, :edited_at
attribute :favourited, if: :current_user?
@ -92,6 +93,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
ActivityPub::TagManager.instance.url_for(object)
end
def status_reference_ids
@status_reference_ids = object.reference_objects.pluck(:target_status_id)
end
def status_references_count
status_reference_ids.size
end
def favourited
if instance_options && instance_options[:relationships]
instance_options[:relationships].favourites_map[object.id] || false

View file

@ -126,6 +126,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
:kmyblue_markdown,
:kmyblue_reaction_deck,
:kmyblue_visibility_login,
:status_reference,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class ActivityPub::FetchReferencesService < BaseService
include JsonLdHelper
def call(status, collection_or_uri)
@account = status.account
collection_items(collection_or_uri)&.map { |item| value_or_id(item) }
end
private
def collection_items(collection_or_uri)
collection = fetch_collection(collection_or_uri)
return unless collection.is_a?(Hash) && collection['first'].present?
all_items = []
collection = fetch_collection(collection['first'])
while collection.is_a?(Hash)
items = begin
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
end
end
break if items.blank?
all_items.concat(items)
break if all_items.size >= StatusReferenceValidator::LIMIT
collection = collection['next'].present? ? fetch_collection(collection['next']) : nil
end
all_items
end
def fetch_collection(collection_or_uri)
return collection_or_uri if collection_or_uri.is_a?(Hash)
return if invalid_origin?(collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true)
end
end

View file

@ -29,6 +29,8 @@ class ApproveAppealService < BaseService
undo_delete_statuses!
when 'mark_statuses_as_sensitive'
undo_mark_statuses_as_sensitive!
when 'force_cw'
undo_force_cw!
when 'sensitive'
undo_sensitive!
when 'silence'
@ -58,6 +60,13 @@ class ApproveAppealService < BaseService
end
end
def undo_force_cw!
representative_account = Account.representative
@strike.statuses.includes(:media_attachments).each do |status|
UpdateStatusService.new.call(status, representative_account.id, spoiler_text: '')
end
end
def undo_sensitive!
target_account.unsensitize!
end

View file

@ -34,6 +34,7 @@ class PostStatusService < BaseService
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [Enumerable] :allowed_mentions Optional array of expected mentioned account IDs, raises `UnexpectedMentionsError` if unexpected accounts end up in mentions
# @option [Enumerable] :status_reference_ids Optional array
# @return [Status]
def call(account, options = {})
@account = account
@ -78,6 +79,7 @@ class PostStatusService < BaseService
@markdown = @options[:markdown] || false
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
@ -146,6 +148,7 @@ class PostStatusService < BaseService
def postprocess_status!
process_hashtags_service.call(@status)
ProcessReferencesWorker.perform_async(@status.id, @reference_ids, [])
Trends.tags.register(@status)
LinkCrawlWorker.perform_async(@status.id)
DistributionWorker.perform_async(@status.id)
@ -221,6 +224,7 @@ class PostStatusService < BaseService
media_attachments: @media || [],
ordered_media_attachment_ids: (@options[:media_ids] || []).map(&:to_i) & @media.map(&:id),
thread: @in_reply_to,
status_reference_ids: @status_reference_ids,
poll_attributes: poll_attributes,
sensitive: @sensitive,
spoiler_text: @options[:spoiler_text] || '',

View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
class ProcessReferencesService < BaseService
include Payloadable
DOMAIN = ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil)
REFURL_EXP = /(RT|QT|BT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/
def call(status, reference_parameters, save_records: true, urls: nil)
@status = status
@reference_parameters = reference_parameters || []
@save_records = save_records
@urls = urls || []
old_references
return unless added_references.size.positive? || removed_references.size.positive?
StatusReference.transaction do
remove_old_references
add_references
@status.save! if @save_records
create_notifications!
end
end
private
def references
@references = @reference_parameters + scan_text!
end
def old_references
@old_references = @status.references.pluck(:id)
end
def added_references
(references - old_references).uniq
end
def removed_references
(old_references - references).uniq
end
def scan_text!
text = @status.account.local? ? @status.text : @status.text.gsub(%r{</?[^>]*>}, '')
@scan_text = fetch_statuses!(text.scan(REFURL_EXP).pluck(3).uniq).map(&:id).uniq.filter { |status_id| !status_id.zero? }
end
def fetch_statuses!(urls)
(urls + @urls)
.map { |url| ResolveURLService.new.call(url) }
.filter { |status| status }
end
def add_references
return if added_references.empty?
@added_objects = []
statuses = Status.where(id: added_references)
statuses.each do |status|
@added_objects << @status.reference_objects.new(target_status: status)
status.increment_count!(:status_referred_by_count)
end
end
def create_notifications!
local_reference_objects = @added_objects.filter { |ref| ref.target_status.account.local? }
return if local_reference_objects.empty?
LocalNotificationWorker.push_bulk(local_reference_objects) do |ref|
[ref.target_status.account_id, ref.id, 'StatusReference', 'status_reference']
end
end
def remove_old_references
return if removed_references.empty?
statuses = Status.where(id: removed_references)
@status.reference_objects.where(target_status: statuses).destroy_all
statuses.each do |status|
status.decrement_count!(:status_referred_by_count)
end
end
end

View file

@ -46,6 +46,7 @@ class RemoveStatusService < BaseService
remove_from_public
remove_from_media if @status.with_media?
remove_media
decrement_references
end
@status.destroy! if permanently?
@ -123,6 +124,12 @@ class RemoveStatusService < BaseService
end
end
def decrement_references
@status.references.each do |ref|
ref.decrement_count!(:status_referred_by_count)
end
end
def remove_from_hashtags
@account.featured_tags.where(tag_id: @status.tags.map(&:id)).each do |featured_tag|
featured_tag.decrement(@status.id)

View file

@ -142,6 +142,7 @@ class UpdateStatusService < BaseService
def update_metadata!
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status)
ProcessReferencesWorker.perform_async(@status.id, (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?), [])
end
def broadcast_updates!

View file

@ -11,6 +11,11 @@
= button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button'
.report-actions__item__description
= t('admin.reports.actions.mark_as_sensitive_description_html')
.report-actions__item
.report-actions__item__button
= button_tag t('admin.reports.force_cw'), name: :force_cw, class: 'button'
.report-actions__item__description
= t('admin.reports.actions.force_cw_description_html')
.report-actions__item
.report-actions__item__button
= button_tag t('admin.reports.delete_and_resolve'), name: :delete, class: 'button button--destructive'

View file

@ -60,7 +60,7 @@
= t('antennas.index.tags', count: antenna.antenna_tags.size)
.permissions-list__item__text__type
- tags = antenna.antenna_tags.map { |tag| tag.tag.name }
- tags = keywords.take(5) + ['…'] if tags.size > 5 # TODO
- tags = tags.take(5) + ['…'] if tags.size > 5 # TODO
= tags.join(', ')
.announcements-list__item__action-bar

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ProcessReferencesWorker
include Sidekiq::Worker
def perform(status_id, ids, urls)
ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls || [])
rescue ActiveRecord::RecordNotFound
true
end
end