diff --git a/.dockerignore b/.dockerignore index 9d990ab9c..fe87f6e60 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/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml index bb4b71dd9..2261275a4 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 c6d7e8e16..99ec9cf14 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 1850a45bb..c1a1c99eb 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-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 000000000..4a56f720e --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,62 @@ +name: Build nightly container image +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + cache: false + push_to_images: | + tootsuite/mastodon + ghcr.io/mastodon/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + cache: false + push_to_images: | + tootsuite/mastodon-streaming + ghcr.io/mastodon/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 7608535f0..245b25a93 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -21,7 +21,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }} + latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} @@ -39,7 +39,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }} + latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index d3cb4e5e0..72729b544 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 new file mode 100644 index 000000000..c864e12d2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,66 @@ +name: 'CodeQL' + +on: + merge_group: + push: + branches: + - 'main' + - 'stable-*' + pull_request: + branches: + - 'main' + - 'stable-*' + schedule: + - cron: '22 6 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + 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: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 6d9a05862..8e18a9d0a 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 new file mode 100644 index 000000000..d0d79d919 --- /dev/null +++ b/.github/workflows/crowdin-upload.yml @@ -0,0 +1,38 @@ +name: Crowdin / Upload translations + +on: + push: + branches: + - 'main' + - 'stable-*' + paths: + - crowdin.yml + - app/javascript/mastodon/locales/en.json + - config/locales/en.yml + - config/locales/simple_form.en.yml + - config/locales/activerecord.en.yml + - config/locales/devise.en.yml + - config/locales/doorkeeper.en.yml + - .github/workflows/crowdin-upload.yml + workflow_dispatch: + +jobs: + upload-translations: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + download_translations: false + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.gitignore b/.gitignore index db63bc07f..4727d9ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /public/packs /public/packs-dev /public/packs-test +stats.html .env .env.production node_modules/ diff --git a/.nvmrc b/.nvmrc index 4a203c23d..f666621e5 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.17 +24.10 diff --git a/.rubocop/metrics.yml b/.rubocop/metrics.yml index 89532af42..bb15e6ff3 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 4ec92f341..0cc9c8d8f 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 f9892605c..2aa513199 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 72321cbf3..bb69f0c66 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 000000000..1870d95b8 --- /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 f25d0547e..d66f0fb11 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 de7bc0f29..15623f109 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 000000000..ac2989089 --- /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 59a8a9268..f30b502ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,251 @@ All notable changes to this project will be documented in this file. +## [4.5.0] - 2025-11-06 + +### Added + +- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550, #36559, #36693, #36704, #36690, #36689, #36696, #36721, #36695 and #36736 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\ + This includes a revamp of the composer interface.\ + See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation. +- **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap) +- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron) +- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, #36607 and #36703 by @ClearlyClaire)\ + This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\ + The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `public`, `authenticated`, `disabled`.\ + When `disabled`, users with the “View live and topic feeds” will still be able to view them. +- Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm) +- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm) +- Add a new server setting to choose the server landing page (#36588 and #36602 by @ClearlyClaire and @renchap) +- Add support for `Update` activities on converted object types (#36322 by @ClearlyClaire) +- Add support for dynamic viewport height (#36272 by @e1berd) +- Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire) +- Add default visualizer for audio upload without poster (#36734 by @ChaosExAnima) +- Add Traditional Mongolian to posting languages (#36196 by @shimon1024) +- Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire) +- Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire) +- Add schema.org markup to SEO-enabled posts (#36075 by @Gargron) +- Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire) +- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion) +- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima) +- Add feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, #36402, #36503, #36502, #36532, #36603, #36409, #36638 and #36750 by @ChaosExAnima, @ClearlyClaire and @braddunbar)\ + This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places. +- Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus) +- Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire) +- Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts) +- Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros) +- Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire) +- Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire) + +### Changed + +- Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion) +- Change “Follow” button labels (#36264 by @diondiondion) +- Change appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion) +- Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\ + This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities. +- Change submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion) +- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes) +- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire) +- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm) +- Change styling of column banners (#36531 by @ClearlyClaire) +- Change recommended Node version to 24 (LTS) (#36539 by @renchap) +- Change min. characters required for logged-out account search from 5 to 3 (#36487 by @Gargron) +- Change browser target to Vite legacy plugin defaults (#36611 by @larouxn) +- Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire) +- Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion) +- Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire) +- Change support for RFC9421 HTTP signatures to be enabled unconditionally (#36610 by @oneiros) +- Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion) +- Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion) +- Change modal background colours in light mode (#36069 by @diondiondion) +- Change “Posting defaults” settings page to enforce `nobody` quote policy for `private` default visibility (#36040 by @ClearlyClaire) +- Change description of “Quiet public” (#36032 by @ClearlyClaire) +- Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire) +- Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm) +- Change design of quote posts in web UI (#35584 and #35834 by @Gargron) +- Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk) +- Change order of translation restoration and service credit on post card (#33619 by @colindean) +- Change position of ‘add more’ to be inside table toolbar on reports (#35963 by @ThisIsMissEm) +- Change docker-compose.yml sidekiq health check to work for both 4.4 and 4.5 (#36498 by @ClearlyClaire) + +### Fixed + +- Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire) +- Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm) +- Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk) +- Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672 by @diondiondion) +- Fix URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire) +- Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron) +- Fix layout of severed relationships when purged events are listed (#36593 by @mejofi) +- Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire) +- Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron) +- Fix handling of unreachable network error for search services (#36587 by @mjankowski) +- Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire) +- Fix text overflow alignment for long author names in News (#36562 by @diondiondion) +- Fix discovery preamble missing word in admin settings (#36560 by @belatedly) +- Fix overflow handling of `.more-from-author` (#36310 by @edent) +- Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion) +- Fix translate button width in Safari (#36164 and #36216 by @diondiondion) +- Fix login page linking to other pages within OAuth authorization flow (#36115 by @Gargron) +- Fix stale search results being displayed in Web UI while new query is in progress (#36053 by @ChaosExAnima) +- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros) +- Fix banned text being able to be circumvented via unicode (#35978 by @Gargron) +- Fix batch table toolbar displaying under status media (#35962 by @ThisIsMissEm) +- Fix incorrect RSS feed MIME type in gzip_types directive (#35562 by @iioflow) +- Fix 404 error after deleting status from detail view (#35800) (#35881 by @crafkaz) +- Fix feeds keyboard navigation issues (#35853, #35864, and #36267 by @braddunbar and @diondiondion) +- Fix layout shift caused by “Who to follow” widget (#35861 by @diondiondion) +- Fix Vagrantfile (#35765 by @ClearlyClaire) +- Fix reply indicator displaying wrong avatar in rare cases (#35756 by @ClearlyClaire) +- Fix `Chewy::UndefinedUpdateStrategy` in `dev:populate_sample_data` task when Elasticsearch is enabled (#35615 by @ClearlyClaire) +- Fix unnecessary account note addition for already-muted moved-to users (#35566 by @mjankowski) +- Fix seeded admin user creation failing on specific configurations (#35565 by @oneiros) +- Fix media modal images in Web UI having redundant `title` attribute (#35468 by @mayank99) +- Fix inconsistent default privacy post setting when unset in settings (#35422 by @oneiros) +- Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion) +- Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion) + +### Removed + +- Remove support for PostgreSQL 13 (#36540 by @renchap) + +## [4.4.8] - 2025-10-21 + +### Security + +- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6)) + +## [4.4.7] - 2025-10-15 + +### Fixed + +- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire) +- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire) +- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan) + +## [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 @@ -531,7 +776,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 @@ -543,7 +787,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 6cb4a2a1c..c64d52991 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" -# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +ARG RUBY_VERSION="3.4.7" +# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"] # 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) +ARG NODE_MAJOR_VERSION="24" +# 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.3 # 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,14 +206,14 @@ 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 +ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags WORKDIR /usr/local/ffmpeg/src # Download and extract ffmpeg source code -ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/ -RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; +ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/ +RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION}; WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION} @@ -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 ffd5371b0..7d219344b 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-exporter-otlp', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.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 299507cac..417b89ffa 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.20.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,26 +109,26 @@ 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) bcrypt (3.1.20) - benchmark (0.4.1) + benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.3.1) bindata (2.5.1) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.1) racc browser (6.2.0) builder (3.3.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,20 +164,20 @@ 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) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) 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) @@ -190,10 +190,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (6.1.0) - activesupport (>= 7.0, < 8.1) + devise-two-factor (6.2.0) + activesupport (>= 7.0, < 8.2) devise (~> 4.0) - railties (>= 7.0, < 8.1) + railties (>= 7.0, < 8.2) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.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.1.3) 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.1) + 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.22.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,8 +336,8 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.1) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -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) @@ -425,7 +426,8 @@ GEM loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop @@ -433,24 +435,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) + minitest (5.26.0) 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 +462,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,126 +499,101 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.0) + openssl (3.3.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.5.0) - opentelemetry-common (0.22.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.30.0) + opentelemetry-exporter-otlp (0.31.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) - opentelemetry-sdk (~> 1.2) + opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-helpers-sql (0.1.1) - opentelemetry-api (~> 1.0) - opentelemetry-helpers-sql-obfuscation (0.3.0) + opentelemetry-helpers-sql (0.2.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-obfuscation (0.4.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.4.0) - opentelemetry-api (~> 1.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-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.9.0) - opentelemetry-api (~> 1.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-action_mailer (0.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.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_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_record (0.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.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.0) - 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.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.30.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.32.0) 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.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.8.0) + opentelemetry-sdk (1.10.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,26 +604,25 @@ 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.2) + 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.1.0) 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-attack (6.7.0) + rack (3.2.4) + rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (3.0.0) logger @@ -668,76 +647,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.1) 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) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + 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 +731,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 +739,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.6) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -769,10 +753,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 +765,17 @@ GEM rubocop-i18n (3.2.3) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.1) 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.47.1, < 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,61 +785,60 @@ 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.2.2) 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.9) + 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) thor (>= 1.0, < 3.0) simple-navigation (4.4.0) activesupport (>= 2.3.2) - simple_form (5.3.1) - actionpack (>= 5.2) - activemodel (>= 5.2) + simple_form (5.4.0) + actionpack (>= 7.0) + activemodel (>= 7.0) simplecov (0.22.0) 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.4.0) + 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 +846,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 +880,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,24 +899,24 @@ GEM zeitwerk (~> 2.2) warden (1.2.9) rack (>= 2.0.9) - webauthn (3.4.1) + webauthn (3.4.3) 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 faraday (~> 2.0) faraday-follow_redirects - webmock (3.25.1) + webmock (3.26.0) addressable (>= 2.8.0) 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 +969,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 +986,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 +1008,32 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) - opentelemetry-api (~> 1.5.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-api (~> 1.7.0) + opentelemetry-exporter-otlp (~> 0.31.0) + opentelemetry-instrumentation-active_job (~> 0.10.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-excon (~> 0.26.0) + opentelemetry-instrumentation-faraday (~> 0.30.0) + opentelemetry-instrumentation-http (~> 0.27.0) + opentelemetry-instrumentation-http_client (~> 0.26.0) + opentelemetry-instrumentation-net_http (~> 0.26.0) + opentelemetry-instrumentation-pg (~> 0.32.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.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 +1057,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 +1081,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 483d62ad4..4464ba010 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,16 @@ Hometown uses [semantic versioning](https://semver.org) and follows a versioning Setting up your Hometown development environment is [exactly like setting up your Mastodon development environment](https://docs.joinmastodon.org/dev/overview/). Pull requests should be made to the `hometown-dev` branch, which is our default branch in Github. -You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes. -**IRC channel**: #mastodon on irc.libera.chat +You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding. -## License +You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation. Copyright (C) 2016-2023 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +## LICENSE -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md)) You should have received a copy of the GNU Affero General Public License along with this program. If not, see . diff --git a/SECURITY.md b/SECURITY.md index 19f431fac..385c94651 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | Version | Supported | | ------- | ---------------- | | 4.4.x | Yes | -| 4.3.x | Yes | +| 4.3.x | Until 2026-05-06 | | 4.2.x | Until 2026-01-08 | | < 4.2 | No | diff --git a/Vagrantfile b/Vagrantfile index ce456060c..0a3436702 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 bb16d871f..dd5a82854 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -82,6 +82,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 000000000..4daa75552 --- /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 4aa6a4a77..e875517b0 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 a9476b806..928977768 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 000000000..f4a150555 --- /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.quoted_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.quoted_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 0a19275d3..1959f50d6 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 65b4a5b38..2d1e38988 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/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 10391aa3e..e14069301 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 8b8e83fde..61ca16618 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 702550eec..5d1555796 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 5b0867dcf..fe314daec 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 0c4155367..7c70603e2 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 c3443b707..5e1074b22 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 ca88c6525..d1a2ea5bb 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 554f7906f..fb7b6878b 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 2ae5ec825..a08375e0a 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 a7bfd6479..f2c28328f 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 000000000..22ac94081 --- /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 8d29fc644..7e4b41adf 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -49,6 +49,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive), default_language: source_params.fetch(:language, @account.user.setting_default_language), default_federation: source_params.fetch(:federation, @account.user.setting_default_federation), + 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 283383acb..dd272120e 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 ea17ba740..7b24cec79 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 f2c52f284..3b0cda7d9 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 000000000..5cfb2d0e8 --- /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 000000000..be3a4edc8 --- /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 91c27525c..4b3b58936 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,6 +60,20 @@ class Api::V1::StatusesController < Api::BaseController @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) statuses = [@status] + @context.ancestors + @context.descendants + refresh_key = "context:#{@status.id}:refresh" + async_refresh = AsyncRefresh.new(refresh_key) + + 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) ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies? @@ -67,6 +84,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], @@ -99,7 +118,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 @@ -139,6 +159,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])&.proper 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 @@ -155,6 +183,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 1dba4a5bb..e79eba79e 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 b8384a136..a07faae72 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 37ed084f0..9e6ddd692 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 029e8fc2c..670c3b02b 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 b0c20635b..7eb1518c1 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 000000000..6faf54f70 --- /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 3ca13cc42..c00ddf92c 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 2711071b4..ced68d39f 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 42abe9904..82d9e8380 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 9d496220a..16de03fd7 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 7c1ff5967..2680a1c5f 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 3b42dc48b..780c0be31 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 @@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def check_enabled_registrations - redirect_to root_path unless allowed_registration?(request.remote_ip, @invite) + redirect_to new_user_session_path, alert: I18n.t('devise.failure.closed_registrations', email: Setting.site_contact_email) unless allowed_registration?(request.remote_ip, @invite) end def invite_code diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 2808066aa..182f242ae 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 2b132417f..7b3cd4d3e 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 000000000..f1e1480c0 --- /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 29122e16b..2d0e9ff4f 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 c01da2124..a6232db94 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,25 +54,34 @@ 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? + request.content_security_policy = captcha_adjusted_policy + end - %w(script_src frame_src style_src connect_src).each do |directive| - values = policy.send(directive) + def render_captcha + return unless captcha_required? - 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:') + hcaptcha_tags + end - policy.send(directive, *values) - end + private - request.content_security_policy = policy + def captcha_adjusted_policy + request.content_security_policy.clone.tap do |policy| + populate_captcha_policy(policy) + end end - def render_captcha - return unless captcha_required? + def populate_captcha_policy(policy) + CAPTCHA_DIRECTIVES.each do |directive| + values = policy.send(directive) - hcaptcha_tags + 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 b61a56986..2bdd35586 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 @@ -107,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 f4c7b3708..e9727b756 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 268fad96d..803d6e342 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 50e2d70cb..ae32dbf55 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 d850e05e9..08b01d6b1 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 000000000..dcff94fc7 --- /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 ee2fc5dc8..fe59bdc49 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 9714d54f9..83dedb411 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 341b0e647..af6bebf36 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 859f92468..4a55a36ec 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 5a5ee0553..d80c050b8 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,7 +102,18 @@ 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 = {}) + whitespace = attributes.delete(:whitespace) { true } safe_join( [ inline_svg_tag( @@ -111,7 +122,7 @@ module ApplicationHelper role: :img, data: attributes[:data] ), - ' ', + whitespace ? ' ' : '', ] ) end @@ -233,6 +244,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 +261,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 33d726790..885f578fd 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 0800601f9..000000000 --- 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 8bb3576e9..94808835f 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) @@ -69,12 +71,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) @@ -85,10 +87,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 c5b83326d..59bc06031 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 078aba456..675d8b873 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 0a8ebcde5..ddb6b79c8 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 16b9d3fb5..84dea96fa 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -46,6 +46,14 @@ module StatusesHelper status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n") end + def status_classnames(status, is_quote) + if is_quote + 'status--is-quote' + elsif status.quote.present? + 'status--has-quote' + end + end + def status_description(status) components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')] @@ -57,6 +65,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 +86,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 0f2406338..00b4a6d2b 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 000000000..c78811348 --- /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 a60778f0c..af9309d34 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 0970fc585..dd1956446 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -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 ecd4b949e..9fb6841f1 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 000000000..c2af73282 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 7dab64dcd..a199b0cd1 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -5,6 +5,7 @@ import { throttle } from 'lodash'; import api from 'mastodon/api'; import { browserHistory } from 'mastodon/components/router'; +import { countableText } from 'mastodon/features/compose/util/counter'; import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'mastodon/settings'; @@ -55,8 +56,11 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +<<<<<<< HEAD export const COMPOSE_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +======= +>>>>>>> v4.5.0 export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; @@ -85,9 +89,11 @@ 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.' }, + blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' }, }); export const ensureComposeIsVisible = (getState) => { @@ -97,12 +103,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) { @@ -147,7 +158,7 @@ export function resetCompose() { }; } -export const focusCompose = (defaultText) => (dispatch, getState) => { +export const focusCompose = (defaultText = '') => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, @@ -184,13 +195,23 @@ 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'], '') : ''; + + const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`; + const hasText = fulltext.trim().length > 0; + + if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) { + dispatch(showAlert({ + message: messages.blankPostError, + })); + dispatch(focusCompose()); - if ((!status || !status.length) && media.size === 0) { return; } @@ -216,20 +237,23 @@ 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), local_only: !getState().getIn(['compose', 'federation']), 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']), @@ -241,6 +265,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 @@ -293,6 +320,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']); @@ -598,6 +630,7 @@ export function fetchComposeSuggestions(token) { fetchComposeSuggestionsEmojis(dispatch, getState, token); break; case '#': + case '#': fetchComposeSuggestionsTags(dispatch, getState, token); break; default: @@ -639,11 +672,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) { dispatch(useEmoji(suggestion)); } else if (suggestion.type === 'hashtag') { - completion = `#${suggestion.name}`; - startPosition = position - 1; + completion = suggestion.name.slice(token.length - 1); + startPosition = position + token.length; } else if (suggestion.type === 'account') { - completion = getState().getIn(['accounts', suggestion.id, 'acct']); - startPosition = position; + completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`; + startPosition = position - 1; } // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that @@ -703,7 +736,7 @@ function insertIntoTagHistory(recognizedTags, text) { // complicated because of new normalization rules, it's no longer just // a case sensitivity issue const names = recognizedTags.map(tag => { - const matches = text.match(new RegExp(`#${tag.name}`, 'i')); + const matches = text.match(new RegExp(`[##]${tag.name}`, 'i')); if (matches && matches.length > 0) { return matches[0].slice(1); @@ -759,6 +792,7 @@ export function changeComposeSpoilerText(text) { }; } +<<<<<<< HEAD export function changeComposeVisibility(value) { return { type: COMPOSE_VISIBILITY_CHANGE, @@ -773,6 +807,8 @@ export function changeComposeFederation(value) { }; }; +======= +>>>>>>> v4.5.0 export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 97f0d68c5..3ba99486b 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -1,9 +1,61 @@ +<<<<<<< HEAD import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { apiUpdateMedia } from 'mastodon/api/compose'; 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 { 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, + createAppThunk, +} from 'mastodon/store/typed_functions'; + +import type { ApiQuotePolicy } from '../api_types/quotes'; +import type { Status, StatusVisibility } from '../models/status'; +import type { RootState } from '../store'; + +import { showAlert } from './alerts'; +import { changeCompose, 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.', + }, + quoteErrorPrivateMention: { + id: 'quote_error.private_mentions', + defaultMessage: 'Quoting is not allowed with direct mentions.', + }, +}); +>>>>>>> v4.5.0 type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { unattached?: boolean; @@ -29,6 +81,42 @@ const simulateModifiedApiResponse = ( return data; }; +<<<<<<< HEAD +======= +export const changeComposeVisibility = createAppThunk( + 'compose/visibility_change', + (visibility: StatusVisibility, { dispatch, getState }) => { + if (visibility !== 'direct') { + return visibility; + } + + const state = getState(); + const quotedStatusId = state.compose.get('quoted_status_id') as + | string + | null; + if (!quotedStatusId) { + return visibility; + } + + // Remove the quoted status + dispatch(quoteComposeCancel()); + const quotedStatus = state.statuses.get(quotedStatusId) as Status | null; + if (!quotedStatus) { + return visibility; + } + + // Append the quoted status URL to the compose text + const url = quotedStatus.get('url') as string; + const text = state.compose.get('text') as string; + if (!text.includes(url)) { + const newText = text.trim() ? `${text}\n\n${url}` : url; + dispatch(changeCompose(newText)); + } + return visibility; + }, +); + +>>>>>>> v4.5.0 export const changeUploadCompose = createDataLoadingThunk( 'compose/changeUpload', async ( @@ -68,3 +156,135 @@ export const changeUploadCompose = createDataLoadingThunk( useLoadingBar: false, }, ); +<<<<<<< HEAD +======= + +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('privacy') === 'direct') { + dispatch(showAlert({ message: messages.quoteErrorPrivateMention })); + } 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)); + } + }, +); + +const composeStateForbidsLink = (composeState: RootState['compose']) => { + return ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') || + composeState.get('id') || + composeState.get('privacy') === 'direct' + ); +}; + +export const pasteLinkCompose = createDataLoadingThunk( + 'compose/pasteLink', + async ({ url }: { url: string }) => { + return await apiGetSearch({ + q: url, + type: 'statuses', + resolve: true, + limit: 2, + }); + }, + (data, { dispatch, getState, requestId }) => { + const composeState = getState().compose; + + if ( + composeStateForbidsLink(composeState) || + composeState.get('fetching_link') !== requestId // Request has been cancelled + ) + 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)); + } + }, + { + useLoadingBar: false, + condition: (_, { getState }) => + !getState().compose.get('fetching_link') && + !composeStateForbidsLink(getState().compose), + }, +); + +// Ideally this would cancel the action and the HTTP request, but this is good enough +export const cancelPasteLinkCompose = createAction( + 'compose/cancelPasteLinkCompose', +); + +export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); + +export const setComposeQuotePolicy = createAction( + 'compose/setQuotePolicy', +); +>>>>>>> v4.5.0 diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 330da7400..60a579c5f 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,8 +1,11 @@ import escapeTextContentForBrowser from 'escape-html'; +<<<<<<< HEAD import { makeEmojiMap } from 'mastodon/models/custom_emoji'; import emojify from '../../features/emoji/emoji'; +======= +>>>>>>> v4.5.0 import { expandSpoilers } from '../../initial_state'; const domParser = new DOMParser(); @@ -21,6 +24,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,20 +84,27 @@ 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 = ''; } const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); - const emojiMap = makeEmojiMap(normalStatus.emojis); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + normalStatus.contentHtml = normalStatus.content; + normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; +<<<<<<< HEAD +======= + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + +>>>>>>> v4.5.0 if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) { normalStatus.url = null; } @@ -114,25 +133,30 @@ export function normalizeStatus(status, normalOldStatus) { } export function normalizeStatusTranslation(translation, status) { - const emojiMap = makeEmojiMap(status.get('emojis').toJS()); - const normalTranslation = { detected_source_language: translation.detected_source_language, language: translation.language, provider: translation.provider, - contentHtml: emojify(translation.content, emojiMap), - spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), + contentHtml: translation.content, + spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text), spoiler_text: translation.spoiler_text, }; +<<<<<<< HEAD +======= + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (status.get('quote')) { + normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml); + } + +>>>>>>> v4.5.0 return normalTranslation; } export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; - const emojiMap = makeEmojiMap(normalAnnouncement.emojis); - normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + normalAnnouncement.contentHtml = normalAnnouncement.content; return normalAnnouncement; } diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index f58faffa8..36f9f85b9 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 438632548..fc6fe2d96 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,22 @@ export const processNewNotificationForGroups = createAppAsyncThunk( const showInColumn = activeFilter === 'all' +<<<<<<< HEAD ? notificationShows[notification.type] !== false : activeFilter === notification.type; +======= + ? notificationShows[notificationTypeForFilter(notification.type)] !== + false + : activeFilter === notificationTypeForQuickFilter(notification.type); +>>>>>>> v4.5.0 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 2499b8da1..558390b9c 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 42d0c1c0f..62885efde 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,9 +1,16 @@ +import { defineMessages } from 'react-intl'; + import { browserHistory } from 'mastodon/components/router'; import api from '../api'; +import { showAlert } from './alerts'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; +<<<<<<< HEAD import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; +======= +import { importFetchedStatus, importFetchedAccount } from './importer'; +>>>>>>> v4.5.0 import { fetchContext } from './statuses_typed'; import { deleteFromTimelines } from './timelines'; @@ -40,6 +47,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 +59,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 +88,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 +100,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 +165,11 @@ export function deleteStatus(id, withRedraft = false) { dispatch(deleteStatusRequest(id)); +<<<<<<< HEAD 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 => { +>>>>>>> v4.5.0 dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); dispatch(importFetchedAccount(response.data.account)); @@ -145,9 +177,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 b98abbe12..ed25c5cad 100644 --- a/app/javascript/mastodon/actions/statuses_typed.ts +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -1,18 +1,61 @@ +<<<<<<< HEAD import { apiGetContext } from 'mastodon/api/statuses'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +======= +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'; + +>>>>>>> v4.5.0 import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', +<<<<<<< HEAD ({ statusId }: { statusId: string }) => apiGetContext(statusId), (context, { dispatch }) => { +======= + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { +>>>>>>> v4.5.0 const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); return { context, +<<<<<<< HEAD }; }, ); +======= + 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); + }, +); +>>>>>>> v4.5.0 diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 478e0cae4..4299bad5c 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -32,13 +32,20 @@ import { const randomUpTo = max => Math.floor(Math.random() * Math.floor(max)); +/** + * @typedef {import('mastodon/store').AppDispatch} Dispatch + * @typedef {import('mastodon/store').GetState} GetState + * @typedef {import('redux').UnknownAction} UnknownAction + * @typedef {function(Dispatch, GetState): Promise} FallbackFunction + */ + /** * @param {string} timelineId * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function, Function): Promise} [options.fallback] - * @param {function(): void} [options.fillGaps] + * @param {FallbackFunction} [options.fallback] + * @param {function(): UnknownAction} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} */ @@ -46,13 +53,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti const { messages } = getLocale(); return connectStream(channelName, params, (dispatch, getState) => { + // @ts-ignore const locale = getState().getIn(['meta', 'locale']); // @ts-expect-error let pollingId; /** - * @param {function(Function, Function): Promise} fallback + * @param {FallbackFunction} fallback */ const useFallback = async fallback => { @@ -132,7 +140,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti }; /** - * @param {Function} dispatch + * @param {Dispatch} dispatch */ async function refreshHomeTimelineAndNotification(dispatch) { await dispatch(expandHomeTimeline({ maxId: undefined })); @@ -151,7 +159,11 @@ async function refreshHomeTimelineAndNotification(dispatch) { * @returns {function(): void} */ export const connectUserStream = () => - connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); + connectTimelineStream('home', 'user', {}, { + fallback: refreshHomeTimelineAndNotification, + // @ts-expect-error + fillGaps: fillHomeTimelineGaps + }); /** * @param {Object} options @@ -159,7 +171,10 @@ export const connectUserStream = () => * @returns {function(): void} */ export const connectCommunityStream = ({ onlyMedia } = {}) => - connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { + // @ts-expect-error + fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) + }); /** * @param {Object} options @@ -168,7 +183,10 @@ export const connectCommunityStream = ({ onlyMedia } = {}) => * @returns {function(): void} */ export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => - connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote }) }); + connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, { + // @ts-expect-error + fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote }) + }); /** * @param {string} columnId @@ -191,4 +209,7 @@ export const connectDirectStream = () => * @returns {function(): void} */ export const connectListStream = listId => - connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); + connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { + // @ts-expect-error + fillGaps: () => fillListTimelineGaps(listId) + }); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index dc9c20b50..0dc259fc5 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,11 @@ export default function api(withAuthorization = true) { return instance; } +<<<<<<< HEAD type ApiUrl = `v${1 | 2}/${string}`; +======= +type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; +>>>>>>> v4.5.0 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 000000000..953300a4a --- /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 118b5f06d..36aaeef18 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 921a7bfe6..3b9247dad 100644 --- a/app/javascript/mastodon/api/statuses.ts +++ b/app/javascript/mastodon/api/statuses.ts @@ -1,5 +1,39 @@ +<<<<<<< HEAD import { apiRequestGet } from 'mastodon/api'; import type { ApiContextJSON } from 'mastodon/api_types/statuses'; export const apiGetContext = (statusId: string) => apiRequestGet(`v1/statuses/${statusId}/context`); +======= +import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api'; +import type { + ApiContextJSON, + ApiStatusJSON, +} from 'mastodon/api_types/statuses'; + +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, + }, + ); +}; +>>>>>>> v4.5.0 diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index b93054a1f..913a201fe 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 000000000..03e8922d8 --- /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 000000000..2d2fed241 --- /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 190d8c839..533e99036 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 000000000..f42a3eb72 --- /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 09bd2349b..3cf6f7cee 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,6 +120,25 @@ 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); } export interface ApiContextJSON { 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 9d1b236fa..000000000 --- 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 05a0f4717..000000000 --- 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 5c0716412..e8a35a03d 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +<<<<<<< HEAD +======= +import { Link } from 'react-router-dom'; + +import { EmojiHTML } from '@/mastodon/components/emoji/html'; +>>>>>>> v4.5.0 import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { blockAccount, @@ -24,7 +30,10 @@ import { FollowersCounter } from 'mastodon/components/counters'; import { DisplayName } from 'mastodon/components/display_name'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { FollowButton } from 'mastodon/components/follow_button'; +<<<<<<< HEAD import { Permalink } from 'mastodon/components/permalink'; +======= +>>>>>>> v4.5.0 import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; @@ -293,10 +302,16 @@ export const Account: React.FC = ({ })} >
+<<<<<<< HEAD >>>>>> v4.5.0 to={`/@${account?.acct}`} data-hover-card-account={id} > @@ -327,14 +342,25 @@ export const Account: React.FC = ({
)} +<<<<<<< HEAD +======= + +>>>>>>> v4.5.0 {account && withBio && (account.note.length > 0 ? ( +<<<<<<< HEAD
>>>>>> v4.5.0 /> ) : (
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 301ffcbb2..a7058e941 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -1,20 +1,51 @@ +<<<<<<< HEAD import { useLinks } from 'mastodon/hooks/useLinks'; +======= +import classNames from 'classnames'; +>>>>>>> v4.5.0 -export const AccountBio: React.FC<{ - note: string; +import { useAppSelector } from '../store'; + +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +interface AccountBioProps { className: string; -}> = ({ note, className }) => { - const handleClick = useLinks(); + accountId: string; + showDropdown?: boolean; +} + +export const AccountBio: React.FC = ({ + className, + accountId, + showDropdown = false, +}) => { + 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 || note === '

') { + if (note.length === 0) { return null; } return ( -
); }; diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx index 4ce55f789..844e8369c 100644 --- a/app/javascript/mastodon/components/account_fields.tsx +++ b/app/javascript/mastodon/components/account_fields.tsx @@ -1,3 +1,5 @@ +import { useIntl } from 'react-intl'; + import classNames from 'classnames'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; @@ -5,38 +7,65 @@ 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 000000000..f12f06751 --- /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 000000000..72fee0a4a --- /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 26749fa10..b2a8d99b7 100644 --- a/app/javascript/mastodon/components/alerts_controller.tsx +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -3,16 +3,27 @@ import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import type { IntlShape } from 'react-intl'; +<<<<<<< HEAD import classNames from 'classnames'; import { dismissAlert } from 'mastodon/actions/alerts'; import type { Alert, +======= +import { dismissAlert } from 'mastodon/actions/alerts'; +import type { + Alert as AlertType, +>>>>>>> v4.5.0 TranslatableString, TranslatableValues, } from 'mastodon/models/alert'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +<<<<<<< HEAD +======= +import { Alert } from './alert'; + +>>>>>>> v4.5.0 const formatIfNeeded = ( intl: IntlShape, message: TranslatableString, @@ -25,8 +36,13 @@ const formatIfNeeded = ( return message; }; +<<<<<<< HEAD const Alert: React.FC<{ alert: Alert; +======= +const TimedAlert: React.FC<{ + alert: AlertType; +>>>>>>> v4.5.0 dismissAfter: number; }> = ({ alert: { key, title, message, values, action, onClick }, @@ -62,6 +78,7 @@ const Alert: React.FC<{ }, [dispatch, setActive, key, dismissAfter]); return ( +<<<<<<< HEAD
+======= + +>>>>>>> v4.5.0 ); }; @@ -98,7 +124,15 @@ export const AlertsController: React.FC = () => { return (
{alerts.map((alert, idx) => ( +<<<<<<< HEAD +======= + +>>>>>>> v4.5.0 ))}
); diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index 07369795a..303a5b7d2 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -13,9 +13,15 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; const offset = [0, 4] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; +<<<<<<< HEAD export const AltTextBadge: React.FC<{ description: string; }> = ({ description }) => { +======= +export const AltTextBadge: React.FC<{ description: string }> = ({ + description, +}) => { +>>>>>>> v4.5.0 const accessibilityId = useId(); const anchorRef = useRef(null); const [open, setOpen] = useState(false); @@ -56,7 +62,11 @@ export const AltTextBadge: React.FC<{ {({ props }) => (
>>>>>> v4.5.0 role='region' id={accessibilityId} onMouseDown={handleMouseDown} diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index f707a18e1..267c04421 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -61,7 +61,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { static defaultProps = { autoFocus: true, - searchTokens: ['@', ':', '#'], + searchTokens: ['@', '@', ':', '#', '#'], }; state = { diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index c7ec3779f..137bad9b7 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -25,7 +25,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) { return [null, null]; } @@ -53,6 +53,7 @@ const AutosuggestTextarea = forwardRef(({ onFocus, autoFocus = true, lang, + className, }, textareaRef) => { 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 ( -
+