Merge commit '37ccffa95a30772b55e3f18d486d699ee6c5f9e8' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2025-11-30 17:47:27 +01:00
34 changed files with 381 additions and 328 deletions

View File

@@ -1,31 +1,51 @@
name: 'Chromatic'
permissions:
contents: read
on:
push:
branches-ignore:
- renovate/*
- stable-*
paths:
- 'package.json'
- 'yarn.lock'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '.github/workflows/chromatic.yml'
jobs:
chromatic:
name: Run Chromatic
pathcheck:
name: Check for relevant changes
runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon'
outputs:
changed: ${{ steps.filter.outputs.src }}
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
src:
- 'package.json'
- 'yarn.lock'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '.github/workflows/chromatic.yml'
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
needs: pathcheck
if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
@@ -35,7 +55,8 @@ jobs:
- name: Run Chromatic
uses: chromaui/action@v13
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true
storybookBuildDir: 'storybook-static'
exitZeroOnChanges: false # Fail workflow if changes are found
autoAcceptChanges: 'main' # Auto-accept changes on main branch only

View File

@@ -538,7 +538,7 @@ and provided thanks to the work of the following contributors:
* [Drew Schuster](mailto:dtschust@gmail.com)
* [Dryusdan](mailto:dryusdan@dryusdan.fr)
* [Eai](mailto:eai@mizle.net)
* [Eashwar Ranganathan](mailto:eranganathan@lyft.com)
* [Eashwar Ranganathan](mailto:eashwar@eashwar.com)
* [Ed Knutson](mailto:knutsoned@gmail.com)
* [Elizabeth Martín Campos](mailto:me@elizabeth.sh)
* [Elizabeth Myers](mailto:elizabeth@interlinked.me)

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
class Api::V1Alpha::CollectionsController < Api::BaseController
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422
end
before_action :check_feature_enabled
before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create]
before_action :require_user!
def create
@collection = CreateCollectionService.new.call(collection_params, current_user.account)
render json: @collection, serializer: REST::CollectionSerializer
end
private
def collection_params
params.permit(:name, :description, :sensitive, :discoverable, :tag, account_ids: [])
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
end

View File

@@ -1,9 +1,5 @@
import Rails from '@rails/ujs';
import { setupLinkListeners } from './utils/links';
export function start() {
try {
Rails.start();
} catch {
// If called twice
}
setupLinkListeners();
}

View File

@@ -53,7 +53,7 @@ const renderHashtags = (hashtags: HashtagType[]) =>
const renderStatuses = (statusIds: string[]) =>
hidePeek<string>(statusIds).map((id) => (
<StatusQuoteManager key={id} id={id} />
<StatusQuoteManager contextType='search' key={id} id={id} />
));
type SearchType = 'all' | ApiSearchType;
@@ -189,7 +189,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
onClickMore={handleSelectStatuses}
>
{results.statuses.slice(0, INITIAL_DISPLAY).map((id) => (
<StatusQuoteManager key={id} id={id} />
<StatusQuoteManager contextType='search' key={id} id={id} />
))}
</SearchSection>
)}

View File

@@ -1,11 +1,11 @@
import punycode from 'node:punycode';
import { useCallback, useId, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import punycode from 'punycode/';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';

View File

@@ -1,5 +1,3 @@
import { initialState } from '@/mastodon/initial_state';
interface FocusColumnOptions {
index?: number;
focusItem?: 'first' | 'first-visible';
@@ -14,7 +12,10 @@ export function focusColumn({
focusItem = 'first',
}: FocusColumnOptions = {}) {
// Skip the leftmost drawer in multi-column mode
const indexOffset = initialState?.meta.advanced_layout ? 1 : 0;
const isMultiColumnLayout = !!document.querySelector(
'body.layout-multiple-columns',
);
const indexOffset = isMultiColumnLayout ? 1 : 0;
const column = document.querySelector(
`.column:nth-child(${index + indexOffset})`,

View File

@@ -28,6 +28,7 @@
"account.disable_notifications": "@{name}さんの投稿時の通知を停止",
"account.domain_blocking": "ブロックしているドメイン",
"account.edit_profile": "プロフィール編集",
"account.edit_profile_short": "編集",
"account.enable_notifications": "@{name}さんの投稿時に通知",
"account.endorse": "プロフィールで紹介する",
"account.familiar_followers_many": "{name1}、{name2}、他{othersCount, plural, one {one other you know} other {# others you know}}人のユーザーにフォローされています",
@@ -172,6 +173,8 @@
"column.edit_list": "リストを編集",
"column.favourites": "お気に入り",
"column.firehose": "リアルタイムフィード",
"column.firehose_local": "このサーバーのリアルタイムフィード",
"column.firehose_singular": "リアルタイムフィード",
"column.follow_requests": "フォローリクエスト",
"column.home": "ホーム",
"column.list_members": "リストのメンバーを管理",
@@ -251,6 +254,7 @@
"confirmations.remove_from_followers.message": "{name}さんはあなたをフォローしなくなります。本当によろしいですか?",
"confirmations.remove_from_followers.title": "フォロワーを削除しますか?",
"confirmations.revoke_quote.confirm": "投稿を削除",
"confirmations.revoke_quote.message": "この操作は元に戻せません。",
"confirmations.revoke_quote.title": "投稿を削除しますか?",
"confirmations.unblock.confirm": "ブロック解除",
"confirmations.unblock.title": "@{name}さんのブロックを解除しますか?",
@@ -477,6 +481,7 @@
"keyboard_shortcuts.home": "ホームタイムラインを開く",
"keyboard_shortcuts.hotkey": "ホットキー",
"keyboard_shortcuts.legend": "この一覧を表示",
"keyboard_shortcuts.load_more": "「もっと見る」ボタンに移動",
"keyboard_shortcuts.local": "ローカルタイムラインを開く",
"keyboard_shortcuts.mention": "メンション",
"keyboard_shortcuts.muted": "ミュートしたユーザーのリストを開く",
@@ -497,6 +502,7 @@
"keyboard_shortcuts.translate": "投稿を翻訳する",
"keyboard_shortcuts.unfocus": "投稿の入力欄・検索欄から離れる",
"keyboard_shortcuts.up": "カラム内一つ上に移動",
"learn_more_link.got_it": "了解",
"learn_more_link.learn_more": "もっと見る",
"lightbox.close": "閉じる",
"lightbox.next": "次",
@@ -611,6 +617,7 @@
"notification.moderation_warning.action_suspend": "あなたのアカウントは停止されました。",
"notification.own_poll": "アンケートが終了しました",
"notification.poll": "投票したアンケートが終了しました",
"notification.quoted_update": "あなたが引用した投稿を {name} が編集しました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.reblog.name_and_others_with_link": "{name}さんと<a>ほか{count, plural, other {#人}}</a>がブーストしました",
"notification.relationships_severance_event": "{name} との関係が失われました",
@@ -753,6 +760,7 @@
"relative_time.minutes": "{number}分前",
"relative_time.seconds": "{number}秒前",
"relative_time.today": "今日",
"remove_quote_hint.button_label": "了解",
"reply_indicator.attachments": "{count, plural, other {#件のメディア}}",
"reply_indicator.cancel": "キャンセル",
"reply_indicator.poll": "アンケート",

View File

@@ -14,7 +14,7 @@ const getStatusInputSelectors = [
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFilters,
(_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType),
(_, { contextType }) => ['detailed', 'bookmarks', 'favourites', 'search'].includes(contextType),
];
function getStatusResultFunction(

View File

@@ -0,0 +1,88 @@
import { on } from 'delegated-events';
export function setupLinkListeners() {
on('click', 'a[data-confirm]', handleConfirmLink);
// We don't want to target links with data-confirm here, as those are handled already.
on('click', 'a[data-method]:not([data-confirm])', handleMethodLink);
}
function handleConfirmLink(event: MouseEvent) {
const anchor = event.currentTarget;
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const message = anchor.dataset.confirm;
if (!message || !window.confirm(message)) {
event.preventDefault();
return;
}
if (anchor.dataset.method) {
handleMethodLink(event);
}
}
function handleMethodLink(event: MouseEvent) {
const anchor = event.currentTarget;
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const method = anchor.dataset.method?.toLowerCase();
if (!method) {
return;
}
event.preventDefault();
// Create and submit a form with the specified method.
const form = document.createElement('form');
form.method = 'post';
form.action = anchor.href;
// Add the hidden _method input to simulate other HTTP methods.
const methodInput = document.createElement('input');
methodInput.type = 'hidden';
methodInput.name = '_method';
methodInput.value = method;
form.appendChild(methodInput);
// Add CSRF token if available for same-origin requests.
const csrf = getCSRFToken();
if (csrf && !isCrossDomain(anchor.href)) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = csrf.param;
csrfInput.value = csrf.token;
form.appendChild(csrfInput);
}
// The form needs to be in the document to be submitted.
form.style.display = 'none';
document.body.appendChild(form);
// We use requestSubmit to ensure any form submit handlers are properly invoked.
form.requestSubmit();
}
function getCSRFToken() {
const param = document.querySelector<HTMLMetaElement>(
'meta[name="csrf-param"]',
);
const token = document.querySelector<HTMLMetaElement>(
'meta[name="csrf-token"]',
);
if (param && token) {
return { param: param.content, token: token.content };
}
return null;
}
function isCrossDomain(href: string) {
const link = document.createElement('a');
link.href = href;
return (
link.protocol !== window.location.protocol ||
link.host !== window.location.host
);
}

View File

@@ -6204,12 +6204,14 @@ a.status-card {
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
align-items: center;
justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned.
> div {
flex-shrink: 0;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
}
@@ -7373,6 +7375,13 @@ a.status-card {
grid-template-rows: 1fr 1fr;
gap: 2px;
&--layout-1 {
// The size of single images is determined by their
// aspect-ratio, applied via inline style attribute
width: initial;
max-height: 460px;
}
&--layout-2 {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
@@ -7698,14 +7707,11 @@ a.status-card {
overflow: hidden;
position: relative;
background: $base-shadow-color;
max-width: 100%;
max-height: max(400px, 60vh);
margin-inline: auto;
max-height: 460px;
border-radius: 8px;
box-sizing: border-box;
color: $white;
display: flex;
align-items: center;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
z-index: 2;

View File

@@ -113,8 +113,8 @@
}
.current {
color: var(--color-bg-inverted);
background: var(--color-text-on-inverted);
color: var(--color-bg-primary);
background: var(--color-text-primary);
border-radius: 100px;
cursor: default;
margin: 0 10px;

View File

@@ -6085,12 +6085,14 @@ a.status-card {
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
align-items: center;
justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned.
> div {
flex-shrink: 0;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
}
@@ -7159,6 +7161,13 @@ a.status-card {
grid-template-rows: 1fr 1fr;
gap: 2px;
&--layout-1 {
// The size of single images is determined by their
// aspect-ratio, applied via inline style attribute
width: initial;
max-height: 460px;
}
&--layout-2 {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
@@ -7491,13 +7500,10 @@ a.status-card {
position: relative;
color: var(--color-text-on-media);
background: var(--color-bg-media);
max-width: 100%;
max-height: max(400px, 60vh);
margin-inline: auto;
max-height: 460px;
border-radius: 8px;
box-sizing: border-box;
display: flex;
align-items: center;
outline: 1px solid var(--color-border-media);
outline-offset: -1px;
z-index: 2;

View File

@@ -8,14 +8,11 @@ class AnnualReport
AnnualReport::TypeDistribution,
AnnualReport::TopStatuses,
AnnualReport::MostUsedApps,
AnnualReport::CommonlyInteractedWithAccounts,
AnnualReport::TimeSeries,
AnnualReport::TopHashtags,
AnnualReport::MostRebloggedAccounts,
AnnualReport::Percentiles,
].freeze
SCHEMA = 1
SCHEMA = 2
def self.table_name_prefix
'annual_report_'
@@ -26,12 +23,6 @@ class AnnualReport
@year = year
end
def self.prepare(year)
SOURCES.each do |klass|
klass.prepare(year)
end
end
def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year)

View File

@@ -1,27 +0,0 @@
# frozen_string_literal: true
class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
MINIMUM_INTERACTIONS = 1
SET_SIZE = 40
def generate
{
commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
{
account_id: account_id.to_s,
count: count,
}
end,
}
end
private
def commonly_interacted_with_accounts
report_statuses.not_replying_to_account(@account).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count
end
def minimum_interaction_count
Arel.star.count.gt(MINIMUM_INTERACTIONS)
end
end

View File

@@ -1,27 +0,0 @@
# frozen_string_literal: true
class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
MINIMUM_REBLOGS = 1
SET_SIZE = 10
def generate
{
most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
{
account_id: account_id.to_s,
count: count,
}
end,
}
end
private
def most_reblogged_accounts
report_statuses.only_reblogs.joins(reblog: :account).group(accounts: [:id]).having(minimum_reblog_count).order(count_all: :desc).limit(SET_SIZE).count
end
def minimum_reblog_count
Arel.star.count.gt(MINIMUM_REBLOGS)
end
end

View File

@@ -1,37 +0,0 @@
# frozen_string_literal: true
class AnnualReport::Percentiles < AnnualReport::Source
def self.prepare(year)
AnnualReport::StatusesPerAccountCount.connection.exec_query(<<~SQL.squish, nil, [year, Mastodon::Snowflake.id_at(DateTime.new(year).beginning_of_year), Mastodon::Snowflake.id_at(DateTime.new(year).end_of_year)])
INSERT INTO annual_report_statuses_per_account_counts (year, account_id, statuses_count)
SELECT $1, account_id, count(*)
FROM statuses
WHERE id BETWEEN $2 AND $3
AND (local OR uri IS NULL)
GROUP BY account_id
ON CONFLICT (year, account_id) DO NOTHING
SQL
end
def generate
{
percentiles: {
statuses: 100.0 - ((total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100),
},
}
end
private
def statuses_created
@statuses_created ||= report_statuses.count
end
def total_with_fewer_statuses
@total_with_fewer_statuses ||= AnnualReport::StatusesPerAccountCount.where(year: year).where(statuses_count: ...statuses_created).count
end
def total_with_any_statuses
@total_with_any_statuses ||= AnnualReport::StatusesPerAccountCount.where(year: year).count
end
end

View File

@@ -8,10 +8,6 @@ class AnnualReport::Source
@year = year
end
def self.prepare(_year)
# Use this method if any pre-calculations must be made before individual annual reports are generated
end
def generate
raise NotImplementedError
end

View File

@@ -2,7 +2,7 @@
class AnnualReport::TopHashtags < AnnualReport::Source
MINIMUM_TAGGINGS = 1
SET_SIZE = 40
SET_SIZE = 5
def generate
{

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class REST::CollectionSerializer < ActiveModel::Serializer
attributes :uri, :name, :description, :local, :sensitive, :discoverable,
:created_at, :updated_at
belongs_to :account, serializer: REST::AccountSerializer
end

View File

@@ -75,6 +75,7 @@ Doorkeeper.configure do
:'write:accounts',
:'write:blocks',
:'write:bookmarks',
:'write:collections',
:'write:conversations',
:'write:favourites',
:'write:filters',
@@ -89,6 +90,7 @@ Doorkeeper.configure do
:'read:accounts',
:'read:blocks',
:'read:bookmarks',
:'read:collections',
:'read:favourites',
:'read:filters',
:'read:follows',

View File

@@ -60,6 +60,7 @@ ja:
setting_display_media_default: 閲覧注意としてマークされたメディアは隠す
setting_display_media_hide_all: メディアを常に隠す
setting_display_media_show_all: メディアを常に表示する
setting_emoji_style: 絵文字の表示方法。「オート」の場合、可能ならネイティブの絵文字を使用し、レガシーなブラウザではTwemojiで代替します。
setting_system_scrollbars_ui: Safari/Chromeベースのデスクトップブラウザーでのみ有効です
setting_use_blurhash: ぼかしはメディアの色を元に生成されますが、細部は見えにくくなっています
setting_use_pending_items: 新着があってもタイムラインを自動的にスクロールしないようにします
@@ -225,6 +226,7 @@ ja:
setting_default_privacy: 投稿の公開範囲
setting_default_quote_policy: 引用できるユーザー
setting_default_sensitive: メディアを常に閲覧注意としてマークする
setting_delete_modal: 投稿を削除する前に警告する
setting_disable_hover_cards: マウスオーバーでプロフィールをポップアップしない
setting_disable_swiping: スワイプでの切り替えを無効にする
setting_display_media: メディアの表示
@@ -234,6 +236,7 @@ ja:
setting_emoji_style: 絵文字スタイル
setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する
setting_hide_network: 繋がりを隠す
setting_missing_alt_text_modal: 代替テキストなしでメディアを投稿する前に警告する
setting_quick_boosting: クイックブーストの有効化
setting_reduce_motion: アニメーションの動きを減らす
setting_system_font_ui: システムのデフォルトフォントを使う

View File

@@ -7,6 +7,8 @@ namespace :api, format: false do
# Experimental JSON / REST API
namespace :v1_alpha do
resources :async_refreshes, only: :show
resources :collections, only: [:create]
end
# JSON / REST API

View File

@@ -5,9 +5,33 @@ require_relative 'base'
module Mastodon::CLI
class EmailDomainBlocks < Base
option :only_blocked, type: :boolean, defaut: false
option :only_with_approval, type: :boolean, default: false
desc 'list', 'List blocked e-mail domains'
long_desc <<-LONG_DESC
By default this command lists all domains in the email domain block list
and their associated MX records (if included).
If the --only-blocked option is provided, this command will list only email
domains that are fully blocked from signup.
If the --only-with-approval option is provided, this command will list only
email domains that are allowed to be used but require manual approval.
The --only-blocked and --only-with-approval options are mutually exclusive.
LONG_DESC
def list
EmailDomainBlock.parents.find_each do |parent|
fail_with_message 'Cannot specify both --only-blocked and --only-with-approval' if options[:only_blocked] && options[:only_with_approval]
base_query = EmailDomainBlock.parents
if options[:only_blocked]
base_query = base_query.where(allow_with_approval: false)
elsif options[:only_with_approval]
base_query = base_query.where(allow_with_approval: true)
end
base_query.find_each do |parent|
say(parent.domain.to_s, :white)
shell.indent do
@@ -19,6 +43,7 @@ module Mastodon::CLI
end
option :with_dns_records, type: :boolean
option :allow_with_approval, type: :boolean, defaut: false
desc 'add DOMAIN...', 'Block e-mail domain(s)'
long_desc <<-LONG_DESC
Blocking an e-mail domain prevents users from signing up
@@ -30,6 +55,9 @@ module Mastodon::CLI
This can be helpful if you are blocking an e-mail server that has many
different domains pointing to it as it allows you to essentially block
it at the root.
When the --allow-with-approval option is set, the email domains provided will
have to be manually approved for signup.
LONG_DESC
def add(*domains)
fail_with_message 'No domain(s) given' if domains.empty?
@@ -47,19 +75,18 @@ module Mastodon::CLI
other_domains = []
other_domains = DomainResource.new(domain).mx if options[:with_dns_records]
email_domain_block = EmailDomainBlock.new(domain: domain, other_domains: other_domains)
email_domain_block = EmailDomainBlock.new(domain: domain, other_domains: other_domains, allow_with_approval: options[:allow_with_approval])
email_domain_block.save!
processed += 1
(email_domain_block.other_domains || []).uniq.each do |hostname|
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block)
if EmailDomainBlock.exists?(domain: hostname)
say("#{hostname} is already blocked.", :yellow)
skipped += 1
next
end
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block, allow_with_approval: options[:allow_with_approval])
another_email_domain_block.save!
processed += 1
end

View File

@@ -48,7 +48,6 @@
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.600",
"@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1",
"@use-gesture/react": "^10.3.1",
@@ -151,7 +150,6 @@
"@types/object-assign": "^4.0.30",
"@types/prop-types": "^15.7.5",
"@types/punycode": "^2.1.0",
"@types/rails__ujs": "^6.0.4",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@types/react-helmet": "^6.1.6",

View File

@@ -1,50 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AnnualReport::CommonlyInteractedWithAccounts do
describe '#generate' do
subject { described_class.new(account, Time.zone.now.year) }
context 'with an inactive account' do
let(:account) { Fabricate :account }
it 'builds a report for an account' do
expect(subject.generate)
.to include(
commonly_interacted_with_accounts: be_an(Array).and(be_empty)
)
end
end
context 'with an active account' do
let(:account) { Fabricate :account }
let(:other_account) { Fabricate :account }
let(:most_other_account) { Fabricate :account }
before do
_other = Fabricate :status
Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: other_account).id
Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: other_account).id
Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id
Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id
Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id
end
it 'builds a report for an account' do
expect(subject.generate)
.to include(
commonly_interacted_with_accounts: eq(
[
{ account_id: most_other_account.id.to_s, count: 3 },
{ account_id: other_account.id.to_s, count: 2 },
]
)
)
end
end
end
end

View File

@@ -1,49 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AnnualReport::MostRebloggedAccounts do
describe '#generate' do
subject { described_class.new(account, Time.zone.now.year) }
context 'with an inactive account' do
let(:account) { Fabricate :account }
it 'builds a report for an account' do
expect(subject.generate)
.to include(
most_reblogged_accounts: be_an(Array).and(be_empty)
)
end
end
context 'with an active account' do
let(:account) { Fabricate :account }
let(:other_account) { Fabricate :account }
let(:most_other_account) { Fabricate :account }
before do
_other = Fabricate :status
Fabricate :status, account: account, reblog: Fabricate(:status, account: other_account)
Fabricate :status, account: account, reblog: Fabricate(:status, account: other_account)
Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account)
Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account)
Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account)
end
it 'builds a report for an account' do
expect(subject.generate)
.to include(
most_reblogged_accounts: eq(
[
{ account_id: most_other_account.id.to_s, count: 3 },
{ account_id: other_account.id.to_s, count: 2 },
]
)
)
end
end
end
end

View File

@@ -1,46 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AnnualReport::Percentiles do
describe '#generate' do
subject { described_class.new(account, year) }
let(:year) { Time.zone.now.year }
context 'with an inactive account' do
let(:account) { Fabricate :account }
it 'builds a report for an account' do
described_class.prepare(year)
expect(subject.generate)
.to include(
percentiles: include(
statuses: 100
)
)
end
end
context 'with an active account' do
let(:account) { Fabricate :account }
before do
Fabricate.times 2, :status # Others as `account`
Fabricate.times 2, :status, account: account
end
it 'builds a report for an account' do
described_class.prepare(year)
expect(subject.generate)
.to include(
percentiles: include(
statuses: 50
)
)
end
end
end
end

View File

@@ -13,13 +13,4 @@ RSpec.describe AnnualReport do
.to change(GeneratedAnnualReport, :count).by(1)
end
end
describe '.prepare' do
before { Fabricate :status }
it 'generates records from source class which prepare data' do
expect { described_class.prepare(Time.current.year) }
.to change(AnnualReport::StatusesPerAccountCount, :count).by(1)
end
end
end

View File

@@ -15,17 +15,62 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
describe '#list' do
let(:action) { :list }
context 'with both --only-blocked and --only-with-approval' do
let(:options) { { only_blocked: true, only_with_approval: true } }
it 'warns about usage and exits' do
expect { subject }
.to raise_error(Thor::Error, 'Cannot specify both --only-blocked and --only-with-approval')
end
end
context 'with email domain block records' do
let!(:parent_block) { Fabricate(:email_domain_block) }
let!(:child_block) { Fabricate(:email_domain_block, parent: parent_block) }
let!(:parent_allow_block) { Fabricate(:email_domain_block, allow_with_approval: true) }
let!(:child_allow_block) { Fabricate(:email_domain_block, parent: parent_allow_block, allow_with_approval: true) }
it 'lists the blocks' do
it 'lists all the blocks by default' do
expect { subject }
.to output_results(
parent_block.domain,
child_block.domain
child_block.domain,
parent_allow_block.domain,
child_allow_block.domain
)
end
context 'with the --only-blocked flag set' do
let(:options) { { only_blocked: true } }
it 'lists only blocked domains' do
expect { subject }
.to output_results(
parent_block.domain,
child_block.domain
)
.and not_output_results(
parent_allow_block.domain,
child_allow_block.domain
)
end
end
context 'with the --only-with-approval flag set' do
let(:options) { { only_with_approval: true } }
it 'lists only manually approvable domains' do
expect { subject }
.to output_results(
parent_allow_block.domain,
child_allow_block.domain
)
.and not_output_results(
parent_block.domain,
child_block.domain
)
end
end
end
end
@@ -56,6 +101,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
context 'when no blocks exist' do
let(:domain) { 'host.example' }
let(:arguments) { [domain] }
let(:options) { { allow_with_approval: false } }
it 'adds a new block' do
expect { subject }
@@ -67,7 +113,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
context 'with --with-dns-records true' do
let(:domain) { 'host.example' }
let(:arguments) { [domain] }
let(:options) { { with_dns_records: true } }
let(:options) { { allow_with_approval: false, with_dns_records: true } }
before do
configure_mx(domain: domain, exchange: 'other.host')

View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
include_context 'with API authentication', oauth_scopes: 'read:collections write:collections'
describe 'POST /api/v1_alpha/collections' do
subject do
post '/api/v1_alpha/collections', headers: headers, params: params
end
let(:params) { {} }
it_behaves_like 'forbidden for wrong scope', 'read'
context 'with valid params' do
let(:params) do
{
name: 'Low-traffic bots',
description: 'Really nice bots, please follow',
sensitive: '0',
discoverable: '1',
}
end
it 'creates a collection and returns http success' do
expect do
subject
end.to change(Collection, :count).by(1)
expect(response).to have_http_status(200)
end
end
context 'with invalid params' do
it 'returns http unprocessable content and detailed errors' do
expect do
subject
end.to_not change(Collection, :count)
expect(response).to have_http_status(422)
expect(response.parsed_body).to include({
'error' => a_hash_including({
'details' => a_hash_including({
'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
'description' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
}),
}),
})
end
end
end
end

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::CollectionSerializer do
subject { serialized_record_json(collection, described_class) }
let(:collection) do
Fabricate(:collection,
name: 'Exquisite follows',
description: 'Always worth a follow',
local: true,
sensitive: true,
discoverable: false)
end
it 'includes the relevant attributes' do
expect(subject)
.to include(
'account' => an_instance_of(Hash),
'name' => 'Exquisite follows',
'description' => 'Always worth a follow',
'local' => true,
'sensitive' => true,
'discoverable' => false,
'created_at' => match_api_datetime_format,
'updated_at' => match_api_datetime_format
)
end
end

View File

@@ -7,3 +7,5 @@ module CommandLineHelpers
).to_stdout
end
end
RSpec::Matchers.define_negated_matcher :not_output_results, :output_results

View File

@@ -2708,7 +2708,6 @@ __metadata:
"@gamestdio/websocket": "npm:^0.3.2"
"@github/webauthn-json": "npm:^2.1.1"
"@optimize-lodash/rollup-plugin": "npm:^5.0.2"
"@rails/ujs": "npm:7.1.600"
"@react-spring/web": "npm:^9.7.5"
"@reduxjs/toolkit": "npm:^2.0.1"
"@storybook/addon-a11y": "npm:^10.0.6"
@@ -2728,7 +2727,6 @@ __metadata:
"@types/object-assign": "npm:^4.0.30"
"@types/prop-types": "npm:^15.7.5"
"@types/punycode": "npm:^2.1.0"
"@types/rails__ujs": "npm:^6.0.4"
"@types/react": "npm:^18.2.7"
"@types/react-dom": "npm:^18.2.4"
"@types/react-helmet": "npm:^6.1.6"
@@ -3207,13 +3205,6 @@ __metadata:
languageName: node
linkType: hard
"@rails/ujs@npm:7.1.600":
version: 7.1.600
resolution: "@rails/ujs@npm:7.1.600"
checksum: 10c0/0ccaa68a08fbc7b084ab89a1fe49520a5cba6d99f4b0feaf0cb3d00334c59d8d798932d7e49b84aa388875d039ea1e17eb115ed96a80ad157e408a13eceef53e
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/animated@npm:9.7.5"
@@ -4299,13 +4290,6 @@ __metadata:
languageName: node
linkType: hard
"@types/rails__ujs@npm:^6.0.4":
version: 6.0.4
resolution: "@types/rails__ujs@npm:6.0.4"
checksum: 10c0/7477cb03a0e1339b9cd5c8ac4a197a153e2ff48742b2f527c5a39dcdf80f01493011e368483290d3717662c63066fada3ab203a335804cbb3573cf575f37007e
languageName: node
linkType: hard
"@types/range-parser@npm:*":
version: 1.2.7
resolution: "@types/range-parser@npm:1.2.7"