mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 16:28:59 +00:00
Merge commit '37ccffa95a30772b55e3f18d486d699ee6c5f9e8' into glitch-soc/merge-upstream
This commit is contained in:
29
.github/workflows/chromatic.yml
vendored
29
.github/workflows/chromatic.yml
vendored
@@ -1,11 +1,30 @@
|
||||
name: 'Chromatic'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- renovate/*
|
||||
- stable-*
|
||||
paths:
|
||||
|
||||
jobs:
|
||||
pathcheck:
|
||||
name: Check for relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
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'
|
||||
@@ -16,16 +35,17 @@ on:
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/chromatic.yml'
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
app/controllers/api/v1_alpha/collections_controller.rb
Normal file
29
app/controllers/api/v1_alpha/collections_controller.rb
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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": "アンケート",
|
||||
|
||||
@@ -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(
|
||||
|
||||
88
app/javascript/mastodon/utils/links.ts
Normal file
88
app/javascript/mastodon/utils/links.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class AnnualReport::TopHashtags < AnnualReport::Source
|
||||
MINIMUM_TAGGINGS = 1
|
||||
SET_SIZE = 40
|
||||
SET_SIZE = 5
|
||||
|
||||
def generate
|
||||
{
|
||||
|
||||
8
app/serializers/rest/collection_serializer.rb
Normal file
8
app/serializers/rest/collection_serializer.rb
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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: システムのデフォルトフォントを使う
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -15,16 +15,61 @@ 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,
|
||||
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')
|
||||
|
||||
54
spec/requests/api/v1_alpha/collections_spec.rb
Normal file
54
spec/requests/api/v1_alpha/collections_spec.rb
Normal 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
|
||||
30
spec/serializers/rest/collection_serializer_spec.rb
Normal file
30
spec/serializers/rest/collection_serializer_spec.rb
Normal 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
|
||||
@@ -7,3 +7,5 @@ module CommandLineHelpers
|
||||
).to_stdout
|
||||
end
|
||||
end
|
||||
|
||||
RSpec::Matchers.define_negated_matcher :not_output_results, :output_results
|
||||
|
||||
16
yarn.lock
16
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"
|
||||
|
||||
Reference in New Issue
Block a user