Merge commit 'e5826777b6c06a32b97388657beaca1e5eccb421' into glitch-soc/merge-upstream

Conflicts:
- `config/settings.yml`:
  Not a real conflict, upstream removed settings that are identical in glitch-soc
  but textually adjacent to glitch-soc-only settings.
  Removed what upstream removed.
This commit is contained in:
Claire
2025-07-30 20:05:45 +02:00
172 changed files with 2202 additions and 647 deletions

View File

@@ -6,10 +6,6 @@
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
Lint/NonLocalExitFromIterator:
Exclude:
- 'app/helpers/json_ld_helper.rb'
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 90

View File

@@ -96,7 +96,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1131.0)
aws-partitions (1.1135.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -233,7 +233,7 @@ GEM
fabrication (3.0.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.2)
faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -345,7 +345,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.13.0)
json (2.13.2)
json-canonicalization (1.0.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
@@ -438,7 +438,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0715)
mime-types-data (3.2025.0722)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
@@ -468,7 +468,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-cas (3.0.1)
omniauth-cas (3.0.2)
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
@@ -601,13 +601,13 @@ GEM
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.9)
pg (1.6.0)
pghero (3.7.0)
activerecord (>= 7.1)
playwright-ruby-client (1.54.0)
@@ -731,7 +731,7 @@ GEM
railties (>= 5.2)
rexml (3.4.1)
rotp (6.3.0)
rouge (4.5.2)
rouge (4.6.0)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
@@ -860,7 +860,7 @@ GEM
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.7)
strong_migrations (2.4.0)
strong_migrations (2.5.0)
activerecord (>= 7.1)
swd (2.0.3)
activesupport (>= 3)
@@ -868,7 +868,7 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
temple (0.10.3)
temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
@@ -1108,4 +1108,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.7.0
2.7.1

View File

@@ -6,7 +6,7 @@ module Admin
def index
authorize :audit_log, :index?
@auditable_accounts = Account.auditable.select(:id, :username)
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
end
private

View File

@@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
end
def reject
authorize @appeal, :approve?
authorize @appeal, :reject?
log_action :reject, @appeal
@appeal.reject!(current_account)
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later

View File

@@ -36,7 +36,7 @@ module Admin
end
def edit
authorize :domain_block, :create?
authorize :domain_block, :update?
end
def create
@@ -129,7 +129,7 @@ module Admin
end
def requires_confirmation?
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
end
end
end

View File

@@ -0,0 +1,77 @@
# frozen_string_literal: true
class Admin::UsernameBlocksController < Admin::BaseController
before_action :set_username_block, only: [:edit, :update]
def index
authorize :username_block, :index?
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
@form = Form::UsernameBlockBatch.new
end
def batch
authorize :username_block, :index?
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
ensure
redirect_to admin_username_blocks_path
end
def new
authorize :username_block, :create?
@username_block = UsernameBlock.new(exact: true)
end
def edit
authorize @username_block, :update?
end
def create
authorize :username_block, :create?
@username_block = UsernameBlock.new(resource_params)
if @username_block.save
log_action :create, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
else
render :new
end
end
def update
authorize @username_block, :update?
if @username_block.update(resource_params)
log_action :update, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
else
render :new
end
end
private
def set_username_block
@username_block = UsernameBlock.find(params[:id])
end
def form_username_block_batch_params
params
.expect(form_username_block_batch: [username_block_ids: []])
end
def resource_params
params
.expect(username_block: [:username, :comparison, :allow_with_approval])
end
def action_from_button
'delete' if params[:delete]
end
end

View File

@@ -66,7 +66,11 @@ class Api::V1::StatusesController < Api::BaseController
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
end
end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)

View File

@@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
end
def destroy
if current_account.moved_to_account_id.present?
if current_account.moved?
current_account.update!(moved_to_account: nil)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
end

View File

@@ -11,6 +11,7 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status
before_action :redirect_to_original, only: :show
before_action :verify_embed_allowed, only: :embed
after_action :set_link_headers
@@ -40,8 +41,6 @@ class StatusesController < ApplicationController
end
def embed
return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true
response.headers.delete('X-Frame-Options')
@@ -50,6 +49,10 @@ class StatusesController < ApplicationController
private
def verify_embed_allowed
not_found if @status.hidden? || @status.reblog?
end
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]

View File

@@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end
when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id)
when 'UsernameBlock'
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'

View File

@@ -65,13 +65,13 @@ module FormattingHelper
end
def rss_content_preroll(status)
if status.spoiler_text?
return unless status.spoiler_text?
safe_join [
tag.p { spoiler_with_warning(status) },
tag.hr,
]
end
end
def spoiler_with_warning(status)
safe_join [
@@ -81,12 +81,12 @@ module FormattingHelper
end
def rss_content_postroll(status)
if status.preloadable_poll
return unless status.preloadable_poll
tag.p do
poll_option_tags(status)
end
end
end
def poll_option_tags(status)
safe_join(

View File

@@ -134,7 +134,7 @@ module JsonLdHelper
patch_for_forwarding!(value, compacted_value)
elsif value.is_a?(Array)
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
return if value.size != compacted_value.size
return nil if value.size != compacted_value.size
compacted[key] = value.zip(compacted_value).map do |v, vc|
if v.is_a?(Hash) && vc.is_a?(Hash)

View File

@@ -28,7 +28,8 @@ module ThemeHelper
end
def custom_stylesheet
if active_custom_stylesheet.present?
return if active_custom_stylesheet.blank?
stylesheet_link_tag(
custom_css_path(active_custom_stylesheet),
host: root_url,
@@ -36,17 +37,16 @@ module ThemeHelper
skip_pipeline: true
)
end
end
private
def active_custom_stylesheet
if cached_custom_css_digest.present?
return if cached_custom_css_digest.blank?
[:custom, cached_custom_css_digest.to_s.first(8)]
.compact_blank
.join('-')
end
end
def cached_custom_css_digest
Rails.cache.fetch(:setting_digest_custom_css) do

View File

@@ -40,7 +40,11 @@ type KeyMatcher = (
*/
function just(keyName: string): KeyMatcher {
return (event) => ({
isMatch: normalizeKey(event.key) === keyName,
isMatch:
normalizeKey(event.key) === keyName &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey,
priority: hotkeyPriority.singleKey,
});
}

View File

@@ -0,0 +1,63 @@
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/Overlay';
export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
return (
<>
<button
className='link-button'
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
<FormattedMessage
id='learn_more_link.learn_more'
defaultMessage='Learn more'
/>
</button>
<Overlay
show={open}
rootClose
onHide={handleClick}
offset={[5, 5]}
placement='bottom-end'
target={triggerRef}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className='account__domain-pill__popout learn-more__popout dropdown-animation'
>
<div className='learn-more__popout__content'>{children}</div>
<div>
<button className='link-button' onClick={handleClick}>
<FormattedMessage
id='learn_more_link.got_it'
defaultMessage='Got it'
/>
</button>
</div>
</div>
)}
</Overlay>
</>
);
};

View File

