Merge remote-tracking branch 'upstream/stable-4.4' into HEAD

Conflicts:
- `app/views/layouts/application.html.haml`:
  Conflict because of glitch-soc's theming system.
  Updated the line as upstream did.
This commit is contained in:
Claire
2025-09-04 18:33:21 +02:00
45 changed files with 416 additions and 236 deletions

View File

@@ -102,6 +102,16 @@ module ApplicationHelper
policy(record).public_send(:"#{action}?") policy(record).public_send(:"#{action}?")
end end
def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
if condition && !current_page?(block_given? ? name : options)
link_to(name, options, html_options, &block)
elsif block_given?
content_tag(:span, options, html_options, &block)
else
content_tag(:span, name, html_options)
end
end
def material_symbol(icon, attributes = {}) def material_symbol(icon, attributes = {})
safe_join( safe_join(
[ [

View File

@@ -96,12 +96,17 @@ export const ensureComposeIsVisible = (getState) => {
}; };
export function setComposeToStatus(status, text, spoiler_text) { export function setComposeToStatus(status, text, spoiler_text) {
return{ return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
dispatch({
type: COMPOSE_SET_STATUS, type: COMPOSE_SET_STATUS,
status, status,
text, text,
spoiler_text, spoiler_text,
}; maxOptions,
});
}
} }
export function changeCompose(text) { export function changeCompose(text) {
@@ -183,7 +188,7 @@ export function directCompose(account) {
}; };
} }
export function submitCompose() { export function submitCompose(successCallback) {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
@@ -239,6 +244,9 @@ export function submitCompose() {
dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
if (typeof successCallback === 'function') {
successCallback(response.data);
}
// To make the app more responsive, immediately push the status // To make the app more responsive, immediately push the status
// into the columns // into the columns

View File

@@ -3,7 +3,7 @@ import { browserHistory } from 'mastodon/components/router';
import api from '../api'; import api from '../api';
import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; import { importFetchedStatus, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed'; import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
@@ -48,7 +48,18 @@ export function fetchStatusRequest(id, skipLoading) {
}; };
} }
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { /**
* @param {string} id
* @param {Object} [options]
* @param {boolean} [options.forceFetch]
* @param {boolean} [options.alsoFetchContext]
* @param {string | null | undefined} [options.parentQuotePostId]
*/
export function fetchStatus(id, {
forceFetch = false,
alsoFetchContext = true,
parentQuotePostId,
} = {}) {
return (dispatch, getState) => { return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
@@ -66,7 +77,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading)); dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => { }).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading)); dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
}); });
}; };
} }
@@ -78,21 +89,27 @@ export function fetchStatusSuccess(skipLoading) {
}; };
} }
export function fetchStatusFail(id, error, skipLoading) { export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
return { return {
type: STATUS_FETCH_FAIL, type: STATUS_FETCH_FAIL,
id, id,
error, error,
parentQuotePostId,
skipLoading, skipLoading,
skipAlert: true, skipAlert: true,
}; };
} }
export function redraft(status, raw_text) { export function redraft(status, raw_text) {
return { return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
dispatch({
type: REDRAFT, type: REDRAFT,
status, status,
raw_text, raw_text,
maxOptions,
});
}; };
} }

View File

