diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index e0383b83bc..fbc0d9da0c 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -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 diff --git a/AUTHORS.md b/AUTHORS.md index 78cc37a17b..5d243ed43b 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -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) diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb new file mode 100644 index 0000000000..5583bb395d --- /dev/null +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -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 diff --git a/app/javascript/mastodon/common.ts b/app/javascript/mastodon/common.ts index e621a24e39..33d2b5ad17 100644 --- a/app/javascript/mastodon/common.ts +++ b/app/javascript/mastodon/common.ts @@ -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(); } diff --git a/app/javascript/mastodon/features/search/index.tsx b/app/javascript/mastodon/features/search/index.tsx index a55255bcde..5b4ed807fa 100644 --- a/app/javascript/mastodon/features/search/index.tsx +++ b/app/javascript/mastodon/features/search/index.tsx @@ -53,7 +53,7 @@ const renderHashtags = (hashtags: HashtagType[]) => const renderStatuses = (statusIds: string[]) => hidePeek(statusIds).map((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) => ( - + ))} )} diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx index d3de36a1e1..c6e0c1655a 100644 --- a/app/javascript/mastodon/features/status/components/card.tsx +++ b/app/javascript/mastodon/features/status/components/card.tsx @@ -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'; diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts index e46ede3553..a728a3c5eb 100644 --- a/app/javascript/mastodon/features/ui/util/focusUtils.ts +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -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})`, diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index fcf7e3134e..e693387b12 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -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}さんとほか{count, plural, other {#人}}がブーストしました", "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": "アンケート", diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 471c7af411..166c3e3e3a 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -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( diff --git a/app/javascript/mastodon/utils/links.ts b/app/javascript/mastodon/utils/links.ts new file mode 100644 index 0000000000..02b74bde4d --- /dev/null +++ b/app/javascript/mastodon/utils/links.ts @@ -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( + 'meta[name="csrf-param"]', + ); + const token = document.querySelector( + '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 + ); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5571fd6f47..dbf67cec51 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; diff --git a/app/javascript/styles_new/mastodon/accounts.scss b/app/javascript/styles_new/mastodon/accounts.scss index dab604be2a..d1c35e3f9e 100644 --- a/app/javascript/styles_new/mastodon/accounts.scss +++ b/app/javascript/styles_new/mastodon/accounts.scss @@ -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; diff --git a/app/javascript/styles_new/mastodon/components.scss b/app/javascript/styles_new/mastodon/components.scss index d5b61e6abb..13d19c5b8c 100644 --- a/app/javascript/styles_new/mastodon/components.scss +++ b/app/javascript/styles_new/mastodon/components.scss @@ -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; diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb index 275cc4b87d..8fab2111ed 100644 --- a/app/lib/annual_report.rb +++ b/app/lib/annual_report.rb @@ -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) diff --git a/app/lib/annual_report/commonly_interacted_with_accounts.rb b/app/lib/annual_report/commonly_interacted_with_accounts.rb deleted file mode 100644 index 219c30063a..0000000000 --- a/app/lib/annual_report/commonly_interacted_with_accounts.rb +++ /dev/null @@ -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 diff --git a/app/lib/annual_report/most_reblogged_accounts.rb b/app/lib/annual_report/most_reblogged_accounts.rb deleted file mode 100644 index df4dedb734..0000000000 --- a/app/lib/annual_report/most_reblogged_accounts.rb +++ /dev/null @@ -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 diff --git a/app/lib/annual_report/percentiles.rb b/app/lib/annual_report/percentiles.rb deleted file mode 100644 index 2b0305c415..0000000000 --- a/app/lib/annual_report/percentiles.rb +++ /dev/null @@ -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 diff --git a/app/lib/annual_report/source.rb b/app/lib/annual_report/source.rb index 86528731f5..7f48655369 100644 --- a/app/lib/annual_report/source.rb +++ b/app/lib/annual_report/source.rb @@ -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 diff --git a/app/lib/annual_report/top_hashtags.rb b/app/lib/annual_report/top_hashtags.rb index 42420a2770..a775c29bac 100644 --- a/app/lib/annual_report/top_hashtags.rb +++ b/app/lib/annual_report/top_hashtags.rb @@ -2,7 +2,7 @@ class AnnualReport::TopHashtags < AnnualReport::Source MINIMUM_TAGGINGS = 1 - SET_SIZE = 40 + SET_SIZE = 5 def generate { diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb new file mode 100644 index 0000000000..c03cc53856 --- /dev/null +++ b/app/serializers/rest/collection_serializer.rb @@ -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 diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 516db258df..908acb5503 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -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', diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 9025e16b18..8c06f28510 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -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: システムのデフォルトフォントを使う diff --git a/config/routes/api.rb b/config/routes/api.rb index e5a59682b7..47ae01a534 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/lib/mastodon/cli/email_domain_blocks.rb b/lib/mastodon/cli/email_domain_blocks.rb index 0cc9ccb705..a6093685b9 100644 --- a/lib/mastodon/cli/email_domain_blocks.rb +++ b/lib/mastodon/cli/email_domain_blocks.rb @@ -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 diff --git a/package.json b/package.json index e45a4a53ce..97077b2751 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb b/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb deleted file mode 100644 index 12bf3810db..0000000000 --- a/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb +++ /dev/null @@ -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 diff --git a/spec/lib/annual_report/most_reblogged_accounts_spec.rb b/spec/lib/annual_report/most_reblogged_accounts_spec.rb deleted file mode 100644 index 956549c325..0000000000 --- a/spec/lib/annual_report/most_reblogged_accounts_spec.rb +++ /dev/null @@ -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 diff --git a/spec/lib/annual_report/percentiles_spec.rb b/spec/lib/annual_report/percentiles_spec.rb deleted file mode 100644 index 11df81cfb6..0000000000 --- a/spec/lib/annual_report/percentiles_spec.rb +++ /dev/null @@ -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 diff --git a/spec/lib/annual_report_spec.rb b/spec/lib/annual_report_spec.rb index fa898d3ac5..bd4d0f3387 100644 --- a/spec/lib/annual_report_spec.rb +++ b/spec/lib/annual_report_spec.rb @@ -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 diff --git a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb index 6ce1a7c5f3..1662785f39 100644 --- a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb +++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb @@ -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') diff --git a/spec/requests/api/v1_alpha/collections_spec.rb b/spec/requests/api/v1_alpha/collections_spec.rb new file mode 100644 index 0000000000..5f9c5e5f34 --- /dev/null +++ b/spec/requests/api/v1_alpha/collections_spec.rb @@ -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 diff --git a/spec/serializers/rest/collection_serializer_spec.rb b/spec/serializers/rest/collection_serializer_spec.rb new file mode 100644 index 0000000000..c498937b50 --- /dev/null +++ b/spec/serializers/rest/collection_serializer_spec.rb @@ -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 diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb index 09b2b70ba1..5cd86b1019 100644 --- a/spec/support/command_line_helpers.rb +++ b/spec/support/command_line_helpers.rb @@ -7,3 +7,5 @@ module CommandLineHelpers ).to_stdout end end + +RSpec::Matchers.define_negated_matcher :not_output_results, :output_results diff --git a/yarn.lock b/yarn.lock index 71014a8379..6d0ae003ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"