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 ( -