@@ -1,10 +1,11 @@
import { apiRequestPost } from 'mastodon/api'; import { apiRequestPost } from 'mastodon/api';
import type { Status, StatusVisibility } from 'mastodon/models/status'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { StatusVisibility } from 'mastodon/models/status';
export const apiReblog = (statusId: string, visibility: StatusVisibility) => export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
visibility, visibility,
}); });
export const apiUnreblog = (statusId: string) => export const apiUnreblog = (statusId: string) =>
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`); apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);

View File

@@ -37,9 +37,7 @@ const QuoteWrapper: React.FC<{
); );
}; };
const NestedQuoteLink: React.FC<{ const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
status: Status;
}> = ({ status }) => {
const accountId = status.get('account') as string; const accountId = status.get('account') as string;
const account = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,
@@ -78,21 +76,40 @@ type GetStatusSelector = (
export const QuotedStatus: React.FC<{ export const QuotedStatus: React.FC<{
quote: QuoteMap; quote: QuoteMap;
contextType?: string; contextType?: string;
parentQuotePostId?: string | null;
variant?: 'full' | 'link'; variant?: 'full' | 'link';
nestingLevel?: number; nestingLevel?: number;
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => { }> = ({
quote,
contextType,
parentQuotePostId,
nestingLevel = 1,
variant = 'full',
}) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const quoteState = useAppSelector((state) =>
parentQuotePostId
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
: quote.get('state'),
);
const quotedStatusId = quote.get('quoted_status'); const quotedStatusId = quote.get('quoted_status');
const quoteState = quote.get('state');
const status = useAppSelector((state) => const status = useAppSelector((state) =>
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined, quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
); );
const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted';
useEffect(() => { useEffect(() => {
if (!status && quotedStatusId) { if (shouldLoadQuote && quotedStatusId) {
dispatch(fetchStatus(quotedStatusId)); dispatch(
fetchStatus(quotedStatusId, {
parentQuotePostId,
alsoFetchContext: false,
}),
);
} }
}, [status, quotedStatusId, dispatch]); }, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]);
// In order to find out whether the quoted post should be completely hidden // In order to find out whether the quoted post should be completely hidden
// due to a matching filter, we run it through the selector used by `status_container`. // due to a matching filter, we run it through the selector used by `status_container`.
@@ -173,6 +190,7 @@ export const QuotedStatus: React.FC<{
{canRenderChildQuote && ( {canRenderChildQuote && (
<QuotedStatus <QuotedStatus
quote={childQuote} quote={childQuote}
parentQuotePostId={quotedStatusId}
contextType={contextType} contextType={contextType}
variant={ variant={
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full' nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
@@ -208,7 +226,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
if (quote) { if (quote) {
return ( return (
<StatusContainer {...props}> <StatusContainer {...props}>
<QuotedStatus quote={quote} contextType={props.contextType} /> <QuotedStatus
quote={quote}
parentQuotePostId={status?.get('id') as string}
contextType={props.contextType}
/>
</StatusContainer> </StatusContainer>
); );
} }

View File

@@ -73,6 +73,7 @@ class ComposeForm extends ImmutablePureComponent {
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
maxChars: PropTypes.number, maxChars: PropTypes.number,
redirectOnSuccess: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {

View File

@@ -34,7 +34,7 @@ const mapStateToProps = state => ({
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch, props) => ({
onChange (text) { onChange (text) {
dispatch(changeCompose(text)); dispatch(changeCompose(text));
@@ -47,7 +47,11 @@ const mapDispatchToProps = (dispatch) => ({
modalProps: {}, modalProps: {},
})); }));
} else { } else {
dispatch(submitCompose()); dispatch(submitCompose((status) => {
if (props.redirectOnSuccess) {
window.location.assign(status.url);
}
}));
} }
}, },

View File

@@ -5,7 +5,7 @@ import ModalContainer from 'mastodon/features/ui/containers/modal_container';
const Compose = () => ( const Compose = () => (
<> <>
<ComposeFormContainer autoFocus withoutNavigation /> <ComposeFormContainer autoFocus withoutNavigation redirectOnSuccess />
<AlertsController /> <AlertsController />
<ModalContainer /> <ModalContainer />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />

View File

@@ -32,7 +32,7 @@ const Embed: React.FC<{ id: string }> = ({ id }) => {
const dispatchRenderSignal = useRenderSignal(); const dispatchRenderSignal = useRenderSignal();
useEffect(() => { useEffect(() => {
dispatch(fetchStatus(id, false, false)); dispatch(fetchStatus(id, { alsoFetchContext: false }));
}, [dispatch, id]); }, [dispatch, id]);
const handleToggleHidden = useCallback(() => { const handleToggleHidden = useCallback(() => {

View File

@@ -381,7 +381,10 @@ export const DetailedStatus: React.FC<{
{hashtagBar} {hashtagBar}
{status.get('quote') && ( {status.get('quote') && (
<QuotedStatus quote={status.get('quote')} /> <QuotedStatus
quote={status.get('quote')}
parentQuotePostId={status.get('id')}
/>
)} )}
</> </>
)} )}

View File

@@ -38,7 +38,7 @@ class FilterModal extends ImmutablePureComponent {
handleSuccess = () => { handleSuccess = () => {
const { dispatch, statusId } = this.props; const { dispatch, statusId } = this.props;
dispatch(fetchStatus(statusId, true)); dispatch(fetchStatus(statusId, {forceFetch: true}));
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' }); this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
}; };

View File

@@ -501,8 +501,13 @@ export const composeReducer = (state = initialState, action) => {
} }
if (action.status.get('poll')) { if (action.status.get('poll')) {
let options = ImmutableList(action.status.get('poll').options.map(x => x.title));
if (options.size < action.maxOptions) {
options = options.push('');
}
map.set('poll', ImmutableMap({ map.set('poll', ImmutableMap({
options: ImmutableList(action.status.get('poll').options.map(x => x.title)), options: options,
multiple: action.status.get('poll').multiple, multiple: action.status.get('poll').multiple,
expires_in: expiresInFromExpiresAt(action.status.get('poll').expires_at), expires_in: expiresInFromExpiresAt(action.status.get('poll').expires_at),
})); }));
@@ -530,8 +535,13 @@ export const composeReducer = (state = initialState, action) => {
} }
if (action.status.get('poll')) { if (action.status.get('poll')) {
let options = ImmutableList(action.status.get('poll').options.map(x => x.title));
if (options.size < action.maxOptions) {
options = options.push('');
}
map.set('poll', ImmutableMap({ map.set('poll', ImmutableMap({
options: ImmutableList(action.status.get('poll').options.map(x => x.title)), options: options,
multiple: action.status.get('poll').multiple, multiple: action.status.get('poll').multiple,
expires_in: expiresInFromExpiresAt(action.status.get('poll').expires_at), expires_in: expiresInFromExpiresAt(action.status.get('poll').expires_at),
})); }));

View File

@@ -73,8 +73,15 @@ export default function statuses(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STATUS_FETCH_REQUEST: case STATUS_FETCH_REQUEST:
return state.setIn([action.id, 'isLoading'], true); return state.setIn([action.id, 'isLoading'], true);
case STATUS_FETCH_FAIL: case STATUS_FETCH_FAIL: {
if (action.parentQuotePostId && action.error.status === 404) {
return state
.delete(action.id)
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
} else {
return state.delete(action.id); return state.delete(action.id);
}
}
case STATUS_IMPORT: case STATUS_IMPORT:
return importStatus(state, action.status); return importStatus(state, action.status);
case STATUSES_IMPORT: case STATUSES_IMPORT:

View File

@@ -1888,7 +1888,7 @@ a.sparkline {
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
li { > li {
counter-increment: step 1; counter-increment: step 1;
padding-inline-start: 2.5rem; padding-inline-start: 2.5rem;
padding-bottom: 8px; padding-bottom: 8px;

View File

@@ -2333,6 +2333,7 @@ a .account__avatar {
.detailed-status__display-name, .detailed-status__display-name,
.detailed-status__datetime, .detailed-status__datetime,
.detailed-status__application, .detailed-status__application,
.detailed-status__link,
.account__display-name { .account__display-name {
text-decoration: none; text-decoration: none;
} }
@@ -2365,7 +2366,8 @@ a.account__display-name {
} }
.detailed-status__application, .detailed-status__application,
.detailed-status__datetime { .detailed-status__datetime,
.detailed-status__link {
color: inherit; color: inherit;
} }
@@ -2551,8 +2553,9 @@ a.account__display-name {
} }
.status__relative-time, .status__relative-time,
.detailed-status__datetime { .detailed-status__datetime,
&:hover { .detailed-status__link {
&:is(a):hover {
text-decoration: underline; text-decoration: underline;
} }
} }

View File

@@ -101,7 +101,8 @@
cursor: pointer; cursor: pointer;
} }
&.editable { &.editable,
&.disabled {
align-items: center; align-items: center;
overflow: visible; overflow: visible;
} }
@@ -159,7 +160,8 @@
} }
} }
&__option.editable &__input { &__option.editable &__input,
&__option.disabled &__input {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {

View File

@@ -61,6 +61,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward! ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
@quote.reject! @quote.reject!
DistributionWorker.perform_async(@quote.status_id, { 'update' => true })
end end
def forwarder def forwarder

View File

@@ -57,8 +57,16 @@ class Antispam
end end
def report_if_needed!(account) def report_if_needed!(account)
return if Report.unresolved.exists?(account: Account.representative, target_account: account) return if system_reports.unresolved.exists?(target_account: account)
Report.create!(account: Account.representative, target_account: account, category: :spam, comment: 'Account automatically reported for posting a banned URL') system_reports.create!(
category: :spam,
comment: 'Account automatically reported for posting a banned URL',
target_account: account
)
end
def system_reports
Account.representative.reports
end end
end end

View File

@@ -116,7 +116,7 @@ class Account < ApplicationRecord
# Local user validations # Local user validations
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: USERNAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_username? && !actor_type_application? } validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: USERNAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_username? && !actor_type_application? }
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && !actor_type_application? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && !actor_type_application? && !user&.bypass_registration_checks }
validates :display_name, length: { maximum: DISPLAY_NAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_display_name? } validates :display_name, length: { maximum: DISPLAY_NAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_display_name? }
validates :note, note_length: { maximum: NOTE_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_note? } validates :note, note_length: { maximum: NOTE_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_note? }
validates :fields, length: { maximum: DEFAULT_FIELDS_SIZE }, if: -> { local? && will_save_change_to_fields? } validates :fields, length: { maximum: DEFAULT_FIELDS_SIZE }, if: -> { local? && will_save_change_to_fields? }

View File

@@ -44,12 +44,36 @@ class StatusEdit < ApplicationRecord
scope :ordered, -> { order(id: :asc) } scope :ordered, -> { order(id: :asc) }
delegate :local?, :application, :edited?, :edited_at, delegate :local?, :application, :edited?, :edited_at,
:discarded?, :visibility, :language, to: :status :discarded?, :reply?, :visibility, :language, to: :status
def with_media?
ordered_media_attachments.any?
end
def with_poll?
poll_options.present?
end
def poll
return @poll if defined?(@poll)
return @poll = nil if poll_options.blank?
@poll = Poll.new({
options: poll_options,
account_id: account_id,
status_id: status_id,
})
end
alias preloadable_poll poll
def emojis def emojis
return @emojis if defined?(@emojis) return @emojis if defined?(@emojis)
@emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain) fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
@emojis = CustomEmoji.from_text(fields.join(' '), status.account.domain)
end end
def ordered_media_attachments def ordered_media_attachments

View File

@@ -142,7 +142,9 @@ class User < ApplicationRecord
delegate :can?, to: :role delegate :can?, to: :role
attr_reader :invite_code, :date_of_birth attr_reader :invite_code, :date_of_birth
attr_writer :external, :bypass_registration_checks, :current_account attr_writer :external, :current_account
attribute :bypass_registration_checks, :boolean, default: false
def self.those_who_can(*any_of_privileges) def self.those_who_can(*any_of_privileges)
matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id) matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id)
@@ -505,10 +507,6 @@ class User < ApplicationRecord
!!@external !!@external
end end
def bypass_registration_checks?
@bypass_registration_checks
end
def sanitize_role def sanitize_role
self.role = nil if role.present? && role.everyone? self.role = nil if role.present? && role.everyone?
end end

View File

@@ -74,7 +74,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end end
def update_interaction_policies! def update_interaction_policies!
@status.quote_approval_policy = @status_parser.quote_policy @status.update(quote_approval_policy: @status_parser.quote_policy)
end end
def update_media_attachments! def update_media_attachments!
@@ -112,6 +112,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@status.ordered_media_attachment_ids = @next_media_attachments.map(&:id) @status.ordered_media_attachment_ids = @next_media_attachments.map(&:id)
@media_attachments_changed = true if @status.ordered_media_attachment_ids != previous_media_attachments_ids @media_attachments_changed = true if @status.ordered_media_attachment_ids != previous_media_attachments_ids
@status.media_attachments.reload if @media_attachments_changed
end end
def download_media_files! def download_media_files!

View File

@@ -55,6 +55,7 @@ class BackupService < BaseService
def build_archive! def build_archive!
tmp_file = Tempfile.new(%w(archive .zip)) tmp_file = Tempfile.new(%w(archive .zip))
Zip.write_zip64_support = true
Zip::File.open(tmp_file, create: true) do |zipfile| Zip::File.open(tmp_file, create: true) do |zipfile|
dump_outbox!(zipfile) dump_outbox!(zipfile)
dump_media_attachments!(zipfile) dump_media_attachments!(zipfile)

View File

@@ -1,6 +0,0 @@
- if status.ordered_media_attachments.first.video?
= render_video_component(status, visible: false)
- elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status)
- else
= render_media_gallery_component(status, visible: false)

View File

@@ -1,53 +0,0 @@
.batch-table__row
%label.batch-table__row__select.batch-checkbox
= f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
.batch-table__row__content
.status__card
- if status.reblog?
.status__prepend
= material_symbol('repeat')
= t('statuses.boosted_from_html', acct_link: admin_account_inline_link_to(status.proper.account, path: admin_account_status_path(status.proper.account.id, status.proper.id)))
- elsif status.reply? && status.in_reply_to_id.present?
.status__prepend
= material_symbol('reply')
= t('admin.statuses.replied_to_html', acct_link: admin_account_inline_link_to(status.in_reply_to_account, path: status.thread.present? ? admin_account_status_path(status.thread.account_id, status.in_reply_to_id) : nil))
.status__content><
- if status.proper.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status.proper.spoiler_text), status.proper.emojis)}
= prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
- unless status.proper.ordered_media_attachments.empty?
= render partial: 'admin/reports/media_attachments', locals: { status: status.proper }
.detailed-status__meta
- if status.application
= status.application.name
·
= link_to admin_account_status_path(status.account.id, status), class: 'detailed-status__datetime' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- if status.edited?
·
= link_to t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')),
admin_account_status_path(status.account_id, status),
class: 'detailed-status__datetime'
- if status.discarded?
·
%span.negative-hint= t('admin.statuses.deleted')
·
= material_symbol visibility_icon(status)
= t("statuses.visibilities.#{status.visibility}")
·
= link_to ActivityPub::TagManager.instance.url_for(status.proper), class: 'detailed-status__link', rel: 'noopener' do
= t('admin.statuses.view_publicly')
- if status.proper.sensitive?
·
= material_symbol('visibility_off')
= t('stream_entries.sensitive_content')

View File

@@ -57,7 +57,7 @@
- if @statuses.empty? - if @statuses.empty?
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'
- else - else
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } = render partial: 'admin/shared/status_batch_row', collection: @statuses, as: :status, locals: { f: f }
- if @report.unresolved? - if @report.unresolved?
%hr.spacer/ %hr.spacer/

View File

@@ -0,0 +1,40 @@
-# locals: (status:)
.status__card><
- if status.reblog?
.status__prepend
= material_symbol('repeat')
= t('statuses.boosted_from_html', acct_link: admin_account_inline_link_to(status.proper.account, path: admin_account_status_path(status.proper.account.id, status.proper.id)))
- elsif status.reply? && status.in_reply_to_id.present?
.status__prepend
= material_symbol('reply')
= t('admin.statuses.replied_to_html', acct_link: admin_account_inline_link_to(status.in_reply_to_account, path: status.thread.present? ? admin_account_status_path(status.thread.account_id, status.in_reply_to_id) : nil))
= render partial: 'admin/shared/status_content', locals: { status: status.proper }
.detailed-status__meta
- if status.application
= status.application.name
·
= conditional_link_to can?(:show, status), admin_account_status_path(status.account.id, status), class: 'detailed-status__datetime' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }><= l(status.created_at)
- if status.edited?
&nbsp;·
= conditional_link_to can?(:show, status), admin_account_status_path(status.account.id, status, { anchor: 'history' }), class: 'detailed-status__datetime' do
%span><= t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'relative-formatted'))
- if status.discarded?
&nbsp;·
%span.negative-hint= t('admin.statuses.deleted')
- unless status.reblog?
&nbsp;·
%span<
= material_symbol(visibility_icon(status))
= t("statuses.visibilities.#{status.visibility}")
- if status.proper.sensitive?
&nbsp;·
= material_symbol('visibility_off')
= t('stream_entries.sensitive_content')
- unless status.direct_visibility?
&nbsp;·
= link_to ActivityPub::TagManager.instance.url_for(status.proper), class: 'detailed-status__link', target: 'blank', rel: 'noopener' do
= t('admin.statuses.view_publicly')

View File

@@ -0,0 +1,22 @@
- if status.with_poll?
.poll
%ul
- status.preloadable_poll.options.each_with_index do |option, _index|
%li
%label.poll__option.disabled<>
- if status.preloadable_poll.multiple?
%span.poll__input.checkbox{ role: 'checkbox', 'aria-label': option }
- else
%span.poll__input{ role: 'radio', 'aria-label': option }
%span.poll__option__text
= prerender_custom_emojis(html_aware_format(option, status.local?, multiline: false), status.emojis)
%button.button.button-secondary{ disabled: true }
= t('polls.vote')
- if status.with_media?
- if status.ordered_media_attachments.first.video?
= render_video_component(status, visible: false)
- elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status)
- else
= render_media_gallery_component(status, visible: false)

View File

@@ -0,0 +1,5 @@
.batch-table__row
%label.batch-table__row__select.batch-checkbox
= f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
.batch-table__row__content
= render partial: 'admin/shared/status', object: status

View File

@@ -0,0 +1,10 @@
.status__content><
- if status.spoiler_text.present?
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
= prerender_custom_emojis(status_content_format(status), status.emojis)
= render partial: 'admin/shared/status_attachments', locals: { status: status.proper }
- else
= prerender_custom_emojis(status_content_format(status), status.emojis)
= render partial: 'admin/shared/status_attachments', locals: { status: status.proper }

View File

@@ -9,17 +9,7 @@
%time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at) %time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at)
.status .status
.status__content>< = render partial: 'admin/shared/status_content', locals: { status: status_edit }
- if status_edit.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status_edit.spoiler_text), status_edit.emojis)}
= prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis)
- unless status_edit.ordered_media_attachments.empty?
= render partial: 'admin/reports/media_attachments', locals: { status: status_edit }
.detailed-status__meta .detailed-status__meta
%time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at) %time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at)

View File

@@ -47,6 +47,6 @@
- if @statuses.empty? - if @statuses.empty?
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'
- else - else
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } = render partial: 'admin/shared/status_batch_row', collection: @statuses, as: :status, locals: { f: f }
= paginate @statuses = paginate @statuses

View File

@@ -53,52 +53,11 @@
%h3= t('admin.statuses.contents') %h3= t('admin.statuses.contents')
.status__card = render partial: 'admin/shared/status', object: @status
- if @status.reblog?
.status__prepend
= material_symbol('repeat')
= t('statuses.boosted_from_html', acct_link: admin_account_inline_link_to(@status.proper.account, path: admin_account_status_path(@status.proper.account.id, @status.proper.id)))
- elsif @status.reply? && @status.in_reply_to_id.present?
.status__prepend
= material_symbol('reply')
= t('admin.statuses.replied_to_html', acct_link: admin_account_inline_link_to(@status.in_reply_to_account, path: @status.thread.present? ? admin_account_status_path(@status.thread.account_id, @status.in_reply_to_id) : nil))
.status__content><
- if @status.proper.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(@status.proper), @status.proper.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(@status.proper.spoiler_text), @status.proper.emojis)}
= prerender_custom_emojis(status_content_format(@status.proper), @status.proper.emojis)
- unless @status.proper.ordered_media_attachments.empty?
= render partial: 'admin/reports/media_attachments', locals: { status: @status.proper }
.detailed-status__meta
- if @status.application
= @status.application.name
·
%span.detailed-status__datetime
%time.formatted{ datetime: @status.created_at.iso8601, title: l(@status.created_at) }= l(@status.created_at)
- if @status.edited?
·
%span.detailed-status__datetime
= t('statuses.edited_at_html', date: content_tag(:time, l(@status.edited_at), datetime: @status.edited_at.iso8601, title: l(@status.edited_at), class: 'formatted'))
- if @status.discarded?
·
%span.negative-hint= t('admin.statuses.deleted')
- unless @status.reblog?
·
= material_symbol(visibility_icon(@status))
= t("statuses.visibilities.#{@status.visibility}")
- if @status.proper.sensitive?
·
= material_symbol('visibility_off')
= t('stream_entries.sensitive_content')
%hr.spacer/ %hr.spacer/
%h3= t('admin.statuses.history') %h3#history= t('admin.statuses.history')
- if @status.edits.empty? - if @status.edits.empty?
%p= t('admin.statuses.no_history') %p= t('admin.statuses.no_history')
- else - else

View File

@@ -30,7 +30,7 @@
= vite_react_refresh_tag = vite_react_refresh_tag
= vite_polyfills_tag = vite_polyfills_tag
-# Needed for the wicg-inert polyfill. It needs to be on it's own <style> tag, with this `id` -# Needed for the wicg-inert polyfill. It needs to be on it's own <style> tag, with this `id`
= vite_stylesheet_tag 'styles/entrypoints/inert.scss', media: 'all', id: 'inert-style' # TODO: flavour = vite_stylesheet_tag 'styles/entrypoints/inert.scss', media: 'all', id: 'inert-style', crossorigin: 'anonymous' # TODO: flavour
= flavoured_vite_typescript_tag 'common.ts', crossorigin: 'anonymous' = flavoured_vite_typescript_tag 'common.ts', crossorigin: 'anonymous'
= vite_preload_file_tag "mastodon/locales/#{I18n.locale}.json" # TODO: fix preload for flavour = vite_preload_file_tag "mastodon/locales/#{I18n.locale}.json" # TODO: fix preload for flavour

View File

@@ -21,8 +21,9 @@ class Scheduler::SelfDestructScheduler
def sidekiq_overwhelmed? def sidekiq_overwhelmed?
redis_mem_info = Sidekiq.default_configuration.redis_info redis_mem_info = Sidekiq.default_configuration.redis_info
maxmemory = [redis_mem_info['maxmemory'].to_f, redis_mem_info['total_system_memory'].to_f].filter(&:positive?).min
Sidekiq::Stats.new.enqueued > MAX_ENQUEUED || redis_mem_info['used_memory'].to_f > redis_mem_info['total_system_memory'].to_f * MAX_REDIS_MEM_USAGE Sidekiq::Stats.new.enqueued > MAX_ENQUEUED || redis_mem_info['used_memory'].to_f > maxmemory * MAX_REDIS_MEM_USAGE
end end
def delete_accounts! def delete_accounts!

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
# Disable httplog in production unless log_level is `debug` # Disable in production unless log level is `debug`
if !Rails.env.production? || Rails.configuration.log_level == :debug if Rails.env.local? || Rails.logger.debug?
require 'httplog' require 'httplog'
HttpLog.configure do |config| HttpLog.configure do |config|

View File

@@ -1707,6 +1707,7 @@ en:
self_vote: You cannot vote in your own polls self_vote: You cannot vote in your own polls
too_few_options: must have more than one item too_few_options: must have more than one item
too_many_options: can't contain more than %{max} items too_many_options: can't contain more than %{max} items
vote: Vote
preferences: preferences:
other: Other other: Other
posting_defaults: Posting defaults posting_defaults: Posting defaults
@@ -1876,12 +1877,12 @@ en:
ownership: Someone else's post cannot be pinned ownership: Someone else's post cannot be pinned
reblog: A boost cannot be pinned reblog: A boost cannot be pinned
quote_policies: quote_policies:
followers: Only your followers followers: Followers only
nobody: Nobody nobody: Just me
public: Everyone public: Anyone
title: '%{name}: "%{quote}"' title: '%{name}: "%{quote}"'
visibilities: visibilities:
direct: Direct direct: Private mention
private: Followers-only private: Followers-only
private_long: Only show to followers private_long: Only show to followers
public: Public public: Public

View File

@@ -363,10 +363,6 @@ namespace :api, format: false do
namespace :web do namespace :web do
resource :settings, only: [:update] resource :settings, only: [:update]
resources :embeds, only: [:show] resources :embeds, only: [:show]
resources :push_subscriptions, only: [:create, :destroy] do resources :push_subscriptions, only: [:create, :destroy, :update]
member do
put :update
end
end
end end
end end

View File

@@ -12,27 +12,22 @@ RSpec.describe ActivityPub::Activity::Delete do
id: 'foo', id: 'foo',
type: 'Delete', type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(status), object: object_json,
signature: 'foo', signature: 'foo',
}.with_indifferent_access }.deep_stringify_keys
end end
let(:object_json) { ActivityPub::TagManager.instance.uri_for(status) }
describe '#perform' do describe '#perform' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
before do
subject.perform
end
it 'deletes sender\'s status' do it 'deletes sender\'s status' do
subject.perform
expect(Status.find_by(id: status.id)).to be_nil expect(Status.find_by(id: status.id)).to be_nil
end end
end
context 'when the status has been reblogged' do context 'when the status has been reblogged' do
describe '#perform' do
subject { described_class.new(json, sender) }
let!(:reblogger) { Fabricate(:account) } let!(:reblogger) { Fabricate(:account) }
let!(:follower) { Fabricate(:account, username: 'follower', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let!(:follower) { Fabricate(:account, username: 'follower', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
let!(:reblog) { Fabricate(:status, account: reblogger, reblog: status) } let!(:reblog) { Fabricate(:status, account: reblogger, reblog: status) }
@@ -55,12 +50,8 @@ RSpec.describe ActivityPub::Activity::Delete do
expect { reblog.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { reblog.reload }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
end
context 'when the status has been reported' do context 'when the status has been reported' do
describe '#perform' do
subject { described_class.new(json, sender) }
let!(:reporter) { Fabricate(:account) } let!(:reporter) { Fabricate(:account) }
before do before do
@@ -76,23 +67,9 @@ RSpec.describe ActivityPub::Activity::Delete do
expect(Status.with_discarded.find_by(id: status.id)).to_not be_nil expect(Status.with_discarded.find_by(id: status.id)).to_not be_nil
end end
end end
end
context 'when the deleted object is an account' do context 'when the deleted object is an account' do
let(:json) do let(:object_json) { ActivityPub::TagManager.instance.uri_for(sender) }
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(sender),
signature: 'foo',
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
let(:service) { instance_double(DeleteAccountService, call: true) } let(:service) { instance_double(DeleteAccountService, call: true) }
before do before do
@@ -106,7 +83,6 @@ RSpec.describe ActivityPub::Activity::Delete do
.to have_received(:call).with(sender, { reserve_username: false, skip_activitypub: true }) .to have_received(:call).with(sender, { reserve_username: false, skip_activitypub: true })
end end
end end
end
context 'when the deleted object is a quote authorization' do context 'when the deleted object is a quote authorization' do
let(:quoter) { Fabricate(:account, domain: 'b.example.com') } let(:quoter) { Fabricate(:account, domain: 'b.example.com') }
@@ -114,19 +90,29 @@ RSpec.describe ActivityPub::Activity::Delete do
let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') } let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') }
let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) } let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) }
let(:json) do let(:object_json) { quote.approval_uri }
{
'@context': 'https://www.w3.org/ns/activitystreams', it 'revokes the authorization' do
id: 'foo', expect { subject.perform }
type: 'Delete', .to change { quote.reload.state }.to('revoked')
actor: ActivityPub::TagManager.instance.uri_for(sender), end
object: quote.approval_uri,
signature: 'foo',
}.with_indifferent_access
end end
describe '#perform' do context 'when the deleted object is an inlined quote authorization' do
subject { described_class.new(json, sender) } let(:quoter) { Fabricate(:account, domain: 'b.example.com') }
let(:status) { Fabricate(:status, account: quoter) }
let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') }
let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) }
let(:object_json) do
{
type: 'QuoteAuthorization',
id: quote.approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
}.deep_stringify_keys
end
it 'revokes the authorization' do it 'revokes the authorization' do
expect { subject.perform } expect { subject.perform }

47
spec/lib/antispam_spec.rb Normal file
View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Antispam do
describe '#local_preflight_check!' do
subject { described_class.new.local_preflight_check!(status) }
let(:status) { Fabricate :status }
context 'when there is no spammy text registered' do
it { is_expected.to be_nil }
end
context 'with spammy text' do
before { redis.sadd 'antispam:spammy_texts', 'https://banned.example' }
context 'when status matches' do
let(:status) { Fabricate :status, text: 'I use https://banned.example urls in my text' }
it 'raises error and reports' do
expect { subject }
.to raise_error(described_class::SilentlyDrop)
.and change(spam_reports, :count).by(1)
end
context 'when report already exists' do
before { Fabricate :report, account: Account.representative, target_account: status.account }
it 'raises error and does not report' do
expect { subject }
.to raise_error(described_class::SilentlyDrop)
.and not_change(spam_reports, :count)
end
end
def spam_reports
Account.representative.reports.where(target_account: status.account).spam
end
end
context 'when status does not match' do
it { is_expected.to be_nil }
end
end
end
end

View File

@@ -32,6 +32,7 @@ RSpec.describe Mastodon::CLI::Accounts do
describe '#create' do describe '#create' do
let(:action) { :create } let(:action) { :create }
let(:username) { 'tootctl_username' }
shared_examples 'a new user with given email address and username' do shared_examples 'a new user with given email address and username' do
it 'creates user and accounts from options and displays success message' do it 'creates user and accounts from options and displays success message' do
@@ -48,18 +49,24 @@ RSpec.describe Mastodon::CLI::Accounts do
end end
def account_from_options def account_from_options
Account.find_local('tootctl_username') Account.find_local(username)
end end
end end
context 'when required USERNAME and --email are provided' do context 'when required USERNAME and --email are provided' do
let(:arguments) { ['tootctl_username'] } let(:arguments) { [username] }
context 'with USERNAME and --email only' do context 'with USERNAME and --email only' do
let(:options) { { email: 'tootctl@example.com' } } let(:options) { { email: 'tootctl@example.com' } }
it_behaves_like 'a new user with given email address and username' it_behaves_like 'a new user with given email address and username'
context 'with a reserved username' do
let(:username) { 'security' }
it_behaves_like 'a new user with given email address and username'
end
context 'with invalid --email value' do context 'with invalid --email value' do
let(:options) { { email: 'invalid' } } let(:options) { { email: 'invalid' } }

View File

@@ -64,7 +64,7 @@ RSpec.describe 'API Web Push Subscriptions' do
end end
end end
describe 'PUT /api/web/push_subscriptions' do describe 'PUT /api/web/push_subscriptions/:id' do
before { sign_in Fabricate :user } before { sign_in Fabricate :user }
let(:subscription) { Fabricate :web_push_subscription } let(:subscription) { Fabricate :web_push_subscription }

View File

@@ -316,6 +316,23 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
expect(existing_status.edits).to_not be_empty expect(existing_status.edits).to_not be_empty
end end
end end
context 'with an implicit update to quoting policy' do
let(:object) do
note.merge({
'content' => existing_status.text,
'interactionPolicy' => {
'canQuote' => {
'automaticApproval' => ['https://www.w3.org/ns/activitystreams#Public'],
},
},
})
end
it 'updates status' do
expect(existing_status.reload.quote_approval_policy).to eq(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16)
end
end
end end
end end

View File

@@ -343,6 +343,42 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end end
end end
context 'when originally without media attachments and text is removed' do
before do
stub_request(:get, 'https://example.com/foo.png').to_return(body: attachment_fixture('emojo.png'))
end
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Note',
content: '',
updated: '2021-09-08T22:39:25Z',
attachment: [
{ type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png' },
],
}
end
it 'updates media attachments, fetches attachment, records media and text removal in edit' do
subject.call(status, json, json)
expect(status.reload.ordered_media_attachments.first)
.to be_present
.and(have_attributes(remote_url: 'https://example.com/foo.png'))
expect(a_request(:get, 'https://example.com/foo.png'))
.to have_been_made
expect(status.edits.reload.last.ordered_media_attachment_ids)
.to_not be_empty
expect(status.edits.reload.last.text)
.to_not be_present
end
end
context 'when originally with media attachments' do context 'when originally with media attachments' do
let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] } let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] }

View File

@@ -23,7 +23,7 @@ RSpec.describe 'Share page', :js, :streaming do
fill_in_form fill_in_form
expect(page) expect(page)
.to have_css('.notification-bar-message', text: frontend_translations('compose.published.body')) .to have_current_path(%r{/@bob/[0-9]+})
end end
def fill_in_form def fill_in_form