diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml
index 808adc7de6..0c7ead1c15 100644
--- a/.github/actions/setup-javascript/action.yml
+++ b/.github/actions/setup-javascript/action.yml
@@ -9,7 +9,7 @@ runs:
using: 'composite'
steps:
- name: Set up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml
index 260730004c..84b729df43 100644
--- a/.github/workflows/build-container-image.yml
+++ b/.github/workflows/build-container-image.yml
@@ -35,7 +35,7 @@ jobs:
- linux/arm64
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Prepare
env:
@@ -100,7 +100,7 @@ jobs:
- name: Upload digest
if: ${{ inputs.push_to_images != '' }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
# `hashFiles` is used to disambiguate between streaming and non-streaming images
name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }}
@@ -119,10 +119,10 @@ jobs:
PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
# `hashFiles` is used to disambiguate between streaming and non-streaming images
diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml
index 6ec561d2fb..f934f2e550 100644
--- a/.github/workflows/build-push-pr.yml
+++ b/.github/workflows/build-push-pr.yml
@@ -18,7 +18,7 @@ jobs:
steps:
# Repository needs to be cloned so `git rev-parse` below works
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- id: version_vars
run: |
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT
diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml
index fa28d28f74..9cc49a7f79 100644
--- a/.github/workflows/bundler-audit.yml
+++ b/.github/workflows/bundler-audit.yml
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml
index c46090c1b5..9d500ffc44 100644
--- a/.github/workflows/check-i18n.yml
+++ b/.github/workflows/check-i18n.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml
index 4e6179bc77..e0383b83bc 100644
--- a/.github/workflows/chromatic.yml
+++ b/.github/workflows/chromatic.yml
@@ -23,7 +23,7 @@ jobs:
if: github.repository == 'mastodon/mastodon'
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Javascript environment
@@ -33,7 +33,7 @@ jobs:
run: yarn build-storybook
- name: Run Chromatic
- uses: chromaui/action@v12
+ uses: chromaui/action@v13
with:
# â ī¸ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c864e12d2d..cf038ae480 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -31,11 +31,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3
+ uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -48,7 +48,7 @@ jobs:
# 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
+ uses: github/codeql-action/autobuild@v4
# âšī¸ 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
@@ -61,6 +61,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
+ uses: github/codeql-action/analyze@v4
with:
category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml
index c9d5f6bbe0..6e7862b368 100644
--- a/.github/workflows/crowdin-download-stable.yml
+++ b/.github/workflows/crowdin-download-stable.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version?
diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml
index 1fdd1e08b4..c0c3374219 100644
--- a/.github/workflows/crowdin-download.yml
+++ b/.github/workflows/crowdin-download.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version?
diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml
index d6c542eb36..6bbd931c53 100644
--- a/.github/workflows/crowdin-upload.yml
+++ b/.github/workflows/crowdin-upload.yml
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: crowdin action
uses: crowdin/github-action@v2
diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml
index c10f350a02..6803686b4f 100644
--- a/.github/workflows/format-check.yml
+++ b/.github/workflows/format-check.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml
index c1385bf789..51a78d679f 100644
--- a/.github/workflows/lint-css.yml
+++ b/.github/workflows/lint-css.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml
index 499be2010a..d3452c9ffc 100644
--- a/.github/workflows/lint-haml.yml
+++ b/.github/workflows/lint-haml.yml
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml
index 86e9af23e7..c9ba1a13f1 100644
--- a/.github/workflows/lint-js.yml
+++ b/.github/workflows/lint-js.yml
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml
index 87f8aee24e..e73617d85d 100644
--- a/.github/workflows/lint-ruby.yml
+++ b/.github/workflows/lint-ruby.yml
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml
index 0699e6c9ef..b8e1cc89aa 100644
--- a/.github/workflows/test-js.yml
+++ b/.github/workflows/test-js.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Clone repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml
index 7aab34f0cf..b1b94692f0 100644
--- a/.github/workflows/test-migrations.yml
+++ b/.github/workflows/test-migrations.yml
@@ -72,7 +72,7 @@ jobs:
BUNDLE_RETRY: 3
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index 63d3172504..8f05812d60 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -32,7 +32,7 @@ jobs:
SECRET_KEY_BASE_DUMMY: 1
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
@@ -65,7 +65,7 @@ jobs:
run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v5
if: matrix.mode == 'test'
with:
path: |-
@@ -128,9 +128,9 @@ jobs:
- '3.3'
- '.ruby-version'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
@@ -230,9 +230,9 @@ jobs:
- '3.3'
- '.ruby-version'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
@@ -309,9 +309,9 @@ jobs:
- '.ruby-version'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
@@ -350,14 +350,14 @@ jobs:
- run: bin/rspec spec/system --tag streaming --tag js
- name: Archive logs
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: failure()
with:
name: e2e-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: failure()
with:
name: e2e-screenshots-${{ matrix.ruby-version }}
@@ -447,9 +447,9 @@ jobs:
search-image: opensearchproject/opensearch:2
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
@@ -469,14 +469,14 @@ jobs:
- run: bin/rspec --tag search
- name: Archive logs
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: failure()
with:
name: test-search-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: failure()
with:
name: test-search-screenshots
diff --git a/.storybook/main.ts b/.storybook/main.ts
index bb69f0c664..c249d1c06d 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -31,7 +31,7 @@ const config: StorybookConfig = {
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');
+ config.root = resolve(import.meta.dirname, '../app/javascript');
return config;
},
};
diff --git a/Gemfile b/Gemfile
index f5b1753aa6..91e2655886 100644
--- a/Gemfile
+++ b/Gemfile
@@ -114,7 +114,7 @@ group :opentelemetry do
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-pg', '~> 0.33.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
diff --git a/Gemfile.lock b/Gemfile.lock
index 54fcff1efc..a798595fa7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -128,7 +128,7 @@ GEM
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)
@@ -512,9 +512,9 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
- opentelemetry-helpers-sql (0.2.0)
+ opentelemetry-helpers-sql (0.3.0)
opentelemetry-api (~> 1.7)
- opentelemetry-helpers-sql-obfuscation (0.4.0)
+ opentelemetry-helpers-sql-obfuscation (0.5.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10)
@@ -548,7 +548,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.26.0)
opentelemetry-instrumentation-base (~> 0.25)
- opentelemetry-instrumentation-pg (0.32.0)
+ opentelemetry-instrumentation-pg (0.33.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.25)
@@ -621,7 +621,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.3)
+ rack (3.2.4)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
@@ -803,9 +803,9 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
- shoulda-matchers (6.5.0)
- activesupport (>= 5.2.0)
- sidekiq (8.0.8)
+ shoulda-matchers (7.0.1)
+ activesupport (>= 7.1)
+ sidekiq (8.0.9)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -883,7 +883,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
- uri (1.0.4)
+ uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -1018,7 +1018,7 @@ DEPENDENCIES
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-pg (~> 0.33.0)
opentelemetry-instrumentation-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-redis (~> 0.28.0)
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index da03ddb5a6..dd3e7b530d 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -56,7 +56,6 @@ 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';
-export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
@@ -796,13 +795,6 @@ export function changeComposeSpoilerText(text) {
};
}
-export function changeComposeVisibility(value) {
- return {
- type: COMPOSE_VISIBILITY_CHANGE,
- value,
- };
-}
-
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 8088306028..6b38b25c25 100644
--- a/app/javascript/mastodon/actions/compose_typed.ts
+++ b/app/javascript/mastodon/actions/compose_typed.ts
@@ -13,10 +13,11 @@ import {
} from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
-import type { Status } from '../models/status';
+import type { Status, StatusVisibility } from '../models/status';
+import type { RootState } from '../store';
import { showAlert } from './alerts';
-import { focusCompose } from './compose';
+import { changeCompose, focusCompose } from './compose';
import { importFetchedStatuses } from './importer';
import { openModal } from './modal';
@@ -41,6 +42,10 @@ const messages = defineMessages({
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.',
+ },
});
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
@@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
return data;
};
+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;
+ },
+);
+
export const changeUploadCompose = createDataLoadingThunk(
'compose/changeUpload',
async (
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
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 (
@@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
},
);
+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 }) => {
@@ -183,16 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
limit: 2,
});
},
- (data, { dispatch, getState }) => {
+ (data, { dispatch, getState, requestId }) => {
const composeState = getState().compose;
if (
- composeState.get('quoted_status_id') ||
- composeState.get('is_submitting') ||
- composeState.get('poll') ||
- composeState.get('is_uploading') ||
- composeState.get('id') ||
- composeState.get('privacy') === 'direct'
+ composeStateForbidsLink(composeState) ||
+ composeState.get('fetching_link') !== requestId // Request has been cancelled
)
return;
@@ -208,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
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');
diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx
index a4f79fcf94..08e106e5d8 100644
--- a/app/javascript/mastodon/containers/media_container.jsx
+++ b/app/javascript/mastodon/containers/media_container.jsx
@@ -10,7 +10,7 @@ import ModalRoot from 'mastodon/components/modal_root';
import { Poll } from 'mastodon/components/poll';
import { Audio } from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card';
-import MediaModal from 'mastodon/features/ui/components/media_modal';
+import { MediaModal } from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll';
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index 299de12e7e..770f776049 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
return;
}
- this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct');
+ this.props.onSubmit({
+ missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
+ quoteToPrivate: this.props.quoteToPrivate,
+ });
if (e) {
e.preventDefault();
diff --git a/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx
new file mode 100644
index 0000000000..706594e9cb
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx
@@ -0,0 +1,48 @@
+import { useCallback } from 'react';
+import type { FC } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import { cancelPasteLinkCompose } from '@/mastodon/actions/compose_typed';
+import { useAppDispatch } from '@/mastodon/store';
+import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
+import { DisplayName } from 'mastodon/components/display_name';
+import { IconButton } from 'mastodon/components/icon_button';
+import { Skeleton } from 'mastodon/components/skeleton';
+
+const messages = defineMessages({
+ quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
+});
+
+export const QuotePlaceholder: FC = () => {
+ const intl = useIntl();
+ const dispatch = useAppDispatch();
+ const handleQuoteCancel = useCallback(() => {
+ dispatch(cancelPasteLinkCompose());
+ }, [dispatch]);
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx
index f09d6fcd34..8be3c7e62c 100644
--- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx
+++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx
@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+import { QuotePlaceholder } from './quote_placeholder';
+
export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null,
);
+ const isFetchingLink = useAppSelector(
+ (state) => !!state.compose.get('fetching_link'),
+ );
+
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo(
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
dispatch(quoteComposeCancel());
}, [dispatch]);
- if (!quote) {
+ if (isFetchingLink && !quote) {
+ return ;
+ } else if (!quote) {
return null;
}
diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx
index 1ea504ab1a..d939405020 100644
--- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx
+++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx
@@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
-import { changeComposeVisibility } from '@/mastodon/actions/compose';
-import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
+import {
+ changeComposeVisibility,
+ setComposeQuotePolicy,
+} from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index 3dad46bc52..15b1c7cc41 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -12,6 +12,7 @@ import {
} from 'mastodon/actions/compose';
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
import { openModal } from 'mastodon/actions/modal';
+import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify';
import ComposeForm from '../components/compose_form';
@@ -32,6 +33,10 @@ const mapStateToProps = state => ({
isUploading: state.getIn(['compose', 'is_uploading']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
+ quoteToPrivate:
+ !!state.getIn(['compose', 'quoted_status_id'])
+ && state.getIn(['compose', 'privacy']) === 'private'
+ && !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
@@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({
dispatch(changeCompose(text));
},
- onSubmit (missingAltText) {
+ onSubmit ({ missingAltText, quoteToPrivate }) {
if (missingAltText) {
dispatch(openModal({
modalType: 'CONFIRM_MISSING_ALT_TEXT',
modalProps: {},
}));
+ } else if (quoteToPrivate) {
+ dispatch(openModal({
+ modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
+ modalProps: {},
+ }));
} else {
dispatch(submitCompose((status) => {
if (props.redirectOnSuccess) {
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
index 6d3eef13aa..803dcb1a4a 100644
--- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -1,8 +1,7 @@
import { connect } from 'react-redux';
-import { changeComposeVisibility } from '../../../actions/compose';
-import { openModal, closeModal } from '../../../actions/modal';
-import { isUserTouching } from '../../../is_mobile';
+import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
+
import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index bcccc11044..7d0bce8666 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -299,6 +299,12 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
};
+ handleQuote = (status) => {
+ const { dispatch } = this.props;
+
+ dispatch(quoteComposeById(status.get('id')));
+ };
+
handleEditClick = (status) => {
const { dispatch, askReplyConfirmation } = this.props;
@@ -592,7 +598,7 @@ class Status extends ImmutablePureComponent {
showBackButton
multiColumn={multiColumn}
extraButton={(
-
+
)}
/>
@@ -625,6 +631,7 @@ class Status extends ImmutablePureComponent {
onDelete={this.handleDeleteClick}
onRevokeQuote={this.handleRevokeQuoteClick}
onQuotePolicyChange={this.handleQuotePolicyChange}
+ onQuote={this.handleQuote}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick}
onMention={this.handleMentionClick}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx
index 47f9fca890..cfa50855a8 100644
--- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx
@@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC<
onSecondary?: () => void;
onConfirm: () => void;
closeWhenConfirm?: boolean;
+ extraContent?: React.ReactNode;
} & BaseConfirmationModalProps
> = ({
title,
@@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC<
secondary,
onSecondary,
closeWhenConfirm = true,
+ extraContent,
}) => {
const handleClick = useCallback(() => {
if (closeWhenConfirm) {
@@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<
{title}
{message &&
{message}
}
+
+ {extraContent}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx
new file mode 100644
index 0000000000..ef917a1027
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx
@@ -0,0 +1,88 @@
+import { forwardRef, useCallback, useState } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { submitCompose } from '@/mastodon/actions/compose';
+import { changeSetting } from '@/mastodon/actions/settings';
+import { CheckBox } from '@/mastodon/components/check_box';
+import { useAppDispatch } from '@/mastodon/store';
+
+import { ConfirmationModal } from './confirmation_modal';
+import type { BaseConfirmationModalProps } from './confirmation_modal';
+import classes from './styles.module.css';
+
+export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify';
+
+const messages = defineMessages({
+ title: {
+ id: 'confirmations.private_quote_notify.title',
+ defaultMessage: 'Share with followers and mentioned users?',
+ },
+ message: {
+ id: 'confirmations.private_quote_notify.message',
+ defaultMessage:
+ 'The person you are quoting and other mentions ' +
+ "will be notified and will be able to view your post, even if they're not following you.",
+ },
+ confirm: {
+ id: 'confirmations.private_quote_notify.confirm',
+ defaultMessage: 'Publish post',
+ },
+ cancel: {
+ id: 'confirmations.private_quote_notify.cancel',
+ defaultMessage: 'Back to editing',
+ },
+});
+
+export const PrivateQuoteNotify = forwardRef<
+ HTMLDivElement,
+ BaseConfirmationModalProps
+>(
+ (
+ { onClose },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _ref,
+ ) => {
+ const intl = useIntl();
+
+ const [dismiss, setDismissed] = useState(false);
+ const handleDismissToggle = useCallback(() => {
+ setDismissed((prev) => !prev);
+ }, []);
+
+ const dispatch = useAppDispatch();
+ const handleConfirm = useCallback(() => {
+ dispatch(submitCompose());
+ if (dismiss) {
+ dispatch(
+ changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true),
+ );
+ }
+ }, [dismiss, dispatch]);
+
+ return (
+
+ {' '}
+
+
+ }
+ />
+ );
+ },
+);
+PrivateQuoteNotify.displayName = 'PrivateQuoteNotify';
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css
new file mode 100644
index 0000000000..f685c4525f
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css
@@ -0,0 +1,7 @@
+.checkbox_wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 1rem 0;
+ cursor: pointer;
+}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
deleted file mode 100644
index 2ce13bf1d3..0000000000
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ /dev/null
@@ -1,296 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import classNames from 'classnames';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import ReactSwipeableViews from 'react-swipeable-views';
-
-import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
-import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
-import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
-import { getAverageFromBlurhash } from 'mastodon/blurhash';
-import { GIFV } from 'mastodon/components/gifv';
-import { Icon } from 'mastodon/components/icon';
-import { IconButton } from 'mastodon/components/icon_button';
-import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
-import { Video } from 'mastodon/features/video';
-import { disableSwiping } from 'mastodon/initial_state';
-
-import { ZoomableImage } from './zoomable_image';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
- previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
- next: { id: 'lightbox.next', defaultMessage: 'Next' },
- zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
- zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
-});
-
-class MediaModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.list.isRequired,
- statusId: PropTypes.string,
- lang: PropTypes.string,
- index: PropTypes.number.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- onChangeBackgroundColor: PropTypes.func.isRequired,
- currentTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- };
-
- state = {
- index: null,
- navigationHidden: false,
- zoomedIn: false,
- };
-
- handleZoomClick = () => {
- this.setState(prevState => ({
- zoomedIn: !prevState.zoomedIn,
- }));
- };
-
- handleZoomChange = (zoomedIn) => {
- this.setState({
- zoomedIn,
- });
- };
-
- handleSwipe = (index) => {
- this.setState({
- index: index % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleTransitionEnd = () => {
- this.setState({
- zoomedIn: false,
- });
- };
-
- handleNextClick = () => {
- this.setState({
- index: (this.getIndex() + 1) % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handlePrevClick = () => {
- this.setState({
- index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleChangeIndex = (e) => {
- const index = Number(e.currentTarget.getAttribute('data-index'));
-
- this.setState({
- index: index % this.props.media.size,
- zoomedIn: false,
- });
- };
-
- handleKeyDown = (e) => {
- switch(e.key) {
- case 'ArrowLeft':
- this.handlePrevClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- case 'ArrowRight':
- this.handleNextClick();
- e.preventDefault();
- e.stopPropagation();
- break;
- }
- };
-
- componentDidMount () {
- window.addEventListener('keydown', this.handleKeyDown, false);
-
- this._sendBackgroundColor();
- }
-
- componentDidUpdate (prevProps, prevState) {
- if (prevState.index !== this.state.index) {
- this._sendBackgroundColor();
- }
- }
-
- _sendBackgroundColor () {
- const { media, onChangeBackgroundColor } = this.props;
- const index = this.getIndex();
- const blurhash = media.getIn([index, 'blurhash']);
-
- if (blurhash) {
- const backgroundColor = getAverageFromBlurhash(blurhash);
- onChangeBackgroundColor(backgroundColor);
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('keydown', this.handleKeyDown);
-
- this.props.onChangeBackgroundColor(null);
- }
-
- getIndex () {
- return this.state.index !== null ? this.state.index : this.props.index;
- }
-
- handleToggleNavigation = () => {
- this.setState(prevState => ({
- navigationHidden: !prevState.navigationHidden,
- }));
- };
-
- setRef = c => {
- this.setState({
- viewportWidth: c?.clientWidth,
- viewportHeight: c?.clientHeight,
- });
- };
-
- render () {
- const { media, statusId, lang, intl, onClose } = this.props;
- const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
-
- const index = this.getIndex();
-
- const leftNav = media.size > 1 && ;
- const rightNav = media.size > 1 && ;
-
- const content = media.map((image, idx) => {
- const width = image.getIn(['meta', 'original', 'width']) || null;
- const height = image.getIn(['meta', 'original', 'height']) || null;
- const description = image.getIn(['translation', 'description']) || image.get('description');
-
- if (image.get('type') === 'image') {
- return (
-
- );
- } else if (image.get('type') === 'video') {
- const { currentTime, autoPlay, volume } = this.props;
-
- return (
-
- );
- } else if (image.get('type') === 'gifv') {
- return (
-
- );
- }
-
- return null;
- }).toArray();
-
- // you can't use 100vh, because the viewport height is taller
- // than the visible part of the document in some mobile
- // browsers when it's address bar is visible.
- // https://developers.google.com/web/updates/2016/12/url-bar-resizing
- const swipeableViewsStyle = {
- width: '100%',
- height: '100%',
- };
-
- const containerStyle = {
- alignItems: 'center', // center vertically
- };
-
- const navigationClassName = classNames('media-modal__navigation', {
- 'media-modal__navigation--hidden': navigationHidden,
- });
-
- let pagination;
-
- if (media.size > 1) {
- pagination = media.map((item, i) => (
-
- ));
- }
-
- const currentMedia = media.get(index);
- const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
-
- return (
-
-
-
- {content}
-
-
-
-
-
- {zoomable && }
-
-
-
- {leftNav}
- {rightNav}
-
-
- {pagination &&
}
- {statusId &&
}
-
-
-
- );
- }
-
-}
-
-export default injectIntl(MediaModal);
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.tsx b/app/javascript/mastodon/features/ui/components/media_modal.tsx
new file mode 100644
index 0000000000..d1203552ea
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.tsx
@@ -0,0 +1,363 @@
+import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
+import type { RefCallback, FC } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import type { List as ImmutableList } from 'immutable';
+
+import { animated, useSpring } from '@react-spring/web';
+import { useDrag } from '@use-gesture/react';
+
+import type { MediaAttachment } from '@/mastodon/models/status';
+import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
+import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
+import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
+import type { RGB } from 'mastodon/blurhash';
+import { getAverageFromBlurhash } from 'mastodon/blurhash';
+import { GIFV } from 'mastodon/components/gifv';
+import { Icon } from 'mastodon/components/icon';
+import { IconButton } from 'mastodon/components/icon_button';
+import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
+import { Video } from 'mastodon/features/video';
+
+import { ZoomableImage } from './zoomable_image';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+ zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
+ zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
+});
+
+interface MediaModalProps {
+ media: ImmutableList;
+ statusId?: string;
+ lang?: string;
+ index: number;
+ onClose: () => void;
+ onChangeBackgroundColor: (color: RGB | null) => void;
+ currentTime?: number;
+ autoPlay?: boolean;
+ volume?: number;
+}
+
+export const MediaModal: FC = forwardRef<
+ HTMLDivElement,
+ MediaModalProps
+>(
+ (
+ {
+ media,
+ onClose,
+ index: startIndex,
+ lang,
+ currentTime,
+ autoPlay,
+ volume,
+ statusId,
+ onChangeBackgroundColor,
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- _ref is required to keep the ref forwarding working
+ _ref,
+ ) => {
+ const [index, setIndex] = useState(startIndex);
+ const currentMedia = media.get(index);
+
+ const handleChangeIndex = useCallback(
+ (newIndex: number) => {
+ if (newIndex < 0) {
+ newIndex = media.size + newIndex;
+ }
+ setIndex(newIndex % media.size);
+ setZoomedIn(false);
+ },
+ [media.size],
+ );
+ const handlePrevClick = useCallback(() => {
+ handleChangeIndex(index - 1);
+ }, [handleChangeIndex, index]);
+ const handleNextClick = useCallback(() => {
+ handleChangeIndex(index + 1);
+ }, [handleChangeIndex, index]);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ handlePrevClick();
+ event.preventDefault();
+ event.stopPropagation();
+ } else if (event.key === 'ArrowRight') {
+ handleNextClick();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ },
+ [handleNextClick, handlePrevClick],
+ );
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown, false);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handleKeyDown]);
+
+ useEffect(() => {
+ const blurhash = currentMedia?.get('blurhash') as string | undefined;
+ if (blurhash) {
+ const backgroundColor = getAverageFromBlurhash(blurhash);
+ if (backgroundColor) {
+ onChangeBackgroundColor(backgroundColor);
+ }
+ }
+ }, [currentMedia, onChangeBackgroundColor]);
+
+ const [viewportDimensions, setViewportDimensions] = useState<{
+ width: number;
+ height: number;
+ }>({ width: 0, height: 0 });
+ const handleRef: RefCallback = useCallback((ele) => {
+ if (ele?.clientWidth && ele.clientHeight) {
+ setViewportDimensions({
+ width: ele.clientWidth,
+ height: ele.clientHeight,
+ });
+ }
+ }, []);
+
+ const [zoomedIn, setZoomedIn] = useState(false);
+ const zoomable =
+ currentMedia?.get('type') === 'image' &&
+ ((currentMedia.getIn(['meta', 'original', 'width']) as number) >
+ viewportDimensions.width ||
+ (currentMedia.getIn(['meta', 'original', 'height']) as number) >
+ viewportDimensions.height);
+ const handleZoomClick = useCallback(() => {
+ setZoomedIn((prev) => !prev);
+ }, []);
+
+ const wrapperStyles = useSpring({
+ x: `-${index * 100}%`,
+ });
+ const bind = useDrag(
+ ({ swipe: [swipeX] }) => {
+ if (swipeX === 0) return;
+ handleChangeIndex(index + swipeX * -1); // Invert swipe as swiping left loads the next slide.
+ },
+ { pointer: { capture: false } },
+ );
+
+ const [navigationHidden, setNavigationHidden] = useState(false);
+ const handleToggleNavigation = useCallback(() => {
+ setNavigationHidden((prev) => !prev);
+ }, []);
+
+ const content = useMemo(
+ () =>
+ media.map((item, idx) => {
+ const url = item.get('url') as string;
+ const blurhash = item.get('blurhash') as string;
+ const width = item.getIn(['meta', 'original', 'width'], 0) as number;
+ const height = item.getIn(
+ ['meta', 'original', 'height'],
+ 0,
+ ) as number;
+ const description = item.getIn(
+ ['translation', 'description'],
+ item.get('description'),
+ ) as string;
+ if (item.get('type') === 'image') {
+ return (
+
+ );
+ } else if (item.get('type') === 'video') {
+ return (
+
+ );
+ } else if (item.get('type') === 'gifv') {
+ return (
+
+ );
+ }
+
+ return null;
+ }),
+ [
+ autoPlay,
+ currentTime,
+ handleToggleNavigation,
+ handleZoomClick,
+ index,
+ lang,
+ media,
+ onClose,
+ volume,
+ zoomedIn,
+ ],
+ );
+
+ const intl = useIntl();
+
+ const leftNav = media.size > 1 && (
+
+ );
+ const rightNav = media.size > 1 && (
+
+ );
+
+ return (
+
+
+ {content}
+
+
+
+
+ {zoomable && (
+
+ )}
+
+
+
+ {leftNav}
+ {rightNav}
+
+
+
+ {statusId && (
+
+ )}
+
+
+
+ );
+ },
+);
+MediaModal.displayName = 'MediaModal';
+
+interface MediaPaginationProps {
+ itemsCount: number;
+ index: number;
+ onChangeIndex: (newIndex: number) => void;
+}
+
+const MediaPagination: FC = ({
+ itemsCount,
+ index,
+ onChangeIndex,
+}) => {
+ const handleChangeIndex = useCallback(
+ (curIndex: number) => {
+ return () => {
+ onChangeIndex(curIndex);
+ };
+ },
+ [onChangeIndex],
+ );
+
+ if (itemsCount <= 1) {
+ return null;
+ }
+
+ return (
+
+ {Array.from({ length: itemsCount }).map((_, i) => (
+
+ ))}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index 944feb325e..9afda83908 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -43,10 +43,11 @@ import {
QuietPostQuoteInfoModal,
} from './confirmation_modals';
import { ImageModal } from './image_modal';
-import MediaModal from './media_modal';
+import { MediaModal } from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal';
import { VisibilityModal } from './visibility_modal';
+import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify';
export const MODAL_COMPONENTS = {
'MEDIA': () => Promise.resolve({ default: MediaModal }),
@@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
+ 'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
'MUTE': MuteModal,
diff --git a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx
index afd9ee7ed0..7bc7e0ab97 100644
--- a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx
+++ b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx
@@ -128,9 +128,12 @@ export const VisibilityModal: FC = forwardRef(
const disableVisibility = !!statusId;
const disableQuotePolicy =
visibility === 'private' || visibility === 'direct';
- const disablePublicVisibilities: boolean = useAppSelector(
+ const disablePublicVisibilities = useAppSelector(
selectDisablePublicVisibilities,
);
+ const isQuotePost = useAppSelector(
+ (state) => state.compose.get('quoted_status_id') !== null,
+ );
const visibilityItems = useMemo[]>(() => {
const items: SelectItem[] = [
@@ -315,6 +318,21 @@ export const VisibilityModal: FC = forwardRef(
id={quoteDescriptionId}
/>
+
+ {isQuotePost && visibility === 'direct' && (
+
+
+
+
+ )}