@@ -48,13 +48,13 @@ class TranslateButton extends PureComponent {
return (
<div className='translate-button'>
<div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div>
<button className='link-button' onClick={onClick}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
<div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div>
</div>
);
}
@@ -138,6 +138,16 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed);
}
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
}
handleMouseEnter = ({ currentTarget }) => {

View File

@@ -3,19 +3,15 @@ import { useEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { Icon } from 'mastodon/components/icon';
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
import StatusContainer from 'mastodon/containers/status_container';
import type { Status } from 'mastodon/models/status';
import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import QuoteIcon from '../../images/quote.svg?react';
import { fetchStatus } from '../actions/statuses';
import { makeGetStatus } from '../selectors';
@@ -31,7 +27,6 @@ const QuoteWrapper: React.FC<{
'status__quote--error': isError,
})}
>
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
{children}
</div>
);
@@ -45,27 +40,20 @@ const NestedQuoteLink: React.FC<{
accountId ? state.accounts.get(accountId) : undefined,
);
const quoteAuthorName = account?.display_name_html;
const quoteAuthorName = account?.acct;
if (!quoteAuthorName) {
return null;
}
const quoteAuthorElement = (
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
);
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
return (
<Link to={quoteUrl} className='status__quote-author-button'>
<div className='status__quote-author-button'>
<FormattedMessage
id='status.quote_post_author'
defaultMessage='Post by {name}'
values={{ name: quoteAuthorElement }}
defaultMessage='Quoted a post by @{name}'
values={{ name: quoteAuthorName }}
/>
<Icon id='chevron_right' icon={ChevronRightIcon} />
<Icon id='article' icon={ArticleIcon} />
</Link>
</div>
);
};
@@ -112,39 +100,42 @@ export const QuotedStatus: React.FC<{
defaultMessage='Hidden due to one of your filters'
/>
);
} else if (quoteState === 'deleted') {
quoteError = (
<FormattedMessage
id='status.quote_error.removed'
defaultMessage='This post was removed by its author.'
/>
);
} else if (quoteState === 'unauthorized') {
quoteError = (
<FormattedMessage
id='status.quote_error.unauthorized'
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
/>
);
} else if (quoteState === 'pending') {
quoteError = (
<>
<FormattedMessage
id='status.quote_error.pending_approval'
defaultMessage='This post is pending approval from the original author.'
defaultMessage='Post pending'
/>
<LearnMoreLink>
<h6>
<FormattedMessage
id='status.quote_error.pending_approval_popout.title'
defaultMessage='Pending quote? Remain calm'
/>
</h6>
<p>
<FormattedMessage
id='status.quote_error.pending_approval_popout.body'
defaultMessage='Quotes shared across the Fediverse may take time to display, as different servers have different protocols.'
/>
</p>
</LearnMoreLink>
</>
);
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
} else if (
!status ||
!quotedStatusId ||
quoteState === 'deleted' ||
quoteState === 'rejected' ||
quoteState === 'revoked' ||
quoteState === 'unauthorized'
) {
quoteError = (
<FormattedMessage
id='status.quote_error.rejected'
defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
/>
);
} else if (!status || !quotedStatusId) {
quoteError = (
<FormattedMessage
id='status.quote_error.not_found'
defaultMessage='This post cannot be displayed.'
id='status.quote_error.not_available'
defaultMessage='Post unavailable'
/>
);
}
@@ -168,7 +159,7 @@ export const QuotedStatus: React.FC<{
isQuotedPost
id={quotedStatusId}
contextType={contextType}
avatarSize={40}
avatarSize={32}
>
{canRenderChildQuote && (
<QuotedStatus

View File

@@ -2,8 +2,6 @@ import { useEffect, useState, useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import {
fetchContext,
completeContextRefresh,
@@ -22,11 +20,15 @@ const messages = defineMessages({
export const RefreshController: React.FC<{
statusId: string;
withBorder?: boolean;
}> = ({ statusId, withBorder }) => {
}> = ({ statusId }) => {
const refresh = useAppSelector(
(state) => state.contexts.refreshing[statusId],
);
const autoRefresh = useAppSelector(
(state) =>
!state.contexts.replies[statusId] ||
state.contexts.replies[statusId].length === 0,
);
const dispatch = useAppDispatch();
const intl = useIntl();
const [ready, setReady] = useState(false);
@@ -42,6 +44,11 @@ export const RefreshController: React.FC<{
dispatch(completeContextRefresh({ statusId }));
if (result.async_refresh.result_count > 0) {
if (autoRefresh) {
void dispatch(fetchContext({ statusId }));
return '';
}
setReady(true);
}
} else {
@@ -60,7 +67,7 @@ export const RefreshController: React.FC<{
return () => {
clearTimeout(timeoutId);
};
}, [dispatch, setReady, statusId, refresh]);
}, [dispatch, setReady, statusId, refresh, autoRefresh]);
const handleClick = useCallback(() => {
setLoading(true);
@@ -78,12 +85,7 @@ export const RefreshController: React.FC<{
if (ready && !loading) {
return (
<button
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
onClick={handleClick}
>
<button className='load-more load-gap' onClick={handleClick}>
<FormattedMessage
id='status.context.load_new_replies'
defaultMessage='New replies available'
@@ -98,9 +100,7 @@ export const RefreshController: React.FC<{
return (
<div
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
className='load-more load-gap'
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)}

View File

@@ -580,7 +580,6 @@ class Status extends ImmutablePureComponent {
remoteHint = (
<RefreshController
statusId={status.get('id')}
withBorder={!!descendants}
/>
);
}
@@ -653,8 +652,8 @@ class Status extends ImmutablePureComponent {
</div>
</Hotkeys>
{descendants}
{remoteHint}
{descendants}
</div>
</ScrollContainer>

View File

@@ -110,7 +110,7 @@
"announcement.announcement": "إعلان",
"annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "المتصفح الصامت",
"annual_report.summary.archetype.oracle": "حكيم",
"annual_report.summary.archetype.oracle": "الحكيم",
"annual_report.summary.archetype.pollster": "مستطلع للرأي",
"annual_report.summary.archetype.replier": "الفراشة الاجتماعية",
"annual_report.summary.followers.followers": "المُتابِعُون",
@@ -845,6 +845,7 @@
"status.bookmark": "أضفه إلى الفواصل المرجعية",
"status.cancel_reblog_private": "إلغاء إعادة النشر",
"status.cannot_reblog": "لا يمكن إعادة نشر هذا المنشور",
"status.context.load_new_replies": "الردود الجديدة المتاحة",
"status.continued_thread": "تكملة للخيط",
"status.copy": "انسخ رابط الرسالة",
"status.delete": "احذف",

View File

@@ -324,7 +324,7 @@
"empty_column.follow_requests": "Du har endnu ingen følgeanmodninger. Når du modtager én, vil den dukke op her.",
"empty_column.followed_tags": "Ingen hashtags følges endnu. Når det sker, vil de fremgå her.",
"empty_column.hashtag": "Der er intet med dette hashtag endnu.",
"empty_column.home": "Din hjemmetidslinje er tom! Følg nogle personer, for at fylde den op.",
"empty_column.home": "Din hjem-tidslinje er tom! Følg nogle personer, for at fylde den op.",
"empty_column.list": "Der er ikke noget på denne liste endnu. Når medlemmer af denne liste udgiver nye indlæg, vil de blive vist her.",
"empty_column.mutes": "Du har endnu ikke skjult nogle brugere.",
"empty_column.notification_requests": "Alt er klar! Der er intet her. Når der modtages nye notifikationer, fremgår de her jævnfør dine indstillinger.",
@@ -476,7 +476,7 @@
"keyboard_shortcuts.favourites": "Åbn favoritlisten",
"keyboard_shortcuts.federated": "Åbn fødereret tidslinje",
"keyboard_shortcuts.heading": "Tastaturgenveje",
"keyboard_shortcuts.home": "Åbn hjemmetidslinje",
"keyboard_shortcuts.home": "Åbn hjem-tidslinje",
"keyboard_shortcuts.hotkey": "Hurtigtast",
"keyboard_shortcuts.legend": "Vis dette symbol",
"keyboard_shortcuts.local": "Åbn lokal tidslinje",
@@ -518,7 +518,7 @@
"lists.done": "Færdig",
"lists.edit": "Redigér liste",
"lists.exclusive": "Skjul medlemmer i Hjem",
"lists.exclusive_hint": "Er nogen er på denne liste, skjul personen i hjemme-feeds for at undgå at se vedkommendes indlæg to gange.",
"lists.exclusive_hint": "Hvis nogen er på denne liste, skjul dem i hjem-feed for at undgå at se deres indlæg to gange.",
"lists.find_users_to_add": "Find brugere at tilføje",
"lists.list_members_count": "{count, plural, one {# medlem} other {# medlemmer}}",
"lists.list_name": "Listetitel",
@@ -792,7 +792,7 @@
"report.thanks.title": "Ønsker ikke at se dette?",
"report.thanks.title_actionable": "Tak for anmeldelsen, der vil blive set nærmere på dette.",
"report.unfollow": "Følg ikke længere @{name}",
"report.unfollow_explanation": "Du følger denne konto. For ikke længere at se vedkommendes indlæg i din hjemmestrøm, kan du stoppe med at følge dem.",
"report.unfollow_explanation": "Du følger denne konto. Hvis du ikke længere vil se vedkommendes indlæg i dit hjem-feed, så stop med at følge dem.",
"report_notification.attached_statuses": "{count, plural, one {{count} indlæg} other {{count} indlæg}} vedhæftet",
"report_notification.categories.legal": "Juridisk",
"report_notification.categories.legal_sentence": "ikke-tilladt indhold",

View File

@@ -43,7 +43,7 @@
"account.followers": "Follower",
"account.followers.empty": "Diesem Profil folgt noch niemand.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
"account.followers_you_know_counter": "{counter} Follower kennst Du",
"account.followers_you_know_counter": "{counter} bekannt",
"account.following": "Folge ich",
"account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
"account.follows.empty": "Dieses Profil folgt noch niemandem.",

View File

@@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "to translate a post",
"keyboard_shortcuts.unfocus": "Unfocus compose textarea/search",
"keyboard_shortcuts.up": "Move up in the list",
"learn_more_link.got_it": "Got it",
"learn_more_link.learn_more": "Learn more",
"lightbox.close": "Close",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
@@ -873,12 +875,11 @@
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.quote_error.filtered": "Hidden due to one of your filters",
"status.quote_error.not_found": "This post cannot be displayed.",
"status.quote_error.pending_approval": "This post is pending approval from the original author.",
"status.quote_error.rejected": "This post cannot be displayed as the original author does not allow it to be quoted.",
"status.quote_error.removed": "This post was removed by its author.",
"status.quote_error.unauthorized": "This post cannot be displayed as you are not authorized to view it.",
"status.quote_post_author": "Post by {name}",
"status.quote_error.not_available": "Post unavailable",
"status.quote_error.pending_approval": "Post pending",
"status.quote_error.pending_approval_popout.body": "Quotes shared across the Fediverse may take time to display, as different servers have different protocols.",
"status.quote_error.pending_approval_popout.title": "Pending quote? Remain calm",
"status.quote_post_author": "Quoted a post by @{name}",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",

View File

@@ -845,6 +845,8 @@
"status.bookmark": "Järjehoidja",
"status.cancel_reblog_private": "Lõpeta jagamine",
"status.cannot_reblog": "Seda postitust ei saa jagada",
"status.context.load_new_replies": "Leidub uusi vastuseid",
"status.context.loading": "Kontrollin täiendavate vastuste olemasolu",
"status.continued_thread": "Jätkatud lõim",
"status.copy": "Kopeeri postituse link",
"status.delete": "Kustuta",

View File

@@ -235,7 +235,7 @@
"confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
"confirmations.logout.title": "خروج؟",
"confirmations.missing_alt_text.confirm": "متن جایگزین را اضافه کنید",
"confirmations.missing_alt_text.message": "پست شما حاوی رسانه بدون متن جایگزین است. افزودن توضیحات کمک می کند تا محتوای شما برای افراد بیشتری قابل دسترسی باشد.",
"confirmations.missing_alt_text.message": "فرسته‌تان رسانه‌هایی بدون متن جایگزین دارد. افزودن شرح به دسترس‌پذیر شدن محتوایتان برای افراد بیشتری کمک می‌کند.",
"confirmations.missing_alt_text.secondary": "به هر حال پست کن",
"confirmations.missing_alt_text.title": "متن جایگزین اضافه شود؟",
"confirmations.mute.confirm": "خموش",
@@ -424,7 +424,7 @@
"hints.profiles.see_more_followers": "دیدن پی‌گیرندگان بیش‌تر روی {domain}",
"hints.profiles.see_more_follows": "دیدن پی‌گرفته‌های بیش‌تر روی {domain}",
"hints.profiles.see_more_posts": "دیدن فرسته‌های بیش‌تر روی {domain}",
"home.column_settings.show_quotes": "نمایش نقل‌قول‌ها",
"home.column_settings.show_quotes": "نمایش نقل‌ها",
"home.column_settings.show_reblogs": "نمایش تقویت‌ها",
"home.column_settings.show_replies": "نمایش پاسخ‌ها",
"home.hide_announcements": "نهفتن اعلامیه‌ها",
@@ -845,6 +845,8 @@
"status.bookmark": "نشانک",
"status.cancel_reblog_private": "ناتقویت",
"status.cannot_reblog": "این فرسته قابل تقویت نیست",
"status.context.load_new_replies": "پاسخ‌های جدیدی موجودند",
"status.context.loading": "بررسی کردن برای پاسخ‌های بیش‌تر",
"status.continued_thread": "رشتهٔ دنباله دار",
"status.copy": "رونوشت از پیوند فرسته",
"status.delete": "حذف",
@@ -873,7 +875,7 @@
"status.quote_error.filtered": "نهفته بنا بر یکی از پالایه‌هایتان",
"status.quote_error.not_found": "این فرسته قابل نمایش نیست.",
"status.quote_error.pending_approval": "این فرسته منظر تأیید نگارندهٔ اصلی است.",
"status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی فرسته اجازهٔ نقل قولش را نمی‌دهد این فرسته قابل نمایش نیست.",
"status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی این فرسته اجازهٔ نقلش را نمی‌دهد قابل نمایش نیست.",
"status.quote_error.removed": "این فرسته به دست نگارنده‌اش برداشته شده.",
"status.quote_error.unauthorized": "از آن‌جا که اجازهٔ دیدن این فرسته را ندارید قابل نمایش نیست.",
"status.quote_post_author": "فرسته توسط {name}",

View File

@@ -845,6 +845,8 @@
"status.bookmark": "Blêdwizer tafoegje",
"status.cancel_reblog_private": "Net langer booste",
"status.cannot_reblog": "Dit berjocht kin net boost wurde",
"status.context.load_new_replies": "Nije reaksjes beskikber",
"status.context.loading": "Op nije reaksjes oan it kontrolearjen",
"status.continued_thread": "Ferfolgje it petear",
"status.copy": "Copy link to status",
"status.delete": "Fuortsmite",

View File

@@ -346,7 +346,7 @@
"featured_carousel.post": "הודעה",
"featured_carousel.previous": "הקודם",
"featured_carousel.slide": "{index} מתוך {total}",
"filter_modal.added.context_mismatch_explanation": "קטגוריית המסנן הזאת לא חלה על ההקשר שממנו הגעת אל ההודעה הזו. אם תרצה/י שההודעה תסונן גם בהקשר זה, תצטרך/י לערוך את הסנן.",
"filter_modal.added.context_mismatch_explanation": "קטגוריית הסנן הזאת לא חלה על ההקשר שממנו הגעת אל ההודעה הזו. אם תרצה/י שההודעה תסונן גם בהקשר זה, תצטרך/י לערוך את הסנן.",
"filter_modal.added.context_mismatch_title": "אין התאמה להקשר!",
"filter_modal.added.expired_explanation": "פג תוקפה של קטגוריית הסינון הזו, יש צורך לשנות את תאריך התפוגה כדי שהסינון יוחל.",
"filter_modal.added.expired_title": "פג תוקף המסנן!",

View File

@@ -513,7 +513,7 @@
"lists.add_to_lists": "리스트에 {name} 추가",
"lists.create": "생성",
"lists.create_a_list_to_organize": "새 리스트를 만들어 홈 피드를 정리하세요",
"lists.create_list": "리스트 생성",
"lists.create_list": "리스트 만들기",
"lists.delete": "리스트 삭제",
"lists.done": "완료",
"lists.edit": "리스트 편집",

View File

@@ -292,6 +292,7 @@
"emoji_button.search_results": "Paieškos rezultatai",
"emoji_button.symbols": "Simboliai",
"emoji_button.travel": "Kelionės ir vietos",
"empty_column.account_featured_other.unknown": "Ši paskyra dar nieko neparodė.",
"empty_column.account_hides_collections": "Šis (-i) naudotojas (-a) pasirinko nepadaryti šią informaciją prieinamą.",
"empty_column.account_suspended": "Paskyra pristabdyta.",
"empty_column.account_timeline": "Nėra čia įrašų.",
@@ -794,6 +795,8 @@
"status.bookmark": "Pridėti į žymės",
"status.cancel_reblog_private": "Nebepasidalinti",
"status.cannot_reblog": "Šis įrašas negali būti pakeltas.",
"status.context.load_new_replies": "Yra naujų atsakymų",
"status.context.loading": "Tikrinama dėl daugiau atsakymų",
"status.continued_thread": "Tęsiama gijoje",
"status.copy": "Kopijuoti nuorodą į įrašą",
"status.delete": "Ištrinti",

View File

@@ -845,6 +845,8 @@
"status.bookmark": "冊籤",
"status.cancel_reblog_private": "取消轉送",
"status.cannot_reblog": "Tsit篇PO文bē當轉送",
"status.context.load_new_replies": "有新ê回應",
"status.context.loading": "Leh檢查其他ê回應",
"status.continued_thread": "接續ê討論線",
"status.copy": "Khóo-pih PO文ê連結",
"status.delete": "Thâi掉",

View File

@@ -224,6 +224,8 @@
"confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas ao post sendo editado.",
"confirmations.discard_draft.edit.title": "Descartar mudanças no seu post?",
"confirmations.discard_draft.post.cancel": "Continuar rascunho",
"confirmations.discard_draft.post.message": "Continuar eliminará a publicação que está sendo elaborada no momento.",
"confirmations.discard_draft.post.title": "Eliminar seu esboço de publicação?",
"confirmations.discard_edit_media.confirm": "Descartar",
"confirmations.discard_edit_media.message": "Há mudanças não salvas na descrição ou pré-visualização da mídia. Descartar assim mesmo?",
"confirmations.follow_to_list.confirm": "Seguir e adicionar à lista",
@@ -333,9 +335,13 @@
"errors.unexpected_crash.copy_stacktrace": "Copiar dados do erro para área de transferência",
"errors.unexpected_crash.report_issue": "Reportar problema",
"explore.suggested_follows": "Pessoas",
"explore.title": "Em alta",
"explore.trending_links": "Notícias",
"explore.trending_statuses": "Publicações",
"explore.trending_tags": "Hashtags",
"featured_carousel.header": "{count, plural, one {Postagem fixada} other {Postagens fixadas}}",
"featured_carousel.next": "Próximo",
"featured_carousel.previous": "Anterior",
"filter_modal.added.context_mismatch_explanation": "Esta categoria de filtro não se aplica ao contexto no qual você acessou esta publicação. Se quiser que a publicação seja filtrada nesse contexto também, você terá que editar o filtro.",
"filter_modal.added.context_mismatch_title": "Incompatibilidade de contexto!",
"filter_modal.added.expired_explanation": "Esta categoria de filtro expirou, você precisará alterar a data de expiração para aplicar.",
@@ -550,6 +556,7 @@
"navigation_bar.lists": "Listas",
"navigation_bar.logout": "Sair",
"navigation_bar.moderation": "Moderação",
"navigation_bar.more": "Mais",
"navigation_bar.mutes": "Usuários silenciados",
"navigation_bar.opened_in_classic_interface": "Publicações, contas e outras páginas específicas são abertas por padrão na interface 'web' clássica.",
"navigation_bar.preferences": "Preferências",

View File

@@ -27,6 +27,8 @@
"account.edit_profile": "Upraviť profil",
"account.enable_notifications": "Zapnúť upozornenia na príspevky od @{name}",
"account.endorse": "Zobraziť na vlastnom profile",
"account.familiar_followers_one": "Nasledovanie od {name1}",
"account.familiar_followers_two": "Nasledovanie od {name1} a {name2}",
"account.featured": "Zviditeľnené",
"account.featured.accounts": "Profily",
"account.featured.hashtags": "Hashtagy",

View File

@@ -845,6 +845,8 @@
"status.bookmark": "Bokmärk",
"status.cancel_reblog_private": "Sluta boosta",
"status.cannot_reblog": "Detta inlägg kan inte boostas",
"status.context.load_new_replies": "Nya svar finns",
"status.context.loading": "Letar efter fler svar",
"status.continued_thread": "Fortsatt tråd",
"status.copy": "Kopiera inläggslänk",
"status.delete": "Radera",

View File

@@ -845,6 +845,8 @@
"status.bookmark": "Yer işareti ekle",
"status.cancel_reblog_private": "Yeniden paylaşımı geri al",
"status.cannot_reblog": "Bu gönderi yeniden paylaşılamaz",
"status.context.load_new_replies": "Yeni yanıtlar mevcut",
"status.context.loading": "Daha fazla yanıt için kontrol ediliyor",
"status.continued_thread": "Devam eden akış",
"status.copy": "Gönderi bağlantısını kopyala",
"status.delete": "Sil",

View File

@@ -301,6 +301,9 @@
"emoji_button.search_results": "搜索结果",
"emoji_button.symbols": "符号",
"emoji_button.travel": "旅行与地点",
"empty_column.account_featured.me": "你尚未设置任何精选。你知道吗?你也可以将自己最常使用的话题标签,甚至是好友的帐号,在你的个人主页上设为精选。",
"empty_column.account_featured.other": "{acct} 尚未设置任何精选。你知道吗?你也可以将自己最常使用的话题标签,甚至是好友的帐号,在你的个人主页上设为精选。",
"empty_column.account_featured_other.unknown": "该用户尚未设置任何精选。",
"empty_column.account_hides_collections": "该用户选择不公开此信息",
"empty_column.account_suspended": "账号已被停用",
"empty_column.account_timeline": "这里没有嘟文!",
@@ -402,8 +405,10 @@
"hashtag.counter_by_accounts": "{count, plural,other {{counter} 人讨论}}",
"hashtag.counter_by_uses": "{count, plural, other {{counter} 条嘟文}}",
"hashtag.counter_by_uses_today": "今日 {count, plural, other {{counter} 条嘟文}}",
"hashtag.feature": "设为精选",
"hashtag.follow": "关注话题",
"hashtag.mute": "停止提醒 #{hashtag}",
"hashtag.unfeature": "取消精选",
"hashtag.unfollow": "取消关注话题",
"hashtags.and_other": "… 和另外 {count, plural, other {# 个话题}}",
"hints.profiles.followers_may_be_missing": "该账号的关注者列表可能没有完全显示。",
@@ -558,6 +563,7 @@
"navigation_bar.preferences": "偏好设置",
"navigation_bar.privacy_and_reach": "隐私与可达性",
"navigation_bar.search": "搜索",
"navigation_bar.search_trends": "搜索/热门趋势",
"navigation_panel.collapse_lists": "收起菜单列表",
"navigation_panel.expand_lists": "展开菜单列表",
"not_signed_in_indicator.not_signed_in": "你需要登录才能访问此资源。",
@@ -786,6 +792,7 @@
"report_notification.categories.violation": "违反规则",
"report_notification.categories.violation_sentence": "违反规则",
"report_notification.open": "打开举报",
"search.clear": "清空搜索内容",
"search.no_recent_searches": "无最近搜索",
"search.placeholder": "搜索",
"search.quick_action.account_search": "包含 {x} 的账号",
@@ -827,6 +834,8 @@
"status.bookmark": "添加到书签",
"status.cancel_reblog_private": "取消转嘟",
"status.cannot_reblog": "不能转嘟这条嘟文",
"status.context.load_new_replies": "有新回复",
"status.context.loading": "正在检查更多回复",
"status.continued_thread": "上接嘟文串",
"status.copy": "复制嘟文链接",
"status.delete": "删除",
@@ -854,8 +863,10 @@
"status.pin": "在个人资料页面置顶",
"status.quote_error.filtered": "已根据你的筛选器过滤",
"status.quote_error.not_found": "无法显示这篇贴文。",
"status.quote_error.pending_approval": "此嘟文正在等待原作者批准。",
"status.quote_error.rejected": "由于原作者不允许引用转发,无法显示这篇贴文。",
"status.quote_error.removed": "该帖子已被作者删除。",
"status.quote_error.unauthorized": "你无权查看此嘟文,因此无法显示。",
"status.quote_post_author": "{name} 的嘟文",
"status.read_more": "查看更多",
"status.reblog": "转嘟",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM477-161q45 0 86-11.5t77-33.5l-91-91q-38 17-74.5 47.5T412-168q16 3 32 5t33 2Zm-124-25q35-72 79.5-107t67.5-47q-29-9-58.5-14.5T380-360q-45 0-89 11t-85 31q26 43 63.5 77.5T353-186Zm461-74L690-384q31-10 50.5-36t19.5-60q0-42-29-71t-71-29q-34 0-60 19.5T564-510l-44-44q2-61-41-104.5T374-700L260-814q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM380-420q11 0 20.5-1.5T420-426L246-600q-3 10-4.5 19.5T240-560q0 58 41 99t99 41Z"/></svg>

After

Width:  |  Height:  |  Size: 698 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM412-168q26-51 62-81.5t75-47.5L204-642q-21 36-32.5 76.5T160-480q0 45 11.5 86t34.5 76q41-20 85-31t89-11q32 0 61.5 5.5T500-340q-23 12-43.5 28T418-278q-12-2-20.5-2H380q-32 0-63.5 7T256-252q32 32 71.5 53.5T412-168Zm402-92-58-58q21-35 32.5-76t11.5-86q0-134-93-227t-227-93q-45 0-85.5 11.5T318-756l-58-58q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM520-554 374-700q62-2 105 41.5T520-554ZM380-420q-58 0-99-41t-41-99q0-33 14.5-60.5T292-668l196 196q-20 23-47.5 37.5T380-420Zm310 36L564-510q10-31 36-50.5t60-19.5q42 0 71 29t29 71q0 34-19.5 60T690-384ZM537-537ZM423-423Z"/></svg>

After

Width:  |  Height:  |  Size: 843 B

View File

@@ -12,6 +12,8 @@ body {
--background-color: #fff;
--background-color-tint: rgba(255, 255, 255, 80%);
--background-filter: blur(10px);
--surface-variant-background-color: #f1ebfb;
--surface-border-color: #cac4d0;
--on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.65)};
--rich-text-container-color: rgba(255, 216, 231, 100%);
--rich-text-text-color: rgba(114, 47, 83, 100%);

View File

@@ -1433,10 +1433,6 @@ body > [data-popper-placement] {
}
}
.status--has-quote .quote-inline {
display: none;
}
.status {
padding: 16px;
min-height: 54px;
@@ -1470,10 +1466,6 @@ body > [data-popper-placement] {
margin-top: 16px;
}
&--is-quote {
border: none;
}
&--in-thread {
--thread-margin: calc(46px + 8px);
@@ -1860,79 +1852,99 @@ body > [data-popper-placement] {
// --status-gutter-width is currently only set inside of
// .notification-ungrouped, so everywhere else this will fall back
// to the pixel values
--quote-margin: var(--status-gutter-width, 36px);
--quote-margin: var(--status-gutter-width);
position: relative;
margin-block-start: 16px;
margin-inline-start: calc(var(--quote-margin) + var(--thread-margin, 0px));
border-radius: 8px;
border-radius: 12px;
color: var(--nested-card-text);
background: var(--nested-card-background);
border: var(--nested-card-border);
@container (width > 460px) {
--quote-margin: var(--status-gutter-width, 56px);
}
border: 1px solid var(--surface-border-color);
}
.status__quote--error {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
font-size: 15px;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
min-height: 56px;
.link-button {
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
}
}
.status__quote-author-button {
position: relative;
overflow: hidden;
display: inline-flex;
width: auto;
margin-block-start: 10px;
padding: 5px 12px;
display: flex;
margin-top: 8px;
padding: 8px 12px;
align-items: center;
gap: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: normal;
letter-spacing: 0;
text-decoration: none;
color: $highlight-text-color;
background: var(--nested-card-background);
border: var(--nested-card-border);
border-radius: 4px;
&:active,
&:focus,
&:hover {
border-color: lighten($highlight-text-color, 4%);
color: lighten($highlight-text-color, 4%);
font-weight: 400;
line-height: 20px;
letter-spacing: 0.25px;
color: $darker-text-color;
background: var(--surface-variant-background-color);
border-radius: 8px;
cursor: default;
}
&:focus-visible {
outline: $ui-button-icon-focus-outline;
.status--is-quote {
border: none;
padding: 12px;
.status__info {
padding-bottom: 8px;
}
.display-name,
.status__relative-time {
font-size: 14px;
line-height: 20px;
letter-spacing: 0.1px;
}
.display-name__account {
font-size: 12px;
line-height: 16px;
letter-spacing: 0.5px;
}
.status__content {
display: -webkit-box;
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
p {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
}
.status__quote-icon {
position: absolute;
inset-block-start: 18px;
inset-inline-start: -40px;
display: block;
width: 26px;
height: 26px;
padding: 5px;
color: #6a49ba;
z-index: 10;
.status__quote--error & {
inset-block-start: 50%;
transform: translateY(-50%);
}
@container (width > 460px) {
inset-inline-start: -50px;
.media-gallery,
.video-player,
.audio-player,
.attachment-list,
.poll {
margin-top: 8px;
}
}
@@ -2152,6 +2164,27 @@ body > [data-popper-placement] {
}
}
.learn-more__popout {
gap: 8px;
&__content {
display: flex;
flex-direction: column;
gap: 4px;
}
h6 {
font-size: inherit;
font-weight: 500;
line-height: inherit;
letter-spacing: 0.1px;
}
.link-button {
font-weight: 500;
}
}
.account__wrapper {
display: flex;
gap: 10px;

View File

@@ -16,6 +16,7 @@
--surface-background-color: #{darken($ui-base-color, 4%)};
--surface-variant-background-color: #{$ui-base-color};
--surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
--surface-border-color: #{lighten($ui-base-color, 8%)};
--on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)};
--avatar-border-radius: 8px;
--media-outline-color: #{rgba(#fcf8ff, 0.15)};

View File

@@ -206,8 +206,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@quote.save
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth])
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end

View File

@@ -65,4 +65,16 @@ class Admin::Metrics::Dimension::BaseDimension
def canonicalized_params
params.to_h.to_a.sort_by { |k, _v| k.to_s }.map { |k, v| "#{k}=#{v}" }.join(';')
end
def earliest_status_id
snowflake_id(@start_at.beginning_of_day)
end
def latest_status_id
snowflake_id(@end_at.end_of_day)
end
def snowflake_id(datetime)
Mastodon::Snowflake.id_at(datetime, with_random: false)
end
end

View File

@@ -19,7 +19,7 @@ class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Di
end
def sql_array
[sql_query_string, { domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
[sql_query_string, { domain: params[:domain], earliest_status_id:, latest_status_id:, limit: @limit }]
end
def sql_query_string
@@ -36,14 +36,6 @@ class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Di
SQL
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def params
@params.permit(:domain)
end

View File

@@ -14,7 +14,7 @@ class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::B
end
def sql_array
[sql_query_string, { earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
[sql_query_string, { earliest_status_id:, latest_status_id:, limit: @limit }]
end
def sql_query_string
@@ -28,12 +28,4 @@ class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::B
LIMIT :limit
SQL
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
end

View File

@@ -40,7 +40,7 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension
def media_size
value = [
MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')),
MediaAttachment.sum(MediaAttachment.combined_media_file_size),
CustomEmoji.sum(:image_file_size),
PreviewCard.sum(:image_file_size),
Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')),

View File

@@ -19,7 +19,7 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
end
def sql_array
[sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
[sql_query_string, { tag_id: tag_id, earliest_status_id:, latest_status_id:, limit: @limit }]
end
def sql_query_string
@@ -39,14 +39,6 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
params[:id]
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def params
@params.permit(:id)
end

View File

@@ -18,7 +18,7 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
end
def sql_array
[sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
[sql_query_string, { tag_id: tag_id, earliest_status_id:, latest_status_id:, limit: @limit }]
end
def sql_query_string
@@ -39,14 +39,6 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
params[:id]
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def params
@params.permit(:id)
end

View File

@@ -104,4 +104,16 @@ class Admin::Metrics::Measure::BaseMeasure
def canonicalized_params
params.to_h.to_a.sort_by { |k, _v| k.to_s }.map { |k, v| "#{k}=#{v}" }.join(';')
end
def earliest_status_id
snowflake_id(@start_at.beginning_of_day)
end
def latest_status_id
snowflake_id(@end_at.end_of_day)
end
def snowflake_id(datetime)
Mastodon::Snowflake.id_at(datetime, with_random: false)
end
end

View File

@@ -29,7 +29,7 @@ class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics:
def perform_total_query
domain = params[:domain]
domain = Instance.by_domain_and_subdomains(params[:domain]).select(:domain) if params[:include_subdomains]
MediaAttachment.joins(:account).merge(Account.where(domain: domain)).sum('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')
MediaAttachment.joins(:account).merge(Account.where(domain: domain)).sum(MediaAttachment.combined_media_file_size)
end
def perform_previous_total_query
@@ -44,7 +44,7 @@ class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics:
<<~SQL.squish
SELECT axis.*, (
WITH new_media_attachments AS (
SELECT COALESCE(media_attachments.file_file_size, 0) + COALESCE(media_attachments.thumbnail_file_size, 0) AS size
SELECT #{media_size_total} AS size
FROM media_attachments
INNER JOIN accounts ON accounts.id = media_attachments.account_id
WHERE date_trunc('day', media_attachments.created_at)::date = axis.period
@@ -58,6 +58,10 @@ class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics:
SQL
end
def media_size_total
MediaAttachment.combined_media_file_size.to_sql
end
def params
@params.permit(:domain, :include_subdomains)
end

View File

@@ -28,7 +28,7 @@ class Admin::Metrics::Measure::InstanceStatusesMeasure < Admin::Metrics::Measure
end
def sql_array
[sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id }]
[sql_query_string, { start_at: @start_at, end_at: @end_at, domain: params[:domain], earliest_status_id:, latest_status_id: }]
end
def sql_query_string
@@ -50,14 +50,6 @@ class Admin::Metrics::Measure::InstanceStatusesMeasure < Admin::Metrics::Measure
SQL
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def params
@params.permit(:domain, :include_subdomains)
end

View File

@@ -22,7 +22,7 @@ class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::Base
end
def sql_array
[sql_query_string, { start_at: @start_at, end_at: @end_at, tag_id: tag.id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id }]
[sql_query_string, { start_at: @start_at, end_at: @end_at, tag_id: tag.id, earliest_status_id:, latest_status_id: }]
end
def sql_query_string
@@ -45,14 +45,6 @@ class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::Base
SQL
end
def earliest_status_id
Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def tag
@tag ||= Tag.find(params[:id])
end

View File

@@ -33,9 +33,7 @@ class Antispam
end
def local_preflight_check!(status)
return unless spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
return unless suspicious_reply_or_mention?(status)
return unless status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago
return unless considered_spam?(status)
report_if_needed!(status.account)
@@ -44,10 +42,26 @@ class Antispam
private
def considered_spam?(status)
(all_time_suspicious?(status) || recent_suspicious?(status)) && suspicious_reply_or_mention?(status)
end
def all_time_suspicious?(status)
all_time_spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
end
def recent_suspicious?(status)
status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago && spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
end
def spammy_texts
redis.smembers('antispam:spammy_texts')
end
def all_time_spammy_texts
redis.smembers('antispam:all_time_spammy_texts')
end
def suspicious_reply_or_mention?(status)
parent = status.thread
return true if parent.present? && !Follow.exists?(account_id: parent.account_id, target_account: status.account_id)

View File

@@ -30,8 +30,10 @@ class StatusReachFinder
[
replied_to_account_id,
reblog_of_account_id,
quote_of_account_id,
mentioned_account_ids,
reblogs_account_ids,
quotes_account_ids,
favourites_account_ids,
replies_account_ids,
].tap do |arr|
@@ -46,6 +48,10 @@ class StatusReachFinder
@status.in_reply_to_account_id if distributable?
end
def quote_of_account_id
@status.quote&.quoted_account_id
end
def reblog_of_account_id
@status.reblog.account_id if @status.reblog?
end
@@ -54,6 +60,11 @@ class StatusReachFinder
@status.mentions.pluck(:account_id)
end
# Beware: Quotes can be created without the author having had access to the status
def quotes_account_ids
@status.quotes.pluck(:account_id) if distributable? || unsafe?
end
# Beware: Reblogs can be created without the author having had access to the status
def reblogs_account_ids
@status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).pluck(:account_id) if distributable? || unsafe?

View File

@@ -84,22 +84,18 @@ class Webfinger
def body_from_host_meta
host_meta_request.perform do |res|
if res.code == 200
raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}" unless res.code == 200
body_from_webfinger(url_from_template(res.body_with_limit), use_fallback: false)
else
raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
end
end
end
def url_from_template(str)
link = Nokogiri::XML(str).at_xpath('//xmlns:Link[@rel="lrdd"]')
if link.present?
raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger" if link.blank?
link['template'].gsub('{uri}', @uri)
else
raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger"
end
rescue Nokogiri::XML::XPath::SyntaxError
raise Webfinger::Error, "Invalid XML encountered in host-meta for #{@uri}"
end

View File

@@ -54,11 +54,9 @@ class WebfingerResource
end
def username_from_acct
if domain_matches_local?
raise ActiveRecord::RecordNotFound unless domain_matches_local?
local_username
else
raise ActiveRecord::RecordNotFound
end
end
def split_acct

View File

@@ -74,7 +74,7 @@ class AccountMigration < ApplicationRecord
errors.add(:acct, I18n.t('migrations.errors.not_found'))
else
errors.add(:acct, I18n.t('migrations.errors.missing_also_known_as')) unless target_account.also_known_as.include?(ActivityPub::TagManager.instance.uri_for(account))
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id
end
end

View File

@@ -26,6 +26,7 @@ class AccountSuggestions::FriendsOfFriendsSource < AccountSuggestions::Source
AND NOT EXISTS (SELECT 1 FROM mutes m WHERE m.target_account_id = follows.target_account_id AND m.account_id = :id)
AND (accounts.domain IS NULL OR NOT EXISTS (SELECT 1 FROM account_domain_blocks b WHERE b.account_id = :id AND b.domain = accounts.domain))
AND NOT EXISTS (SELECT 1 FROM follows f WHERE f.target_account_id = follows.target_account_id AND f.account_id = :id)
AND NOT EXISTS (SELECT 1 FROM follow_requests f WHERE f.target_account_id = follows.target_account_id AND f.account_id = :id)
AND follows.target_account_id <> :id
AND accounts.discoverable
AND accounts.suspended_at IS NULL

View File

@@ -77,6 +77,9 @@ class Admin::ActionLogFilter
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
create_username_block: { target_type: 'UsernameBlock', action: 'create' }.freeze,
update_username_block: { target_type: 'UsernameBlock', action: 'update' }.freeze,
destroy_username_block: { target_type: 'UsernameBlock', action: 'destroy' }.freeze,
}.freeze
attr_reader :params

View File

@@ -14,7 +14,7 @@
#
class AnnouncementReaction < ApplicationRecord
before_validation :set_custom_emoji
before_validation :set_custom_emoji, if: :name?
after_commit :queue_publish
belongs_to :account
@@ -27,7 +27,7 @@ class AnnouncementReaction < ApplicationRecord
private
def set_custom_emoji
self.custom_emoji = CustomEmoji.local.enabled.find_by(shortcode: name) if name.present?
self.custom_emoji = CustomEmoji.local.enabled.find_by(shortcode: name)
end
def queue_publish

View File

@@ -3,12 +3,8 @@
module RateLimitable
extend ActiveSupport::Concern
def rate_limit=(value)
@rate_limit = value
end
def rate_limit?
@rate_limit
included do
attribute :rate_limit, :boolean, default: false
end
def rate_limiter(by, options = {})

View File

@@ -33,7 +33,7 @@ module Status::FetchRepliesConcern
def should_fetch_replies?
# we aren't brand new, and we haven't fetched replies since the debounce window
!local? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
!local? && distributable? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
fetched_replies_at.nil? || fetched_replies_at <= FETCH_REPLIES_COOLDOWN_MINUTES.ago
)
end

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
module User::Activity
extend ActiveSupport::Concern
# The home and list feeds will be stored for this amount of time, and status
# fan-out to followers will include only people active within this time frame.
#
# Lowering the duration may improve performance if many people sign up, but
# most will not check their feed every day. Raising the duration reduces the
# amount of background processing that happens when people become active.
ACTIVE_DURATION = ENV.fetch('USER_ACTIVE_DAYS', 7).to_i.days
included do
scope :signed_in_recently, -> { where(current_sign_in_at: ACTIVE_DURATION.ago..) }
scope :not_signed_in_recently, -> { where(current_sign_in_at: ...ACTIVE_DURATION.ago) }
end
def signed_in_recently?
current_sign_in_at.present? && current_sign_in_at >= ACTIVE_DURATION.ago
end
private
def inactive_since_duration?
last_sign_in_at < ACTIVE_DURATION.ago
end
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
module User::Confirmation
extend ActiveSupport::Concern
included do
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :unconfirmed, -> { where(confirmed_at: nil) }
def confirm
wrap_email_confirmation { super }
end
end
def confirmed?
confirmed_at.present?
end
def unconfirmed?
!confirmed?
end
end

View File

@@ -37,7 +37,7 @@ class FollowRequest < ApplicationRecord
if account.local?
ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id)
MergeWorker.perform_async(target_account.id, account.id, 'home')
MergeWorker.push_bulk(List.where(account: account).joins(:list_accounts).where(list_accounts: { account_id: target_account.id }).pluck(:id)) do |list_id|
MergeWorker.push_bulk(account.owned_lists.with_list_account(target_account).pluck(:id)) do |list_id|
[target_account.id, list_id, 'list']
end
end

View File

@@ -40,7 +40,7 @@ class Form::Redirect
if target_account.nil?
errors.add(:acct, I18n.t('migrations.errors.not_found'))
else
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id
end
end

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
class Form::UsernameBlockBatch < Form::BaseBatch
attr_accessor :username_block_ids
def save
case action
when 'delete'
delete!
end
end
private
def username_blocks
@username_blocks ||= UsernameBlock.where(id: username_block_ids)
end
def delete!
verify_authorization(:destroy?)
username_blocks.each do |username_block|
username_block.destroy
log_action :destroy, username_block
end
end
def verify_authorization(permission)
username_blocks.each { |username_block| authorize(username_block, permission) }
end
end

View File

@@ -32,6 +32,8 @@ class List < ApplicationRecord
before_destroy :clean_feed_manager
scope :with_list_account, ->(account) { joins(:list_accounts).where(list_accounts: { account: }) }
private
def validate_account_lists_limit

View File

@@ -298,6 +298,10 @@ class MediaAttachment < ApplicationRecord
IMAGE_FILE_EXTENSIONS + VIDEO_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS
end
def combined_media_file_size
arel_table.coalesce(arel_table[:file_file_size], 0) + arel_table.coalesce(arel_table[:thumbnail_file_size], 0)
end
private
def file_styles(attachment)

View File

@@ -170,10 +170,9 @@ class PreviewCard < ApplicationRecord
private
def serialized_authors
if author_name? || author_url? || author_account_id?
PreviewCard::Author
.new(self)
end
return unless author_name? || author_url? || author_account_id?
PreviewCard::Author.new(self)
end
def extract_dimensions

View File

@@ -80,6 +80,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
has_many :quotes, foreign_key: 'quoted_status_id', inverse_of: :quoted_status, dependent: :nullify
# The `dependent` option is enabled by the initial `mentions` association declaration
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status # rubocop:disable Rails/HasManyOrHasOneDependent

View File

@@ -164,9 +164,10 @@ class Tag < ApplicationRecord
end
def validate_display_name_change
unless HashtagNormalizer.new.normalize(display_name).casecmp(name).zero?
errors.add(:display_name,
I18n.t('tags.does_not_match_previous_name'))
end
errors.add(:display_name, I18n.t('tags.does_not_match_previous_name')) unless display_name_matches_name?
end
def display_name_matches_name?
HashtagNormalizer.new.normalize(display_name).casecmp(name).zero?
end
end

View File

@@ -58,20 +58,13 @@ class User < ApplicationRecord
include LanguagesHelper
include Redisable
include User::Activity
include User::Confirmation
include User::HasSettings
include User::LdapAuthenticable
include User::Omniauthable
include User::PamAuthenticable
# The home and list feeds will be stored in Redis for this amount
# of time, and status fan-out to followers will include only people
# within this time frame. Lowering the duration may improve performance
# if lots of people sign up, but not a lot of them check their feed
# every day. Raising the duration reduces the amount of expensive
# RegenerationWorker jobs that need to be run when those people come
# to check their feed
ACTIVE_DURATION = ENV.fetch('USER_ACTIVE_DAYS', 7).to_i.days.freeze
devise :two_factor_authenticatable,
otp_secret_length: 32
@@ -118,13 +111,9 @@ class User < ApplicationRecord
scope :recent, -> { order(id: :desc) }
scope :pending, -> { where(approved: false) }
scope :approved, -> { where(approved: true) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :unconfirmed, -> { where(confirmed_at: nil) }
scope :enabled, -> { where(disabled: false) }
scope :disabled, -> { where(disabled: true) }
scope :active, -> { confirmed.signed_in_recently.account_not_suspended }
scope :signed_in_recently, -> { where(current_sign_in_at: ACTIVE_DURATION.ago..) }
scope :not_signed_in_recently, -> { where(current_sign_in_at: ...ACTIVE_DURATION.ago) }
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
scope :matches_ip, ->(value) { left_joins(:ips).merge(IpBlock.contained_by(value)).group(users: [:id]) }
@@ -143,7 +132,10 @@ class User < ApplicationRecord
delegate :can?, to: :role
attr_reader :invite_code, :date_of_birth
attr_writer :external, :bypass_registration_checks, :current_account
attr_writer :current_account
attribute :external, :boolean, default: false
attribute :bypass_registration_checks, :boolean, default: false
def self.those_who_can(*any_of_privileges)
matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id)
@@ -178,14 +170,6 @@ class User < ApplicationRecord
end
end
def signed_in_recently?
current_sign_in_at.present? && current_sign_in_at >= ACTIVE_DURATION.ago
end
def confirmed?
confirmed_at.present?
end
def invited?
invite_id.present?
end
@@ -210,12 +194,6 @@ class User < ApplicationRecord
account_id
end
def confirm
wrap_email_confirmation do
super
end
end
# Mark current email as confirmed, bypassing Devise
def mark_email_as_confirmed!
wrap_email_confirmation do
@@ -231,16 +209,11 @@ class User < ApplicationRecord
end
def update_sign_in!(new_sign_in: false)
old_current = current_sign_in_at
new_current = Time.now.utc
self.last_sign_in_at = old_current || new_current
self.last_sign_in_at = current_sign_in_at || new_current
self.current_sign_in_at = new_current
if new_sign_in
self.sign_in_count ||= 0
self.sign_in_count += 1
end
increment(:sign_in_count) if new_sign_in
save(validate: false) unless new_record?
prepare_returning_user!
@@ -262,10 +235,6 @@ class User < ApplicationRecord
confirmed? && approved? && !disabled? && !account.unavailable? && !account.memorial?
end
def unconfirmed?
!confirmed?
end
def unconfirmed_or_pending?
unconfirmed? || pending?
end
@@ -443,7 +412,7 @@ class User < ApplicationRecord
def set_approved
self.approved = begin
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval?
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval? || sign_up_username_requires_approval?
false
else
open_registrations? || valid_invitation? || external?
@@ -466,9 +435,11 @@ class User < ApplicationRecord
yield
if new_user
# Avoid extremely unlikely race condition when approving and confirming
# the user at the same time
after_confirmation_tasks if new_user
end
def after_confirmation_tasks
# Handle condition when approving and confirming a user at the same time
reload unless approved?
if approved?
@@ -477,7 +448,6 @@ class User < ApplicationRecord
notify_staff_about_pending_account!
end
end
end
def sign_up_from_ip_requires_approval?
sign_up_ip.present? && IpBlock.severity_sign_up_requires_approval.containing(sign_up_ip.to_s).exists?
@@ -498,18 +468,14 @@ class User < ApplicationRecord
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
end
def sign_up_username_requires_approval?
account.username? && UsernameBlock.matches?(account.username, allow_with_approval: true)
end
def open_registrations?
Setting.registrations_mode == 'open'
end
def external?
!!@external
end
def bypass_registration_checks?
@bypass_registration_checks
end
def sanitize_role
self.role = nil if role.present? && role.everyone?
end
@@ -526,7 +492,7 @@ class User < ApplicationRecord
return unless confirmed?
ActivityTracker.record('activity:logins', id)
regenerate_feed! if needs_feed_update?
regenerate_feed! if inactive_since_duration?
end
def notify_staff_about_pending_account!
@@ -539,15 +505,11 @@ class User < ApplicationRecord
def regenerate_feed!
home_feed = HomeFeed.new(account)
unless home_feed.regenerating?
return if home_feed.regenerating?
home_feed.regeneration_in_progress!
RegenerationWorker.perform_async(account_id)
end
end
def needs_feed_update?
last_sign_in_at < ACTIVE_DURATION.ago
end
def validate_email_dns?
email_changed? && !external? && !self.class.skip_mx_check?

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: username_blocks
#
# id :bigint(8) not null, primary key
# allow_with_approval :boolean default(FALSE), not null
# exact :boolean default(FALSE), not null
# normalized_username :string not null
# username :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class UsernameBlock < ApplicationRecord
HOMOGLYPHS = {
'1' => 'i',
'2' => 'z',
'3' => 'e',
'4' => 'a',
'5' => 's',
'7' => 't',
'8' => 'b',
'9' => 'g',
'0' => 'o',
}.freeze
validates :username, presence: true, uniqueness: true
scope :matches_exactly, ->(str) { where(exact: true).where(normalized_username: str) }
scope :matches_partially, ->(str) { where(exact: false).where(Arel::Nodes.build_quoted(str).matches(Arel::Nodes.build_quoted('%').concat(arel_table[:normalized_username]).concat(Arel::Nodes.build_quoted('%')))) }
before_save :set_normalized_username
def comparison
exact? ? 'equals' : 'contains'
end
def comparison=(val)
self.exact = val == 'equals'
end
def self.matches?(str, allow_with_approval: false)
normalized_str = str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
where(allow_with_approval: allow_with_approval).matches_exactly(normalized_str).or(matches_partially(normalized_str)).any?
end
def to_log_human_identifier
username
end
private
def set_normalized_username
self.normalized_username = normalize(username)
end
def normalize(str)
str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
end
end

View File

@@ -19,19 +19,21 @@ class WorkerBatch
redis.hset(key, { 'async_refresh_key' => async_refresh_key, 'threshold' => threshold })
end
def within
raise NoBlockGivenError unless block_given?
begin
Thread.current[:batch] = self
yield(self)
ensure
Thread.current[:batch] = nil
end
end
# Add jobs to the batch. Usually when the batch is created.
# @param [Array<String>] jids
def add_jobs(jids)
if jids.blank?
async_refresh_key = redis.hget(key, 'async_refresh_key')
if async_refresh_key.present?
async_refresh = AsyncRefresh.new(async_refresh_key)
async_refresh.finish!
end
return
end
return if jids.empty?
redis.multi do |pipeline|
pipeline.sadd(key('jobs'), jids)
@@ -43,7 +45,7 @@ class WorkerBatch
# Remove a job from the batch, such as when it's been processed or it has failed.
# @param [String] jid
def remove_job(jid)
def remove_job(jid, increment: false)
_, pending, processed, async_refresh_key, threshold = redis.multi do |pipeline|
pipeline.srem(key('jobs'), jid)
pipeline.hincrby(key, 'pending', -1)
@@ -52,11 +54,24 @@ class WorkerBatch
pipeline.hget(key, 'threshold')
end
async_refresh = AsyncRefresh.new(async_refresh_key) if async_refresh_key.present?
async_refresh&.increment_result_count(by: 1) if increment
if pending.zero? || processed >= (threshold || 1.0).to_f * (processed + pending)
async_refresh&.finish!
cleanup
end
end
def finish!
async_refresh_key = redis.hget(key, 'async_refresh_key')
if async_refresh_key.present?
async_refresh = AsyncRefresh.new(async_refresh_key)
async_refresh.increment_result_count(by: 1)
async_refresh.finish! if pending.zero? || processed >= threshold.to_f * (processed + pending)
async_refresh.finish!
end
cleanup
end
# Get pending jobs.
@@ -76,4 +91,8 @@ class WorkerBatch
def key(suffix = nil)
"worker_batch:#{@id}#{":#{suffix}" if suffix}"
end
def cleanup
redis.del(key, key('jobs'))
end
end

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
class UsernameBlockPolicy < ApplicationPolicy
def index?
role.can?(:manage_blocks)
end
def create?
role.can?(:manage_blocks)
end
def update?
role.can?(:manage_blocks)
end
def destroy?
role.can?(:manage_blocks)
end
end

View File

@@ -6,7 +6,7 @@ class ActivityPub::FetchAllRepliesService < ActivityPub::FetchRepliesService
# Limit of replies to fetch per status
MAX_REPLIES = (ENV['FETCH_REPLIES_MAX_SINGLE'] || 500).to_i
def call(status_uri, collection_or_uri, max_pages: 1, async_refresh_key: nil, request_id: nil)
def call(status_uri, collection_or_uri, max_pages: 1, batch_id: nil, request_id: nil)
@status_uri = status_uri
super

View File

@@ -8,9 +8,10 @@ class ActivityPub::FetchRemoteStatusService < BaseService
DISCOVERIES_PER_REQUEST = 1000
# Should be called when uri has already been checked for locality
def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil, depth: nil)
return if domain_not_allowed?(uri)
@depth = depth || 0
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
@json = if prefetched_body.nil?
fetch_status(uri, true, on_behalf_of)
@@ -52,7 +53,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
return nil if discoveries > DISCOVERIES_PER_REQUEST
end
ActivityPub::Activity.factory(activity_json, actor, request_id: @request_id).perform
ActivityPub::Activity.factory(activity_json, actor, request_id: @request_id, depth: @depth).perform
end
private

View File

@@ -6,7 +6,7 @@ class ActivityPub::FetchRepliesService < BaseService
# Limit of fetched replies
MAX_REPLIES = 5
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, async_refresh_key: nil, request_id: nil)
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, batch_id: nil, request_id: nil)
@reference_uri = reference_uri
@allow_synchronous_requests = allow_synchronous_requests
@@ -15,9 +15,11 @@ class ActivityPub::FetchRepliesService < BaseService
@items = filter_replies(@items)
batch = WorkerBatch.new
batch.connect(async_refresh_key) if async_refresh_key.present?
batch.add_jobs(FetchReplyWorker.push_bulk(@items) { |reply_uri| [reply_uri, { 'request_id' => request_id, 'batch_id' => batch.id }] })
WorkerBatch.new(batch_id).within do |batch|
FetchReplyWorker.push_bulk(@items) do |reply_uri|
[reply_uri, { 'request_id' => request_id, 'batch_id' => batch.id }]
end
end
[@items, n_pages]
end

View File

@@ -3,9 +3,12 @@
class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper
MAX_SYNCHRONOUS_DEPTH = 2
# Optionally fetch quoted post, and verify the quote is authorized
def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil)
def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil, depth: nil)
@request_id = request_id
@depth = depth || 0
@quote = quote
@fetching_error = nil
@@ -72,10 +75,12 @@ class ActivityPub::VerifyQuoteService < BaseService
return if uri.nil? || @quote.quoted_status.present?
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id)
raise Mastodon::RecursionLimitExceededError if @depth > MAX_SYNCHRONOUS_DEPTH && status.nil?
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id, depth: @depth + 1)
@quote.update(quoted_status: status) if status.present?
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
@fetching_error = e
end
@@ -90,7 +95,7 @@ class ActivityPub::VerifyQuoteService < BaseService
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth)
if status.present?
@quote.update(quoted_status: status)

View File

@@ -82,7 +82,7 @@ class FollowService < BaseService
LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, 'follow')
MergeWorker.perform_async(@target_account.id, @source_account.id, 'home')
MergeWorker.push_bulk(List.where(account: @source_account).joins(:list_accounts).where(list_accounts: { account_id: @target_account.id }).pluck(:id)) do |list_id|
MergeWorker.push_bulk(@source_account.owned_lists.with_list_account(@target_account).pluck(:id)) do |list_id|
[@target_account.id, list_id, 'list']
end

View File

@@ -34,7 +34,7 @@ class UnfollowService < BaseService
unless @options[:skip_unmerge]
UnmergeWorker.perform_async(@target_account.id, @source_account.id, 'home')
UnmergeWorker.push_bulk(List.where(account: @source_account).joins(:list_accounts).where(list_accounts: { account_id: @target_account.id }).pluck(:list_id)) do |list_id|
UnmergeWorker.push_bulk(@source_account.owned_lists.with_list_account(@target_account).pluck(:list_id)) do |list_id|
[@target_account.id, list_id, 'list']
end
end

View File

@@ -9,7 +9,7 @@ class UnmuteService < BaseService
if account.following?(target_account)
MergeWorker.perform_async(target_account.id, account.id, 'home')
MergeWorker.push_bulk(List.where(account: account).joins(:list_accounts).where(list_accounts: { account_id: target_account.id }).pluck(:id)) do |list_id|
MergeWorker.push_bulk(account.owned_lists.with_list_account(target_account).pluck(:id)) do |list_id|
[target_account.id, list_id, 'list']
end
end

View File

@@ -28,14 +28,6 @@ class UnreservedUsernameValidator < ActiveModel::Validator
end
def settings_username_reserved?
settings_has_reserved_usernames? && settings_reserves_username?
end
def settings_has_reserved_usernames?
Setting.reserved_usernames.present?
end
def settings_reserves_username?
Setting.reserved_usernames.include?(@username.downcase)
UsernameBlock.matches?(@username, allow_with_approval: false)
end
end

View File

@@ -30,9 +30,9 @@
= t('admin.accounts.suspended')
- elsif account.silenced?
= t('admin.accounts.silenced')
- elsif account.local? && account.user&.disabled?
- elsif account.local? && account.user_disabled?
= t('admin.accounts.disabled')
- elsif account.local? && !account.user&.confirmed?
- elsif account.local? && !account.user_confirmed?
= t('admin.accounts.confirming')
- elsif account.local? && !account.user_approved?
= t('admin.accounts.pending')

View File

@@ -34,7 +34,7 @@
%tr
%th= t('admin.accounts.email_status')
%td
- if account.user&.confirmed?
- if account.user_confirmed?
= t('admin.accounts.confirmed')
- else
= t('admin.accounts.confirming')

View File

@@ -42,7 +42,7 @@
%span.red= t('admin.accounts.suspended')
- elsif target_account.silenced?
%span.red= t('admin.accounts.silenced')
- elsif target_account.user&.disabled?
- elsif target_account.user_disabled?
%span.red= t('admin.accounts.disabled')
- else
%span.neutral= t('admin.accounts.no_limits_imposed')

View File

@@ -0,0 +1,16 @@
.fields-group
= form.input :username,
wrapper: :with_block_label,
input_html: { autocomplete: 'new-password', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }
.fields-group
= form.input :comparison,
as: :select,
wrapper: :with_block_label,
collection: %w(equals contains),
include_blank: false,
label_method: ->(type) { I18n.t(type, scope: 'admin.username_blocks.comparison') }
.fields-group
= form.input :allow_with_approval,
wrapper: :with_label

View File

@@ -0,0 +1,12 @@
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :username_block_ids, { multiple: true, include_hidden: false }, username_block.id
.sr-only= username_block.username
.batch-table__row__content.pending-account
.pending-account__header
= t(username_block.exact? ? 'admin.username_blocks.matches_exactly_html' : 'admin.username_blocks.contains_html', string: content_tag(:samp, link_to(username_block.username, edit_admin_username_block_path(username_block))))
%br/
- if username_block.allow_with_approval?
= t('admin.email_domain_blocks.allow_registrations_with_approval')
- else
= t('admin.username_blocks.block_registrations')

View File

@@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.edit.title')
= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('generic.save_changes'), type: :submit

View File

@@ -0,0 +1,26 @@
- content_for :page_title do
= t('admin.username_blocks.title')
- content_for :heading_actions do
= link_to t('admin.username_blocks.add_new'), new_admin_username_block_path, class: 'button'
= form_with model: @form, url: batch_admin_username_blocks_path do |f|
= hidden_field_tag :page, params[:page] || 1
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([material_symbol('close'), t('admin.username_blocks.delete')]),
class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') },
name: :delete,
type: :submit
.batch-table__body
- if @username_blocks.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'username_block', collection: @username_blocks, locals: { f: f }
= paginate @username_blocks

View File

@@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.new.title')
= simple_form_for @username_block, url: admin_username_blocks_path do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('admin.username_blocks.new.create'), type: :submit

View File

@@ -16,7 +16,9 @@ class ActivityPub::FetchAllRepliesWorker
MAX_PAGES = (ENV['FETCH_REPLIES_MAX_PAGES'] || 500).to_i
def perform(root_status_id, options = {})
@batch = WorkerBatch.new(options['batch_id'])
@root_status = Status.remote.find_by(id: root_status_id)
return unless @root_status&.should_fetch_replies?
@root_status.touch(:fetched_replies_at)
@@ -45,6 +47,8 @@ class ActivityPub::FetchAllRepliesWorker
# Workers shouldn't be returning anything, but this is used in tests
fetched_uris
ensure
@batch.remove_job(jid)
end
private
@@ -53,9 +57,10 @@ class ActivityPub::FetchAllRepliesWorker
# status URI, or the prefetched body of the Note object
def get_replies(status, max_pages, options = {})
replies_collection_or_uri = get_replies_uri(status)
return if replies_collection_or_uri.nil?
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, async_refresh_key: "context:#{@root_status.id}:refresh", **options.deep_symbolize_keys)
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, **options.deep_symbolize_keys)
end
# Get the URI of the replies collection of a status
@@ -78,9 +83,10 @@ class ActivityPub::FetchAllRepliesWorker
# @param root_status_uri [String]
def get_root_replies(root_status_uri, options = {})
root_status_body = fetch_resource(root_status_uri, true)
return if root_status_body.nil?
FetchReplyWorker.perform_async(root_status_uri, { **options.deep_stringify_keys, 'prefetched_body' => root_status_body })
FetchReplyWorker.perform_async(root_status_uri, { **options.deep_stringify_keys.except('batch_id'), 'prefetched_body' => root_status_body })
get_replies(root_status_body, MAX_PAGES, options)
end

View File

@@ -8,8 +8,8 @@ class FetchReplyWorker
def perform(child_url, options = {})
batch = WorkerBatch.new(options.delete('batch_id')) if options['batch_id']
FetchRemoteStatusService.new.call(child_url, **options.symbolize_keys)
result = FetchRemoteStatusService.new.call(child_url, **options.symbolize_keys)
ensure
batch&.remove_job(jid)
batch&.remove_job(jid, increment: result.present?)
end
end

View File

@@ -81,7 +81,7 @@ class MoveWorker
def copy_account_notes!
AccountNote.where(target_account: @source_account).find_each do |note|
text = I18n.with_locale(note.account.user&.locale.presence || I18n.default_locale) do
text = I18n.with_locale(note.account.user_locale.presence || I18n.default_locale) do
I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
end
@@ -104,7 +104,7 @@ class MoveWorker
def carry_blocks_over!
@source_account.blocked_by_relationships.where(account: Account.local).find_each do |block|
unless block.account.blocking?(@target_account) || block.account.following?(@target_account)
unless skip_block_move?(block)
BlockService.new.call(block.account, @target_account)
add_account_note_if_needed!(block.account, 'move_handler.carry_blocks_over_text')
end
@@ -115,19 +115,29 @@ class MoveWorker
def carry_mutes_over!
@source_account.muted_by_relationships.where(account: Account.local).find_each do |mute|
MuteService.new.call(mute.account, @target_account, notifications: mute.hide_notifications) unless mute.account.muting?(@target_account) || mute.account.following?(@target_account)
unless skip_mute_move?(mute)
MuteService.new.call(mute.account, @target_account, notifications: mute.hide_notifications)
add_account_note_if_needed!(mute.account, 'move_handler.carry_mutes_over_text')
end
rescue => e
@deferred_error = e
end
end
def add_account_note_if_needed!(account, id)
unless AccountNote.exists?(account: account, target_account: @target_account)
text = I18n.with_locale(account.user&.locale.presence || I18n.default_locale) do
return if AccountNote.exists?(account: account, target_account: @target_account)
text = I18n.with_locale(account.user_locale.presence || I18n.default_locale) do
I18n.t(id, acct: @source_account.acct)
end
AccountNote.create!(account: account, target_account: @target_account, comment: text)
end
def skip_mute_move?(mute)
mute.account.muting?(@target_account) || mute.account.following?(@target_account)
end
def skip_block_move?(block)
block.account.blocking?(@target_account) || block.account.following?(@target_account)
end
end

View File

@@ -9,7 +9,7 @@ class PublishScheduledStatusWorker
scheduled_status = ScheduledStatus.find(scheduled_status_id)
scheduled_status.destroy!
return true if scheduled_status.account.user.disabled?
return true if scheduled_status.account.user_disabled?
PostStatusService.new.call(
scheduled_status.account,

View File

@@ -80,6 +80,8 @@ ignore_unused:
- 'preferences.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'edit_profile.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'admin.terms_of_service.generate' # temporarily disabled
- 'admin.username_blocks.matches_exactly_html'
- 'admin.username_blocks.contains_html'
ignore_inconsistent_interpolations:
- '*.one'

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative '../../lib/mastodon/sidekiq_middleware'
require_relative '../../lib/mastodon/worker_batch_middleware'
Sidekiq.configure_server do |config|
config.redis = REDIS_CONFIGURATION.sidekiq
@@ -72,14 +73,12 @@ Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add Mastodon::SidekiqMiddleware
end
config.server_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Server
end
config.client_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Client
chain.add Mastodon::WorkerBatchMiddleware
end
config.on(:startup) do
@@ -105,6 +104,7 @@ Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Client
chain.add Mastodon::WorkerBatchMiddleware
end
config.logger.level = Logger.const_get(ENV.fetch('RAILS_LOG_LEVEL', 'info').upcase.to_s)

View File

@@ -560,6 +560,8 @@ br:
one: "%{count} skeudenn"
other: "%{count} skeudenn"
two: "%{count} skeudenn"
errors:
quoted_status_not_found: War a seblant, n'eus ket eus an embannadenn emaoc'h o klask menegiñ.
pin_errors:
ownership: N'hallit ket spilhennañ embannadurioù ar re all
quote_policies:

View File

@@ -1870,6 +1870,7 @@ ca:
edited_at_html: Editat %{date}
errors:
in_reply_not_found: El tut al qual intentes respondre sembla que no existeix.
quoted_status_not_found: Sembla que la publicació que vols citar no existeix.
over_character_limit: Límit de caràcters de %{max} superat
pin_errors:
direct: Els tuts que només són visibles per als usuaris mencionats no poden ser fixats

View File

@@ -1958,6 +1958,7 @@ cs:
edited_at_html: Upraven %{date}
errors:
in_reply_not_found: Příspěvek, na který se pokoušíte odpovědět, neexistuje.
quoted_status_not_found: Zdá se, že příspěvek, který se pokoušíte citovat neexistuje.
over_character_limit: byl překročen limit %{max} znaků
pin_errors:
direct: Příspěvky viditelné pouze zmíněným uživatelům nelze připnout

Some files were not shown because too many files have changed in this diff Show More