diff --git a/.dockerignore b/.dockerignore
index 9d990ab9ce6..fe87f6e6006 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,7 @@
.gitattributes
.gitignore
.github
+.vscode
public/system
public/assets
public/packs
@@ -20,6 +21,7 @@ postgres14
redis
elasticsearch
chart
+storybook-static
.yarn/
!.yarn/patches
!.yarn/plugins
diff --git a/.env.production.sample b/.env.production.sample
index 12e3d32be73..52a2838cbf5 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -91,9 +91,6 @@ SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior
# --------------------------
-# When a user expands a post (DetailedStatus view), fetch all of its replies
-# (default: false)
-FETCH_REPLIES_ENABLED=false
# Period to wait between fetching replies (in minutes)
FETCH_REPLIES_COOLDOWN_MINUTES=15
diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml
index bb4b71dd9f7..2261275a432 100644
--- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml
@@ -1,6 +1,6 @@
name: Bug Report (Web Interface)
description: There is a problem using Mastodon's web interface.
-labels: ['status/to triage', 'area/web interface']
+labels: ['area/web interface']
type: Bug
body:
- type: markdown
diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml
index c6d7e8e16b6..99ec9cf146f 100644
--- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml
@@ -1,7 +1,6 @@
name: Bug Report (server / API)
description: |
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
-labels: ['status/to triage']
type: 'Bug'
body:
- type: markdown
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 1850a45bbcd..c1a1c99eb70 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -6,6 +6,7 @@
':labels(dependencies)',
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
+ ':enableVulnerabilityAlertsWithLabel(security)',
],
rebaseWhen: 'conflicted',
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
@@ -23,7 +24,6 @@
matchManagers: ['npm'],
matchPackageNames: [
'tesseract.js', // Requires code changes
- 'react-hotkeys', // Requires code changes
// react-router: Requires manual upgrade
'history',
@@ -94,6 +94,19 @@
matchUpdateTypes: ['patch', 'minor'],
groupName: 'eslint (non-major)',
},
+ {
+ // Group all Storybook-related packages in the same PR
+ matchManagers: ['npm'],
+ matchPackageNames: [
+ 'chromatic',
+ 'storybook',
+ '@storybook/*',
+ 'msw',
+ 'msw-storybook-addon',
+ ],
+ matchUpdateTypes: ['patch', 'minor'],
+ groupName: 'storybook (non-major)',
+ },
{
// Group actions/*-artifact in the same PR
matchManagers: ['github-actions'],
@@ -142,6 +155,12 @@
matchUpdateTypes: ['patch', 'minor'],
groupName: 'opentelemetry-ruby (non-major)',
},
+ {
+ // Group Playwright Ruby & JS deps in the same PR, as they need to be in sync
+ matchManagers: ['bundler', 'npm'],
+ matchPackageNames: ['playwright-ruby-client', 'playwright'],
+ groupName: 'Playwright',
+ },
// Add labels depending on package manager
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml
index d3cb4e5e0ae..72729b544b3 100644
--- a/.github/workflows/build-security.yml
+++ b/.github/workflows/build-security.yml
@@ -9,7 +9,6 @@ permissions:
jobs:
compute-suffix:
runs-on: ubuntu-latest
- if: github.repository == 'mastodon/mastodon'
steps:
- id: version_vars
env:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 8690e9ed6d1..c864e12d2d8 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -25,8 +25,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- language: ['javascript', 'ruby']
- # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ language: ['actions', 'javascript', 'ruby']
+ # CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml
index 6d9a0586298..8e18a9d0a0f 100644
--- a/.github/workflows/crowdin-download-stable.yml
+++ b/.github/workflows/crowdin-download-stable.yml
@@ -50,7 +50,7 @@ jobs:
# Create or update the pull request
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v7.0.6
+ uses: peter-evans/create-pull-request@v7.0.8
with:
commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml
index 4f4d917d15a..d0d79d91996 100644
--- a/.github/workflows/crowdin-upload.yml
+++ b/.github/workflows/crowdin-upload.yml
@@ -14,6 +14,7 @@ on:
- config/locales/devise.en.yml
- config/locales/doorkeeper.en.yml
- .github/workflows/crowdin-upload.yml
+ workflow_dispatch:
jobs:
upload-translations:
diff --git a/.nvmrc b/.nvmrc
index 4a203c23d83..403f75d0382 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22.17
+22.20
diff --git a/.rubocop/metrics.yml b/.rubocop/metrics.yml
index 89532af42ab..bb15e6ff313 100644
--- a/.rubocop/metrics.yml
+++ b/.rubocop/metrics.yml
@@ -1,17 +1,21 @@
---
Metrics/AbcSize:
- Exclude:
- - lib/mastodon/cli/*.rb
+ Enabled: false
Metrics/BlockLength:
Enabled: false
+Metrics/BlockNesting:
+ Enabled: false
+
Metrics/ClassLength:
Enabled: false
+Metrics/CollectionLiteralLength:
+ Enabled: false
+
Metrics/CyclomaticComplexity:
- Exclude:
- - lib/mastodon/cli/*.rb
+ Enabled: false
Metrics/MethodLength:
Enabled: false
@@ -20,4 +24,7 @@ Metrics/ModuleLength:
Enabled: false
Metrics/ParameterLists:
- CountKeywordArgs: false
+ Enabled: false
+
+Metrics/PerceivedComplexity:
+ Enabled: false
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 4ec92f34121..0cc9c8d8fc1 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,32 +1,11 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
-# using RuboCop version 1.77.0.
+# using RuboCop version 1.80.2.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
-Lint/NonLocalExitFromIterator:
- Exclude:
- - 'app/helpers/json_ld_helper.rb'
-
-# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
-Metrics/AbcSize:
- Max: 82
-
-# Configuration parameters: CountBlocks, CountModifierForms, Max.
-Metrics/BlockNesting:
- Exclude:
- - 'lib/tasks/mastodon.rake'
-
-# Configuration parameters: AllowedMethods, AllowedPatterns.
-Metrics/CyclomaticComplexity:
- Max: 25
-
-# Configuration parameters: AllowedMethods, AllowedPatterns.
-Metrics/PerceivedComplexity:
- Max: 27
-
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars, DefaultToNil.
Style/FetchEnvVar:
diff --git a/.ruby-version b/.ruby-version
index f9892605c75..2aa51319921 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.4.4
+3.4.7
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 72321cbf3f1..bb69f0c6649 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,3 +1,5 @@
+import { resolve } from 'node:path';
+
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
@@ -26,6 +28,12 @@ const config: StorybookConfig = {
'oops.png',
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
],
+ viteFinal(config) {
+ // For an unknown reason, Storybook does not use the root
+ // from the Vite config so we need to set it manually.
+ config.root = resolve(__dirname, '../app/javascript');
+ return config;
+ },
};
export default config;
diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html
new file mode 100644
index 00000000000..1870d95b8fe
--- /dev/null
+++ b/.storybook/preview-body.html
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index f25d0547e83..d66f0fb11a8 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales';
-import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
+import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
+import './styles.css';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
@@ -49,12 +50,23 @@ const preview: Preview = {
locale: 'en',
},
decorators: [
- (Story, { parameters }) => {
+ (Story, { parameters, globals, args }) => {
+ // Get the locale from the global toolbar
+ // and merge it with any parameters or args state.
+ const { locale } = globals as { locale: string };
const { state = {} } = parameters;
- let reducer = rootReducer;
- if (typeof state === 'object' && state) {
- reducer = reducerWithInitialState(state as Record);
- }
+ const { state: argsState = {} } = args;
+
+ const reducer = reducerWithInitialState(
+ {
+ meta: {
+ locale,
+ },
+ },
+ state as Record,
+ argsState as Record,
+ );
+
const store = configureStore({
reducer,
middleware(getDefaultMiddleware) {
diff --git a/.storybook/static/mockServiceWorker.js b/.storybook/static/mockServiceWorker.js
index de7bc0f292d..15623f1090b 100644
--- a/.storybook/static/mockServiceWorker.js
+++ b/.storybook/static/mockServiceWorker.js
@@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/
-const PACKAGE_VERSION = '2.10.2'
-const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
+const PACKAGE_VERSION = '2.11.3'
+const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
break
}
- case 'MOCK_DEACTIVATE': {
- activeClientIds.delete(clientId)
- break
- }
-
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
@@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
})
addEventListener('fetch', function (event) {
+ const requestInterceptedAt = Date.now()
+
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
@@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
- // after it's been deleted (still remains active until the next reload).
+ // after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
- event.respondWith(handleRequest(event, requestId))
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
+ * @param {number} requestInterceptedAt
*/
-async function handleRequest(event, requestId) {
+async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
- const response = await getResponse(event, client, requestId)
+ const response = await getResponse(
+ event,
+ client,
+ requestId,
+ requestInterceptedAt,
+ )
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
@@ -204,7 +207,7 @@ async function resolveMainClient(event) {
* @param {string} requestId
* @returns {Promise}
*/
-async function getResponse(event, client, requestId) {
+async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
@@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) {
type: 'REQUEST',
payload: {
id: requestId,
+ interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
diff --git a/.storybook/styles.css b/.storybook/styles.css
new file mode 100644
index 00000000000..ac29890895e
--- /dev/null
+++ b/.storybook/styles.css
@@ -0,0 +1,8 @@
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efdd3adf120..249596dc055 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,129 @@
All notable changes to this project will be documented in this file.
-## [4.4.0] - UNRELEASED
+## [4.4.6] - 2025-10-13
+
+### Security
+
+- Update dependencies `rack` and `uri`
+- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh))
+- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655))
+- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp))
+
+### Added
+
+- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire)
+
+### Fixed
+
+- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire)
+- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski)
+- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire)
+- Fix quotes not being displayed in email notifications (#36379 by @diondiondion)
+- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire)
+- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion)
+
+## [4.4.5] - 2025-09-23
+
+### Security
+
+- Update dependencies
+
+### Added
+
+- Add support for `has:quote` in search (#36217 by @ClearlyClaire)
+
+### Changed
+
+- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire)
+
+### Fixed
+
+- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire)
+- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire)
+- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire)
+
+## [4.4.4] - 2025-09-16
+
+### Security
+
+- Update dependencies
+
+### Fixed
+
+- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire)
+- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire)
+- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire)
+- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus)
+- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm)
+- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion)
+- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire)
+- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable)
+- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski)
+- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire)
+- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire)
+- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire)
+- Fix quote revocation not being streamed (#35710 by @ClearlyClaire)
+- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire)
+
+### Changed
+
+- Change labels for quote policy settings (#35893 by @ClearlyClaire)
+- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima)
+
+## [4.4.3] - 2025-08-05
+
+### Security
+
+- Update dependencies
+- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
+
+### Fixed
+
+- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
+- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
+- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
+- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
+- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
+- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
+
+### Changed
+
+- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
+- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
+- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
+
+## [4.4.2] - 2025-07-23
+
+### Security
+
+- Update dependencies
+
+### Fixed
+
+- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
+- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
+- Fix quote posts styling on notifications page (#35411 by @diondiondion)
+- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
+- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
+- Update age limit wording (#35387 by @diondiondion)
+- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
+- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
+- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
+- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
+- Fix styling of external log-in button (#35320 by @ClearlyClaire)
+
+## [4.4.1] - 2025-07-09
+
+### Fixed
+
+- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
+- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
+- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
+- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
+- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
+
+## [4.4.0] - 2025-07-08
### Added
@@ -38,7 +160,7 @@ All notable changes to this project will be documented in this file.
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
-- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126 and #35127 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
+- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
@@ -51,7 +173,7 @@ All notable changes to this project will be documented in this file.
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
-- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033 and #35218 by @oneiros)\
+- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
@@ -64,7 +186,7 @@ All notable changes to this project will be documented in this file.
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
-- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033 and #35109 by @oneiros)\
+- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
- Add experimental Async Refreshes API (#34918 by @oneiros)
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
@@ -218,6 +340,7 @@ All notable changes to this project will be documented in this file.
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
+- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
- Fix directory scroll position reset (#34560 by @przucidlo)
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
@@ -232,7 +355,7 @@ All notable changes to this project will be documented in this file.
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
-- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096 and #35150 by @diondiondion)
+- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
@@ -530,7 +653,6 @@ The following changelog entries focus on changes visible to users, administrator
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
This adds the following REST API endpoints:
-
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
@@ -542,7 +664,6 @@ The following changelog entries focus on changes visible to users, administrator
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
In addition, accepting one or more notification requests generates a new streaming event:
-
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
diff --git a/Dockerfile b/Dockerfile
index 6cb4a2a1c0a..ad8150552a4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# syntax=docker/dockerfile:1.12
+# syntax=docker/dockerfile:1.18
# This file is designed for production server deployment, not local development work
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker
@@ -13,15 +13,15 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby
-ARG RUBY_VERSION="3.4.4"
+ARG RUBY_VERSION="3.4.7"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22"
-# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
-ARG DEBIAN_VERSION="bookworm"
-# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim)
+# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
+ARG DEBIAN_VERSION="trixie"
+# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
-# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
+# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-trixie)
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
@@ -96,9 +96,6 @@ RUN \
# Set /opt/mastodon as working directory
WORKDIR /opt/mastodon
-# Add backport repository for some specific packages where we need the latest version
-RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
-
# hadolint ignore=DL3008,DL3005
RUN \
# Mount Apt cache and lib directories from Docker buildx caches
@@ -161,11 +158,11 @@ RUN \
libexif-dev \
libexpat1-dev \
libgirepository1.0-dev \
- libheif-dev/bookworm-backports \
+ libheif-dev \
+ libhwy-dev \
libimagequant-dev \
libjpeg62-turbo-dev \
liblcms2-dev \
- liborc-dev \
libspng-dev \
libtiff-dev \
libwebp-dev \
@@ -186,7 +183,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
-ARG VIPS_VERSION=8.17.0
+ARG VIPS_VERSION=8.17.2
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
@@ -209,7 +206,7 @@ FROM build AS ffmpeg
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
-ARG FFMPEG_VERSION=7.1
+ARG FFMPEG_VERSION=8.0
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
ARG FFMPEG_URL=https://ffmpeg.org/releases
@@ -327,28 +324,28 @@ RUN \
# Apt update install non-dev versions of necessary components
apt-get install -y --no-install-recommends \
libexpat1 \
- libglib2.0-0 \
- libicu72 \
+ libglib2.0-0t64 \
+ libicu76 \
libidn12 \
libpq5 \
- libreadline8 \
- libssl3 \
+ libreadline8t64 \
+ libssl3t64 \
libyaml-0-2 \
# libvips components
libcgif0 \
libexif12 \
- libheif1/bookworm-backports \
+ libheif1 \
+ libhwy1t64 \
libimagequant0 \
libjpeg62-turbo \
liblcms2-2 \
- liborc-0.4-0 \
libspng0 \
libtiff6 \
libwebp7 \
libwebpdemux2 \
libwebpmux3 \
# ffmpeg components
- libdav1d6 \
+ libdav1d7 \
libmp3lame0 \
libopencore-amrnb0 \
libopencore-amrwb0 \
@@ -358,9 +355,9 @@ RUN \
libvorbis0a \
libvorbisenc2 \
libvorbisfile3 \
- libvpx7 \
+ libvpx9 \
libx264-164 \
- libx265-199 \
+ libx265-215 \
;
# Copy Mastodon sources into final layer
diff --git a/Gemfile b/Gemfile
index ffd5371b06d..aa201d1e72b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,12 +4,12 @@ source 'https://rubygems.org'
ruby '>= 3.2.0', '< 3.5.0'
gem 'propshaft'
-gem 'puma', '~> 6.3'
+gem 'puma', '~> 7.0'
gem 'rails', '~> 8.0'
gem 'thor', '~> 1.2'
gem 'dotenv'
-gem 'haml-rails', '~>2.0'
+gem 'haml-rails', '~>3.0'
gem 'pg', '~> 1.5'
gem 'pghero'
@@ -62,7 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
-gem 'linzer', '~> 0.7.2'
+gem 'linzer', '~> 0.7.7'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
gem 'mutex_m'
@@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0'
gem 'ruby-progressbar', '~> 1.13'
gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7'
-gem 'sidekiq', '< 8'
+gem 'sidekiq', '< 9'
gem 'sidekiq-bulk', '~> 0.2.0'
-gem 'sidekiq-scheduler', '~> 5.0'
+gem 'sidekiq-scheduler', '~> 6.0'
gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4'
-gem 'stoplight', '~> 4.1'
+gem 'stoplight'
gem 'strong_migrations'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
@@ -102,23 +102,23 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false
-gem 'opentelemetry-api', '~> 1.5.0'
+gem 'opentelemetry-api', '~> 1.7.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
- gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
- gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
- gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
- gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
- gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
- gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
- gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
- gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
- gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
- gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
- gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false
- gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
- gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
+ gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false
+ gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false
+ gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false
+ gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false
+ gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false
+ gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false
+ gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false
+ gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false
+ gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false
+ gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false
+ gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false
+ gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false
+ gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false
end
@@ -138,6 +138,7 @@ group :test do
# Browser integration testing
gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver'
+ gem 'playwright-ruby-client', '1.55.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
# Used to reset the database between system tests
gem 'database_cleaner-active_record'
@@ -146,7 +147,7 @@ group :test do
gem 'climate_control'
# Validate schemas in specs
- gem 'json-schema', '~> 5.0'
+ gem 'json-schema', '~> 6.0'
# Test harness fo rack components
gem 'rack-test', '~> 2.1'
@@ -159,6 +160,9 @@ group :test do
# Stub web requests for specs
gem 'webmock', '~> 3.18'
+
+ # Websocket driver for testing integration between rails/sidekiq and streaming
+ gem 'websocket-driver', '~> 0.8', require: false
end
group :development do
@@ -223,7 +227,7 @@ gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'net-http', '~> 0.6.0'
-gem 'rubyzip', '~> 2.3'
+gem 'rubyzip', '~> 3.0'
gem 'hcaptcha', '~> 7.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index b2eff8ad359..1d63e0d955c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -10,29 +10,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ actioncable (8.0.3)
+ actionpack (= 8.0.3)
+ activesupport (= 8.0.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailbox (8.0.3)
+ actionpack (= 8.0.3)
+ activejob (= 8.0.3)
+ activerecord (= 8.0.3)
+ activestorage (= 8.0.3)
+ activesupport (= 8.0.3)
mail (>= 2.8.0)
- actionmailer (8.0.2)
- actionpack (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailer (8.0.3)
+ actionpack (= 8.0.3)
+ actionview (= 8.0.3)
+ activejob (= 8.0.3)
+ activesupport (= 8.0.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.0.2)
- actionview (= 8.0.2)
- activesupport (= 8.0.2)
+ actionpack (8.0.3)
+ actionview (= 8.0.3)
+ activesupport (= 8.0.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -40,15 +40,15 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.0.2)
- actionpack (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actiontext (8.0.3)
+ actionpack (= 8.0.3)
+ activerecord (= 8.0.3)
+ activestorage (= 8.0.3)
+ activesupport (= 8.0.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.0.2)
- activesupport (= 8.0.2)
+ actionview (8.0.3)
+ activesupport (= 8.0.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@@ -58,22 +58,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
- activejob (8.0.2)
- activesupport (= 8.0.2)
+ activejob (8.0.3)
+ activesupport (= 8.0.3)
globalid (>= 0.3.6)
- activemodel (8.0.2)
- activesupport (= 8.0.2)
- activerecord (8.0.2)
- activemodel (= 8.0.2)
- activesupport (= 8.0.2)
+ activemodel (8.0.3)
+ activesupport (= 8.0.3)
+ activerecord (8.0.3)
+ activemodel (= 8.0.3)
+ activesupport (= 8.0.3)
timeout (>= 0.4.0)
- activestorage (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activesupport (= 8.0.2)
+ activestorage (8.0.3)
+ actionpack (= 8.0.3)
+ activejob (= 8.0.3)
+ activerecord (= 8.0.3)
+ activesupport (= 8.0.3)
marcel (~> 1.0)
- activesupport (8.0.2)
+ activesupport (8.0.3)
base64
benchmark (>= 0.3)
bigdecimal
@@ -90,13 +90,13 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
android_key_attestation (0.3.0)
- annotaterb (4.16.0)
+ annotaterb (4.19.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
attr_required (1.0.2)
- aws-eventstream (1.3.2)
- aws-partitions (1.1103.0)
+ aws-eventstream (1.4.0)
+ aws-partitions (1.1168.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -109,9 +109,9 @@ GEM
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
- aws-sigv4 (1.11.0)
+ aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
- azure-blob (0.5.8)
+ azure-blob (0.5.9.1)
rexml
base64 (0.3.0)
bcp47_spec (0.2.1)
@@ -121,7 +121,7 @@ GEM
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
- bigdecimal (3.2.2)
+ bigdecimal (3.2.3)
bindata (2.5.1)
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
@@ -144,13 +144,13 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
- capybara-playwright-driver (0.5.6)
+ capybara-playwright-driver (0.5.7)
addressable
capybara
playwright-ruby-client (>= 1.16.0)
case_transform (0.2)
activesupport
- cbor (0.5.9.8)
+ cbor (0.5.10.1)
cgi (0.4.2)
charlock_holmes (0.7.9)
chewy (7.6.0)
@@ -164,7 +164,7 @@ GEM
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.5)
- connection_pool (2.5.3)
+ connection_pool (2.5.4)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
@@ -175,9 +175,9 @@ GEM
css_parser (1.21.1)
addressable
csv (3.3.5)
- database_cleaner-active_record (2.2.1)
+ database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
- database_cleaner-core (~> 2.0.0)
+ database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
debug (1.11.0)
@@ -207,7 +207,7 @@ GEM
railties (>= 5)
dotenv (3.1.8)
drb (2.2.3)
- dry-cli (1.2.0)
+ dry-cli (1.3.0)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
@@ -224,24 +224,24 @@ GEM
mail (~> 2.7)
email_validator (2.2.4)
activemodel
- erb (5.0.1)
+ erb (5.0.2)
erubi (1.13.1)
- et-orbi (1.2.11)
+ et-orbi (1.4.0)
tzinfo
- excon (1.2.5)
+ excon (1.3.0)
logger
fabrication (3.0.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
- faraday (2.13.1)
+ faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
- faraday-follow_redirects (0.3.0)
+ faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-httpclient (2.0.2)
httpclient (>= 2.2)
- faraday-net_http (3.4.0)
+ faraday-net_http (3.4.1)
net-http (>= 0.5.0)
fast_blank (1.0.1)
fastimage (2.4.0)
@@ -266,42 +266,43 @@ GEM
fog-openstack (1.1.5)
fog-core (~> 2.1)
fog-json (>= 1.0)
- formatador (1.1.0)
+ formatador (1.2.1)
+ reline
forwardable (1.3.3)
- fugit (1.11.1)
- et-orbi (~> 1, >= 1.2.11)
+ fugit (1.12.0)
+ et-orbi (~> 1.4)
raabro (~> 1.4)
- globalid (1.2.1)
+ globalid (1.3.0)
activesupport (>= 6.1)
- google-protobuf (4.31.0)
+ google-protobuf (4.32.1)
bigdecimal
rake (>= 13)
- googleapis-common-protos-types (1.20.0)
- google-protobuf (>= 3.18, < 5.a)
+ googleapis-common-protos-types (1.21.0)
+ google-protobuf (~> 4.26)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
- haml-rails (2.1.0)
+ haml-rails (3.0.0)
actionpack (>= 5.1)
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
- haml_lint (0.64.0)
+ haml_lint (0.66.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
rubocop (>= 1.0)
sysexits (~> 1.1)
- hashdiff (1.1.2)
+ hashdiff (1.2.1)
hashie (5.0.0)
hcaptcha (7.1.0)
json
highline (3.1.2)
reline
hiredis (0.6.3)
- hiredis-client (0.24.0)
- redis-client (= 0.24.0)
+ hiredis-client (0.26.1)
+ redis-client (= 0.26.1)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.3.1)
@@ -309,13 +310,13 @@ GEM
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0)
- http-cookie (1.0.8)
+ http-cookie (1.1.0)
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
httpclient (2.9.0)
mutex_m
- httplog (1.7.0)
+ httplog (1.7.3)
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.14.7)
@@ -335,7 +336,7 @@ GEM
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
- io-console (0.8.0)
+ io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
@@ -345,9 +346,9 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
- json (2.12.2)
+ json (2.15.1)
json-canonicalization (1.0.0)
- json-jwt (1.16.7)
+ json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
@@ -362,14 +363,14 @@ GEM
rack (>= 2.2, < 4)
rdf (~> 3.3)
rexml (~> 3.2)
- json-ld-preloaded (3.3.1)
+ json-ld-preloaded (3.3.2)
json-ld (~> 3.3)
rdf (~> 3.3)
- json-schema (5.1.1)
+ json-schema (6.0.0)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
- jwt (2.10.1)
+ jwt (2.10.2)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -403,7 +404,7 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
- linzer (0.7.3)
+ linzer (0.7.7)
cgi (~> 0.4.2)
forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0)
@@ -433,24 +434,26 @@ GEM
marcel (1.0.4)
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
- matrix (0.4.2)
+ matrix (0.4.3)
memory_profiler (1.1.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
- mime-types-data (3.2025.0514)
+ mime-types-data (3.2025.0924)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.8.0)
- multi_json (1.15.0)
+ multi_json (1.17.0)
mutex_m (0.3.0)
net-http (0.6.0)
uri
- net-imap (0.5.8)
+ net-imap (0.5.12)
date
net-protocol
- net-ldap (0.19.0)
+ net-ldap (0.20.0)
+ base64
+ ostruct
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
@@ -458,17 +461,18 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
- nokogiri (1.18.8)
+ nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
- omniauth (2.1.3)
+ omniauth (2.1.4)
hashie (>= 3.4.6)
+ logger
rack (>= 2.2.3)
rack-protection
- omniauth-cas (3.0.1)
+ omniauth-cas (3.0.2)
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
@@ -494,10 +498,10 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
- openssl (3.3.0)
+ openssl (3.3.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
- opentelemetry-api (1.5.0)
+ opentelemetry-api (1.7.0)
opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.30.0)
@@ -507,113 +511,88 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
- opentelemetry-helpers-sql (0.1.1)
- opentelemetry-api (~> 1.0)
+ opentelemetry-helpers-sql (0.2.0)
+ opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-obfuscation (0.3.0)
opentelemetry-common (~> 0.21)
- opentelemetry-instrumentation-action_mailer (0.4.0)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-action_mailer (0.5.0)
opentelemetry-instrumentation-active_support (~> 0.7)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-action_pack (0.12.1)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
+ opentelemetry-instrumentation-action_pack (0.14.1)
opentelemetry-instrumentation-rack (~> 0.21)
- opentelemetry-instrumentation-action_view (0.9.0)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-action_view (0.10.0)
opentelemetry-instrumentation-active_support (~> 0.7)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-active_job (0.8.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-active_model_serializers (0.22.0)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-active_job (0.9.2)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-active_model_serializers (0.23.0)
opentelemetry-instrumentation-active_support (>= 0.7.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-active_record (0.9.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-active_storage (0.1.1)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-active_record (0.10.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-active_storage (0.2.0)
opentelemetry-instrumentation-active_support (~> 0.7)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-active_support (0.8.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-base (0.23.0)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-active_support (0.9.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-base (0.24.0)
+ opentelemetry-api (~> 1.7)
opentelemetry-common (~> 0.21)
opentelemetry-registry (~> 0.1)
- opentelemetry-instrumentation-concurrent_ruby (0.22.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-excon (0.23.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-faraday (0.27.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-http (0.25.1)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-http_client (0.23.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-net_http (0.23.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-pg (0.30.1)
- opentelemetry-api (~> 1.0)
+ opentelemetry-instrumentation-concurrent_ruby (0.23.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-excon (0.25.2)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-faraday (0.29.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-http (0.26.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-http_client (0.25.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-net_http (0.25.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-pg (0.31.1)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-rack (0.26.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-rails (0.36.0)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-action_mailer (~> 0.4.0)
- opentelemetry-instrumentation-action_pack (~> 0.12.0)
- opentelemetry-instrumentation-action_view (~> 0.9.0)
- opentelemetry-instrumentation-active_job (~> 0.8.0)
- opentelemetry-instrumentation-active_record (~> 0.9.0)
- opentelemetry-instrumentation-active_storage (~> 0.1.0)
- opentelemetry-instrumentation-active_support (~> 0.8.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
- opentelemetry-instrumentation-redis (0.26.1)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
- opentelemetry-instrumentation-sidekiq (0.26.1)
- opentelemetry-api (~> 1.0)
- opentelemetry-instrumentation-base (~> 0.23.0)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-rack (0.28.2)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-rails (0.38.0)
+ opentelemetry-instrumentation-action_mailer (~> 0.4)
+ opentelemetry-instrumentation-action_pack (~> 0.13)
+ opentelemetry-instrumentation-action_view (~> 0.9)
+ opentelemetry-instrumentation-active_job (~> 0.8)
+ opentelemetry-instrumentation-active_record (~> 0.9)
+ opentelemetry-instrumentation-active_storage (~> 0.1)
+ opentelemetry-instrumentation-active_support (~> 0.8)
+ opentelemetry-instrumentation-concurrent_ruby (~> 0.22)
+ opentelemetry-instrumentation-redis (0.27.1)
+ opentelemetry-instrumentation-base (~> 0.24)
+ opentelemetry-instrumentation-sidekiq (0.27.1)
+ opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
- opentelemetry-sdk (1.8.0)
+ opentelemetry-sdk (1.9.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
- opentelemetry-semantic_conventions (1.11.0)
+ opentelemetry-semantic_conventions (1.36.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
- ostruct (0.6.1)
+ ostruct (0.6.3)
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
- parser (3.3.8.0)
+ parser (3.3.9.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
- pg (1.5.9)
+ pg (1.6.2)
pghero (3.7.0)
activerecord (>= 7.1)
- playwright-ruby-client (1.52.0)
+ playwright-ruby-client (1.55.0)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
- pp (0.6.2)
+ pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
@@ -624,25 +603,24 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
- prism (1.4.0)
- prometheus_exporter (2.2.0)
+ prism (1.5.1)
+ prometheus_exporter (2.3.0)
webrick
- propshaft (1.1.0)
+ propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
- railties (>= 7.0.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
- puma (6.6.0)
+ puma (7.0.4)
nio4r (~> 2.0)
- pundit (2.5.0)
+ pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.1.16)
+ rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
@@ -668,76 +646,81 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
- rails (8.0.2)
- actioncable (= 8.0.2)
- actionmailbox (= 8.0.2)
- actionmailer (= 8.0.2)
- actionpack (= 8.0.2)
- actiontext (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activemodel (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ rails (8.0.3)
+ actioncable (= 8.0.3)
+ actionmailbox (= 8.0.3)
+ actionmailer (= 8.0.3)
+ actionpack (= 8.0.3)
+ actiontext (= 8.0.3)
+ actionview (= 8.0.3)
+ activejob (= 8.0.3)
+ activemodel (= 8.0.3)
+ activerecord (= 8.0.3)
+ activestorage (= 8.0.3)
+ activesupport (= 8.0.3)
bundler (>= 1.15.0)
- railties (= 8.0.2)
- rails-dom-testing (2.2.0)
+ railties (= 8.0.3)
+ rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- rails-i18n (8.0.1)
+ rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
- railties (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ railties (8.0.3)
+ actionpack (= 8.0.3)
+ activesupport (= 8.0.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
- rdf (3.3.2)
+ rdf (3.3.4)
bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5)
link_header (~> 0.0, >= 0.0.8)
+ logger (~> 1.5)
+ ostruct (~> 0.6)
+ readline (~> 0.0)
rdf-normalize (0.7.0)
rdf (~> 3.3)
- rdoc (6.14.1)
+ rdoc (6.15.0)
erb
psych (>= 4.0.0)
+ tsort
+ readline (0.0.4)
+ reline
redcarpet (3.6.1)
redis (4.8.1)
- redis-client (0.24.0)
+ redis-client (0.26.1)
connection_pool
- redlock (1.3.2)
- redis (>= 3.0.0, < 6.0)
- regexp_parser (2.10.0)
- reline (0.6.1)
+ regexp_parser (2.11.3)
+ reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
- rexml (3.4.1)
+ rexml (3.4.4)
rotp (6.3.0)
- rouge (4.5.2)
+ rouge (4.6.1)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
- rspec (3.13.0)
+ rspec (3.13.1)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
- rspec-core (3.13.4)
+ rspec-core (3.13.5)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
@@ -747,7 +730,7 @@ GEM
rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-rails (8.0.1)
+ rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
@@ -755,13 +738,13 @@ GEM
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
- rspec-sidekiq (5.1.0)
+ rspec-sidekiq (5.2.0)
rspec-core (~> 3.0)
rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
- rspec-support (3.13.4)
- rubocop (1.77.0)
+ rspec-support (3.13.6)
+ rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -769,10 +752,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.45.1, < 2.0)
+ rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.45.1)
+ rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
@@ -781,17 +764,17 @@ GEM
rubocop-i18n (3.2.3)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
- rubocop-performance (1.25.0)
+ rubocop-performance (1.26.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
- rubocop-ast (>= 1.38.0, < 2.0)
- rubocop-rails (2.32.0)
+ rubocop-ast (>= 1.44.0, < 2.0)
+ rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
- rubocop-rspec (3.6.0)
+ rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
@@ -801,38 +784,37 @@ GEM
ruby-prof (1.7.2)
base64
ruby-progressbar (1.13.0)
- ruby-saml (1.18.0)
+ ruby-saml (1.18.1)
nokogiri (>= 1.13.10)
rexml
- ruby-vips (2.2.4)
+ ruby-vips (2.2.5)
ffi (~> 1.12)
logger
- rubyzip (2.4.1)
+ rubyzip (3.1.1)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
- safety_net_attestation (0.4.0)
- jwt (~> 2.0)
+ safety_net_attestation (0.5.0)
+ jwt (>= 2.0, < 4.0)
sanitize (7.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
- scenic (1.8.0)
+ scenic (1.9.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
- sidekiq (7.3.9)
- base64
- connection_pool (>= 2.3.0)
- logger
- rack (>= 2.2.4)
- redis-client (>= 0.22.2)
+ sidekiq (8.0.8)
+ connection_pool (>= 2.5.0)
+ json (>= 2.9.0)
+ logger (>= 1.6.2)
+ rack (>= 3.1.0)
+ redis-client (>= 0.23.2)
sidekiq-bulk (0.2.0)
sidekiq
- sidekiq-scheduler (5.0.6)
+ sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2)
- sidekiq (>= 6, < 8)
- tilt (>= 1.4.0, < 3)
+ sidekiq (>= 7.3, < 9)
sidekiq-unique-jobs (8.0.11)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
@@ -846,16 +828,16 @@ GEM
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
- simplecov-html (0.13.1)
- simplecov-lcov (0.8.0)
+ simplecov-html (0.13.2)
+ simplecov-lcov (0.9.0)
simplecov_json_formatter (0.1.4)
stackprof (0.2.27)
starry (0.2.0)
base64
- stoplight (4.1.1)
- redlock (~> 1.0)
+ stoplight (5.3.8)
+ zeitwerk
stringio (3.1.7)
- strong_migrations (2.4.0)
+ strong_migrations (2.5.1)
activerecord (>= 7.1)
swd (2.0.3)
activesupport (>= 3)
@@ -863,19 +845,20 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
- temple (0.10.3)
+ temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
- terrapin (1.1.0)
+ terrapin (1.1.1)
climate_control
test-prof (1.4.4)
- thor (1.3.2)
- tilt (2.6.0)
+ thor (1.4.0)
+ tilt (2.6.1)
timeout (0.4.3)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
+ tsort (0.2.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
@@ -896,10 +879,10 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
- unicode-display_width (3.1.4)
- unicode-emoji (~> 4.0, >= 4.0.4)
- unicode-emoji (4.0.4)
- uri (1.0.3)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.1.0)
+ uri (1.0.4)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -915,13 +898,13 @@ GEM
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
- webauthn (3.4.1)
+ webauthn (3.4.2)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
- safety_net_attestation (~> 0.4.0)
+ safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
webfinger (2.1.3)
activesupport
@@ -932,7 +915,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
- websocket-driver (0.7.7)
+ websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -985,7 +968,7 @@ DEPENDENCIES
flatware-rspec
fog-core (<= 2.6.0)
fog-openstack (~> 1.0)
- haml-rails (~> 2.0)
+ haml-rails (~> 3.0)
haml_lint
hcaptcha (~> 7.1)
hiredis (~> 0.6)
@@ -1002,13 +985,13 @@ DEPENDENCIES
jd-paperclip-azure (~> 3.0)
json-ld
json-ld-preloaded (~> 3.2)
- json-schema (~> 5.0)
+ json-schema (~> 6.0)
kaminari (~> 1.2)
kt-paperclip (~> 7.2)
letter_opener (~> 1.8)
letter_opener_web (~> 3.0)
link_header (~> 0.0)
- linzer (~> 0.7.2)
+ linzer (~> 0.7.7)
lograge (~> 0.12)
mail (~> 2.8)
mario-redis-lock (~> 1.2)
@@ -1024,31 +1007,32 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
- opentelemetry-api (~> 1.5.0)
+ opentelemetry-api (~> 1.7.0)
opentelemetry-exporter-otlp (~> 0.30.0)
- opentelemetry-instrumentation-active_job (~> 0.8.0)
- opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
- opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
- opentelemetry-instrumentation-excon (~> 0.23.0)
- opentelemetry-instrumentation-faraday (~> 0.27.0)
- opentelemetry-instrumentation-http (~> 0.25.0)
- opentelemetry-instrumentation-http_client (~> 0.23.0)
- opentelemetry-instrumentation-net_http (~> 0.23.0)
- opentelemetry-instrumentation-pg (~> 0.30.0)
- opentelemetry-instrumentation-rack (~> 0.26.0)
- opentelemetry-instrumentation-rails (~> 0.36.0)
- opentelemetry-instrumentation-redis (~> 0.26.0)
- opentelemetry-instrumentation-sidekiq (~> 0.26.0)
+ opentelemetry-instrumentation-active_job (~> 0.9.0)
+ opentelemetry-instrumentation-active_model_serializers (~> 0.23.0)
+ opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0)
+ opentelemetry-instrumentation-excon (~> 0.25.0)
+ opentelemetry-instrumentation-faraday (~> 0.29.0)
+ opentelemetry-instrumentation-http (~> 0.26.0)
+ opentelemetry-instrumentation-http_client (~> 0.25.0)
+ opentelemetry-instrumentation-net_http (~> 0.25.0)
+ opentelemetry-instrumentation-pg (~> 0.31.0)
+ opentelemetry-instrumentation-rack (~> 0.28.0)
+ opentelemetry-instrumentation-rails (~> 0.38.0)
+ opentelemetry-instrumentation-redis (~> 0.27.0)
+ opentelemetry-instrumentation-sidekiq (~> 0.27.0)
opentelemetry-sdk (~> 1.4)
ox (~> 2.14)
parslet
pg (~> 1.5)
pghero
+ playwright-ruby-client (= 1.55.0)
premailer-rails
prometheus_exporter (~> 2.2)
propshaft
public_suffix (~> 6.0)
- puma (~> 6.3)
+ puma (~> 7.0)
pundit (~> 2.3)
rack-attack (~> 6.6)
rack-cors
@@ -1072,20 +1056,20 @@ DEPENDENCIES
ruby-prof
ruby-progressbar (~> 1.13)
ruby-vips (~> 2.2)
- rubyzip (~> 2.3)
+ rubyzip (~> 3.0)
sanitize (~> 7.0)
scenic (~> 1.7)
shoulda-matchers
- sidekiq (< 8)
+ sidekiq (< 9)
sidekiq-bulk (~> 0.2.0)
- sidekiq-scheduler (~> 5.0)
+ sidekiq-scheduler (~> 6.0)
sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4)
simple_form (~> 5.2)
simplecov (~> 0.22)
simplecov-lcov (~> 0.8)
stackprof
- stoplight (~> 4.1)
+ stoplight
strong_migrations
test-prof
thor (~> 1.2)
@@ -1096,10 +1080,11 @@ DEPENDENCIES
webauthn (~> 3.0)
webmock (~> 3.18)
webpush!
+ websocket-driver (~> 0.8)
xorcist (~> 1.1)
RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
- 2.6.9
+ 2.7.2
diff --git a/README.md b/README.md
index 552940fbea7..5c0e596b727 100644
--- a/README.md
+++ b/README.md
@@ -51,14 +51,14 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
- [Node.js](https://nodejs.org/) powers the streaming API.
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
-- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers.
-- [Chromatic](https://www.chromatic.com/) provides visual regression testing.
+- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
+- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
### Requirements
- **Ruby** 3.2+
- **PostgreSQL** 13+
-- **Redis** 6.2+
+- **Redis** 7.0+
- **Node.js** 20+
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
diff --git a/SECURITY.md b/SECURITY.md
index 26c06e67f83..19f431fac59 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
## Supported Versions
-| Version | Supported |
-| ------- | --------- |
-| 4.3.x | Yes |
-| 4.2.x | Yes |
-| < 4.2 | No |
+| Version | Supported |
+| ------- | ---------------- |
+| 4.4.x | Yes |
+| 4.3.x | Yes |
+| 4.2.x | Until 2026-01-08 |
+| < 4.2 | No |
diff --git a/Vagrantfile b/Vagrantfile
index ce456060cdd..0a343670240 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -54,6 +54,7 @@ sudo apt-get install \
pkg-config \
protobuf-compiler \
zlib1g-dev \
+ libvips42t64 \
-y
# Install rvm
@@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
- config.vm.box = "ubuntu/focal64"
+ config.vm.box = "bento/ubuntu-24.04"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index c3131edce93..efd0c92cef2 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -71,6 +71,10 @@ class AccountsController < ApplicationController
params[:username]
end
+ def account_id_param
+ params[:id]
+ end
+
def skip_temporary_suspension_response?
request.format == :json
end
diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb
new file mode 100644
index 00000000000..4daa75552e2
--- /dev/null
+++ b/app/controllers/activitypub/contexts_controller.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+class ActivityPub::ContextsController < ActivityPub::BaseController
+ vary_by -> { 'Signature' if authorized_fetch_mode? }
+
+ before_action :require_account_signature!, if: :authorized_fetch_mode?
+ before_action :set_conversation
+ before_action :set_items
+
+ DESCENDANTS_LIMIT = 60
+
+ def show
+ expires_in 3.minutes, public: public_fetch_mode?
+ render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+ end
+
+ def items
+ expires_in 3.minutes, public: public_fetch_mode?
+ render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+ end
+
+ private
+
+ def account_required?
+ false
+ end
+
+ def set_conversation
+ account_id, status_id = params[:id].split('-')
+ @conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id)
+ end
+
+ def set_items
+ @items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
+ end
+
+ def context_presenter
+ first_page = ActivityPub::CollectionPresenter.new(
+ id: items_context_url(@conversation, page_params),
+ type: :unordered,
+ part_of: items_context_url(@conversation),
+ next: next_page,
+ items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
+ )
+
+ ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter|
+ presenter.first = first_page
+ end
+ end
+
+ def items_collection_presenter
+ page = ActivityPub::CollectionPresenter.new(
+ id: items_context_url(@conversation, page_params),
+ type: :unordered,
+ part_of: items_context_url(@conversation),
+ next: next_page,
+ items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
+ )
+
+ return page if page_requested?
+
+ ActivityPub::CollectionPresenter.new(
+ id: items_context_url(@conversation),
+ type: :unordered,
+ first: page
+ )
+ end
+
+ def page_requested?
+ truthy_param?(:page)
+ end
+
+ def next_page
+ return nil if @items.size < DESCENDANTS_LIMIT
+
+ items_context_url(@conversation, page: true, min_id: @items.last.id)
+ end
+
+ def page_params
+ params.permit(:page, :min_id)
+ end
+end
diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb
index 4aa6a4a771f..e875517b021 100644
--- a/app/controllers/activitypub/likes_controller.rb
+++ b/app/controllers/activitypub/likes_controller.rb
@@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def likes_collection_presenter
ActivityPub::CollectionPresenter.new(
- id: account_status_likes_url(@account, @status),
+ id: ActivityPub::TagManager.instance.likes_uri_for(@status),
type: :unordered,
size: @status.favourites_count
)
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index a9476b806f5..928977768b9 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
end
def set_account
- @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
+ return super if params[:account_username].present? || params[:account_id].present?
+
+ @account = Account.representative
end
end
diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb
new file mode 100644
index 00000000000..f2f5313e1ad
--- /dev/null
+++ b/app/controllers/activitypub/quote_authorizations_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
+ include Authorization
+
+ vary_by -> { 'Signature' if authorized_fetch_mode? }
+
+ before_action :require_account_signature!, if: :authorized_fetch_mode?
+ before_action :set_quote_authorization
+
+ def show
+ expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode?
+ render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+ end
+
+ private
+
+ def pundit_user
+ signed_request_account
+ end
+
+ def set_quote_authorization
+ @quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
+ return not_found unless @quote.status.present? && @quote.quoted_status.present?
+
+ authorize @quote.status, :show?
+ rescue Mastodon::NotPermittedError
+ not_found
+ end
+end
diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb
index 0a19275d38e..1959f50d676 100644
--- a/app/controllers/activitypub/replies_controller.rb
+++ b/app/controllers/activitypub/replies_controller.rb
@@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def replies_collection_presenter
page = ActivityPub::CollectionPresenter.new(
- id: account_status_replies_url(@account, @status, page_params),
+ id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params),
type: :unordered,
part_of: account_status_replies_url(@account, @status),
next: next_page,
@@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
return page if page_requested?
ActivityPub::CollectionPresenter.new(
- id: account_status_replies_url(@account, @status),
+ id: ActivityPub::TagManager.instance.replies_uri_for(@status),
type: :unordered,
first: page
)
@@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
# Only consider remote accounts
return nil if @replies.size < DESCENDANTS_LIMIT
- account_status_replies_url(
- @account,
+ ActivityPub::TagManager.instance.replies_uri_for(
@status,
page: true,
min_id: @replies&.last&.id,
@@ -77,8 +76,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
# For now, we're serving only self-replies, but next page might be other accounts
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
- account_status_replies_url(
- @account,
+ ActivityPub::TagManager.instance.replies_uri_for(
@status,
page: true,
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb
index 65b4a5b3831..2d1e389885a 100644
--- a/app/controllers/activitypub/shares_controller.rb
+++ b/app/controllers/activitypub/shares_controller.rb
@@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def shares_collection_presenter
ActivityPub::CollectionPresenter.new(
- id: account_status_shares_url(@account, @status),
+ id: ActivityPub::TagManager.instance.shares_uri_for(@status),
type: :unordered,
size: @status.reblogs_count
)
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index 91849811e36..3cfd1e17617 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -14,16 +14,20 @@ module Admin
def create
authorize @account, :show?
- account_action = Admin::AccountAction.new(resource_params)
- account_action.target_account = @account
- account_action.current_account = current_account
+ @account_action = Admin::AccountAction.new(resource_params)
+ @account_action.target_account = @account
+ @account_action.current_account = current_account
- account_action.save!
-
- if account_action.with_report?
- redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
+ if @account_action.save
+ if @account_action.with_report?
+ redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
+ else
+ redirect_to admin_account_path(@account.id)
+ end
else
- redirect_to admin_account_path(@account.id)
+ @warning_presets = AccountWarningPreset.all
+
+ render :new
end
end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 10391aa3e21..e1406930147 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -16,11 +16,14 @@ module Admin
def batch
authorize :account, :index?
- @form = Form::AccountBatch.new(form_account_batch_params)
- @form.current_account = current_account
- @form.action = action_from_button
- @form.select_all_matching = params[:select_all_matching]
- @form.query = filtered_accounts
+ @form = Form::AccountBatch.new(
+ form_account_batch_params.merge(
+ action: action_from_button,
+ current_account:,
+ query: filtered_accounts,
+ select_all_matching: params[:select_all_matching]
+ )
+ )
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb
index 8b8e83fde77..61ca1661898 100644
--- a/app/controllers/admin/action_logs_controller.rb
+++ b/app/controllers/admin/action_logs_controller.rb
@@ -6,7 +6,7 @@ module Admin
def index
authorize :audit_log, :index?
- @auditable_accounts = Account.auditable.select(:id, :username)
+ @auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
end
private
diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb
index 702550eecc1..5d1555796f4 100644
--- a/app/controllers/admin/confirmations_controller.rb
+++ b/app/controllers/admin/confirmations_controller.rb
@@ -19,15 +19,13 @@ module Admin
log_action :resend, @user
- flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
- redirect_to admin_accounts_path
+ redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
end
private
def redirect_confirmed_user
- flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
- redirect_to admin_accounts_path
+ redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
end
def user_confirmed?
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 5b0867dcfba..fe314daeca6 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -9,10 +9,16 @@ module Admin
@pending_appeals_count = Appeal.pending.async_count
@pending_reports_count = Report.unresolved.async_count
- @pending_tags_count = Tag.pending_review.async_count
+ @pending_tags_count = pending_tags.async_count
@pending_users_count = User.pending.async_count
@system_checks = Admin::SystemCheck.perform(current_user)
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
end
+
+ private
+
+ def pending_tags
+ ::Trends::TagFilter.new(status: :pending_review).results
+ end
end
end
diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb
index 0c415536767..7c70603e231 100644
--- a/app/controllers/admin/disputes/appeals_controller.rb
+++ b/app/controllers/admin/disputes/appeals_controller.rb
@@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
end
def reject
- authorize @appeal, :approve?
+ authorize @appeal, :reject?
log_action :reject, @appeal
@appeal.reject!(current_account)
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index c3443b70776..5e1074b224a 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -36,7 +36,7 @@ module Admin
end
def edit
- authorize :domain_block, :create?
+ authorize :domain_block, :update?
end
def create
@@ -129,7 +129,7 @@ module Admin
end
def requires_confirmation?
- @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
+ @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
end
end
end
diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb
index ca88c6525e0..d1a2ea5bbf2 100644
--- a/app/controllers/admin/export_domain_allows_controller.rb
+++ b/app/controllers/admin/export_domain_allows_controller.rb
@@ -49,8 +49,8 @@ module Admin
def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content|
- DomainAllow.allowed_domains.each do |instance|
- content << [instance.domain]
+ DomainAllow.allowed_domains.each do |domain|
+ content << [domain]
end
end
end
diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb
index 554f7906f83..fb7b6878bae 100644
--- a/app/controllers/admin/reports/actions_controller.rb
+++ b/app/controllers/admin/reports/actions_controller.rb
@@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
case action_from_button
when 'delete', 'mark_as_sensitive'
- status_batch_action = Admin::StatusBatchAction.new(
- type: action_from_button,
- status_ids: @report.status_ids,
- current_account: current_account,
- report_id: @report.id,
- send_email_notification: !@report.spam?,
- text: params[:text]
- )
-
- status_batch_action.save!
+ Admin::StatusBatchAction.new(status_batch_action_params).save!
when 'silence', 'suspend'
- account_action = Admin::AccountAction.new(
- type: action_from_button,
- report_id: @report.id,
- target_account: @report.target_account,
- current_account: current_account,
- send_email_notification: !@report.spam?,
- text: params[:text]
- )
-
- account_action.save!
+ Admin::AccountAction.new(account_action_params).save!
else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end
@@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
private
+ def status_batch_action_params
+ shared_params
+ .merge(status_ids: @report.status_ids)
+ end
+
+ def account_action_params
+ shared_params
+ .merge(target_account: @report.target_account)
+ end
+
+ def shared_params
+ {
+ current_account: current_account,
+ report_id: @report.id,
+ send_email_notification: !@report.spam?,
+ text: params[:text],
+ type: action_from_button,
+ }
+ end
+
def set_report
@report = Report.find(params[:report_id])
end
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index 2ae5ec82556..a08375e0a41 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -14,8 +14,7 @@ module Admin
@admin_settings = Form::AdminSettings.new(settings_params)
if @admin_settings.save
- flash[:notice] = I18n.t('generic.changes_saved_msg')
- redirect_to after_update_redirect_path
+ redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
else
render :show
end
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index a7bfd647944..f2c28328f86 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -5,6 +5,7 @@ module Admin
before_action :set_tag, except: [:index]
PER_PAGE = 20
+ PERIOD_DAYS = 6.days
def index
authorize :tag, :index?
@@ -15,7 +16,7 @@ module Admin
def show
authorize @tag, :show?
- @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
+ @time_period = report_range
end
def update
@@ -24,7 +25,7 @@ module Admin
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
else
- @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
+ @time_period = report_range
render :show
end
@@ -36,6 +37,10 @@ module Admin
@tag = Tag.find(params[:id])
end
+ def report_range
+ (PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
+ end
+
def tag_params
params
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
diff --git a/app/controllers/admin/username_blocks_controller.rb b/app/controllers/admin/username_blocks_controller.rb
new file mode 100644
index 00000000000..22ac9408178
--- /dev/null
+++ b/app/controllers/admin/username_blocks_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Admin::UsernameBlocksController < Admin::BaseController
+ before_action :set_username_block, only: [:edit, :update]
+
+ def index
+ authorize :username_block, :index?
+ @username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
+ @form = Form::UsernameBlockBatch.new
+ end
+
+ def batch
+ authorize :username_block, :index?
+
+ @form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
+ @form.save
+ rescue ActionController::ParameterMissing
+ flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
+ rescue Mastodon::NotPermittedError
+ flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
+ ensure
+ redirect_to admin_username_blocks_path
+ end
+
+ def new
+ authorize :username_block, :create?
+ @username_block = UsernameBlock.new(exact: true)
+ end
+
+ def edit
+ authorize @username_block, :update?
+ end
+
+ def create
+ authorize :username_block, :create?
+
+ @username_block = UsernameBlock.new(resource_params)
+
+ if @username_block.save
+ log_action :create, @username_block
+ redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
+ else
+ render :new
+ end
+ end
+
+ def update
+ authorize @username_block, :update?
+
+ if @username_block.update(resource_params)
+ log_action :update, @username_block
+ redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def set_username_block
+ @username_block = UsernameBlock.find(params[:id])
+ end
+
+ def form_username_block_batch_params
+ params
+ .expect(form_username_block_batch: [username_block_ids: []])
+ end
+
+ def resource_params
+ params
+ .expect(username_block: [:username, :comparison, :allow_with_approval])
+ end
+
+ def action_from_button
+ 'delete' if params[:delete]
+ end
+end
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index b90036a5cd9..2c46afff609 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
default_language: source_params.fetch(:language, @account.user.setting_default_language),
+ default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy),
},
}
end
diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb
index 283383acb4a..dd272120e21 100644
--- a/app/controllers/api/v1/admin/tags_controller.rb
+++ b/app/controllers/api/v1/admin/tags_controller.rb
@@ -2,6 +2,7 @@
class Api::V1::Admin::TagsController < Api::BaseController
include Authorization
+
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
diff --git a/app/controllers/api/v1/invites_controller.rb b/app/controllers/api/v1/invites_controller.rb
index ea17ba74038..7b24cec7918 100644
--- a/app/controllers/api/v1/invites_controller.rb
+++ b/app/controllers/api/v1/invites_controller.rb
@@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
skip_around_action :set_locale
before_action :set_invite
+ before_action :check_valid_usage!
before_action :check_enabled_registrations!
# Override `current_user` to avoid reading session cookies
@@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
@invite = Invite.find_by!(code: params[:invite_code])
end
- def check_enabled_registrations!
- return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
+ def check_valid_usage!
+ render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
+ end
+ def check_enabled_registrations!
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
end
end
diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb
index f2c52f2846e..3b0cda7d931 100644
--- a/app/controllers/api/v1/push/subscriptions_controller.rb
+++ b/app/controllers/api/v1/push/subscriptions_controller.rb
@@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def create
with_redis_lock("push_subscription:#{current_user.id}") do
destroy_web_push_subscriptions!
-
- @push_subscription = Web::PushSubscription.create!(
- endpoint: subscription_params[:endpoint],
- key_p256dh: subscription_params[:keys][:p256dh],
- key_auth: subscription_params[:keys][:auth],
- standard: subscription_params[:standard] || false,
- data: data_params,
- user_id: current_user.id,
- access_token_id: doorkeeper_token.id
- )
+ @push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
end
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
@@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
not_found if @push_subscription.nil?
end
+ def web_push_subscription_params
+ {
+ access_token_id: doorkeeper_token.id,
+ data: data_params,
+ endpoint: subscription_params[:endpoint],
+ key_auth: subscription_params[:keys][:auth],
+ key_p256dh: subscription_params[:keys][:p256dh],
+ standard: subscription_params[:standard] || false,
+ user_id: current_user.id,
+ }
+ end
+
def subscription_params
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
end
diff --git a/app/controllers/api/v1/statuses/interaction_policies_controller.rb b/app/controllers/api/v1/statuses/interaction_policies_controller.rb
new file mode 100644
index 00000000000..5cfb2d0e8fd
--- /dev/null
+++ b/app/controllers/api/v1/statuses/interaction_policies_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::BaseController
+ include Api::InteractionPoliciesConcern
+
+ before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
+
+ def update
+ authorize @status, :update?
+
+ @status.update!(quote_approval_policy: quote_approval_policy)
+
+ broadcast_updates! if @status.quote_approval_policy_previously_changed?
+
+ render json: @status, serializer: REST::StatusSerializer
+ end
+
+ private
+
+ def status_params
+ params.permit(:quote_approval_policy)
+ end
+
+ def broadcast_updates!
+ DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true })
+ ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
+ end
+end
diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb
new file mode 100644
index 00000000000..be3a4edc83d
--- /dev/null
+++ b/app/controllers/api/v1/statuses/quotes_controller.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
+ before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
+ before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
+
+ before_action :set_statuses, only: :index
+
+ before_action :set_quote, only: :revoke
+ after_action :insert_pagination_headers, only: :index
+
+ def index
+ cache_if_unauthenticated!
+ render json: @statuses, each_serializer: REST::StatusSerializer
+ end
+
+ def revoke
+ authorize @quote, :revoke?
+
+ RevokeQuoteService.new.call(@quote)
+
+ render json: @quote.status, serializer: REST::StatusSerializer
+ end
+
+ private
+
+ def set_quote
+ @quote = @status.quotes.find_by!(status_id: params[:id])
+ end
+
+ def set_statuses
+ scope = default_statuses
+ scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
+ @statuses = scope.merge(paginated_quotes).to_a
+
+ # Store next page info before filtering
+ @records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+ @pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
+ @pagination_max_id = @statuses.last.quote.id if @records_continue
+
+ if current_account&.id != @status.account_id
+ domains = @statuses.filter_map(&:account_domain).uniq
+ account_ids = @statuses.map(&:account_id).uniq
+ relations = current_account&.relations_map(account_ids, domains) || {}
+ @statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
+ end
+ end
+
+ def default_statuses
+ Status.includes(:quote).references(:quote)
+ end
+
+ def paginated_quotes
+ @status.quotes.accepted.paginate_by_max_id(
+ limit_param(DEFAULT_STATUSES_LIMIT),
+ params[:max_id],
+ params[:since_id]
+ )
+ end
+
+ def next_path
+ api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue?
+ end
+
+ def prev_path
+ api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
+ end
+
+ attr_reader :pagination_max_id, :pagination_since_id
+
+ def records_continue?
+ @records_continue
+ end
+end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index d3b0e89e97b..6c4e7619b7b 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -2,6 +2,8 @@
class Api::V1::StatusesController < Api::BaseController
include Authorization
+ include AsyncRefreshesConcern
+ include Api::InteractionPoliciesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
@@ -9,6 +11,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create]
+ before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses
@@ -57,9 +60,21 @@ class Api::V1::StatusesController < Api::BaseController
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
- render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
+ refresh_key = "context:#{@status.id}:refresh"
+ async_refresh = AsyncRefresh.new(refresh_key)
- ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
+ if async_refresh.running?
+ add_async_refresh_header(async_refresh)
+ elsif !current_account.nil? && @status.should_fetch_replies?
+ add_async_refresh_header(AsyncRefresh.create(refresh_key))
+
+ WorkerBatch.new.within do |batch|
+ batch.connect(refresh_key, threshold: 1.0)
+ ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
+ end
+ end
+
+ render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
def create
@@ -67,6 +82,8 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account,
text: status_params[:status],
thread: @thread,
+ quoted_status: @quoted_status,
+ quote_approval_policy: quote_approval_policy,
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
@@ -98,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
sensitive: status_params[:sensitive],
language: status_params[:language],
spoiler_text: status_params[:spoiler_text],
- poll: status_params[:poll]
+ poll: status_params[:poll],
+ quote_approval_policy: quote_approval_policy
)
render json: @status, serializer: REST::StatusSerializer
@@ -138,6 +156,14 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end
+ def set_quoted_status
+ @quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
+ authorize(@quoted_status, :quote?) if @quoted_status.present?
+ rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
+ # TODO: distinguish between non-existing and non-quotable posts
+ render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
+ end
+
def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end
@@ -154,6 +180,8 @@ class Api::V1::StatusesController < Api::BaseController
params.permit(
:status,
:in_reply_to_id,
+ :quoted_status_id,
+ :quote_approval_policy,
:sensitive,
:spoiler_text,
:visibility,
diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb
index 1dba4a5bb21..e79eba79ee5 100644
--- a/app/controllers/api/v1/timelines/base_controller.rb
+++ b/app/controllers/api/v1/timelines/base_controller.rb
@@ -3,14 +3,8 @@
class Api::V1::Timelines::BaseController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
- before_action :require_user!, if: :require_auth?
-
private
- def require_auth?
- !Setting.timeline_preview
- end
-
def pagination_collection
@statuses
end
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index b8384a13687..a07faae7208 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -3,8 +3,8 @@
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
include AsyncRefreshesConcern
- before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
- before_action :require_user!, only: [:show]
+ before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
+ before_action :require_user!
PERMITTED_PARAMS = %i(local limit).freeze
diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb
index 37ed084f062..9e6ddd69243 100644
--- a/app/controllers/api/v1/timelines/link_controller.rb
+++ b/app/controllers/api/v1/timelines/link_controller.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
+class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_preview_card
before_action :set_statuses
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index 029e8fc2c13..670c3b02b6b 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -2,6 +2,7 @@
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
+ before_action :require_user!, if: :require_auth?
PERMITTED_PARAMS = %i(local remote limit only_media).freeze
@@ -13,6 +14,16 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
private
+ def require_auth?
+ if truthy_param?(:local)
+ Setting.local_live_feed_access != 'public'
+ elsif truthy_param?(:remote)
+ Setting.remote_live_feed_access != 'public'
+ else
+ Setting.local_live_feed_access != 'public' || Setting.remote_live_feed_access != 'public'
+ end
+ end
+
def load_statuses
preloaded_public_statuses_page
end
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 2b097aab0f8..dc3c6a72157 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
+class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :load_tag
@@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
private
- def require_auth?
- !Setting.timeline_preview
- end
-
def load_tag
@tag = Tag.find_normalized(params[:id])
end
diff --git a/app/controllers/api/v1/timelines/topic_controller.rb b/app/controllers/api/v1/timelines/topic_controller.rb
new file mode 100644
index 00000000000..6faf54f7083
--- /dev/null
+++ b/app/controllers/api/v1/timelines/topic_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController
+ before_action :require_user!, if: :require_auth?
+
+ private
+
+ def require_auth?
+ if truthy_param?(:local)
+ Setting.local_topic_feed_access != 'public'
+ elsif truthy_param?(:remote)
+ Setting.remote_topic_feed_access != 'public'
+ else
+ Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public'
+ end
+ end
+end
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 3ca13cc4275..c00ddf92cc0 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
@search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer
rescue Mastodon::SyntaxError
- unprocessable_entity
+ unprocessable_content
rescue ActiveRecord::RecordNotFound
not_found
end
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index 2711071b4a5..ced68d39fc7 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
{
policy: 'all',
alerts: Notification::TYPES.index_with { alerts_enabled },
- }
+ }.deep_stringify_keys
end
def alerts_enabled
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 42abe990483..82d9e8380fc 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::UnknownFormat, with: :not_acceptable
- rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
+ rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
@@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
respond_with_error(410)
end
- def unprocessable_entity
+ def unprocessable_content
respond_with_error(422)
end
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
index 9d496220a3d..16de03fd72d 100644
--- a/app/controllers/auth/omniauth_callbacks_controller.rb
+++ b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
def record_login_activity
- LoginActivity.create(
- user: @user,
+ @user.login_activities.create(
success: true,
authentication_method: :omniauth,
provider: @provider,
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 7c1ff59671d..2680a1c5fdc 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
private
def redirect_invalid_reset_token
- flash[:error] = I18n.t('auth.invalid_reset_password_token')
- redirect_to new_password_path(resource_name)
+ redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
end
def reset_password_token_is_valid?
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 3b42dc48ba9..fc430544fbe 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
super(&:build_invite_request)
end
- def edit # rubocop:disable Lint/UselessMethodDefinition
+ def edit
super
end
- def create # rubocop:disable Lint/UselessMethodDefinition
+ def create
super
end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 2808066aaf9..182f242ae5b 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional!
skip_before_action :update_user_sign_in
+ around_action :preserve_stored_location, only: :destroy, if: :continue_after?
+
prepend_before_action :check_suspicious!, only: [:create]
include Auth::TwoFactorAuthenticationConcern
@@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
end
def destroy
- tmp_stored_location = stored_location_for(:user)
super
session.delete(:challenge_passed_at)
flash.delete(:notice)
- store_location_for(:user, tmp_stored_location) if continue_after?
end
def webauthn_options
@@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
private
+ def preserve_stored_location
+ original_stored_location = stored_location_for(:user)
+ yield
+ store_location_for(:user, original_stored_location)
+ end
+
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
@@ -151,12 +157,11 @@ class Auth::SessionsController < Devise::SessionsController
sign_in(user)
flash.delete(:notice)
- LoginActivity.create(
- user: user,
- success: true,
- authentication_method: security_measure,
- ip: request.remote_ip,
- user_agent: request.user_agent
+ user.login_activities.create(
+ request_details.merge(
+ authentication_method: security_measure,
+ success: true
+ )
)
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
@@ -167,13 +172,12 @@ class Auth::SessionsController < Devise::SessionsController
end
def on_authentication_failure(user, security_measure, failure_reason)
- LoginActivity.create(
- user: user,
- success: false,
- authentication_method: security_measure,
- failure_reason: failure_reason,
- ip: request.remote_ip,
- user_agent: request.user_agent
+ user.login_activities.create(
+ request_details.merge(
+ authentication_method: security_measure,
+ failure_reason: failure_reason,
+ success: false
+ )
)
# Only send a notification email every hour at most
@@ -182,6 +186,13 @@ class Auth::SessionsController < Devise::SessionsController
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end
+ def request_details
+ {
+ ip: request.remote_ip,
+ user_agent: request.user_agent,
+ }
+ end
+
def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end
diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb
index 2b132417f7c..7b3cd4d3ea6 100644
--- a/app/controllers/concerns/account_owned_concern.rb
+++ b/app/controllers/concerns/account_owned_concern.rb
@@ -18,7 +18,11 @@ module AccountOwnedConcern
end
def set_account
- @account = Account.find_local!(username_param)
+ @account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
+ end
+
+ def account_id_param
+ params[:account_id]
end
def username_param
diff --git a/app/controllers/concerns/api/interaction_policies_concern.rb b/app/controllers/concerns/api/interaction_policies_concern.rb
new file mode 100644
index 00000000000..f1e1480c0c0
--- /dev/null
+++ b/app/controllers/concerns/api/interaction_policies_concern.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Api::InteractionPoliciesConcern
+ extend ActiveSupport::Concern
+
+ def quote_approval_policy
+ case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
+ when 'public'
+ Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
+ when 'followers'
+ Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16
+ when 'nobody'
+ 0
+ else
+ # TODO: raise more useful message
+ raise ActiveRecord::RecordInvalid
+ end
+ end
+end
diff --git a/app/controllers/concerns/async_refreshes_concern.rb b/app/controllers/concerns/async_refreshes_concern.rb
index 29122e16b5e..2d0e9ff4ff4 100644
--- a/app/controllers/concerns/async_refreshes_concern.rb
+++ b/app/controllers/concerns/async_refreshes_concern.rb
@@ -6,6 +6,9 @@ module AsyncRefreshesConcern
def add_async_refresh_header(async_refresh, retry_seconds: 3)
return unless async_refresh.running?
- response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
+ value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
+ value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil?
+
+ response.headers['Mastodon-Async-Refresh'] = value
end
end
diff --git a/app/controllers/concerns/auth/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb
index c01da212499..a6232db943b 100644
--- a/app/controllers/concerns/auth/captcha_concern.rb
+++ b/app/controllers/concerns/auth/captcha_concern.rb
@@ -5,6 +5,18 @@ module Auth::CaptchaConcern
include Hcaptcha::Adapters::ViewMethods
+ CAPTCHA_DIRECTIVES = %w(
+ connect_src
+ frame_src
+ script_src
+ style_src
+ ).freeze
+
+ CAPTCHA_SOURCES = %w(
+ https://*.hcaptcha.com
+ https://hcaptcha.com
+ ).freeze
+
included do
helper_method :render_captcha
end
@@ -42,20 +54,9 @@ module Auth::CaptchaConcern
end
def extend_csp_for_captcha!
- policy = request.content_security_policy&.clone
+ return unless captcha_required? && request.content_security_policy.present?
- return unless captcha_required? && policy.present?
-
- %w(script_src frame_src style_src connect_src).each do |directive|
- values = policy.send(directive)
-
- values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
- values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
-
- policy.send(directive, *values)
- end
-
- request.content_security_policy = policy
+ request.content_security_policy = captcha_adjusted_policy
end
def render_captcha
@@ -63,4 +64,24 @@ module Auth::CaptchaConcern
hcaptcha_tags
end
+
+ private
+
+ def captcha_adjusted_policy
+ request.content_security_policy.clone.tap do |policy|
+ populate_captcha_policy(policy)
+ end
+ end
+
+ def populate_captcha_policy(policy)
+ CAPTCHA_DIRECTIVES.each do |directive|
+ values = policy.send(directive)
+
+ CAPTCHA_SOURCES.each do |source|
+ values << source unless values.include?(source) || values.include?('https:')
+ end
+
+ policy.send(directive, *values)
+ end
+ end
end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 902feef6838..2bdd3558643 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -9,6 +9,8 @@ module SignatureVerification
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
+ STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
+ STOPLIGHT_THRESHOLD = 1
def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
@@ -64,6 +66,9 @@ module SignatureVerification
return (@signed_request_actor = actor) if signed_request.verified?(actor)
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
+ rescue Mastodon::MalformedHeaderError => e
+ @signature_verification_failure_code = 400
+ fail_with! e.message
rescue Mastodon::SignatureVerificationError => e
fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
@@ -104,10 +109,12 @@ module SignatureVerification
end
def stoplight_wrapper
- Stoplight("source:#{request.remote_ip}")
- .with_threshold(1)
- .with_cool_off_time(5.minutes.seconds)
- .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
+ Stoplight(
+ "source:#{request.remote_ip}",
+ cool_off_time: STOPLIGHT_COOL_OFF_TIME,
+ threshold: STOPLIGHT_THRESHOLD,
+ tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
+ )
end
def actor_refresh_key!(actor)
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index f4c7b37088a..e9727b756a4 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -60,17 +60,17 @@ class FollowerAccountsController < ApplicationController
def collection_presenter
if page_requested?
ActivityPub::CollectionPresenter.new(
- id: account_followers_url(@account, page: params.fetch(:page, 1)),
+ id: page_url(params.fetch(:page, 1)),
type: :ordered,
size: @account.followers_count,
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
- part_of: account_followers_url(@account),
+ part_of: ActivityPub::TagManager.instance.followers_uri_for(@account),
next: next_page_url,
prev: prev_page_url
)
else
ActivityPub::CollectionPresenter.new(
- id: account_followers_url(@account),
+ id: ActivityPub::TagManager.instance.followers_uri_for(@account),
type: :ordered,
size: @account.followers_count,
first: page_url(1)
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 268fad96d09..803d6e342a9 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
end
def page_url(page)
- account_following_index_url(@account, page: page) unless page.nil?
+ ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil?
end
def next_page_url
@@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
def collection_presenter
if page_requested?
ActivityPub::CollectionPresenter.new(
- id: account_following_index_url(@account, page: params.fetch(:page, 1)),
+ id: page_url(params.fetch(:page, 1)),
type: :ordered,
size: @account.following_count,
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
- part_of: account_following_index_url(@account),
+ part_of: ActivityPub::TagManager.instance.following_uri_for(@account),
next: next_page_url,
prev: prev_page_url
)
else
ActivityPub::CollectionPresenter.new(
- id: account_following_index_url(@account),
+ id: ActivityPub::TagManager.instance.following_uri_for(@account),
type: :ordered,
size: @account.following_count,
first: page_url(1)
diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb
index 50e2d70cb9a..ae32dbf5570 100644
--- a/app/controllers/settings/login_activities_controller.rb
+++ b/app/controllers/settings/login_activities_controller.rb
@@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
skip_before_action :require_functional!
def index
- @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
+ @login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
end
end
diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb
index d850e05e94f..08b01d6b108 100644
--- a/app/controllers/settings/migration/redirects_controller.rb
+++ b/app/controllers/settings/migration/redirects_controller.rb
@@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
end
def destroy
- if current_account.moved_to_account_id.present?
+ if current_account.moved?
current_account.update!(moved_to_account: nil)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
end
diff --git a/app/controllers/settings/preferences/posting_defaults_controller.rb b/app/controllers/settings/preferences/posting_defaults_controller.rb
new file mode 100644
index 00000000000..dcff94fc712
--- /dev/null
+++ b/app/controllers/settings/preferences/posting_defaults_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Settings::Preferences::PostingDefaultsController < Settings::Preferences::BaseController
+ private
+
+ def after_update_redirect_path
+ settings_preferences_posting_defaults_path
+ end
+
+ def user_params
+ super.tap do |params|
+ params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private'
+ end
+ end
+end
diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb
index ee2fc5dc80f..fe59bdc4917 100644
--- a/app/controllers/settings/sessions_controller.rb
+++ b/app/controllers/settings/sessions_controller.rb
@@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
def destroy
@session.destroy!
- flash[:notice] = I18n.t('sessions.revoke_success')
- redirect_to edit_user_registration_path
+ redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
end
private
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
index 9714d54f954..83dedb411d4 100644
--- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -52,7 +52,7 @@ module Settings
end
else
flash[:error] = I18n.t('webauthn_credentials.create.error')
- status = :unprocessable_entity
+ status = :unprocessable_content
end
else
flash[:error] = t('webauthn_credentials.create.error')
@@ -86,13 +86,11 @@ module Settings
private
def redirect_invalid_otp
- flash[:error] = t('webauthn_credentials.otp_required')
- redirect_to settings_two_factor_authentication_methods_path
+ redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
end
def redirect_invalid_webauthn
- flash[:error] = t('webauthn_credentials.not_enabled')
- redirect_to settings_two_factor_authentication_methods_path
+ redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
end
end
end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 341b0e64729..af6bebf36fd 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -11,6 +11,7 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status
before_action :redirect_to_original, only: :show
+ before_action :verify_embed_allowed, only: :embed
after_action :set_link_headers
@@ -40,8 +41,6 @@ class StatusesController < ApplicationController
end
def embed
- return not_found if @status.hidden? || @status.reblog?
-
expires_in 180, public: true
response.headers.delete('X-Frame-Options')
@@ -50,6 +49,10 @@ class StatusesController < ApplicationController
private
+ def verify_embed_allowed
+ not_found if @status.hidden? || @status.reblog?
+ end
+
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 859f9246876..4a55a36ecd1 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end
when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id)
+ when 'UsernameBlock'
+ link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5a5ee055321..80cff698298 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -66,7 +66,7 @@ module ApplicationHelper
def provider_sign_in_link(provider)
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
- link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
+ link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
end
def locale_direction
@@ -102,6 +102,16 @@ module ApplicationHelper
policy(record).public_send(:"#{action}?")
end
+ def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
+ if condition && !current_page?(block_given? ? name : options)
+ link_to(name, options, html_options, &block)
+ elsif block_given?
+ content_tag(:span, options, html_options, &block)
+ else
+ content_tag(:span, name, html_options)
+ end
+ end
+
def material_symbol(icon, attributes = {})
safe_join(
[
@@ -233,6 +243,10 @@ module ApplicationHelper
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
end
+ def recent_tag_users(tag)
+ tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account)
+ end
+
def recent_tag_usage(tag)
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
@@ -246,6 +260,10 @@ module ApplicationHelper
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
end
+ def within_authorization_flow?
+ session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations'
+ end
+
private
def storage_host_var
diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb
index 33d7267905b..885f578fd0d 100644
--- a/app/helpers/context_helper.rb
+++ b/app/helpers/context_helper.rb
@@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
+ quotes: {
+ 'quote' => 'https://w3id.org/fep/044f#quote',
+ 'quoteUri' => 'http://fedibird.com/ns#quoteUri',
+ '_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
+ 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
+ },
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
@@ -33,6 +39,12 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
},
+ quote_authorizations: {
+ 'gts' => 'https://gotosocial.org/ns#',
+ 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
+ 'interactingObject' => { '@id' => 'gts:interactingObject' },
+ 'interactionTarget' => { '@id' => 'gts:interactionTarget' },
+ },
}.freeze
def full_context
diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb
deleted file mode 100644
index 0800601f98b..00000000000
--- a/app/helpers/email_helper.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module EmailHelper
- def self.included(base)
- base.extend(self)
- end
-
- def email_to_canonical_email(str)
- username, domain = str.downcase.split('@', 2)
- username, = username.delete('.').split('+', 2)
-
- "#{username}@#{domain}"
- end
-
- def email_to_canonical_email_hash(str)
- Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
- end
-end
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index 1b364a000cf..68aad4f6771 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -27,7 +27,9 @@ module FormattingHelper
module_function :extract_status_plain_text
def status_content_format(status)
- html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
+ quoted_status = status.quote&.quoted_status if status.local?
+
+ html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), quoted_status: quoted_status)
end
def rss_status_content_format(status)
@@ -65,12 +67,12 @@ module FormattingHelper
end
def rss_content_preroll(status)
- if status.spoiler_text?
- safe_join [
- tag.p { spoiler_with_warning(status) },
- tag.hr,
- ]
- end
+ return unless status.spoiler_text?
+
+ safe_join [
+ tag.p { spoiler_with_warning(status) },
+ tag.hr,
+ ]
end
def spoiler_with_warning(status)
@@ -81,10 +83,10 @@ module FormattingHelper
end
def rss_content_postroll(status)
- if status.preloadable_poll
- tag.p do
- poll_option_tags(status)
- end
+ return unless status.preloadable_poll
+
+ tag.p do
+ poll_option_tags(status)
end
end
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index c5b83326db3..59bc06031ee 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -21,7 +21,13 @@ module HomeHelper
end
end
else
- link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
+ account_url = if account.suspended?
+ ActivityPub::TagManager.instance.url_for(account)
+ else
+ web_url("@#{account.pretty_acct}")
+ end
+
+ link_to(path || account_url, class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') do
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
end +
@@ -39,18 +45,8 @@ module HomeHelper
end
end
- def obscured_counter(count)
- if count <= 0
- '0'
- elsif count == 1
- '1'
- else
- '1+'
- end
- end
-
- def custom_field_classes(field)
- if field.verified?
+ def field_verified_class(verified)
+ if verified
'verified'
else
'emojify'
diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb
index 078aba456ae..675d8b87309 100644
--- a/app/helpers/json_ld_helper.rb
+++ b/app/helpers/json_ld_helper.rb
@@ -134,7 +134,7 @@ module JsonLdHelper
patch_for_forwarding!(value, compacted_value)
elsif value.is_a?(Array)
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
- return if value.size != compacted_value.size
+ return nil if value.size != compacted_value.size
compacted[key] = value.zip(compacted_value).map do |v, vc|
if v.is_a?(Hash) && vc.is_a?(Hash)
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
index 0a8ebcde549..ddb6b79c866 100644
--- a/app/helpers/languages_helper.rb
+++ b/app/helpers/languages_helper.rb
@@ -107,6 +107,7 @@ module LanguagesHelper
mk: ['Macedonian', 'македонски јазик'].freeze,
ml: ['Malayalam', 'മലയാളം'].freeze,
mn: ['Mongolian', 'Монгол хэл'].freeze,
+ 'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze,
mr: ['Marathi', 'मराठी'].freeze,
ms: ['Malay', 'Bahasa Melayu'].freeze,
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index 16b9d3fb531..68e9b130478 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -57,6 +57,20 @@ module StatusesHelper
components.compact_blank.join("\n\n")
end
+ # This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160
+ def preview_card_aspect_ratio_classname(preview_card)
+ interactive = preview_card.type == 'video'
+ large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive
+
+ if large_image && interactive
+ 'status-card__image--video'
+ elsif large_image
+ 'status-card__image--large'
+ else
+ 'status-card__image--normal'
+ end
+ end
+
def visibility_icon(status)
VISIBLITY_ICONS[status.visibility.to_sym]
end
@@ -64,4 +78,16 @@ module StatusesHelper
def prefers_autoplay?
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
end
+
+ def render_seo_schema(status)
+ json = ActiveModelSerializers::SerializableResource.new(
+ status,
+ serializer: SEO::SocialMediaPostingSerializer,
+ adapter: SEO::Adapter
+ ).to_json
+
+ # rubocop:disable Rails/OutputSafety
+ content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json')
+ # rubocop:enable Rails/OutputSafety
+ end
end
diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb
index 0f24063385b..00b4a6d2b3f 100644
--- a/app/helpers/theme_helper.rb
+++ b/app/helpers/theme_helper.rb
@@ -24,24 +24,24 @@ module ThemeHelper
end
def custom_stylesheet
- if active_custom_stylesheet.present?
- stylesheet_link_tag(
- custom_css_path(active_custom_stylesheet),
- host: root_url,
- media: :all,
- skip_pipeline: true
- )
- end
+ return if active_custom_stylesheet.blank?
+
+ stylesheet_link_tag(
+ custom_css_path(active_custom_stylesheet),
+ host: root_url,
+ media: :all,
+ skip_pipeline: true
+ )
end
private
def active_custom_stylesheet
- if cached_custom_css_digest.present?
- [:custom, cached_custom_css_digest.to_s.first(8)]
- .compact_blank
- .join('-')
- end
+ return if cached_custom_css_digest.blank?
+
+ [:custom, cached_custom_css_digest.to_s.first(8)]
+ .compact_blank
+ .join('-')
end
def cached_custom_css_digest
diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json
new file mode 100644
index 00000000000..c788113487c
--- /dev/null
+++ b/app/javascript/config/html-tags.json
@@ -0,0 +1,61 @@
+{
+ "global": {
+ "class": "className",
+ "id": true,
+ "title": true,
+ "dir": true,
+ "lang": true
+ },
+ "tags": {
+ "p": {},
+ "br": {
+ "children": false
+ },
+ "span": {
+ "attributes": {
+ "translate": true
+ }
+ },
+ "a": {
+ "attributes": {
+ "href": true,
+ "rel": true,
+ "translate": true,
+ "target": true
+ }
+ },
+ "del": {},
+ "s": {},
+ "pre": {},
+ "blockquote": {},
+ "code": {},
+ "b": {},
+ "strong": {},
+ "u": {},
+ "i": {},
+ "img": {
+ "children": false,
+ "attributes": {
+ "src": true,
+ "alt": true,
+ "title": true
+ }
+ },
+ "em": {},
+ "ul": {},
+ "ol": {
+ "attributes": {
+ "start": true,
+ "reversed": true
+ }
+ },
+ "li": {
+ "attributes": {
+ "value": true
+ }
+ },
+ "ruby": {},
+ "rt": {},
+ "rp": {}
+ }
+}
diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx
index a60778f0c04..af9309d342c 100644
--- a/app/javascript/entrypoints/admin.tsx
+++ b/app/javascript/entrypoints/admin.tsx
@@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
+import { decode, ValidationError } from 'blurhash';
import ready from '../mastodon/ready';
@@ -362,6 +363,46 @@ ready(() => {
document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element);
});
+
+ document
+ .querySelectorAll('canvas[data-blurhash]')
+ .forEach((canvas) => {
+ const blurhash = canvas.dataset.blurhash;
+ if (blurhash) {
+ try {
+ // decode returns a Uint8ClampedArray not Uint8ClampedArray
+ const pixels = decode(
+ blurhash,
+ 32,
+ 32,
+ ) as Uint8ClampedArray;
+ const ctx = canvas.getContext('2d');
+ const imageData = new ImageData(pixels, 32, 32);
+
+ ctx?.putImageData(imageData, 0, 0);
+ } catch (err) {
+ if (err instanceof ValidationError) {
+ // ignore blurhash validation errors
+ return;
+ }
+
+ throw err;
+ }
+ }
+ });
+
+ document
+ .querySelectorAll('.preview-card')
+ .forEach((previewCard) => {
+ const spoilerButton = previewCard.querySelector('.spoiler-button');
+ if (!spoilerButton) {
+ return;
+ }
+
+ spoilerButton.addEventListener('click', () => {
+ previewCard.classList.toggle('preview-card--image-visible');
+ });
+ });
}).catch((reason: unknown) => {
throw reason;
});
diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx
index 0970fc585e6..fea3eb0d792 100644
--- a/app/javascript/entrypoints/public.tsx
+++ b/app/javascript/entrypoints/public.tsx
@@ -70,7 +70,7 @@ function loaded() {
};
document.querySelectorAll('.emojify').forEach((content) => {
- content.innerHTML = emojify(content.innerHTML);
+ content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
});
document
@@ -145,6 +145,10 @@ function loaded() {
);
});
+ updateDefaultQuotePrivacyFromPrivacy(
+ document.querySelector('#user_settings_attributes_default_privacy'),
+ );
+
const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) {
@@ -347,6 +351,31 @@ const setInputDisabled = (
}
};
+const setInputHint = (
+ input: HTMLInputElement | HTMLSelectElement,
+ hintPrefix: string,
+) => {
+ const fieldWrapper = input.closest('.fields-group > .input');
+ if (!fieldWrapper) return;
+
+ const hint = fieldWrapper.dataset[`${hintPrefix}Hint`];
+ const hintElement =
+ fieldWrapper.querySelector(':scope > .hint');
+
+ if (hint) {
+ if (hintElement) {
+ hintElement.textContent = hint;
+ } else {
+ const newHintElement = document.createElement('span');
+ newHintElement.className = 'hint';
+ newHintElement.textContent = hint;
+ fieldWrapper.appendChild(newHintElement);
+ }
+ } else {
+ hintElement?.remove();
+ }
+};
+
Rails.delegate(
document,
'#account_statuses_cleanup_policy_enabled',
@@ -364,6 +393,36 @@ Rails.delegate(
},
);
+const updateDefaultQuotePrivacyFromPrivacy = (
+ privacySelect: EventTarget | null,
+) => {
+ if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form)
+ return;
+
+ const select = privacySelect.form.querySelector(
+ 'select#user_settings_attributes_default_quote_policy',
+ );
+ if (!select) return;
+
+ setInputHint(select, privacySelect.value);
+
+ if (privacySelect.value === 'private') {
+ select.value = 'nobody';
+ setInputDisabled(select, true);
+ } else {
+ setInputDisabled(select, false);
+ }
+};
+
+Rails.delegate(
+ document,
+ '#user_settings_attributes_default_privacy',
+ 'change',
+ ({ target }) => {
+ updateDefaultQuotePrivacyFromPrivacy(target);
+ },
+);
+
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
diff --git a/app/javascript/images/mailer-new/heading/README.md b/app/javascript/images/mailer-new/heading/README.md
index ecd4b949e76..9fb6841f14b 100644
--- a/app/javascript/images/mailer-new/heading/README.md
+++ b/app/javascript/images/mailer-new/heading/README.md
@@ -1 +1,3 @@
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
+
+Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).
diff --git a/app/javascript/images/mailer-new/heading/quote.png b/app/javascript/images/mailer-new/heading/quote.png
new file mode 100644
index 00000000000..c2af73282f1
Binary files /dev/null and b/app/javascript/images/mailer-new/heading/quote.png differ
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index d70834cec65..ccb69f0a3d3 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
+ uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
@@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
};
export function setComposeToStatus(status, text, spoiler_text) {
- return{
- type: COMPOSE_SET_STATUS,
- status,
- text,
- spoiler_text,
- };
+ return (dispatch, getState) => {
+ const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
+
+ dispatch({
+ type: COMPOSE_SET_STATUS,
+ status,
+ text,
+ spoiler_text,
+ maxOptions,
+ });
+ }
}
export function changeCompose(text) {
@@ -146,7 +152,7 @@ export function resetCompose() {
};
}
-export const focusCompose = (defaultText) => (dispatch, getState) => {
+export const focusCompose = (defaultText = '') => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
@@ -183,13 +189,15 @@ export function directCompose(account) {
};
}
-export function submitCompose() {
+export function submitCompose(successCallback) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
+ const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
+ const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '';
- if ((!status || !status.length) && media.size === 0) {
+ if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
return;
}
@@ -215,19 +223,22 @@ export function submitCompose() {
});
}
+ const visibility = getState().getIn(['compose', 'privacy']);
api().request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
data: {
status,
+ spoiler_text,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']),
- spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
- visibility: getState().getIn(['compose', 'privacy']),
+ visibility: visibility,
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
+ quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
+ quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@@ -239,6 +250,9 @@ export function submitCompose() {
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));
+ if (typeof successCallback === 'function') {
+ successCallback(response.data);
+ }
// To make the app more responsive, immediately push the status
// into the columns
@@ -298,6 +312,11 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
+ // Exit if there's a quote.
+ if (getState().compose.get('quoted_status_id')) {
+ dispatch(showAlert({ message: messages.uploadQuote }));
+ return;
+ }
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts
index 97f0d68c514..0f9bf5cfb3c 100644
--- a/app/javascript/mastodon/actions/compose_typed.ts
+++ b/app/javascript/mastodon/actions/compose_typed.ts
@@ -1,9 +1,47 @@
+import { defineMessages } from 'react-intl';
+
+import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose';
+import { apiGetSearch } from 'mastodon/api/search';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
-import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+import {
+ createDataLoadingThunk,
+ createAppThunk,
+} from 'mastodon/store/typed_functions';
+
+import type { ApiQuotePolicy } from '../api_types/quotes';
+import type { Status } from '../models/status';
+
+import { showAlert } from './alerts';
+import { focusCompose } from './compose';
+import { importFetchedStatuses } from './importer';
+import { openModal } from './modal';
+
+const messages = defineMessages({
+ quoteErrorEdit: {
+ id: 'quote_error.edit',
+ defaultMessage: 'Quotes cannot be added when editing a post.',
+ },
+ quoteErrorUpload: {
+ id: 'quote_error.upload',
+ defaultMessage: 'Quoting is not allowed with media attachments.',
+ },
+ quoteErrorPoll: {
+ id: 'quote_error.poll',
+ defaultMessage: 'Quoting is not allowed with polls.',
+ },
+ quoteErrorQuote: {
+ id: 'quote_error.quote',
+ defaultMessage: 'Only one quote at a time is allowed.',
+ },
+ quoteErrorUnauthorized: {
+ id: 'quote_error.unauthorized',
+ defaultMessage: 'You are not authorized to quote this post.',
+ },
+});
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean;
@@ -68,3 +106,111 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false,
},
);
+
+export const quoteCompose = createAppThunk(
+ 'compose/quoteComposeStatus',
+ (status: Status, { dispatch }) => {
+ dispatch(focusCompose());
+ return status;
+ },
+);
+
+export const quoteComposeByStatus = createAppThunk(
+ (status: Status, { dispatch, getState }) => {
+ const state = getState();
+ const composeState = state.compose;
+ const mediaAttachments = composeState.get('media_attachments');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const wasQuietPostHintModalDismissed: boolean =
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+ state.settings.getIn(
+ ['dismissed_banners', 'quote/quiet_post_hint'],
+ false,
+ );
+
+ if (composeState.get('id')) {
+ dispatch(showAlert({ message: messages.quoteErrorEdit }));
+ } else if (composeState.get('poll')) {
+ dispatch(showAlert({ message: messages.quoteErrorPoll }));
+ } else if (
+ composeState.get('is_uploading') ||
+ (mediaAttachments &&
+ typeof mediaAttachments !== 'string' &&
+ typeof mediaAttachments !== 'number' &&
+ typeof mediaAttachments !== 'boolean' &&
+ mediaAttachments.size !== 0)
+ ) {
+ dispatch(showAlert({ message: messages.quoteErrorUpload }));
+ } else if (composeState.get('quoted_status_id')) {
+ dispatch(showAlert({ message: messages.quoteErrorQuote }));
+ } else if (
+ status.getIn(['quote_approval', 'current_user']) !== 'automatic' &&
+ status.getIn(['quote_approval', 'current_user']) !== 'manual'
+ ) {
+ dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
+ } else if (
+ status.get('visibility') === 'unlisted' &&
+ !wasQuietPostHintModalDismissed
+ ) {
+ dispatch(
+ openModal({
+ modalType: 'CONFIRM_QUIET_QUOTE',
+ modalProps: { status },
+ }),
+ );
+ } else {
+ dispatch(quoteCompose(status));
+ }
+ },
+);
+
+export const quoteComposeById = createAppThunk(
+ (statusId: string, { dispatch, getState }) => {
+ const status = getState().statuses.get(statusId);
+ if (status) {
+ dispatch(quoteComposeByStatus(status));
+ }
+ },
+);
+
+export const pasteLinkCompose = createDataLoadingThunk(
+ 'compose/pasteLink',
+ async ({ url }: { url: string }) => {
+ return await apiGetSearch({
+ q: url,
+ type: 'statuses',
+ resolve: true,
+ limit: 2,
+ });
+ },
+ (data, { dispatch, getState }) => {
+ const composeState = getState().compose;
+
+ if (
+ composeState.get('quoted_status_id') ||
+ composeState.get('is_submitting') ||
+ composeState.get('poll') ||
+ composeState.get('is_uploading') ||
+ composeState.get('id')
+ )
+ return;
+
+ dispatch(importFetchedStatuses(data.statuses));
+
+ if (
+ data.statuses.length === 1 &&
+ data.statuses[0] &&
+ ['automatic', 'manual'].includes(
+ data.statuses[0].quote_approval?.current_user ?? 'denied',
+ )
+ ) {
+ dispatch(quoteComposeById(data.statuses[0].id));
+ }
+ },
+);
+
+export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
+
+export const setComposeQuotePolicy = createAction(
+ 'compose/setQuotePolicy',
+);
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 330da74000b..7723379804c 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
return normalResult;
}
+function stripQuoteFallback(text) {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = text;
+
+ wrapper.querySelector('.quote-inline')?.remove();
+
+ return wrapper.innerHTML;
+}
+
export function normalizeStatus(status, normalOldStatus) {
const normalStatus = { ...status };
@@ -72,7 +81,7 @@ export function normalizeStatus(status, normalOldStatus) {
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
- if (normalStatus.spoiler_text && !normalStatus.content) {
+ if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) {
normalStatus.content = normalStatus.spoiler_text;
normalStatus.spoiler_text = '';
}
@@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
+ // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
+ if (normalStatus.quote) {
+ normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
+ }
+
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
@@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
spoiler_text: translation.spoiler_text,
};
+ // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
+ if (status.get('quote')) {
+ normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
+ }
+
return normalTranslation;
}
diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts
index f58faffa86d..36f9f85b9cc 100644
--- a/app/javascript/mastodon/actions/interactions_typed.ts
+++ b/app/javascript/mastodon/actions/interactions_typed.ts
@@ -1,8 +1,13 @@
-import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
+import {
+ apiReblog,
+ apiUnreblog,
+ apiRevokeQuote,
+ apiGetQuotes,
+} from 'mastodon/api/interactions';
import type { StatusVisibility } from 'mastodon/models/status';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
-import { importFetchedStatus } from './importer';
+import { importFetchedStatus, importFetchedStatuses } from './importer';
export const reblog = createDataLoadingThunk(
'status/reblog',
@@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
return discardLoadData;
},
);
+
+export const revokeQuote = createDataLoadingThunk(
+ 'status/revoke_quote',
+ ({
+ statusId,
+ quotedStatusId,
+ }: {
+ statusId: string;
+ quotedStatusId: string;
+ }) => apiRevokeQuote(quotedStatusId, statusId),
+ (data, { dispatch, discardLoadData }) => {
+ dispatch(importFetchedStatus(data));
+
+ return discardLoadData;
+ },
+);
+
+export const fetchQuotes = createDataLoadingThunk(
+ 'status/fetch_quotes',
+ async ({ statusId, next }: { statusId: string; next?: string }) => {
+ const { links, statuses } = await apiGetQuotes(statusId, next);
+
+ return {
+ links,
+ statuses,
+ replace: !next,
+ };
+ },
+ (payload, { dispatch }) => {
+ dispatch(importFetchedStatuses(payload.statuses));
+ },
+);
diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts
index 43863254818..2d03ef080f5 100644
--- a/app/javascript/mastodon/actions/notification_groups.ts
+++ b/app/javascript/mastodon/actions/notification_groups.ts
@@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';
+function notificationTypeForFilter(type: NotificationType) {
+ if (type === 'quoted_update') return 'update';
+ else return type;
+}
+
+function notificationTypeForQuickFilter(type: NotificationType) {
+ if (type === 'quoted_update') return 'update';
+ else if (type === 'quote') return 'mention';
+ else return type;
+}
+
function excludeAllTypesExcept(filter: string) {
- return allNotificationTypes.filter((item) => item !== filter);
+ return allNotificationTypes.filter(
+ (item) => notificationTypeForQuickFilter(item) !== filter,
+ );
}
function getExcludedTypes(state: RootState) {
@@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn =
activeFilter === 'all'
- ? notificationShows[notification.type] !== false
- : activeFilter === notification.type;
+ ? notificationShows[notificationTypeForFilter(notification.type)] !==
+ false
+ : activeFilter === notificationTypeForQuickFilter(notification.type);
if (!showInColumn) return;
if (
- (notification.type === 'mention' || notification.type === 'update') &&
+ (notification.type === 'mention' ||
+ notification.type === 'quote' ||
+ notification.type === 'update' ||
+ notification.type === 'quoted_update') &&
notification.status?.filtered
) {
const filters = notification.status.filtered.filter((result) =>
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 2499b8da1d7..558390b9cff 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false;
- if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
+ if (['mention', 'quote', 'status'].includes(notification.type) && notification.status.filtered) {
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
if (filters.some(result => result.filter.filter_action === 'hide')) {
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 42d0c1c0f11..7572efe95f5 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -1,9 +1,12 @@
+import { defineMessages } from 'react-intl';
+
import { browserHistory } from 'mastodon/components/router';
import api from '../api';
+import { showAlert } from './alerts';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
-import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
+import { importFetchedStatus, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines';
@@ -40,6 +43,10 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
+const messages = defineMessages({
+ deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
+});
+
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@@ -48,7 +55,18 @@ export function fetchStatusRequest(id, skipLoading) {
};
}
-export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
+/**
+ * @param {string} id
+ * @param {Object} [options]
+ * @param {boolean} [options.forceFetch]
+ * @param {boolean} [options.alsoFetchContext]
+ * @param {string | null | undefined} [options.parentQuotePostId]
+ */
+export function fetchStatus(id, {
+ forceFetch = false,
+ alsoFetchContext = true,
+ parentQuotePostId,
+} = {}) {
return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
@@ -66,7 +84,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
dispatch(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => {
- dispatch(fetchStatusFail(id, error, skipLoading));
+ dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
});
};
}
@@ -78,21 +96,27 @@ export function fetchStatusSuccess(skipLoading) {
};
}
-export function fetchStatusFail(id, error, skipLoading) {
+export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
return {
type: STATUS_FETCH_FAIL,
id,
error,
+ parentQuotePostId,
skipLoading,
skipAlert: true,
};
}
export function redraft(status, raw_text) {
- return {
- type: REDRAFT,
- status,
- raw_text,
+ return (dispatch, getState) => {
+ const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
+
+ dispatch({
+ type: REDRAFT,
+ status,
+ raw_text,
+ maxOptions,
+ });
};
}
@@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id));
- api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
+ return api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));
@@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
if (withRedraft) {
dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState);
+ } else {
+ dispatch(showAlert({ message: messages.deleteSuccess }));
}
+
+ return response;
}).catch(error => {
dispatch(deleteStatusFail(id, error));
+ throw error;
});
};
}
diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts
index b98abbe122e..be9bec71bb3 100644
--- a/app/javascript/mastodon/actions/statuses_typed.ts
+++ b/app/javascript/mastodon/actions/statuses_typed.ts
@@ -1,18 +1,44 @@
-import { apiGetContext } from 'mastodon/api/statuses';
+import { createAction } from '@reduxjs/toolkit';
+
+import { apiGetContext, apiSetQuotePolicy } from 'mastodon/api/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+import type { ApiQuotePolicy } from '../api_types/quotes';
+
import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk(
'status/context',
- ({ statusId }: { statusId: string }) => apiGetContext(statusId),
- (context, { dispatch }) => {
+ ({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
+ apiGetContext(statusId),
+ ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses));
return {
context,
+ refresh,
+ prefetchOnly,
};
},
);
+
+export const completeContextRefresh = createAction<{ statusId: string }>(
+ 'status/context/complete',
+);
+
+export const showPendingReplies = createAction<{ statusId: string }>(
+ 'status/context/showPendingReplies',
+);
+
+export const clearPendingReplies = createAction<{ statusId: string }>(
+ 'status/context/clearPendingReplies',
+);
+
+export const setStatusQuotePolicy = createDataLoadingThunk(
+ 'status/setQuotePolicy',
+ ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
+ return apiSetQuotePolicy(statusId, policy);
+ },
+);
diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts
index dc9c20b5085..1820e00a537 100644
--- a/app/javascript/mastodon/api.ts
+++ b/app/javascript/mastodon/api.ts
@@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => {
return LinkHeader.parse(value);
};
+export interface AsyncRefreshHeader {
+ id: string;
+ retry: number;
+}
+
+const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
+ 'id' in obj && 'retry' in obj;
+
+export const getAsyncRefreshHeader = (
+ response: AxiosResponse,
+): AsyncRefreshHeader | null => {
+ const value = response.headers['mastodon-async-refresh'] as
+ | string
+ | undefined;
+
+ if (!value) {
+ return null;
+ }
+
+ const asyncRefreshHeader: Record = {};
+
+ value.split(/,\s*/).forEach((pair) => {
+ const [key, val] = pair.split('=', 2);
+
+ let typedValue: string | number;
+
+ if (key && ['id', 'retry'].includes(key) && val) {
+ if (val.startsWith('"')) {
+ typedValue = val.slice(1, -1);
+ } else {
+ typedValue = parseInt(val);
+ }
+
+ asyncRefreshHeader[key] = typedValue;
+ }
+ });
+
+ if (isAsyncRefreshHeader(asyncRefreshHeader)) {
+ return asyncRefreshHeader;
+ }
+
+ return null;
+};
+
const csrfHeader: RawAxiosRequestHeaders = {};
const setCSRFHeader = () => {
@@ -83,7 +127,7 @@ export default function api(withAuthorization = true) {
return instance;
}
-type ApiUrl = `v${1 | 2}/${string}`;
+type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
type RequestParamsOrData = Record;
export async function apiRequest(
diff --git a/app/javascript/mastodon/api/async_refreshes.ts b/app/javascript/mastodon/api/async_refreshes.ts
new file mode 100644
index 00000000000..953300a4a86
--- /dev/null
+++ b/app/javascript/mastodon/api/async_refreshes.ts
@@ -0,0 +1,5 @@
+import { apiRequestGet } from 'mastodon/api';
+import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
+
+export const apiGetAsyncRefresh = (id: string) =>
+ apiRequestGet(`v1_alpha/async_refreshes/${id}`);
diff --git a/app/javascript/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts
index 118b5f06d20..36aaeef1866 100644
--- a/app/javascript/mastodon/api/interactions.ts
+++ b/app/javascript/mastodon/api/interactions.ts
@@ -1,10 +1,28 @@
-import { apiRequestPost } from 'mastodon/api';
-import type { Status, StatusVisibility } from 'mastodon/models/status';
+import api, { apiRequestPost, getLinks } from 'mastodon/api';
+import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
+import type { StatusVisibility } from 'mastodon/models/status';
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
- apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
+ apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
visibility,
});
export const apiUnreblog = (statusId: string) =>
- apiRequestPost(`v1/statuses/${statusId}/unreblog`);
+ apiRequestPost(`v1/statuses/${statusId}/unreblog`);
+
+export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
+ apiRequestPost(
+ `v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
+ );
+
+export const apiGetQuotes = async (statusId: string, url?: string) => {
+ const response = await api().request({
+ method: 'GET',
+ url: url ?? `/api/v1/statuses/${statusId}/quotes`,
+ });
+
+ return {
+ statuses: response.data,
+ links: getLinks(response),
+ };
+};
diff --git a/app/javascript/mastodon/api/statuses.ts b/app/javascript/mastodon/api/statuses.ts
index 921a7bfe636..123f2759d09 100644
--- a/app/javascript/mastodon/api/statuses.ts
+++ b/app/javascript/mastodon/api/statuses.ts
@@ -1,5 +1,31 @@
-import { apiRequestGet } from 'mastodon/api';
-import type { ApiContextJSON } from 'mastodon/api_types/statuses';
+import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api';
+import type {
+ ApiContextJSON,
+ ApiStatusJSON,
+} from 'mastodon/api_types/statuses';
-export const apiGetContext = (statusId: string) =>
- apiRequestGet(`v1/statuses/${statusId}/context`);
+import type { ApiQuotePolicy } from '../api_types/quotes';
+
+export const apiGetContext = async (statusId: string) => {
+ const response = await api().request({
+ method: 'GET',
+ url: `/api/v1/statuses/${statusId}/context`,
+ });
+
+ return {
+ context: response.data,
+ refresh: getAsyncRefreshHeader(response),
+ };
+};
+
+export const apiSetQuotePolicy = async (
+ statusId: string,
+ policy: ApiQuotePolicy,
+) => {
+ return apiRequestPut(
+ `v1/statuses/${statusId}/interaction_policy`,
+ {
+ quote_approval_policy: policy,
+ },
+ );
+};
diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts
index b93054a1f6f..913a201fef4 100644
--- a/app/javascript/mastodon/api_types/accounts.ts
+++ b/app/javascript/mastodon/api_types/accounts.ts
@@ -37,7 +37,7 @@ export interface BaseApiAccountJSON {
roles?: ApiAccountJSON[];
statuses_count: number;
uri: string;
- url: string;
+ url?: string;
username: string;
moved?: ApiAccountJSON;
suspended?: boolean;
diff --git a/app/javascript/mastodon/api_types/announcements.ts b/app/javascript/mastodon/api_types/announcements.ts
new file mode 100644
index 00000000000..03e8922d8f1
--- /dev/null
+++ b/app/javascript/mastodon/api_types/announcements.ts
@@ -0,0 +1,28 @@
+// See app/serializers/rest/announcement_serializer.rb
+
+import type { ApiCustomEmojiJSON } from './custom_emoji';
+import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
+
+export interface ApiAnnouncementJSON {
+ id: string;
+ content: string;
+ starts_at: null | string;
+ ends_at: null | string;
+ all_day: boolean;
+ published_at: string;
+ updated_at: null | string;
+ read: boolean;
+ mentions: ApiMentionJSON[];
+ statuses: ApiStatusJSON[];
+ tags: ApiTagJSON[];
+ emojis: ApiCustomEmojiJSON[];
+ reactions: ApiAnnouncementReactionJSON[];
+}
+
+export interface ApiAnnouncementReactionJSON {
+ name: string;
+ count: number;
+ me: boolean;
+ url?: string;
+ static_url?: string;
+}
diff --git a/app/javascript/mastodon/api_types/async_refreshes.ts b/app/javascript/mastodon/api_types/async_refreshes.ts
new file mode 100644
index 00000000000..2d2fed24127
--- /dev/null
+++ b/app/javascript/mastodon/api_types/async_refreshes.ts
@@ -0,0 +1,7 @@
+export interface ApiAsyncRefreshJSON {
+ async_refresh: {
+ id: string;
+ status: 'running' | 'finished';
+ result_count: number;
+ };
+}
diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts
index 190d8c83966..533e9903682 100644
--- a/app/javascript/mastodon/api_types/notifications.ts
+++ b/app/javascript/mastodon/api_types/notifications.ts
@@ -7,12 +7,13 @@ import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb
-export const allNotificationTypes = [
+export const allNotificationTypes: NotificationType[] = [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
+ 'quote',
'poll',
'status',
'update',
@@ -28,8 +29,10 @@ export type NotificationWithStatusType =
| 'reblog'
| 'status'
| 'mention'
+ | 'quote'
| 'poll'
- | 'update';
+ | 'update'
+ | 'quoted_update';
export type NotificationType =
| NotificationWithStatusType
diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts
new file mode 100644
index 00000000000..f42a3eb7289
--- /dev/null
+++ b/app/javascript/mastodon/api_types/quotes.ts
@@ -0,0 +1,39 @@
+import type { ApiStatusJSON } from './statuses';
+
+export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
+export type ApiQuotePolicy =
+ | 'public'
+ | 'followers'
+ | 'following'
+ | 'nobody'
+ | 'unsupported_policy';
+export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown';
+
+interface ApiQuoteEmptyJSON {
+ state: Exclude;
+ quoted_status: null;
+}
+
+interface ApiNestedQuoteJSON {
+ state: 'accepted';
+ quoted_status_id: string;
+}
+
+interface ApiQuoteAcceptedJSON {
+ state: 'accepted';
+ quoted_status: Omit & {
+ quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
+ };
+}
+
+export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON;
+
+export interface ApiQuotePolicyJSON {
+ automatic: ApiQuotePolicy[];
+ manual: ApiQuotePolicy[];
+ current_user: ApiUserQuotePolicy;
+}
+
+export function isQuotePolicy(policy: string): policy is ApiQuotePolicy {
+ return ['public', 'followers', 'nobody'].includes(policy);
+}
diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts
index 09bd2349b39..00418a13d29 100644
--- a/app/javascript/mastodon/api_types/statuses.ts
+++ b/app/javascript/mastodon/api_types/statuses.ts
@@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts';
import type { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMediaAttachmentJSON } from './media_attachments';
import type { ApiPollJSON } from './polls';
+import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes';
// See app/modals/status.rb
export type StatusVisibility =
@@ -95,6 +96,7 @@ export interface ApiStatusJSON {
replies_count: number;
reblogs_count: number;
favorites_count: number;
+ quotes_count: number;
edited_at?: string;
favorited?: boolean;
@@ -118,9 +120,23 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON;
poll?: ApiPollJSON;
+ quote?: ApiQuoteJSON;
+ quote_approval?: ApiQuotePolicyJSON;
}
export interface ApiContextJSON {
ancestors: ApiStatusJSON[];
descendants: ApiStatusJSON[];
}
+
+export interface ApiStatusSourceJSON {
+ id: string;
+ text: string;
+ spoiler_text: string;
+}
+
+export function isStatusVisibility(
+ visibility: string,
+): visibility is StatusVisibility {
+ return ['public', 'unlisted', 'private', 'direct'].includes(visibility);
+}
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap
deleted file mode 100644
index 9d1b236fad0..00000000000
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap
+++ /dev/null
@@ -1,27 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[` > renders display name + account name 1`] = `
-
-
- Foo
",
- }
- }
- />
-
-
-
- @
- bar@baz
-
-
-`;
diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.jsx b/app/javascript/mastodon/components/__tests__/display_name-test.jsx
deleted file mode 100644
index 05a0f47170f..00000000000
--- a/app/javascript/mastodon/components/__tests__/display_name-test.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { fromJS } from 'immutable';
-
-import renderer from 'react-test-renderer';
-
-import { DisplayName } from '../display_name';
-
-describe('', () => {
- it('renders display name + account name', () => {
- const account = fromJS({
- username: 'bar',
- acct: 'bar@baz',
- display_name_html: 'Foo
',
- });
- const component = renderer.create();
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx
index 8397695a443..3aebedc9497 100644
--- a/app/javascript/mastodon/components/account/index.tsx
+++ b/app/javascript/mastodon/components/account/index.tsx
@@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
+import { EmojiHTML } from '@/mastodon/components/emoji/html';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import {
blockAccount,
@@ -331,9 +332,10 @@ export const Account: React.FC = ({
{account &&
withBio &&
(account.note.length > 0 ? (
-
) : (
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx
index 301ffcbb247..e87ae654fdf 100644
--- a/app/javascript/mastodon/components/account_bio.tsx
+++ b/app/javascript/mastodon/components/account_bio.tsx
@@ -1,20 +1,91 @@
+import { useCallback } from 'react';
+
+import classNames from 'classnames';
+
import { useLinks } from 'mastodon/hooks/useLinks';
-export const AccountBio: React.FC<{
- note: string;
- className: string;
-}> = ({ note, className }) => {
- const handleClick = useLinks();
+import { useAppSelector } from '../store';
+import { isModernEmojiEnabled } from '../utils/environment';
- if (note.length === 0 || note === '
') {
+import { EmojiHTML } from './emoji/html';
+import { useElementHandledLink } from './status/handled_link';
+
+interface AccountBioProps {
+ className: string;
+ accountId: string;
+ showDropdown?: boolean;
+}
+
+export const AccountBio: React.FC
= ({
+ className,
+ accountId,
+ showDropdown = false,
+}) => {
+ const handleClick = useLinks(showDropdown);
+ const handleNodeChange = useCallback(
+ (node: HTMLDivElement | null) => {
+ if (
+ !showDropdown ||
+ !node ||
+ node.childNodes.length === 0 ||
+ isModernEmojiEnabled()
+ ) {
+ return;
+ }
+ addDropdownToHashtags(node, accountId);
+ },
+ [showDropdown, accountId],
+ );
+
+ const htmlHandlers = useElementHandledLink({
+ hashtagAccountId: showDropdown ? accountId : undefined,
+ });
+
+ const note = useAppSelector((state) => {
+ const account = state.accounts.get(accountId);
+ if (!account) {
+ return '';
+ }
+ return account.note_emojified;
+ });
+ const extraEmojis = useAppSelector((state) => {
+ const account = state.accounts.get(accountId);
+ return account?.emojis;
+ });
+
+ if (note.length === 0) {
return null;
}
return (
-
);
};
+
+function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
+ if (!node) {
+ return;
+ }
+ for (const childNode of node.childNodes) {
+ if (!(childNode instanceof HTMLElement)) {
+ continue;
+ }
+ if (
+ childNode instanceof HTMLAnchorElement &&
+ (childNode.classList.contains('hashtag') ||
+ childNode.innerText.startsWith('#')) &&
+ !childNode.dataset.menuHashtag
+ ) {
+ childNode.dataset.menuHashtag = accountId;
+ } else if (childNode.childNodes.length > 0) {
+ addDropdownToHashtags(childNode, accountId);
+ }
+ }
+}
diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx
index 4ce55f7896a..dd17b89d865 100644
--- a/app/javascript/mastodon/components/account_fields.tsx
+++ b/app/javascript/mastodon/components/account_fields.tsx
@@ -1,42 +1,70 @@
+import { useIntl } from 'react-intl';
+
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Icon } from 'mastodon/components/icon';
-import { useLinks } from 'mastodon/hooks/useLinks';
import type { Account } from 'mastodon/models/account';
-export const AccountFields: React.FC<{
- fields: Account['fields'];
- limit: number;
-}> = ({ fields, limit = -1 }) => {
- const handleClick = useLinks();
+import { CustomEmojiProvider } from './emoji/context';
+import { EmojiHTML } from './emoji/html';
+import { useElementHandledLink } from './status/handled_link';
+
+export const AccountFields: React.FC> = ({
+ fields,
+ emojis,
+}) => {
+ const intl = useIntl();
+ const htmlHandlers = useElementHandledLink();
if (fields.size === 0) {
return null;
}
return (
-
- {fields.take(limit).map((pair, i) => (
-
- -
+ {fields.map((pair, i) => (
+
+
- -
- {pair.get('verified_at') && (
-
- )}
-
+ {pair.verified_at && (
+
+
+
+ )}{' '}
+
))}
-
+
);
};
+
+const dateFormatOptions: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+};
diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx
new file mode 100644
index 00000000000..f12f06751d7
--- /dev/null
+++ b/app/javascript/mastodon/components/alert/alert.stories.tsx
@@ -0,0 +1,125 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn, expect } from 'storybook/test';
+
+import { Alert } from '.';
+
+const meta = {
+ title: 'Components/Alert',
+ component: Alert,
+ args: {
+ isActive: true,
+ isLoading: false,
+ animateFrom: 'side',
+ title: '',
+ message: '',
+ action: '',
+ onActionClick: fn(),
+ },
+ argTypes: {
+ isActive: {
+ control: 'boolean',
+ type: 'boolean',
+ description: 'Animate to the active (displayed) state of the alert',
+ },
+ isLoading: {
+ control: 'boolean',
+ type: 'boolean',
+ description:
+ 'Display a loading indicator in the alert, replacing the dismiss button if present',
+ },
+ animateFrom: {
+ control: 'radio',
+ type: 'string',
+ options: ['side', 'below'],
+ description:
+ 'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.',
+ },
+ title: {
+ control: 'text',
+ type: 'string',
+ description: '(Optional) title of the alert',
+ },
+ message: {
+ control: 'text',
+ type: 'string',
+ description: 'Main alert text',
+ },
+ action: {
+ control: 'text',
+ type: 'string',
+ description:
+ 'Label of the alert action (requires `onActionClick` handler)',
+ },
+ },
+ tags: ['test'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Simple: Story = {
+ args: {
+ message: 'Post published.',
+ },
+ render: (args) => (
+
+ ),
+};
+
+export const WithAction: Story = {
+ args: {
+ ...Simple.args,
+ action: 'Open',
+ },
+ render: Simple.render,
+ play: async ({ args, canvas, userEvent }) => {
+ const button = await canvas.findByRole('button', { name: 'Open' });
+ await userEvent.click(button);
+ await expect(args.onActionClick).toHaveBeenCalled();
+ },
+};
+
+export const WithTitle: Story = {
+ args: {
+ title: 'Warning:',
+ message: 'This is an alert',
+ },
+ render: Simple.render,
+};
+
+export const WithDismissButton: Story = {
+ args: {
+ message: 'More replies found',
+ action: 'Show',
+ onDismiss: fn(),
+ },
+ render: Simple.render,
+};
+
+export const InSizedContainer: Story = {
+ args: WithDismissButton.args,
+ render: (args) => (
+
+ ),
+};
+
+export const WithLoadingIndicator: Story = {
+ args: {
+ ...WithDismissButton.args,
+ isLoading: true,
+ },
+ render: InSizedContainer.render,
+};
diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx
new file mode 100644
index 00000000000..72fee0a4a30
--- /dev/null
+++ b/app/javascript/mastodon/components/alert/index.tsx
@@ -0,0 +1,77 @@
+import { useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+
+import { IconButton } from '../icon_button';
+
+/**
+ * Snackbar/Toast-style notification component.
+ */
+export const Alert: React.FC<{
+ title?: string;
+ message: string;
+ action?: string;
+ onActionClick?: () => void;
+ onDismiss?: () => void;
+ isActive?: boolean;
+ isLoading?: boolean;
+ animateFrom?: 'side' | 'below';
+}> = ({
+ title,
+ message,
+ action,
+ onActionClick,
+ onDismiss,
+ isActive,
+ isLoading,
+ animateFrom = 'side',
+}) => {
+ const intl = useIntl();
+
+ const hasAction = Boolean(action && onActionClick);
+
+ return (
+
+
+ {Boolean(title) && (
+ {title}
+ )}
+ {message}
+
+
+ {hasAction && (
+
+ )}
+
+ {isLoading && (
+
+
+
+ )}
+
+ {onDismiss && !isLoading && (
+
+ )}
+
+ );
+};
diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx
index 26749fa1037..aa97feeca58 100644
--- a/app/javascript/mastodon/components/alerts_controller.tsx
+++ b/app/javascript/mastodon/components/alerts_controller.tsx
@@ -3,16 +3,16 @@ import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import type { IntlShape } from 'react-intl';
-import classNames from 'classnames';
-
import { dismissAlert } from 'mastodon/actions/alerts';
import type {
- Alert,
+ Alert as AlertType,
TranslatableString,
TranslatableValues,
} from 'mastodon/models/alert';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
+import { Alert } from './alert';
+
const formatIfNeeded = (
intl: IntlShape,
message: TranslatableString,
@@ -25,8 +25,8 @@ const formatIfNeeded = (
return message;
};
-const Alert: React.FC<{
- alert: Alert;
+const TimedAlert: React.FC<{
+ alert: AlertType;
dismissAfter: number;
}> = ({
alert: { key, title, message, values, action, onClick },
@@ -62,29 +62,13 @@ const Alert: React.FC<{
}, [dispatch, setActive, key, dismissAfter]);
return (
-
-
- {title && (
-
- {formatIfNeeded(intl, title, values)}
-
- )}
-
-
- {formatIfNeeded(intl, message, values)}
-
-
- {action && (
-
- )}
-
-
+
);
};
@@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
return (
{alerts.map((alert, idx) => (
-
+
))}
);
diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx
index 07369795aca..c7fb0cd81b1 100644
--- a/app/javascript/mastodon/components/alt_text_badge.tsx
+++ b/app/javascript/mastodon/components/alt_text_badge.tsx
@@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
-export const AltTextBadge: React.FC<{
- description: string;
-}> = ({ description }) => {
+export const AltTextBadge: React.FC<{ description: string }> = ({
+ description,
+}) => {
const accessibilityId = useId();
const anchorRef = useRef(null);
const [open, setOpen] = useState(false);
@@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
{({ props }) => (
{
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@@ -149,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({
}, [suggestions, onSuggestionSelected, textareaRef]);
const handlePaste = useCallback((e) => {
- if (e.clipboardData && e.clipboardData.files.length === 1) {
- onPaste(e.clipboardData.files);
- e.preventDefault();
- }
+ onPaste(e);
}, [onPaste]);
// Show the suggestions again whenever they change and the textarea is focused
@@ -192,7 +190,7 @@ const AutosuggestTextarea = forwardRef(({
};
return (
-
+
diff --git a/app/javascript/mastodon/components/display_name.tsx b/app/javascript/mastodon/components/display_name.tsx
deleted file mode 100644
index 8409244827e..00000000000
--- a/app/javascript/mastodon/components/display_name.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import React from 'react';
-
-import type { List } from 'immutable';
-
-import type { Account } from 'mastodon/models/account';
-
-import { autoPlayGif } from '../initial_state';
-
-import { Skeleton } from './skeleton';
-
-interface Props {
- account?: Account;
- others?: List
;
- localDomain?: string;
-}
-
-export class DisplayName extends React.PureComponent {
- handleMouseEnter: React.ReactEventHandler = ({
- currentTarget,
- }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis =
- currentTarget.querySelectorAll('img.custom-emoji');
-
- emojis.forEach((emoji) => {
- const originalSrc = emoji.getAttribute('data-original');
- if (originalSrc != null) emoji.src = originalSrc;
- });
- };
-
- handleMouseLeave: React.ReactEventHandler = ({
- currentTarget,
- }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis =
- currentTarget.querySelectorAll('img.custom-emoji');
-
- emojis.forEach((emoji) => {
- const staticSrc = emoji.getAttribute('data-static');
- if (staticSrc != null) emoji.src = staticSrc;
- });
- };
-
- render() {
- const { others, localDomain } = this.props;
-
- let displayName: React.ReactNode,
- suffix: React.ReactNode,
- account: Account | undefined;
-
- if (others && others.size > 0) {
- account = others.first();
- } else if (this.props.account) {
- account = this.props.account;
- }
-
- if (others && others.size > 1) {
- displayName = others
- .take(2)
- .map((a) => (
-
-
-
- ))
- .reduce((prev, cur) => [prev, ', ', cur]);
-
- if (others.size - 2 > 0) {
- suffix = `+${others.size - 2}`;
- }
- } else if (account) {
- let acct = account.get('acct');
-
- if (!acct.includes('@') && localDomain) {
- acct = `${acct}@${localDomain}`;
- }
-
- displayName = (
-
-
-
- );
- suffix = @{acct};
- } else {
- displayName = (
-
-
-
-
-
- );
- suffix = (
-
-
-
- );
- }
-
- return (
-
- {displayName} {suffix}
-
- );
- }
-}
diff --git a/app/javascript/mastodon/components/display_name/default.tsx b/app/javascript/mastodon/components/display_name/default.tsx
new file mode 100644
index 00000000000..57ae24ab26f
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name/default.tsx
@@ -0,0 +1,36 @@
+import { useMemo } from 'react';
+import type { ComponentPropsWithoutRef, FC } from 'react';
+
+import { Skeleton } from '../skeleton';
+
+import type { DisplayNameProps } from './index';
+import { DisplayNameWithoutDomain } from './no-domain';
+
+export const DisplayNameDefault: FC<
+ Omit & ComponentPropsWithoutRef<'span'>
+> = ({ account, localDomain, className, ...props }) => {
+ const username = useMemo(() => {
+ if (!account) {
+ return null;
+ }
+ let acct = account.get('acct');
+
+ if (!acct.includes('@') && localDomain) {
+ acct = `${acct}@${localDomain}`;
+ }
+ return `@${acct}`;
+ }, [account, localDomain]);
+
+ return (
+
+ {' '}
+
+ {username ?? }
+
+
+ );
+};
diff --git a/app/javascript/mastodon/components/display_name/display_name.stories.tsx b/app/javascript/mastodon/components/display_name/display_name.stories.tsx
new file mode 100644
index 00000000000..d546fdd135e
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name/display_name.stories.tsx
@@ -0,0 +1,79 @@
+import type { ComponentProps } from 'react';
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { accountFactoryState } from '@/testing/factories';
+
+import { DisplayName, LinkedDisplayName } from './index';
+
+type PageProps = Omit, 'account'> & {
+ name: string;
+ username: string;
+ loading: boolean;
+};
+
+const meta = {
+ title: 'Components/DisplayName',
+ args: {
+ username: 'mastodon@mastodon.social',
+ name: 'Test User 🧪',
+ loading: false,
+ localDomain: 'mastodon.social',
+ },
+ tags: [],
+ render({ name, username, loading, ...args }) {
+ const account = !loading
+ ? accountFactoryState({
+ display_name: name,
+ acct: username,
+ })
+ : undefined;
+ return ;
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Primary: Story = {
+ args: {},
+};
+
+export const Loading: Story = {
+ args: {
+ loading: true,
+ },
+};
+
+export const NoDomain: Story = {
+ args: {
+ variant: 'noDomain',
+ },
+};
+
+export const Simple: Story = {
+ args: {
+ variant: 'simple',
+ },
+};
+
+export const LocalUser: Story = {
+ args: {
+ username: 'localuser',
+ name: 'Local User',
+ localDomain: '',
+ },
+};
+
+export const Linked: Story = {
+ render({ name, username, loading, ...args }) {
+ const account = !loading
+ ? accountFactoryState({
+ display_name: name,
+ acct: username,
+ })
+ : undefined;
+ return ;
+ },
+};
diff --git a/app/javascript/mastodon/components/display_name/index.tsx b/app/javascript/mastodon/components/display_name/index.tsx
new file mode 100644
index 00000000000..06bc380a10b
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name/index.tsx
@@ -0,0 +1,51 @@
+import type { ComponentPropsWithoutRef, FC } from 'react';
+
+import type { LinkProps } from 'react-router-dom';
+import { Link } from 'react-router-dom';
+
+import type { Account } from '@/mastodon/models/account';
+
+import { DisplayNameDefault } from './default';
+import { DisplayNameWithoutDomain } from './no-domain';
+import { DisplayNameSimple } from './simple';
+
+export interface DisplayNameProps {
+ account?: Account;
+ localDomain?: string;
+ variant?: 'default' | 'simple' | 'noDomain';
+}
+
+export const DisplayName: FC<
+ DisplayNameProps & ComponentPropsWithoutRef<'span'>
+> = ({ variant = 'default', ...props }) => {
+ if (variant === 'simple') {
+ return ;
+ } else if (variant === 'noDomain') {
+ return ;
+ }
+ return ;
+};
+
+export const LinkedDisplayName: FC<
+ Omit & {
+ displayProps: DisplayNameProps & ComponentPropsWithoutRef<'span'>;
+ }
+> = ({ displayProps, children, ...linkProps }) => {
+ const { account } = displayProps;
+ if (!account) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/app/javascript/mastodon/components/display_name/no-domain.tsx b/app/javascript/mastodon/components/display_name/no-domain.tsx
new file mode 100644
index 00000000000..ee6e84050c3
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name/no-domain.tsx
@@ -0,0 +1,38 @@
+import type { ComponentPropsWithoutRef, FC } from 'react';
+
+import classNames from 'classnames';
+
+import { AnimateEmojiProvider } from '../emoji/context';
+import { EmojiHTML } from '../emoji/html';
+import { Skeleton } from '../skeleton';
+
+import type { DisplayNameProps } from './index';
+
+export const DisplayNameWithoutDomain: FC<
+ Omit &
+ ComponentPropsWithoutRef<'span'>
+> = ({ account, className, children, ...props }) => {
+ return (
+
+
+ {account ? (
+
+ ) : (
+
+
+
+ )}
+
+ {children}
+
+ );
+};
diff --git a/app/javascript/mastodon/components/display_name/simple.tsx b/app/javascript/mastodon/components/display_name/simple.tsx
new file mode 100644
index 00000000000..29d9ee217b1
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name/simple.tsx
@@ -0,0 +1,25 @@
+import type { ComponentPropsWithoutRef, FC } from 'react';
+
+import { EmojiHTML } from '../emoji/html';
+
+import type { DisplayNameProps } from './index';
+
+export const DisplayNameSimple: FC<
+ Omit &
+ ComponentPropsWithoutRef<'span'>
+> = ({ account, ...props }) => {
+ if (!account) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx
new file mode 100644
index 00000000000..b6a04b9027f
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown/index.tsx
@@ -0,0 +1,141 @@
+import { useCallback, useId, useMemo, useRef, useState } from 'react';
+import type { ComponentPropsWithoutRef, FC } from 'react';
+
+import { useIntl } from 'react-intl';
+import type { MessageDescriptor } from 'react-intl';
+
+import classNames from 'classnames';
+
+import Overlay from 'react-overlays/Overlay';
+
+import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
+
+import type { SelectItem } from '../dropdown_selector';
+import { DropdownSelector } from '../dropdown_selector';
+import { Icon } from '../icon';
+
+import { matchWidth } from './utils';
+
+interface DropdownProps {
+ disabled?: boolean;
+ items: SelectItem[];
+ onChange: (value: string) => void;
+ current: string;
+ labelId: string;
+ descriptionId?: string;
+ emptyText?: MessageDescriptor;
+ classPrefix: string;
+}
+
+export const Dropdown: FC<
+ DropdownProps & Omit, keyof DropdownProps>
+> = ({
+ disabled,
+ items,
+ current,
+ onChange,
+ labelId,
+ descriptionId,
+ classPrefix,
+ className,
+ id,
+ ...buttonProps
+}) => {
+ const intl = useIntl();
+ const buttonRef = useRef(null);
+ const uniqueId = useId();
+ const buttonId = id ?? `${uniqueId}-button`;
+ const listboxId = `${uniqueId}-listbox`;
+
+ const [open, setOpen] = useState(false);
+
+ const handleToggle = useCallback(() => {
+ if (!disabled) {
+ setOpen((prevOpen) => {
+ buttonRef.current?.focus();
+ return !prevOpen;
+ });
+ }
+ }, [disabled]);
+
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ buttonRef.current?.focus();
+ }, []);
+
+ const currentText = useMemo(
+ () =>
+ items.find((i) => i.value === current)?.text ??
+ intl.formatMessage({
+ id: 'dropdown.empty',
+ defaultMessage: 'Select an option',
+ }),
+ [current, intl, items],
+ );
+
+ return (
+ <>
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+ >
+ );
+};
diff --git a/app/javascript/mastodon/components/dropdown/utils.ts b/app/javascript/mastodon/components/dropdown/utils.ts
new file mode 100644
index 00000000000..b45d2cc9d28
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown/utils.ts
@@ -0,0 +1,17 @@
+import type { Modifier, UsePopperState } from 'react-overlays/esm/usePopper';
+
+export const matchWidth: Modifier<'sameWidth', UsePopperState> = {
+ name: 'sameWidth',
+ enabled: true,
+ phase: 'beforeWrite',
+ requires: ['computeStyles'],
+ fn: ({ state }) => {
+ if (state.styles.popper) {
+ state.styles.popper.width = `${state.rects.reference.width}px`;
+ }
+ },
+ effect: ({ state }) => {
+ const reference = state.elements.reference as HTMLElement;
+ state.elements.popper.style.width = `${reference.offsetWidth}px`;
+ },
+};
diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx
index 23d77f0dda2..afcd4176e05 100644
--- a/app/javascript/mastodon/components/dropdown_menu.tsx
+++ b/app/javascript/mastodon/components/dropdown_menu.tsx
@@ -5,6 +5,7 @@ import {
useCallback,
cloneElement,
Children,
+ useId,
} from 'react';
import classNames from 'classnames';
@@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
+ Placement,
} from 'react-overlays/esm/usePopper';
import { fetchRelationships } from 'mastodon/actions/accounts';
@@ -34,18 +36,22 @@ import {
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
+import { Icon } from './icon';
import type { IconProp } from './icon';
import { IconButton } from './icon_button';
let id = 0;
-type RenderItemFn- = (
+export interface RenderItemFnHandlers {
+ onClick: React.MouseEventHandler;
+ onKeyUp: React.KeyboardEventHandler;
+}
+
+export type RenderItemFn
- = (
item: Item,
index: number,
- handlers: {
- onClick: (e: React.MouseEvent) => void;
- onKeyUp: (e: React.KeyboardEvent) => void;
- },
+ handlers: RenderItemFnHandlers,
+ focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
) => React.ReactNode;
type ItemClickFn
- = (item: Item, index: number) => void;
@@ -63,6 +69,27 @@ interface DropdownMenuProps
- {
onItemClick?: ItemClickFn
- ;
}
+export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
+ item,
+}) => {
+ if (item === null) {
+ return null;
+ }
+
+ const { text, description, icon } = item;
+ return (
+ <>
+ {icon && }
+
+ {text}
+ {Boolean(description) && (
+ {description}
+ )}
+
+ >
+ );
+};
+
export const DropdownMenu =
- ({
items,
loading,
@@ -159,19 +186,22 @@ export const DropdownMenu =
- ({
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
+ const isItemDisabled = Boolean(
+ item && typeof item === 'object' && 'disabled' in item && item.disabled,
+ );
- onClose();
-
- if (!item) {
+ if (!item || isItemDisabled) {
return;
}
+ onClose();
+
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
- item.action();
+ item.action(e);
}
},
[onClose, onItemClick, items],
@@ -195,7 +225,7 @@ export const DropdownMenu =
- ({
return ;
}
- const { text, dangerous } = option;
+ const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
@@ -206,8 +236,9 @@ export const DropdownMenu =
- ({
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
+ aria-disabled={disabled}
>
- {text}
+
);
} else if (isExternalLinkItem(option)) {
@@ -222,7 +253,7 @@ export const DropdownMenu =
- ({
onKeyUp={handleItemKeyUp}
data-index={i}
>
- {text}
+
);
} else {
@@ -234,7 +265,7 @@ export const DropdownMenu =
- ({
onKeyUp={handleItemKeyUp}
data-index={i}
>
- {text}
+
);
}
@@ -242,6 +273,7 @@ export const DropdownMenu =
- ({
return (
({
})}
>
{items.map((option, i) =>
- renderItemMethod(option, i, {
- onClick: handleItemClick,
- onKeyUp: handleItemKeyUp,
- }),
+ renderItemMethod(
+ option,
+ i,
+ {
+ onClick: handleItemClick,
+ onKeyUp: handleItemKeyUp,
+ },
+ i === 0 ? handleFocusedItemRef : undefined,
+ ),
)}
)}
@@ -286,7 +323,7 @@ export const DropdownMenu =
- ({
);
};
-interface DropdownProps
- {
+interface DropdownProps
- {
children?: React.ReactElement;
icon?: string;
iconComponent?: IconProp;
@@ -295,19 +332,26 @@ interface DropdownProps
- {
title?: string;
disabled?: boolean;
scrollable?: boolean;
+ placement?: Placement;
+ offset?: OffsetValue;
+ /**
+ * Prevent the `ScrollableList` with this scrollKey
+ * from being scrolled while the dropdown is open
+ */
scrollKey?: string;
status?: ImmutableMap;
forceDropdown?: boolean;
renderItem?: RenderItemFn
- ;
renderHeader?: RenderHeaderFn
- ;
- onOpen?: () => void;
+ onOpen?: // Must use a union type for the full function as a union with void is not allowed.
+ | ((event: React.MouseEvent | React.KeyboardEvent) => void)
+ | ((event: React.MouseEvent | React.KeyboardEvent) => boolean);
onItemClick?: ItemClickFn
- ;
}
-const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
-export const Dropdown =
- ({
+export const Dropdown =
- ({
children,
icon,
iconComponent,
@@ -316,6 +360,8 @@ export const Dropdown =
- ({
title = 'Menu',
disabled,
scrollable,
+ placement = 'bottom',
+ offset = [5, 5],
status,
forceDropdown = false,
renderItem,
@@ -331,16 +377,15 @@ export const Dropdown =
- ({
);
const [currentId] = useState(id++);
const open = currentId === openDropdownId;
- const activeElement = useRef(null);
- const targetRef = useRef(null);
+ const buttonRef = useRef(null);
+ const menuId = useId();
const prefetchAccountId = status
? status.getIn(['account', 'id'])
: undefined;
const handleClose = useCallback(() => {
- if (activeElement.current) {
- activeElement.current.focus({ preventScroll: true });
- activeElement.current = null;
+ if (buttonRef.current) {
+ buttonRef.current.focus({ preventScroll: true });
}
dispatch(
@@ -369,20 +414,23 @@ export const Dropdown =
- ({
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
- item.action();
+ item.action(e);
}
},
[handleClose, onItemClick, items],
);
- const handleClick = useCallback(
+ const toggleDropdown = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
if (open) {
handleClose();
} else {
- onOpen?.();
+ const allow = onOpen?.(e);
+ if (allow === false) {
+ return;
+ }
if (prefetchAccountId) {
dispatch(fetchRelationships([prefetchAccountId]));
@@ -423,38 +471,6 @@ export const Dropdown =
- ({
],
);
- const handleMouseDown = useCallback(() => {
- if (!open && document.activeElement instanceof HTMLElement) {
- activeElement.current = document.activeElement;
- }
- }, [open]);
-
- const handleButtonKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- switch (e.key) {
- case ' ':
- case 'Enter':
- handleMouseDown();
- break;
- }
- },
- [handleMouseDown],
- );
-
- const handleKeyPress = useCallback(
- (e: React.KeyboardEvent) => {
- switch (e.key) {
- case ' ':
- case 'Enter':
- handleClick(e);
- e.stopPropagation();
- e.preventDefault();
- break;
- }
- },
- [handleClick],
- );
-
useEffect(() => {
return () => {
if (currentId === openDropdownId) {
@@ -465,14 +481,16 @@ export const Dropdown =
- ({
let button: React.ReactElement;
+ const buttonProps = {
+ disabled,
+ onClick: toggleDropdown,
+ 'aria-expanded': open,
+ 'aria-controls': menuId,
+ ref: buttonRef,
+ };
+
if (children) {
- button = cloneElement(Children.only(children), {
- onClick: handleClick,
- onMouseDown: handleMouseDown,
- onKeyDown: handleButtonKeyDown,
- onKeyPress: handleKeyPress,
- ref: targetRef,
- });
+ button = cloneElement(Children.only(children), buttonProps);
} else if (icon && iconComponent) {
button = (
({
iconComponent={iconComponent}
title={title}
active={open}
- disabled={disabled}
- onClick={handleClick}
- onMouseDown={handleMouseDown}
- onKeyDown={handleButtonKeyDown}
- onKeyPress={handleKeyPress}
- ref={targetRef}
+ {...buttonProps}
/>
);
} else {
@@ -499,13 +512,13 @@ export const Dropdown =
- ({
{({ props, arrowProps, placement }) => (
-
+