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

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

View File

@ -1,31 +1,51 @@
name: 'Chromatic' name: 'Chromatic'
permissions:
contents: read
on: on:
push: push:
branches-ignore: branches-ignore:
- renovate/* - renovate/*
- stable-* - stable-*
paths:
- 'package.json'
- 'yarn.lock'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '.github/workflows/chromatic.yml'
jobs: jobs:
chromatic: pathcheck:
name: Run Chromatic name: Check for relevant changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon' outputs:
changed: ${{ steps.filter.outputs.src }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 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 - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript
@ -35,7 +55,8 @@ jobs:
- name: Run Chromatic - name: Run Chromatic
uses: chromaui/action@v13 uses: chromaui/action@v13
with: with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true zip: true
storybookBuildDir: 'storybook-static' 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) * [Drew Schuster](mailto:dtschust@gmail.com)
* [Dryusdan](mailto:dryusdan@dryusdan.fr) * [Dryusdan](mailto:dryusdan@dryusdan.fr)
* [Eai](mailto:eai@mizle.net) * [Eai](mailto:eai@mizle.net)
* [Eashwar Ranganathan](mailto:eranganathan@lyft.com) * [Eashwar Ranganathan](mailto:eashwar@eashwar.com)
* [Ed Knutson](mailto:knutsoned@gmail.com) * [Ed Knutson](mailto:knutsoned@gmail.com)
* [Elizabeth Martín Campos](mailto:me@elizabeth.sh) * [Elizabeth Martín Campos](mailto:me@elizabeth.sh)
* [Elizabeth Myers](mailto:elizabeth@interlinked.me) * [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() { export function start() {
try { setupLinkListeners();
Rails.start();
} catch {
// If called twice
}
} }

View File

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

View File

@ -1,11 +1,11 @@
import punycode from 'node:punycode';
import { useCallback, useId, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import punycode from 'punycode/';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.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'; 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 { interface FocusColumnOptions {
index?: number; index?: number;
focusItem?: 'first' | 'first-visible'; focusItem?: 'first' | 'first-visible';
@ -14,7 +12,10 @@ export function focusColumn({
focusItem = 'first', focusItem = 'first',
}: FocusColumnOptions = {}) { }: FocusColumnOptions = {}) {
// Skip the leftmost drawer in multi-column mode // 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( const column = document.querySelector(
`.column:nth-child(${index + indexOffset})`, `.column:nth-child(${index + indexOffset})`,

View File

@ -28,6 +28,7 @@
"account.disable_notifications": "@{name}さんの投稿時の通知を停止", "account.disable_notifications": "@{name}さんの投稿時の通知を停止",
"account.domain_blocking": "ブロックしているドメイン", "account.domain_blocking": "ブロックしているドメイン",
"account.edit_profile": "プロフィール編集", "account.edit_profile": "プロフィール編集",
"account.edit_profile_short": "編集",
"account.enable_notifications": "@{name}さんの投稿時に通知", "account.enable_notifications": "@{name}さんの投稿時に通知",
"account.endorse": "プロフィールで紹介する", "account.endorse": "プロフィールで紹介する",
"account.familiar_followers_many": "{name1}、{name2}、他{othersCount, plural, one {one other you know} other {# others you know}}人のユーザーにフォローされています", "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.edit_list": "リストを編集",
"column.favourites": "お気に入り", "column.favourites": "お気に入り",
"column.firehose": "リアルタイムフィード", "column.firehose": "リアルタイムフィード",
"column.firehose_local": "このサーバーのリアルタイムフィード",
"column.firehose_singular": "リアルタイムフィード",
"column.follow_requests": "フォローリクエスト", "column.follow_requests": "フォローリクエスト",
"column.home": "ホーム", "column.home": "ホーム",
"column.list_members": "リストのメンバーを管理", "column.list_members": "リストのメンバーを管理",
@ -251,6 +254,7 @@
"confirmations.remove_from_followers.message": "{name}さんはあなたをフォローしなくなります。本当によろしいですか?", "confirmations.remove_from_followers.message": "{name}さんはあなたをフォローしなくなります。本当によろしいですか?",
"confirmations.remove_from_followers.title": "フォロワーを削除しますか?", "confirmations.remove_from_followers.title": "フォロワーを削除しますか?",
"confirmations.revoke_quote.confirm": "投稿を削除", "confirmations.revoke_quote.confirm": "投稿を削除",
"confirmations.revoke_quote.message": "この操作は元に戻せません。",
"confirmations.revoke_quote.title": "投稿を削除しますか?", "confirmations.revoke_quote.title": "投稿を削除しますか?",
"confirmations.unblock.confirm": "ブロック解除", "confirmations.unblock.confirm": "ブロック解除",
"confirmations.unblock.title": "@{name}さんのブロックを解除しますか?", "confirmations.unblock.title": "@{name}さんのブロックを解除しますか?",
@ -477,6 +481,7 @@
"keyboard_shortcuts.home": "ホームタイムラインを開く", "keyboard_shortcuts.home": "ホームタイムラインを開く",
"keyboard_shortcuts.hotkey": "ホットキー", "keyboard_shortcuts.hotkey": "ホットキー",
"keyboard_shortcuts.legend": "この一覧を表示", "keyboard_shortcuts.legend": "この一覧を表示",
"keyboard_shortcuts.load_more": "「もっと見る」ボタンに移動",
"keyboard_shortcuts.local": "ローカルタイムラインを開く", "keyboard_shortcuts.local": "ローカルタイムラインを開く",
"keyboard_shortcuts.mention": "メンション", "keyboard_shortcuts.mention": "メンション",
"keyboard_shortcuts.muted": "ミュートしたユーザーのリストを開く", "keyboard_shortcuts.muted": "ミュートしたユーザーのリストを開く",
@ -497,6 +502,7 @@
"keyboard_shortcuts.translate": "投稿を翻訳する", "keyboard_shortcuts.translate": "投稿を翻訳する",
"keyboard_shortcuts.unfocus": "投稿の入力欄・検索欄から離れる", "keyboard_shortcuts.unfocus": "投稿の入力欄・検索欄から離れる",
"keyboard_shortcuts.up": "カラム内一つ上に移動", "keyboard_shortcuts.up": "カラム内一つ上に移動",
"learn_more_link.got_it": "了解",
"learn_more_link.learn_more": "もっと見る", "learn_more_link.learn_more": "もっと見る",
"lightbox.close": "閉じる", "lightbox.close": "閉じる",
"lightbox.next": "次", "lightbox.next": "次",
@ -611,6 +617,7 @@
"notification.moderation_warning.action_suspend": "あなたのアカウントは停止されました。", "notification.moderation_warning.action_suspend": "あなたのアカウントは停止されました。",
"notification.own_poll": "アンケートが終了しました", "notification.own_poll": "アンケートが終了しました",
"notification.poll": "投票したアンケートが終了しました", "notification.poll": "投票したアンケートが終了しました",
"notification.quoted_update": "あなたが引用した投稿を {name} が編集しました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました", "notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.reblog.name_and_others_with_link": "{name}さんと<a>ほか{count, plural, other {#人}}</a>がブーストしました", "notification.reblog.name_and_others_with_link": "{name}さんと<a>ほか{count, plural, other {#人}}</a>がブーストしました",
"notification.relationships_severance_event": "{name} との関係が失われました", "notification.relationships_severance_event": "{name} との関係が失われました",
@ -753,6 +760,7 @@
"relative_time.minutes": "{number}分前", "relative_time.minutes": "{number}分前",
"relative_time.seconds": "{number}秒前", "relative_time.seconds": "{number}秒前",
"relative_time.today": "今日", "relative_time.today": "今日",
"remove_quote_hint.button_label": "了解",
"reply_indicator.attachments": "{count, plural, other {#件のメディア}}", "reply_indicator.attachments": "{count, plural, other {#件のメディア}}",
"reply_indicator.cancel": "キャンセル", "reply_indicator.cancel": "キャンセル",
"reply_indicator.poll": "アンケート", "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', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFilters, getFilters,
(_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), (_, { contextType }) => ['detailed', 'bookmarks', 'favourites', 'search'].includes(contextType),
]; ];
function getStatusResultFunction( 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-start: 0;
inset-inline-end: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
align-items: center;
justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned.
> div { > div {
flex-shrink: 0; flex-shrink: 0;
overflow: auto; overflow: auto;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
} }
} }
@ -7373,6 +7375,13 @@ a.status-card {
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
gap: 2px; 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 { &--layout-2 {
& > .media-gallery__item:nth-child(1) { & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0; border-end-end-radius: 0;
@ -7698,14 +7707,11 @@ a.status-card {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: $base-shadow-color; background: $base-shadow-color;
max-width: 100%; max-height: 460px;
max-height: max(400px, 60vh);
margin-inline: auto;
border-radius: 8px; border-radius: 8px;
box-sizing: border-box; box-sizing: border-box;
color: $white; color: $white;
display: flex; display: flex;
align-items: center;
outline: 1px solid var(--media-outline-color); outline: 1px solid var(--media-outline-color);
outline-offset: -1px; outline-offset: -1px;
z-index: 2; z-index: 2;

View File

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

View File

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

View File

@ -8,14 +8,11 @@ class AnnualReport
AnnualReport::TypeDistribution, AnnualReport::TypeDistribution,
AnnualReport::TopStatuses, AnnualReport::TopStatuses,
AnnualReport::MostUsedApps, AnnualReport::MostUsedApps,
AnnualReport::CommonlyInteractedWithAccounts,
AnnualReport::TimeSeries, AnnualReport::TimeSeries,
AnnualReport::TopHashtags, AnnualReport::TopHashtags,
AnnualReport::MostRebloggedAccounts,
AnnualReport::Percentiles,
].freeze ].freeze
SCHEMA = 1 SCHEMA = 2
def self.table_name_prefix def self.table_name_prefix
'annual_report_' 'annual_report_'
@ -26,12 +23,6 @@ class AnnualReport
@year = year @year = year
end end
def self.prepare(year)
SOURCES.each do |klass|
klass.prepare(year)
end
end
def generate def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year) 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 @year = year
end end
def self.prepare(_year)
# Use this method if any pre-calculations must be made before individual annual reports are generated
end
def generate def generate
raise NotImplementedError raise NotImplementedError
end end

View File

@ -2,7 +2,7 @@
class AnnualReport::TopHashtags < AnnualReport::Source class AnnualReport::TopHashtags < AnnualReport::Source
MINIMUM_TAGGINGS = 1 MINIMUM_TAGGINGS = 1
SET_SIZE = 40 SET_SIZE = 5
def generate 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:accounts',
:'write:blocks', :'write:blocks',
:'write:bookmarks', :'write:bookmarks',
:'write:collections',
:'write:conversations', :'write:conversations',
:'write:favourites', :'write:favourites',
:'write:filters', :'write:filters',
@ -89,6 +90,7 @@ Doorkeeper.configure do
:'read:accounts', :'read:accounts',
:'read:blocks', :'read:blocks',
:'read:bookmarks', :'read:bookmarks',
:'read:collections',
:'read:favourites', :'read:favourites',
:'read:filters', :'read:filters',
:'read:follows', :'read:follows',

View File

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

View File

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

View File

@ -5,9 +5,33 @@ require_relative 'base'
module Mastodon::CLI module Mastodon::CLI
class EmailDomainBlocks < Base 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' 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 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) say(parent.domain.to_s, :white)
shell.indent do shell.indent do
@ -19,6 +43,7 @@ module Mastodon::CLI
end end
option :with_dns_records, type: :boolean option :with_dns_records, type: :boolean
option :allow_with_approval, type: :boolean, defaut: false
desc 'add DOMAIN...', 'Block e-mail domain(s)' desc 'add DOMAIN...', 'Block e-mail domain(s)'
long_desc <<-LONG_DESC long_desc <<-LONG_DESC
Blocking an e-mail domain prevents users from signing up 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 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 different domains pointing to it as it allows you to essentially block
it at the root. 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 LONG_DESC
def add(*domains) def add(*domains)
fail_with_message 'No domain(s) given' if domains.empty? fail_with_message 'No domain(s) given' if domains.empty?
@ -47,19 +75,18 @@ module Mastodon::CLI
other_domains = [] other_domains = []
other_domains = DomainResource.new(domain).mx if options[:with_dns_records] 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! email_domain_block.save!
processed += 1 processed += 1
(email_domain_block.other_domains || []).uniq.each do |hostname| (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) if EmailDomainBlock.exists?(domain: hostname)
say("#{hostname} is already blocked.", :yellow) say("#{hostname} is already blocked.", :yellow)
skipped += 1 skipped += 1
next next
end 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! another_email_domain_block.save!
processed += 1 processed += 1
end end

View File

@ -48,7 +48,6 @@
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1", "@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2", "@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.600",
"@react-spring/web": "^9.7.5", "@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
@ -151,7 +150,6 @@
"@types/object-assign": "^4.0.30", "@types/object-assign": "^4.0.30",
"@types/prop-types": "^15.7.5", "@types/prop-types": "^15.7.5",
"@types/punycode": "^2.1.0", "@types/punycode": "^2.1.0",
"@types/rails__ujs": "^6.0.4",
"@types/react": "^18.2.7", "@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"@types/react-helmet": "^6.1.6", "@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) .to change(GeneratedAnnualReport, :count).by(1)
end end
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 end

View File

@ -15,17 +15,62 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
describe '#list' do describe '#list' do
let(:action) { :list } 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 context 'with email domain block records' do
let!(:parent_block) { Fabricate(:email_domain_block) } let!(:parent_block) { Fabricate(:email_domain_block) }
let!(:child_block) { Fabricate(:email_domain_block, parent: parent_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 } expect { subject }
.to output_results( .to output_results(
parent_block.domain, parent_block.domain,
child_block.domain child_block.domain,
parent_allow_block.domain,
child_allow_block.domain
) )
end 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
end end
@ -56,6 +101,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
context 'when no blocks exist' do context 'when no blocks exist' do
let(:domain) { 'host.example' } let(:domain) { 'host.example' }
let(:arguments) { [domain] } let(:arguments) { [domain] }
let(:options) { { allow_with_approval: false } }
it 'adds a new block' do it 'adds a new block' do
expect { subject } expect { subject }
@ -67,7 +113,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do
context 'with --with-dns-records true' do context 'with --with-dns-records true' do
let(:domain) { 'host.example' } let(:domain) { 'host.example' }
let(:arguments) { [domain] } let(:arguments) { [domain] }
let(:options) { { with_dns_records: true } } let(:options) { { allow_with_approval: false, with_dns_records: true } }
before do before do
configure_mx(domain: domain, exchange: 'other.host') 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 ).to_stdout
end end
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" "@gamestdio/websocket": "npm:^0.3.2"
"@github/webauthn-json": "npm:^2.1.1" "@github/webauthn-json": "npm:^2.1.1"
"@optimize-lodash/rollup-plugin": "npm:^5.0.2" "@optimize-lodash/rollup-plugin": "npm:^5.0.2"
"@rails/ujs": "npm:7.1.600"
"@react-spring/web": "npm:^9.7.5" "@react-spring/web": "npm:^9.7.5"
"@reduxjs/toolkit": "npm:^2.0.1" "@reduxjs/toolkit": "npm:^2.0.1"
"@storybook/addon-a11y": "npm:^10.0.6" "@storybook/addon-a11y": "npm:^10.0.6"
@ -2728,7 +2727,6 @@ __metadata:
"@types/object-assign": "npm:^4.0.30" "@types/object-assign": "npm:^4.0.30"
"@types/prop-types": "npm:^15.7.5" "@types/prop-types": "npm:^15.7.5"
"@types/punycode": "npm:^2.1.0" "@types/punycode": "npm:^2.1.0"
"@types/rails__ujs": "npm:^6.0.4"
"@types/react": "npm:^18.2.7" "@types/react": "npm:^18.2.7"
"@types/react-dom": "npm:^18.2.4" "@types/react-dom": "npm:^18.2.4"
"@types/react-helmet": "npm:^6.1.6" "@types/react-helmet": "npm:^6.1.6"
@ -3207,13 +3205,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@react-spring/animated@npm:~9.7.5":
version: 9.7.5 version: 9.7.5
resolution: "@react-spring/animated@npm:9.7.5" resolution: "@react-spring/animated@npm:9.7.5"
@ -4299,13 +4290,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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:*": "@types/range-parser@npm:*":
version: 1.2.7 version: 1.2.7
resolution: "@types/range-parser@npm:1.2.7" resolution: "@types/range-parser@npm:1.2.7"