Merge commit 'eaa1f9e450
' into kb_migration
This commit is contained in:
commit
2a813d517d
73 changed files with 987 additions and 72 deletions
48
CHANGELOG.md
48
CHANGELOG.md
|
@ -2,6 +2,54 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.1.3] - 2023-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add fallback redirection when getting a webfinger query `LOCAL_DOMAIN@LOCAL_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23600))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change OpenGraph-based embeds to allow fullscreen ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25058))
|
||||||
|
- Change AccessTokensVacuum to also delete expired tokens ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868))
|
||||||
|
- Change profile updates to be sent to recently-mentioned servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24852))
|
||||||
|
- Change automatic post deletion thresholds and load detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24614))
|
||||||
|
- Change `/api/v1/statuses/:id/history` to always return at least one item ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25510))
|
||||||
|
- Change auto-linking to allow carets in URL query params ([renchap](https://github.com/mastodon/mastodon/pull/25216))
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Remove invalid `X-Frame-Options: ALLOWALL` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25070))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix wrong view being displayed when a webhook fails validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25464))
|
||||||
|
- Fix soft-deleted post cleanup scheduler overwhelming the streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25519))
|
||||||
|
- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477))
|
||||||
|
- Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840))
|
||||||
|
- Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361))
|
||||||
|
- Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273))
|
||||||
|
- Fix `tootctl accounts approve --number N` not aproving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605))
|
||||||
|
- Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988))
|
||||||
|
- Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015))
|
||||||
|
- Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016))
|
||||||
|
- Fix “Authorized applications” inefficiently and incorrectly getting last use date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25060))
|
||||||
|
- Fix “Authorized applications” crashing when listing apps with certain admin API scopes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25713))
|
||||||
|
- Fix multiple N+1s in ConversationsController ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25134), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25399), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25499))
|
||||||
|
- Fix user archive takeouts when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24431))
|
||||||
|
- Fix searching for remote content by URL not working under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637))
|
||||||
|
- Fix inefficiencies in indexing content for search ([VyrCossont](https://github.com/mastodon/mastodon/pull/24285), [VyrCossont](https://github.com/mastodon/mastodon/pull/24342))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Add finer permission requirements for managing webhooks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25463))
|
||||||
|
- Update dependencies
|
||||||
|
- Add hardening headers for user-uploaded files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25756))
|
||||||
|
- Fix verified links possibly hiding important parts of the URL (CVE-2023-36462)
|
||||||
|
- Fix timeout handling of outbound HTTP requests (CVE-2023-36461)
|
||||||
|
- Fix arbitrary file creation through media processing (CVE-2023-36460)
|
||||||
|
- Fix possible XSS in preview cards (CVE-2023-36459)
|
||||||
|
|
||||||
## [4.1.2] - 2023-04-04
|
## [4.1.2] - 2023-04-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
authorize @report, :show?
|
authorize @report, :show?
|
||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive', 'force_cw'
|
||||||
status_batch_action = Admin::StatusBatchAction.new(
|
status_batch_action = Admin::StatusBatchAction.new(
|
||||||
type: action_from_button,
|
type: action_from_button,
|
||||||
status_ids: @report.status_ids,
|
status_ids: @report.status_ids,
|
||||||
|
@ -52,6 +52,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
'delete'
|
'delete'
|
||||||
elsif params[:mark_as_sensitive]
|
elsif params[:mark_as_sensitive]
|
||||||
'mark_as_sensitive'
|
'mark_as_sensitive'
|
||||||
|
elsif params[:force_cw]
|
||||||
|
'force_cw'
|
||||||
elsif params[:silence]
|
elsif params[:silence]
|
||||||
'silence'
|
'silence'
|
||||||
elsif params[:suspend]
|
elsif params[:suspend]
|
||||||
|
|
|
@ -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
|
|
@ -44,11 +44,13 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
|
|
||||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
|
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)
|
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
|
||||||
|
references_results = @status.references
|
||||||
loaded_ancestors = cache_collection(ancestors_results, Status)
|
loaded_ancestors = cache_collection(ancestors_results, Status)
|
||||||
loaded_descendants = cache_collection(descendants_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)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants + @context.references
|
||||||
|
|
||||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -58,6 +58,10 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_field_value_format(field, with_rel_me: true)
|
def account_field_value_format(field, with_rel_me: true)
|
||||||
|
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)
|
html_aware_format(field.value, field.account.local?, markdown: false, with_rel_me: with_rel_me, with_domains: true, multiline: false)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,6 +62,7 @@ export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||||
|
|
||||||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
|
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
|
||||||
export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_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_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
|
||||||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
|
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) {
|
export function changeComposing(value) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_COMPOSING_CHANGE,
|
type: COMPOSE_COMPOSING_CHANGE,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
||||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
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_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
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_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST';
|
||||||
export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS';
|
export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS';
|
||||||
export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL';
|
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) {
|
export function pin(status) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(pinRequest(status));
|
dispatch(pinRequest(status));
|
||||||
|
|
|
@ -143,6 +143,7 @@ const excludeTypesFromFilter = filter => {
|
||||||
'favourite',
|
'favourite',
|
||||||
'emoji_reaction',
|
'emoji_reaction',
|
||||||
'reblog',
|
'reblog',
|
||||||
|
'status_reference',
|
||||||
'mention',
|
'mention',
|
||||||
'poll',
|
'poll',
|
||||||
'status',
|
'status',
|
||||||
|
|
|
@ -181,8 +181,8 @@ export function fetchContext(id) {
|
||||||
dispatch(fetchContextRequest(id));
|
dispatch(fetchContextRequest(id));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||||
dispatch(importFetchedStatuses(response.data.ancestors.concat(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));
|
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references));
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
if (error.response && error.response.status === 404) {
|
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 {
|
return {
|
||||||
type: CONTEXT_FETCH_SUCCESS,
|
type: CONTEXT_FETCH_SUCCESS,
|
||||||
id,
|
id,
|
||||||
ancestors,
|
ancestors,
|
||||||
descendants,
|
descendants,
|
||||||
|
references,
|
||||||
statuses: ancestors.concat(descendants),
|
statuses: ancestors.concat(descendants),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,7 @@ const messages = defineMessages({
|
||||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
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}' },
|
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
||||||
|
reference: { id: 'status.reference', defaultMessage: 'Add reference' },
|
||||||
hide: { id: 'status.hide', defaultMessage: 'Hide post' },
|
hide: { id: 'status.hide', defaultMessage: 'Hide post' },
|
||||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||||
|
@ -251,6 +252,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleReference = () => {
|
||||||
|
this.props.onReference(this.props.status);
|
||||||
|
};
|
||||||
|
|
||||||
handleHideClick = () => {
|
handleHideClick = () => {
|
||||||
this.props.onFilter();
|
this.props.onFilter();
|
||||||
};
|
};
|
||||||
|
@ -289,6 +294,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancelReblog : messages.reblog), action: this.handleReblogForceModalClick });
|
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 });
|
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
|
||||||
|
|
||||||
if (writtenByMe && pinnableStatus) {
|
if (writtenByMe && pinnableStatus) {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
replyCompose,
|
replyCompose,
|
||||||
mentionCompose,
|
mentionCompose,
|
||||||
directCompose,
|
directCompose,
|
||||||
|
insertReferenceCompose,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import {
|
import {
|
||||||
blockDomain,
|
blockDomain,
|
||||||
|
@ -192,6 +193,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onReference (status) {
|
||||||
|
dispatch(insertReferenceCompose(0, status.get('url')));
|
||||||
|
},
|
||||||
|
|
||||||
onTranslate (status) {
|
onTranslate (status) {
|
||||||
if (status.get('translation')) {
|
if (status.get('translation')) {
|
||||||
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
||||||
|
|
|
@ -165,8 +165,11 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onAddToAntenna (account) {
|
onAddToAntenna (account) {
|
||||||
dispatch(openModal('ANTENNA_ADDER', {
|
dispatch(openModal({
|
||||||
|
modalType: 'ANTENNA_ADDER',
|
||||||
|
modalProps: {
|
||||||
accountId: account.get('id'),
|
accountId: account.get('id'),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -152,6 +152,17 @@ export default class ColumnSettings extends PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
<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>
|
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ const tooltips = defineMessages({
|
||||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
|
||||||
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
|
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
|
||||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
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' },
|
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||||
|
@ -90,6 +91,13 @@ class FilterBar extends PureComponent {
|
||||||
>
|
>
|
||||||
<Icon id='retweet' fixedWidth />
|
<Icon id='retweet' fixedWidth />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={selectedFilter === 'status_reference' ? 'active' : ''}
|
||||||
|
onClick={this.onClick('status_reference')}
|
||||||
|
title={intl.formatMessage(tooltips.status_references)}
|
||||||
|
>
|
||||||
|
<Icon id='link' fixedWidth />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={selectedFilter === 'poll' ? 'active' : ''}
|
className={selectedFilter === 'poll' ? 'active' : ''}
|
||||||
onClick={this.onClick('poll')}
|
onClick={this.onClick('poll')}
|
||||||
|
|
|
@ -28,6 +28,7 @@ const messages = defineMessages({
|
||||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
||||||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
|
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
|
||||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
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' },
|
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
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) {
|
renderStatus (notification, link) {
|
||||||
const { intl, unread, status } = this.props;
|
const { intl, unread, status } = this.props;
|
||||||
|
|
||||||
|
@ -479,6 +514,8 @@ class Notification extends ImmutablePureComponent {
|
||||||
return this.renderEmojiReaction(notification, link);
|
return this.renderEmojiReaction(notification, link);
|
||||||
case 'reblog':
|
case 'reblog':
|
||||||
return this.renderReblog(notification, link);
|
return this.renderReblog(notification, link);
|
||||||
|
case 'status_reference':
|
||||||
|
return this.renderStatusReference(notification, link);
|
||||||
case 'status':
|
case 'status':
|
||||||
return this.renderStatus(notification, link);
|
return this.renderStatus(notification, link);
|
||||||
case 'update':
|
case 'update':
|
||||||
|
|
|
@ -42,6 +42,7 @@ const messages = defineMessages({
|
||||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
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}' },
|
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
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}' },
|
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
|
@ -69,6 +70,7 @@ class ActionBar extends PureComponent {
|
||||||
onReblogForceModal: PropTypes.func.isRequired,
|
onReblogForceModal: PropTypes.func.isRequired,
|
||||||
onFavourite: PropTypes.func.isRequired,
|
onFavourite: PropTypes.func.isRequired,
|
||||||
onEmojiReact: PropTypes.func.isRequired,
|
onEmojiReact: PropTypes.func.isRequired,
|
||||||
|
onReference: PropTypes.func.isRequired,
|
||||||
onBookmark: PropTypes.func.isRequired,
|
onBookmark: PropTypes.func.isRequired,
|
||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
onEdit: PropTypes.func.isRequired,
|
onEdit: PropTypes.func.isRequired,
|
||||||
|
@ -190,6 +192,10 @@ class ActionBar extends PureComponent {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleReference = () => {
|
||||||
|
this.props.onReference(this.props.status);
|
||||||
|
};
|
||||||
|
|
||||||
handleEmojiPick = (data) => {
|
handleEmojiPick = (data) => {
|
||||||
this.props.onEmojiReact(this.props.status, data);
|
this.props.onEmojiReact(this.props.status, data);
|
||||||
};
|
};
|
||||||
|
@ -227,6 +233,11 @@ class ActionBar extends PureComponent {
|
||||||
|
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.reblog), action: this.handleReblogForceModalClick });
|
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);
|
menu.push(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -137,6 +137,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
let reblogIcon = 'retweet';
|
let reblogIcon = 'retweet';
|
||||||
let favouriteLink = '';
|
let favouriteLink = '';
|
||||||
let emojiReactionsLink = '';
|
let emojiReactionsLink = '';
|
||||||
|
let statusReferencesLink = '';
|
||||||
let edited = '';
|
let edited = '';
|
||||||
|
|
||||||
if (this.props.measureHeight) {
|
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')) {
|
if (status.get('edited_at')) {
|
||||||
edited = (
|
edited = (
|
||||||
<>
|
<>
|
||||||
|
@ -347,7 +368,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
<div className='detailed-status__meta'>
|
<div className='detailed-status__meta'>
|
||||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
<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' />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {
|
||||||
replyCompose,
|
replyCompose,
|
||||||
mentionCompose,
|
mentionCompose,
|
||||||
directCompose,
|
directCompose,
|
||||||
|
insertReferenceCompose,
|
||||||
} from '../../actions/compose';
|
} from '../../actions/compose';
|
||||||
import {
|
import {
|
||||||
blockDomain,
|
blockDomain,
|
||||||
|
@ -88,6 +89,12 @@ const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
const getPictureInPicture = makeGetPictureInPicture();
|
||||||
|
|
||||||
|
const getReferenceIds = createSelector([
|
||||||
|
(state, { id }) => state.getIn(['contexts', 'references', id]),
|
||||||
|
], (references) => {
|
||||||
|
return references;
|
||||||
|
});
|
||||||
|
|
||||||
const getAncestorsIds = createSelector([
|
const getAncestorsIds = createSelector([
|
||||||
(_, { id }) => id,
|
(_, { id }) => id,
|
||||||
state => state.getIn(['contexts', 'inReplyTos']),
|
state => state.getIn(['contexts', 'inReplyTos']),
|
||||||
|
@ -147,10 +154,12 @@ const makeMapStateToProps = () => {
|
||||||
|
|
||||||
let ancestorsIds = Immutable.List();
|
let ancestorsIds = Immutable.List();
|
||||||
let descendantsIds = Immutable.List();
|
let descendantsIds = Immutable.List();
|
||||||
|
let referenceIds = Immutable.List();
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||||
|
referenceIds = getReferenceIds(state, { id: status.get('id') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -158,6 +167,7 @@ const makeMapStateToProps = () => {
|
||||||
status,
|
status,
|
||||||
ancestorsIds,
|
ancestorsIds,
|
||||||
descendantsIds,
|
descendantsIds,
|
||||||
|
referenceIds,
|
||||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||||
domain: state.getIn(['meta', 'domain']),
|
domain: state.getIn(['meta', 'domain']),
|
||||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||||
|
@ -200,6 +210,7 @@ class Status extends ImmutablePureComponent {
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
ancestorsIds: ImmutablePropTypes.list,
|
ancestorsIds: ImmutablePropTypes.list,
|
||||||
descendantsIds: ImmutablePropTypes.list,
|
descendantsIds: ImmutablePropTypes.list,
|
||||||
|
referenceIds: ImmutablePropTypes.list,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
askReplyConfirmation: PropTypes.bool,
|
askReplyConfirmation: PropTypes.bool,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
|
@ -356,6 +367,10 @@ class Status extends ImmutablePureComponent {
|
||||||
this.handleReblogClick(status, e, true);
|
this.handleReblogClick(status, e, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleReference = (status) => {
|
||||||
|
this.props.dispatch(insertReferenceCompose(0, status.get('url')));
|
||||||
|
};
|
||||||
|
|
||||||
handleBookmarkClick = (status) => {
|
handleBookmarkClick = (status) => {
|
||||||
if (status.get('bookmarked')) {
|
if (status.get('bookmarked')) {
|
||||||
this.props.dispatch(unbookmark(status));
|
this.props.dispatch(unbookmark(status));
|
||||||
|
@ -442,8 +457,8 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleToggleAll = () => {
|
handleToggleAll = () => {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds, referenceIds } = this.props;
|
||||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS(), referenceIds.toJS());
|
||||||
|
|
||||||
if (status.get('hidden')) {
|
if (status.get('hidden')) {
|
||||||
this.props.dispatch(revealStatus(statusIds));
|
this.props.dispatch(revealStatus(statusIds));
|
||||||
|
@ -636,8 +651,8 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants, references;
|
||||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
const { isLoading, status, ancestorsIds, descendantsIds, referenceIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||||
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
||||||
}
|
}
|
||||||
|
@ -690,6 +709,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread'>
|
<ScrollContainer scrollKey='thread'>
|
||||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
||||||
|
{references}
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
|
@ -717,6 +737,7 @@ class Status extends ImmutablePureComponent {
|
||||||
onEmojiReact={this.handleEmojiReact}
|
onEmojiReact={this.handleEmojiReact}
|
||||||
onReblog={this.handleReblogClick}
|
onReblog={this.handleReblogClick}
|
||||||
onReblogForceModal={this.handleReblogForceModalClick}
|
onReblogForceModal={this.handleReblogForceModalClick}
|
||||||
|
onReference={this.handleReference}
|
||||||
onBookmark={this.handleBookmarkClick}
|
onBookmark={this.handleBookmarkClick}
|
||||||
onDelete={this.handleDeleteClick}
|
onDelete={this.handleDeleteClick}
|
||||||
onEdit={this.handleEditClick}
|
onEdit={this.handleEditClick}
|
||||||
|
|
95
app/javascript/mastodon/features/status_references/index.jsx
Normal file
95
app/javascript/mastodon/features/status_references/index.jsx
Normal 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));
|
|
@ -45,6 +45,7 @@ import {
|
||||||
Reblogs,
|
Reblogs,
|
||||||
Favourites,
|
Favourites,
|
||||||
EmojiReactions,
|
EmojiReactions,
|
||||||
|
StatusReferences,
|
||||||
DirectTimeline,
|
DirectTimeline,
|
||||||
HashtagTimeline,
|
HashtagTimeline,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
@ -223,6 +224,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
|
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
|
||||||
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} 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/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 */}
|
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
|
||||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
<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/reblogs' component={Reblogs} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} 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/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='/follow_requests' component={FollowRequests} content={children} />
|
||||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||||
|
|
|
@ -86,6 +86,10 @@ export function EmojiReactions () {
|
||||||
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
|
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StatusReferences () {
|
||||||
|
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
|
||||||
|
}
|
||||||
|
|
||||||
export function FollowRequests () {
|
export function FollowRequests () {
|
||||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
|
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
|
||||||
}
|
}
|
||||||
|
|
|
@ -427,6 +427,7 @@
|
||||||
"notification.poll": "A poll you have voted in has ended",
|
"notification.poll": "A poll you have voted in has ended",
|
||||||
"notification.reblog": "{name} boosted your post",
|
"notification.reblog": "{name} boosted your post",
|
||||||
"notification.status": "{name} just posted",
|
"notification.status": "{name} just posted",
|
||||||
|
"notification.status_reference": "{name} refered your post",
|
||||||
"notification.update": "{name} edited a post",
|
"notification.update": "{name} edited a post",
|
||||||
"notifications.clear": "Clear notifications",
|
"notifications.clear": "Clear notifications",
|
||||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||||
|
|
|
@ -415,6 +415,7 @@
|
||||||
"notification.poll": "アンケートが終了しました",
|
"notification.poll": "アンケートが終了しました",
|
||||||
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
|
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
|
||||||
"notification.status": "{name}さんが投稿しました",
|
"notification.status": "{name}さんが投稿しました",
|
||||||
|
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
|
||||||
"notification.update": "{name}さんが投稿を編集しました",
|
"notification.update": "{name}さんが投稿を編集しました",
|
||||||
"notifications.clear": "通知を消去",
|
"notifications.clear": "通知を消去",
|
||||||
"notifications.clear_confirmation": "本当に通知を消去しますか?",
|
"notifications.clear_confirmation": "本当に通知を消去しますか?",
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
COMPOSE_COMPOSING_CHANGE,
|
COMPOSE_COMPOSING_CHANGE,
|
||||||
COMPOSE_EMOJI_INSERT,
|
COMPOSE_EMOJI_INSERT,
|
||||||
COMPOSE_EXPIRATION_INSERT,
|
COMPOSE_EXPIRATION_INSERT,
|
||||||
|
COMPOSE_REFERENCE_INSERT,
|
||||||
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
||||||
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
COMPOSE_UPLOAD_CHANGE_FAIL,
|
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 privacyPreference = (a, b) => {
|
||||||
const order = ['public', 'public_unlisted', 'unlisted', 'login', 'private', 'direct'];
|
const order = ['public', 'public_unlisted', 'unlisted', 'login', 'private', 'direct'];
|
||||||
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
|
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);
|
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
|
||||||
case COMPOSE_EXPIRATION_INSERT:
|
case COMPOSE_EXPIRATION_INSERT:
|
||||||
return insertExpiration(state, action.position, action.data);
|
return insertExpiration(state, action.position, action.data);
|
||||||
|
case COMPOSE_REFERENCE_INSERT:
|
||||||
|
return insertReference(state, action.url);
|
||||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||||
return state
|
return state
|
||||||
.set('is_changing_upload', false)
|
.set('is_changing_upload', false)
|
||||||
|
|
|
@ -11,11 +11,13 @@ import { compareId } from '../compare_id';
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
inReplyTos: ImmutableMap(),
|
inReplyTos: ImmutableMap(),
|
||||||
replies: 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('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
|
||||||
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
|
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
|
||||||
|
state.update('references', immutableReferences => immutableReferences.withMutations(referencePosts => {
|
||||||
function addReply({ id, in_reply_to_id }) {
|
function addReply({ id, in_reply_to_id }) {
|
||||||
if (in_reply_to_id && !inReplyTos.has(id)) {
|
if (in_reply_to_id && !inReplyTos.has(id)) {
|
||||||
|
|
||||||
|
@ -38,6 +40,9 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab
|
||||||
}
|
}
|
||||||
|
|
||||||
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:
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
return filterContexts(state, action.relationship, action.statuses);
|
return filterContexts(state, action.relationship, action.statuses);
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
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:
|
case TIMELINE_DELETE:
|
||||||
return deleteFromContexts(state, [action.id]);
|
return deleteFromContexts(state, [action.id]);
|
||||||
case TIMELINE_UPDATE:
|
case TIMELINE_UPDATE:
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
REBLOGS_FETCH_SUCCESS,
|
REBLOGS_FETCH_SUCCESS,
|
||||||
FAVOURITES_FETCH_SUCCESS,
|
FAVOURITES_FETCH_SUCCESS,
|
||||||
EMOJI_REACTIONS_FETCH_SUCCESS,
|
EMOJI_REACTIONS_FETCH_SUCCESS,
|
||||||
|
STATUS_REFERENCES_FETCH_SUCCESS,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
MUTES_FETCH_REQUEST,
|
MUTES_FETCH_REQUEST,
|
||||||
|
@ -75,6 +76,7 @@ const initialState = ImmutableMap({
|
||||||
reblogged_by: initialListState,
|
reblogged_by: initialListState,
|
||||||
favourited_by: initialListState,
|
favourited_by: initialListState,
|
||||||
emoji_reactioned_by: initialListState,
|
emoji_reactioned_by: initialListState,
|
||||||
|
referred_by: initialListState,
|
||||||
follow_requests: initialListState,
|
follow_requests: initialListState,
|
||||||
blocks: initialListState,
|
blocks: initialListState,
|
||||||
mutes: 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)));
|
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||||
case EMOJI_REACTIONS_FETCH_SUCCESS:
|
case EMOJI_REACTIONS_FETCH_SUCCESS:
|
||||||
return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts));
|
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:
|
case NOTIFICATIONS_UPDATE:
|
||||||
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
|
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
|
||||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||||
|
|
|
@ -6,6 +6,9 @@ $classic-primary-color: #9baec8;
|
||||||
$classic-secondary-color: #d9e1e8;
|
$classic-secondary-color: #d9e1e8;
|
||||||
$classic-highlight-color: #6364ff;
|
$classic-highlight-color: #6364ff;
|
||||||
|
|
||||||
|
$emoji-reaction-color: #42485a !default;
|
||||||
|
$emoji-reaction-selected-color: #617ed5 !default;
|
||||||
|
|
||||||
$ui-base-color: $classic-base-color !default;
|
$ui-base-color: $classic-base-color !default;
|
||||||
$ui-primary-color: $classic-primary-color !default;
|
$ui-primary-color: $classic-primary-color !default;
|
||||||
$ui-secondary-color: $classic-secondary-color !default;
|
$ui-secondary-color: $classic-secondary-color !default;
|
||||||
|
|
|
@ -13,6 +13,9 @@ $blurple-300: #858afa; // Faded Blue
|
||||||
$grey-600: #4e4c5a; // Trout
|
$grey-600: #4e4c5a; // Trout
|
||||||
$grey-100: #dadaf3; // Topaz
|
$grey-100: #dadaf3; // Topaz
|
||||||
|
|
||||||
|
$emoji-reaction-color: #dfe5f5 !default;
|
||||||
|
$emoji-reaction-selected-color: #9ac1f2 !default;
|
||||||
|
|
||||||
// Differences
|
// Differences
|
||||||
$success-green: lighten(#3c754d, 8%);
|
$success-green: lighten(#3c754d, 8%);
|
||||||
|
|
||||||
|
|
|
@ -1326,7 +1326,7 @@ body > [data-popper-placement] {
|
||||||
margin: 12px 0 2px 4px;
|
margin: 12px 0 2px 4px;
|
||||||
|
|
||||||
.emoji-reactions-bar__button {
|
.emoji-reactions-bar__button {
|
||||||
background: lighten($ui-base-color, 12%);
|
background: $emoji-reaction-color;
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1335,7 +1335,7 @@ body > [data-popper-placement] {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
||||||
&.toggled {
|
&.toggled {
|
||||||
background: darken($ui-primary-color, 16%);
|
background: $emoji-reaction-selected-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .emoji {
|
> .emoji {
|
||||||
|
@ -2128,7 +2128,7 @@ a.account__display-name {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 8px 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
@ -23,6 +23,10 @@ $classic-primary-color: #9baec8; // Echo Blue
|
||||||
$classic-secondary-color: #d9e1e8; // Pattens Blue
|
$classic-secondary-color: #d9e1e8; // Pattens Blue
|
||||||
$classic-highlight-color: #6364ff; // Brand purple
|
$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
|
// Variables for defaults in UI
|
||||||
$base-shadow-color: $black !default;
|
$base-shadow-color: $black !default;
|
||||||
$base-overlay-background: $black !default;
|
$base-overlay-background: $black !default;
|
||||||
|
|
|
@ -97,6 +97,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
fetch_replies(@status)
|
fetch_replies(@status)
|
||||||
distribute
|
distribute
|
||||||
forward_for_reply
|
forward_for_reply
|
||||||
|
process_references!
|
||||||
join_group!
|
join_group!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -471,6 +472,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
retry
|
retry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_references!
|
||||||
|
references = @json['references'].nil? ? [] : ActivityPub::FetchReferencesService(@json['references'])
|
||||||
|
ProcessReferencesWorker.perform_async(@status.id, [], references)
|
||||||
|
end
|
||||||
|
|
||||||
def join_group!
|
def join_group!
|
||||||
GroupReblogService.new.call(@status)
|
GroupReblogService.new.call(@status)
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,11 +7,48 @@ require 'resolv'
|
||||||
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
|
# 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
|
# around the Socket#open method, since we use our own timeout blocks inside
|
||||||
# that method
|
# 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
|
class HTTP::Timeout::PerOperation
|
||||||
def connect(socket_class, host, port, nodelay = false)
|
def connect(socket_class, host, port, nodelay = false)
|
||||||
@socket = socket_class.open(host, port)
|
@socket = socket_class.open(host, port)
|
||||||
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
class Request
|
class Request
|
||||||
|
|
|
@ -53,6 +53,26 @@ class TextFormatter
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def rewrite
|
def rewrite
|
||||||
|
@ -75,19 +95,7 @@ class TextFormatter
|
||||||
end
|
end
|
||||||
|
|
||||||
def link_to_url(entity)
|
def link_to_url(entity)
|
||||||
url = Addressable::URI.parse(entity[:url]).to_s
|
TextFormatter.shortened_link(entity[:url], rel_me: with_rel_me?)
|
||||||
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])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def link_to_hashtag(entity)
|
def link_to_hashtag(entity)
|
||||||
|
|
|
@ -20,6 +20,7 @@ class AccountWarning < ApplicationRecord
|
||||||
enum action: {
|
enum action: {
|
||||||
none: 0,
|
none: 0,
|
||||||
disable: 1_000,
|
disable: 1_000,
|
||||||
|
force_cw: 1_200,
|
||||||
mark_statuses_as_sensitive: 1_250,
|
mark_statuses_as_sensitive: 1_250,
|
||||||
delete_statuses: 1_500,
|
delete_statuses: 1_500,
|
||||||
sensitive: 2_000,
|
sensitive: 2_000,
|
||||||
|
|
|
@ -33,6 +33,8 @@ class Admin::StatusBatchAction
|
||||||
handle_delete!
|
handle_delete!
|
||||||
when 'mark_as_sensitive'
|
when 'mark_as_sensitive'
|
||||||
handle_mark_as_sensitive!
|
handle_mark_as_sensitive!
|
||||||
|
when 'force_cw'
|
||||||
|
handle_force_cw!
|
||||||
when 'report'
|
when 'report'
|
||||||
handle_report!
|
handle_report!
|
||||||
when 'remove_from_report'
|
when 'remove_from_report'
|
||||||
|
@ -104,6 +106,42 @@ class Admin::StatusBatchAction
|
||||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||||
end
|
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!
|
def handle_report!
|
||||||
@report = Report.new(report_params) unless with_report?
|
@report = Report.new(report_params) unless with_report?
|
||||||
@report.status_ids = (@report.status_ids + allowed_status_ids).uniq
|
@report.status_ids = (@report.status_ids + allowed_status_ids).uniq
|
||||||
|
|
|
@ -22,15 +22,14 @@ module Attachmentable
|
||||||
|
|
||||||
included do
|
included do
|
||||||
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
|
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
|
||||||
options = { validate_media_type: false }.merge(options)
|
|
||||||
super(name, options)
|
super(name, options)
|
||||||
send(:"before_#{name}_post_process") do
|
|
||||||
|
send(:"before_#{name}_validate") do
|
||||||
attachment = send(name)
|
attachment = send(name)
|
||||||
check_image_dimension(attachment)
|
check_image_dimension(attachment)
|
||||||
set_file_content_type(attachment)
|
set_file_content_type(attachment)
|
||||||
obfuscate_file_name(attachment)
|
obfuscate_file_name(attachment)
|
||||||
set_file_extension(attachment)
|
set_file_extension(attachment)
|
||||||
Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(attributes: [name]).validate(self)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Context < ActiveModelSerializers::Model
|
class Context < ActiveModelSerializers::Model
|
||||||
attributes :ancestors, :descendants
|
attributes :ancestors, :descendants, :references
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,6 +26,7 @@ class Notification < ApplicationRecord
|
||||||
'FollowRequest' => :follow_request,
|
'FollowRequest' => :follow_request,
|
||||||
'Favourite' => :favourite,
|
'Favourite' => :favourite,
|
||||||
'EmojiReaction' => :emoji_reaction,
|
'EmojiReaction' => :emoji_reaction,
|
||||||
|
'StatusReference' => :status_reference,
|
||||||
'Poll' => :poll,
|
'Poll' => :poll,
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ class Notification < ApplicationRecord
|
||||||
mention
|
mention
|
||||||
status
|
status
|
||||||
reblog
|
reblog
|
||||||
|
status_reference
|
||||||
follow
|
follow
|
||||||
follow_request
|
follow_request
|
||||||
favourite
|
favourite
|
||||||
|
@ -47,6 +49,7 @@ class Notification < ApplicationRecord
|
||||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||||
status: :status,
|
status: :status,
|
||||||
reblog: [status: :reblog],
|
reblog: [status: :reblog],
|
||||||
|
status_reference: [status_reference: :status],
|
||||||
mention: [mention: :status],
|
mention: [mention: :status],
|
||||||
favourite: [favourite: :status],
|
favourite: [favourite: :status],
|
||||||
emoji_reaction: [emoji_reaction: :status],
|
emoji_reaction: [emoji_reaction: :status],
|
||||||
|
@ -67,6 +70,7 @@ class Notification < ApplicationRecord
|
||||||
belongs_to :follow_request, inverse_of: :notification
|
belongs_to :follow_request, inverse_of: :notification
|
||||||
belongs_to :favourite, inverse_of: :notification
|
belongs_to :favourite, inverse_of: :notification
|
||||||
belongs_to :emoji_reaction, 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 :poll, inverse_of: false
|
||||||
belongs_to :report, inverse_of: false
|
belongs_to :report, inverse_of: false
|
||||||
end
|
end
|
||||||
|
@ -85,6 +89,8 @@ class Notification < ApplicationRecord
|
||||||
status
|
status
|
||||||
when :reblog
|
when :reblog
|
||||||
status&.reblog
|
status&.reblog
|
||||||
|
when :status_reference
|
||||||
|
status_reference&.status
|
||||||
when :favourite
|
when :favourite
|
||||||
favourite&.status
|
favourite&.status
|
||||||
when :emoji_reaction, :reaction
|
when :emoji_reaction, :reaction
|
||||||
|
@ -136,6 +142,8 @@ class Notification < ApplicationRecord
|
||||||
notification.status = cached_status
|
notification.status = cached_status
|
||||||
when :reblog
|
when :reblog
|
||||||
notification.status.reblog = cached_status
|
notification.status.reblog = cached_status
|
||||||
|
when :status_reference
|
||||||
|
notification.status_reference.status = cached_status
|
||||||
when :favourite
|
when :favourite
|
||||||
notification.favourite.status = cached_status
|
notification.favourite.status = cached_status
|
||||||
when :emoji_reaction, :reaction
|
when :emoji_reaction, :reaction
|
||||||
|
@ -162,7 +170,7 @@ class Notification < ApplicationRecord
|
||||||
case activity_type
|
case activity_type
|
||||||
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report'
|
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report'
|
||||||
self.from_account_id = activity&.account_id
|
self.from_account_id = activity&.account_id
|
||||||
when 'Mention'
|
when 'Mention', 'StatusReference'
|
||||||
self.from_account_id = activity&.status&.account_id
|
self.from_account_id = activity&.status&.account_id
|
||||||
when 'Account'
|
when 'Account'
|
||||||
self.from_account_id = activity&.id
|
self.from_account_id = activity&.id
|
||||||
|
|
|
@ -75,6 +75,10 @@ class Status < ApplicationRecord
|
||||||
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
|
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
|
||||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||||
has_many :media_attachments, dependent: :nullify
|
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 :tags
|
||||||
has_and_belongs_to_many :preview_cards
|
has_and_belongs_to_many :preview_cards
|
||||||
|
@ -332,6 +336,10 @@ class Status < ApplicationRecord
|
||||||
status_stat&.emoji_reaction_accounts_count || 0
|
status_stat&.emoji_reaction_accounts_count || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def status_referred_by_count
|
||||||
|
status_stat&.status_referred_by_count || 0
|
||||||
|
end
|
||||||
|
|
||||||
def increment_count!(key)
|
def increment_count!(key)
|
||||||
update_status_stat!(key => public_send(key) + 1)
|
update_status_stat!(key => public_send(key) + 1)
|
||||||
end
|
end
|
||||||
|
@ -340,6 +348,10 @@ class Status < ApplicationRecord
|
||||||
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
||||||
end
|
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)
|
def emoji_reactions_grouped_by_name(account = nil)
|
||||||
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
|
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
|
||||||
if account.present?
|
if account.present?
|
||||||
|
|
25
app/models/status_reference.rb
Normal file
25
app/models/status_reference.rb
Normal 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
|
|
@ -15,6 +15,7 @@
|
||||||
# emoji_reactions_count :integer default(0), not null
|
# emoji_reactions_count :integer default(0), not null
|
||||||
# test :integer default(0), not null
|
# test :integer default(0), not null
|
||||||
# emoji_reaction_accounts_count :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
|
class StatusStat < ApplicationRecord
|
||||||
|
@ -46,6 +47,10 @@ class StatusStat < ApplicationRecord
|
||||||
[attributes['emoji_reaction_accounts_count'], 0].max
|
[attributes['emoji_reaction_accounts_count'], 0].max
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def status_referred_by_count
|
||||||
|
[attributes['status_referred_by_count'] || 0, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def reset_parent_cache
|
def reset_parent_cache
|
||||||
|
|
|
@ -3,4 +3,5 @@
|
||||||
class REST::ContextSerializer < ActiveModel::Serializer
|
class REST::ContextSerializer < ActiveModel::Serializer
|
||||||
has_many :ancestors, serializer: REST::StatusSerializer
|
has_many :ancestors, serializer: REST::StatusSerializer
|
||||||
has_many :descendants, serializer: REST::StatusSerializer
|
has_many :descendants, serializer: REST::StatusSerializer
|
||||||
|
has_many :references, serializer: REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
|
@ -117,6 +117,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||||
:kmyblue_markdown,
|
:kmyblue_markdown,
|
||||||
:kmyblue_reaction_deck,
|
:kmyblue_reaction_deck,
|
||||||
:kmyblue_visibility_login,
|
:kmyblue_visibility_login,
|
||||||
|
:status_reference,
|
||||||
]
|
]
|
||||||
|
|
||||||
capabilities << :profile_search unless Chewy.enabled?
|
capabilities << :profile_search unless Chewy.enabled?
|
||||||
|
|
|
@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_type?
|
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
|
end
|
||||||
|
|
||||||
def report_type?
|
def report_type?
|
||||||
|
|
|
@ -11,4 +11,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
|
||||||
def image
|
def image
|
||||||
object.image? ? full_asset_url(object.image.url(:original)) : nil
|
object.image? ? full_asset_url(object.image.url(:original)) : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def html
|
||||||
|
Sanitize.fragment(object.html, Sanitize::Config::MASTODON_OEMBED)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
|
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
|
||||||
:sensitive, :spoiler_text, :visibility, :visibility_ex, :language,
|
:sensitive, :spoiler_text, :visibility, :visibility_ex, :language,
|
||||||
:uri, :url, :replies_count, :reblogs_count, :searchability, :markdown,
|
: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
|
:favourites_count, :emoji_reactions, :emoji_reactions_count, :reactions, :edited_at
|
||||||
|
|
||||||
attribute :favourited, if: :current_user?
|
attribute :favourited, if: :current_user?
|
||||||
|
@ -92,6 +93,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
ActivityPub::TagManager.instance.url_for(object)
|
ActivityPub::TagManager.instance.url_for(object)
|
||||||
end
|
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
|
def favourited
|
||||||
if instance_options && instance_options[:relationships]
|
if instance_options && instance_options[:relationships]
|
||||||
instance_options[:relationships].favourites_map[object.id] || false
|
instance_options[:relationships].favourites_map[object.id] || false
|
||||||
|
|
|
@ -126,6 +126,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
||||||
:kmyblue_markdown,
|
:kmyblue_markdown,
|
||||||
:kmyblue_reaction_deck,
|
:kmyblue_reaction_deck,
|
||||||
:kmyblue_visibility_login,
|
:kmyblue_visibility_login,
|
||||||
|
:status_reference,
|
||||||
]
|
]
|
||||||
|
|
||||||
capabilities << :profile_search unless Chewy.enabled?
|
capabilities << :profile_search unless Chewy.enabled?
|
||||||
|
|
49
app/services/activitypub/fetch_references_service.rb
Normal file
49
app/services/activitypub/fetch_references_service.rb
Normal 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
|
|
@ -29,6 +29,8 @@ class ApproveAppealService < BaseService
|
||||||
undo_delete_statuses!
|
undo_delete_statuses!
|
||||||
when 'mark_statuses_as_sensitive'
|
when 'mark_statuses_as_sensitive'
|
||||||
undo_mark_statuses_as_sensitive!
|
undo_mark_statuses_as_sensitive!
|
||||||
|
when 'force_cw'
|
||||||
|
undo_force_cw!
|
||||||
when 'sensitive'
|
when 'sensitive'
|
||||||
undo_sensitive!
|
undo_sensitive!
|
||||||
when 'silence'
|
when 'silence'
|
||||||
|
@ -58,6 +60,13 @@ class ApproveAppealService < BaseService
|
||||||
end
|
end
|
||||||
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!
|
def undo_sensitive!
|
||||||
target_account.unsensitize!
|
target_account.unsensitize!
|
||||||
end
|
end
|
||||||
|
|
|
@ -34,6 +34,7 @@ class PostStatusService < BaseService
|
||||||
# @option [String] :idempotency Optional idempotency key
|
# @option [String] :idempotency Optional idempotency key
|
||||||
# @option [Boolean] :with_rate_limit
|
# @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] :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]
|
# @return [Status]
|
||||||
def call(account, options = {})
|
def call(account, options = {})
|
||||||
@account = account
|
@account = account
|
||||||
|
@ -78,6 +79,7 @@ class PostStatusService < BaseService
|
||||||
@markdown = @options[:markdown] || false
|
@markdown = @options[:markdown] || false
|
||||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||||
@scheduled_at = nil if scheduled_in_the_past?
|
@scheduled_at = nil if scheduled_in_the_past?
|
||||||
|
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
raise ActiveRecord::RecordInvalid
|
raise ActiveRecord::RecordInvalid
|
||||||
end
|
end
|
||||||
|
@ -146,6 +148,7 @@ class PostStatusService < BaseService
|
||||||
|
|
||||||
def postprocess_status!
|
def postprocess_status!
|
||||||
process_hashtags_service.call(@status)
|
process_hashtags_service.call(@status)
|
||||||
|
ProcessReferencesWorker.perform_async(@status.id, @reference_ids, [])
|
||||||
Trends.tags.register(@status)
|
Trends.tags.register(@status)
|
||||||
LinkCrawlWorker.perform_async(@status.id)
|
LinkCrawlWorker.perform_async(@status.id)
|
||||||
DistributionWorker.perform_async(@status.id)
|
DistributionWorker.perform_async(@status.id)
|
||||||
|
@ -221,6 +224,7 @@ class PostStatusService < BaseService
|
||||||
media_attachments: @media || [],
|
media_attachments: @media || [],
|
||||||
ordered_media_attachment_ids: (@options[:media_ids] || []).map(&:to_i) & @media.map(&:id),
|
ordered_media_attachment_ids: (@options[:media_ids] || []).map(&:to_i) & @media.map(&:id),
|
||||||
thread: @in_reply_to,
|
thread: @in_reply_to,
|
||||||
|
status_reference_ids: @status_reference_ids,
|
||||||
poll_attributes: poll_attributes,
|
poll_attributes: poll_attributes,
|
||||||
sensitive: @sensitive,
|
sensitive: @sensitive,
|
||||||
spoiler_text: @options[:spoiler_text] || '',
|
spoiler_text: @options[:spoiler_text] || '',
|
||||||
|
|
88
app/services/process_references_service.rb
Normal file
88
app/services/process_references_service.rb
Normal 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
|
|
@ -46,6 +46,7 @@ class RemoveStatusService < BaseService
|
||||||
remove_from_public
|
remove_from_public
|
||||||
remove_from_media if @status.with_media?
|
remove_from_media if @status.with_media?
|
||||||
remove_media
|
remove_media
|
||||||
|
decrement_references
|
||||||
end
|
end
|
||||||
|
|
||||||
@status.destroy! if permanently?
|
@status.destroy! if permanently?
|
||||||
|
@ -123,6 +124,12 @@ class RemoveStatusService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def decrement_references
|
||||||
|
@status.references.each do |ref|
|
||||||
|
ref.decrement_count!(:status_referred_by_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def remove_from_hashtags
|
def remove_from_hashtags
|
||||||
@account.featured_tags.where(tag_id: @status.tags.map(&:id)).each do |featured_tag|
|
@account.featured_tags.where(tag_id: @status.tags.map(&:id)).each do |featured_tag|
|
||||||
featured_tag.decrement(@status.id)
|
featured_tag.decrement(@status.id)
|
||||||
|
|
|
@ -142,6 +142,7 @@ class UpdateStatusService < BaseService
|
||||||
def update_metadata!
|
def update_metadata!
|
||||||
ProcessHashtagsService.new.call(@status)
|
ProcessHashtagsService.new.call(@status)
|
||||||
ProcessMentionsService.new.call(@status)
|
ProcessMentionsService.new.call(@status)
|
||||||
|
ProcessReferencesWorker.perform_async(@status.id, (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?), [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_updates!
|
def broadcast_updates!
|
||||||
|
|
|
@ -11,6 +11,11 @@
|
||||||
= button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button'
|
= button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button'
|
||||||
.report-actions__item__description
|
.report-actions__item__description
|
||||||
= t('admin.reports.actions.mark_as_sensitive_description_html')
|
= 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
|
||||||
.report-actions__item__button
|
.report-actions__item__button
|
||||||
= button_tag t('admin.reports.delete_and_resolve'), name: :delete, class: 'button button--destructive'
|
= button_tag t('admin.reports.delete_and_resolve'), name: :delete, class: 'button button--destructive'
|
||||||
|
|
|
@ -60,7 +60,7 @@
|
||||||
= t('antennas.index.tags', count: antenna.antenna_tags.size)
|
= t('antennas.index.tags', count: antenna.antenna_tags.size)
|
||||||
.permissions-list__item__text__type
|
.permissions-list__item__text__type
|
||||||
- tags = antenna.antenna_tags.map { |tag| tag.tag.name }
|
- 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(', ')
|
= tags.join(', ')
|
||||||
|
|
||||||
.announcements-list__item__action-bar
|
.announcements-list__item__action-bar
|
||||||
|
|
11
app/workers/process_references_worker.rb
Normal file
11
app/workers/process_references_worker.rb
Normal 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
|
|
@ -28,6 +28,7 @@ require_relative '../lib/paperclip/url_generator_extensions'
|
||||||
require_relative '../lib/paperclip/attachment_extensions'
|
require_relative '../lib/paperclip/attachment_extensions'
|
||||||
require_relative '../lib/paperclip/lazy_thumbnail'
|
require_relative '../lib/paperclip/lazy_thumbnail'
|
||||||
require_relative '../lib/paperclip/gif_transcoder'
|
require_relative '../lib/paperclip/gif_transcoder'
|
||||||
|
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
|
||||||
require_relative '../lib/paperclip/transcoder'
|
require_relative '../lib/paperclip/transcoder'
|
||||||
require_relative '../lib/paperclip/type_corrector'
|
require_relative '../lib/paperclip/type_corrector'
|
||||||
require_relative '../lib/paperclip/response_with_limit_adapter'
|
require_relative '../lib/paperclip/response_with_limit_adapter'
|
||||||
|
|
27
config/imagemagick/policy.xml
Normal file
27
config/imagemagick/policy.xml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<policymap>
|
||||||
|
<!-- Set some basic system resource limits -->
|
||||||
|
<policy domain="resource" name="time" value="60" />
|
||||||
|
|
||||||
|
<policy domain="module" rights="none" pattern="URL" />
|
||||||
|
|
||||||
|
<policy domain="filter" rights="none" pattern="*" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Ideally, we would restrict ImageMagick to only accessing its own
|
||||||
|
disk-backed pixel cache as well as Mastodon-created Tempfiles.
|
||||||
|
|
||||||
|
However, those paths depend on the operating system and environment
|
||||||
|
variables, so they can only be known at runtime.
|
||||||
|
|
||||||
|
Furthermore, those paths are not necessarily shared across Mastodon
|
||||||
|
processes, so even creating a policy.xml at runtime is impractical.
|
||||||
|
|
||||||
|
For the time being, only disable indirect reads.
|
||||||
|
-->
|
||||||
|
<policy domain="path" rights="none" pattern="@*" />
|
||||||
|
|
||||||
|
<!-- Disallow any coder by default, and only enable ones required by Mastodon -->
|
||||||
|
<policy domain="coder" rights="none" pattern="*" />
|
||||||
|
<policy domain="coder" rights="read | write" pattern="{PNG,JPEG,GIF,HEIC,WEBP}" />
|
||||||
|
<policy domain="coder" rights="write" pattern="{HISTOGRAM,RGB,INFO}" />
|
||||||
|
</policymap>
|
|
@ -153,3 +153,10 @@ unless defined?(Seahorse)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Set our ImageMagick security policy, but allow admins to override it
|
||||||
|
ENV['MAGICK_CONFIGURE_PATH'] = begin
|
||||||
|
imagemagick_config_paths = ENV.fetch('MAGICK_CONFIGURE_PATH', '').split(File::PATH_SEPARATOR)
|
||||||
|
imagemagick_config_paths << Rails.root.join('config', 'imagemagick').expand_path.to_s
|
||||||
|
imagemagick_config_paths.join(File::PATH_SEPARATOR)
|
||||||
|
end
|
||||||
|
|
|
@ -1856,6 +1856,7 @@ en:
|
||||||
explanation:
|
explanation:
|
||||||
delete_statuses: Some of your posts have been found to violate one or more community guidelines and have been subsequently removed by the moderators of %{instance}.
|
delete_statuses: Some of your posts have been found to violate one or more community guidelines and have been subsequently removed by the moderators of %{instance}.
|
||||||
disable: You can no longer use your account, but your profile and other data remains intact. You can request a backup of your data, change account settings or delete your account.
|
disable: You can no longer use your account, but your profile and other data remains intact. You can request a backup of your data, change account settings or delete your account.
|
||||||
|
force_cw: Some of your posts have been added warning (CW) by the moderators of %{instance}. This means that people will need to tap the text in the posts before a preview is displayed. You can add posts warning yourself when posting in the future.
|
||||||
mark_statuses_as_sensitive: Some of your posts have been marked as sensitive by the moderators of %{instance}. This means that people will need to tap the media in the posts before a preview is displayed. You can mark media as sensitive yourself when posting in the future.
|
mark_statuses_as_sensitive: Some of your posts have been marked as sensitive by the moderators of %{instance}. This means that people will need to tap the media in the posts before a preview is displayed. You can mark media as sensitive yourself when posting in the future.
|
||||||
sensitive: From now on, all your uploaded media files will be marked as sensitive and hidden behind a click-through warning.
|
sensitive: From now on, all your uploaded media files will be marked as sensitive and hidden behind a click-through warning.
|
||||||
silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various discovery features. However, others may still manually follow you.
|
silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various discovery features. However, others may still manually follow you.
|
||||||
|
@ -1865,6 +1866,7 @@ en:
|
||||||
subject:
|
subject:
|
||||||
delete_statuses: Your posts on %{acct} have been removed
|
delete_statuses: Your posts on %{acct} have been removed
|
||||||
disable: Your account %{acct} has been frozen
|
disable: Your account %{acct} has been frozen
|
||||||
|
force_cw: Your posts on %{acct} have been added warning
|
||||||
mark_statuses_as_sensitive: Your posts on %{acct} have been marked as sensitive
|
mark_statuses_as_sensitive: Your posts on %{acct} have been marked as sensitive
|
||||||
none: Warning for %{acct}
|
none: Warning for %{acct}
|
||||||
sensitive: Your posts on %{acct} will be marked as sensitive from now on
|
sensitive: Your posts on %{acct} will be marked as sensitive from now on
|
||||||
|
@ -1873,6 +1875,7 @@ en:
|
||||||
title:
|
title:
|
||||||
delete_statuses: Posts removed
|
delete_statuses: Posts removed
|
||||||
disable: Account frozen
|
disable: Account frozen
|
||||||
|
force_cw: Posts added warning
|
||||||
mark_statuses_as_sensitive: Posts marked as sensitive
|
mark_statuses_as_sensitive: Posts marked as sensitive
|
||||||
none: Warning
|
none: Warning
|
||||||
sensitive: Account marked as sensitive
|
sensitive: Account marked as sensitive
|
||||||
|
|
|
@ -1768,6 +1768,7 @@ ja:
|
||||||
explanation:
|
explanation:
|
||||||
delete_statuses: あなたの投稿のいくつかは、1つ以上のコミュニティガイドラインに違反していることが判明し、%{instance}のモデレータによって削除されました。
|
delete_statuses: あなたの投稿のいくつかは、1つ以上のコミュニティガイドラインに違反していることが判明し、%{instance}のモデレータによって削除されました。
|
||||||
disable: アカウントは使用できませんが、プロフィールやその他のデータはそのまま残ります。 データのバックアップをリクエストしたり、アカウント設定を変更したり、アカウントを削除したりできます。
|
disable: アカウントは使用できませんが、プロフィールやその他のデータはそのまま残ります。 データのバックアップをリクエストしたり、アカウント設定を変更したり、アカウントを削除したりできます。
|
||||||
|
force_cw: あなたのいくつかの投稿は、%{instance}のモデレータによって閲覧注意としてマークされています。これは、投稿本文が表示される前にユーザが投稿内のボタンをタップする必要があることを意味します。あなたは将来投稿する際に自分自身で文章に警告を記述することができます。
|
||||||
mark_statuses_as_sensitive: あなたのいくつかの投稿は、%{instance}のモデレータによって閲覧注意としてマークされています。これは、プレビューが表示される前にユーザが投稿内のメディアをタップする必要があることを意味します。あなたは将来投稿する際に自分自身でメディアを閲覧注意としてマークすることができます。
|
mark_statuses_as_sensitive: あなたのいくつかの投稿は、%{instance}のモデレータによって閲覧注意としてマークされています。これは、プレビューが表示される前にユーザが投稿内のメディアをタップする必要があることを意味します。あなたは将来投稿する際に自分自身でメディアを閲覧注意としてマークすることができます。
|
||||||
sensitive: 今後、アップロードされたすべてのメディアファイルは閲覧注意としてマークされ、クリック解除式の警告で覆われるようになります。
|
sensitive: 今後、アップロードされたすべてのメディアファイルは閲覧注意としてマークされ、クリック解除式の警告で覆われるようになります。
|
||||||
silence: アカウントが制限されています。このサーバーでは既にフォローしている人だけがあなたの投稿を見ることができます。 様々な発見機能から除外されるかもしれません。他の人があなたを手動でフォローすることは可能です。
|
silence: アカウントが制限されています。このサーバーでは既にフォローしている人だけがあなたの投稿を見ることができます。 様々な発見機能から除外されるかもしれません。他の人があなたを手動でフォローすることは可能です。
|
||||||
|
@ -1777,6 +1778,7 @@ ja:
|
||||||
subject:
|
subject:
|
||||||
delete_statuses: "%{acct}さんの投稿が削除されました"
|
delete_statuses: "%{acct}さんの投稿が削除されました"
|
||||||
disable: あなたのアカウント %{acct}は凍結されました
|
disable: あなたのアカウント %{acct}は凍結されました
|
||||||
|
force_cw: あなたの%{acct}の投稿はCWとして警告文が追加されました
|
||||||
mark_statuses_as_sensitive: あなたの%{acct}の投稿は閲覧注意としてマークされました
|
mark_statuses_as_sensitive: あなたの%{acct}の投稿は閲覧注意としてマークされました
|
||||||
none: "%{acct}に対する警告"
|
none: "%{acct}に対する警告"
|
||||||
sensitive: あなたの%{acct}の投稿はこれから閲覧注意としてマークされます
|
sensitive: あなたの%{acct}の投稿はこれから閲覧注意としてマークされます
|
||||||
|
@ -1785,6 +1787,7 @@ ja:
|
||||||
title:
|
title:
|
||||||
delete_statuses: 投稿が削除されました
|
delete_statuses: 投稿が削除されました
|
||||||
disable: アカウントが凍結されました
|
disable: アカウントが凍結されました
|
||||||
|
force_cw: 閲覧注意として警告が追加された投稿
|
||||||
mark_statuses_as_sensitive: 閲覧注意としてマークされた投稿
|
mark_statuses_as_sensitive: 閲覧注意としてマークされた投稿
|
||||||
none: 警告
|
none: 警告
|
||||||
sensitive: 閲覧注意としてマークされたアカウント
|
sensitive: 閲覧注意としてマークされたアカウント
|
||||||
|
|
|
@ -12,6 +12,7 @@ namespace :api, format: false do
|
||||||
resources :favourited_by, controller: :favourited_by_accounts, only: :index
|
resources :favourited_by, controller: :favourited_by_accounts, only: :index
|
||||||
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
|
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
|
||||||
resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index
|
resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index
|
||||||
|
resources :referred_by, controller: :referred_by_statuses, only: :index
|
||||||
resource :reblog, only: :create
|
resource :reblog, only: :create
|
||||||
post :unreblog, to: 'reblogs#destroy'
|
post :unreblog, to: 'reblogs#destroy'
|
||||||
|
|
||||||
|
|
12
db/migrate/20230705232953_create_status_references.rb
Normal file
12
db/migrate/20230705232953_create_status_references.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateStatusReferences < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :status_references do |t|
|
||||||
|
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
|
||||||
|
t.belongs_to :target_status, null: false, foreign_key: { on_delete: :cascade, to_table: :statuses }
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.datetime :updated_at, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddStatusReferredByCountToStatusStats < ActiveRecord::Migration[6.1]
|
||||||
|
def up
|
||||||
|
safety_assured do
|
||||||
|
add_column :status_stats, :status_referred_by_count, :integer, null: false, default: 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :status_stats, :status_referred_by_count
|
||||||
|
end
|
||||||
|
end
|
14
db/schema.rb
14
db/schema.rb
|
@ -12,7 +12,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2023_07_02_131023) do
|
ActiveRecord::Schema.define(version: 2023_07_06_031715) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -1043,6 +1043,15 @@ ActiveRecord::Schema.define(version: 2023_07_02_131023) do
|
||||||
t.index ["status_id"], name: "index_status_pins_on_status_id"
|
t.index ["status_id"], name: "index_status_pins_on_status_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "status_references", force: :cascade do |t|
|
||||||
|
t.bigint "status_id", null: false
|
||||||
|
t.bigint "target_status_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["status_id"], name: "index_status_references_on_status_id"
|
||||||
|
t.index ["target_status_id"], name: "index_status_references_on_target_status_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "status_stats", force: :cascade do |t|
|
create_table "status_stats", force: :cascade do |t|
|
||||||
t.bigint "status_id", null: false
|
t.bigint "status_id", null: false
|
||||||
t.bigint "replies_count", default: 0, null: false
|
t.bigint "replies_count", default: 0, null: false
|
||||||
|
@ -1054,6 +1063,7 @@ ActiveRecord::Schema.define(version: 2023_07_02_131023) do
|
||||||
t.integer "emoji_reactions_count", default: 0, null: false
|
t.integer "emoji_reactions_count", default: 0, null: false
|
||||||
t.integer "test", default: 0, null: false
|
t.integer "test", default: 0, null: false
|
||||||
t.integer "emoji_reaction_accounts_count", default: 0, null: false
|
t.integer "emoji_reaction_accounts_count", default: 0, null: false
|
||||||
|
t.integer "status_referred_by_count", default: 0, null: false
|
||||||
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
|
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1377,6 +1387,8 @@ ActiveRecord::Schema.define(version: 2023_07_02_131023) do
|
||||||
add_foreign_key "status_edits", "statuses", on_delete: :cascade
|
add_foreign_key "status_edits", "statuses", on_delete: :cascade
|
||||||
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
|
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
|
||||||
add_foreign_key "status_pins", "statuses", on_delete: :cascade
|
add_foreign_key "status_pins", "statuses", on_delete: :cascade
|
||||||
|
add_foreign_key "status_references", "statuses", column: "target_status_id", on_delete: :cascade
|
||||||
|
add_foreign_key "status_references", "statuses", on_delete: :cascade
|
||||||
add_foreign_key "status_stats", "statuses", on_delete: :cascade
|
add_foreign_key "status_stats", "statuses", on_delete: :cascade
|
||||||
add_foreign_key "status_trends", "accounts", on_delete: :cascade
|
add_foreign_key "status_trends", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "status_trends", "statuses", on_delete: :cascade
|
add_foreign_key "status_trends", "statuses", on_delete: :cascade
|
||||||
|
|
2
dist/nginx.conf
vendored
2
dist/nginx.conf
vendored
|
@ -109,6 +109,8 @@ server {
|
||||||
location ~ ^/system/ {
|
location ~ ^/system/ {
|
||||||
add_header Cache-Control "public, max-age=2419200, immutable";
|
add_header Cache-Control "public, max-age=2419200, immutable";
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; form-action 'none'";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
2
|
3
|
||||||
end
|
end
|
||||||
|
|
||||||
def flags
|
def flags
|
||||||
|
|
22
lib/paperclip/media_type_spoof_detector_extensions.rb
Normal file
22
lib/paperclip/media_type_spoof_detector_extensions.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
module MediaTypeSpoofDetectorExtensions
|
||||||
|
def calculated_content_type
|
||||||
|
return @calculated_content_type if defined?(@calculated_content_type)
|
||||||
|
|
||||||
|
@calculated_content_type = type_from_file_command.chomp
|
||||||
|
|
||||||
|
# The `file` command fails to recognize some MP3 files as such
|
||||||
|
@calculated_content_type = type_from_marcel if @calculated_content_type == 'application/octet-stream' && type_from_marcel == 'audio/mpeg'
|
||||||
|
@calculated_content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
def type_from_marcel
|
||||||
|
@type_from_marcel ||= Marcel::MimeType.for Pathname.new(@file.path),
|
||||||
|
name: @file.path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Paperclip::MediaTypeSpoofDetector.prepend(Paperclip::MediaTypeSpoofDetectorExtensions)
|
|
@ -19,10 +19,7 @@ module Paperclip
|
||||||
def make
|
def make
|
||||||
metadata = VideoMetadataExtractor.new(@file.path)
|
metadata = VideoMetadataExtractor.new(@file.path)
|
||||||
|
|
||||||
unless metadata.valid?
|
raise Paperclip::Error, "Error while transcoding #{@file.path}: unsupported file" unless metadata.valid?
|
||||||
Paperclip.log("Unsupported file #{@file.path}")
|
|
||||||
return File.open(@file.path)
|
|
||||||
end
|
|
||||||
|
|
||||||
update_attachment_type(metadata)
|
update_attachment_type(metadata)
|
||||||
update_options_from_metadata(metadata)
|
update_options_from_metadata(metadata)
|
||||||
|
|
|
@ -32,6 +32,11 @@ class PublicFileServerMiddleware
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Override the default CSP header set by the CSP middleware
|
||||||
|
headers['Content-Security-Policy'] = "default-src 'none'; form-action 'none'" if request_path.start_with?(paperclip_root_url)
|
||||||
|
|
||||||
|
headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
|
||||||
[status, headers, response]
|
[status, headers, response]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -115,26 +115,26 @@ class Sanitize
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
MASTODON_OEMBED ||= freeze_config merge(
|
MASTODON_OEMBED ||= freeze_config(
|
||||||
RELAXED,
|
elements: %w(audio embed iframe source video),
|
||||||
elements: RELAXED[:elements] + %w(audio embed iframe source video),
|
|
||||||
|
|
||||||
attributes: merge(
|
attributes: {
|
||||||
RELAXED[:attributes],
|
|
||||||
'audio' => %w(controls),
|
'audio' => %w(controls),
|
||||||
'embed' => %w(height src type width),
|
'embed' => %w(height src type width),
|
||||||
'iframe' => %w(allowfullscreen frameborder height scrolling src width),
|
'iframe' => %w(allowfullscreen frameborder height scrolling src width),
|
||||||
'source' => %w(src type),
|
'source' => %w(src type),
|
||||||
'video' => %w(controls height loop width),
|
'video' => %w(controls height loop width),
|
||||||
'div' => [:data]
|
},
|
||||||
),
|
|
||||||
|
|
||||||
protocols: merge(
|
protocols: {
|
||||||
RELAXED[:protocols],
|
|
||||||
'embed' => { 'src' => HTTP_PROTOCOLS },
|
'embed' => { 'src' => HTTP_PROTOCOLS },
|
||||||
'iframe' => { 'src' => HTTP_PROTOCOLS },
|
'iframe' => { 'src' => HTTP_PROTOCOLS },
|
||||||
'source' => { 'src' => HTTP_PROTOCOLS }
|
'source' => { 'src' => HTTP_PROTOCOLS },
|
||||||
)
|
},
|
||||||
|
|
||||||
|
add_attributes: {
|
||||||
|
'iframe' => { 'sandbox' => 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms' },
|
||||||
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
BIN
spec/fixtures/files/boop.mp3
vendored
Normal file
BIN
spec/fixtures/files/boop.mp3
vendored
Normal file
Binary file not shown.
|
@ -152,6 +152,26 @@ RSpec.describe MediaAttachment, paperclip_processing: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'mp3 with large cover art' do
|
||||||
|
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('boop.mp3')) }
|
||||||
|
|
||||||
|
it 'detects it as an audio file' do
|
||||||
|
expect(media.type).to eq 'audio'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets meta for the duration' do
|
||||||
|
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts thumbnail' do
|
||||||
|
expect(media.thumbnail.present?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'gives the file a random name' do
|
||||||
|
expect(media.file_file_name).to_not eq 'boop.mp3'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'jpeg' do
|
describe 'jpeg' do
|
||||||
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) }
|
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) }
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue