Merge commit '26e7fe97714d077930621f9111b7eaad2774df65' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2025-11-04 20:38:24 +01:00
commit a7ba4ba446
72 changed files with 1365 additions and 1152 deletions

View File

@ -9,7 +9,7 @@ runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'

View File

@ -35,7 +35,7 @@ jobs:
- linux/arm64 - linux/arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Prepare - name: Prepare
env: env:
@ -100,7 +100,7 @@ jobs:
- name: Upload digest - name: Upload digest
if: ${{ inputs.push_to_images != '' }} if: ${{ inputs.push_to_images != '' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
# `hashFiles` is used to disambiguate between streaming and non-streaming images # `hashFiles` is used to disambiguate between streaming and non-streaming images
name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }}
@ -119,10 +119,10 @@ jobs:
PUSH_TO_IMAGES: ${{ inputs.push_to_images }} PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
# `hashFiles` is used to disambiguate between streaming and non-streaming images # `hashFiles` is used to disambiguate between streaming and non-streaming images

View File

@ -18,7 +18,7 @@ jobs:
steps: steps:
# Repository needs to be cloned so `git rev-parse` below works # Repository needs to be cloned so `git rev-parse` below works
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- id: version_vars - id: version_vars
run: | run: |
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT

View File

@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby

View File

@ -23,7 +23,7 @@ jobs:
if: github.repository == 'mastodon/mastodon' if: github.repository == 'mastodon/mastodon'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Javascript environment - name: Set up Javascript environment
@ -33,7 +33,7 @@ jobs:
run: yarn build-storybook run: yarn build-storybook
- name: Run Chromatic - name: Run Chromatic
uses: chromaui/action@v12 uses: chromaui/action@v13
with: with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

View File

@ -31,11 +31,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell. # 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v4
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Increase Git http.postBuffer - name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version? # This is needed due to a bug in Ubuntu's cURL version?

View File

@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Increase Git http.postBuffer - name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version? # This is needed due to a bug in Ubuntu's cURL version?

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2

View File

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -72,7 +72,7 @@ jobs:
BUNDLE_RETRY: 3 BUNDLE_RETRY: 3
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby

View File

@ -32,7 +32,7 @@ jobs:
SECRET_KEY_BASE_DUMMY: 1 SECRET_KEY_BASE_DUMMY: 1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
@ -65,7 +65,7 @@ jobs:
run: | run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json 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' if: matrix.mode == 'test'
with: with:
path: |- path: |-
@ -128,9 +128,9 @@ jobs:
- '3.3' - '3.3'
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -230,9 +230,9 @@ jobs:
- '3.3' - '3.3'
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -309,9 +309,9 @@ jobs:
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -350,14 +350,14 @@ jobs:
- run: bin/rspec spec/system --tag streaming --tag js - run: bin/rspec spec/system --tag streaming --tag js
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: e2e-logs-${{ matrix.ruby-version }} name: e2e-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: e2e-screenshots-${{ matrix.ruby-version }} name: e2e-screenshots-${{ matrix.ruby-version }}
@ -447,9 +447,9 @@ jobs:
search-image: opensearchproject/opensearch:2 search-image: opensearchproject/opensearch:2
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -469,14 +469,14 @@ jobs:
- run: bin/rspec --tag search - run: bin/rspec --tag search
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: test-search-logs-${{ matrix.ruby-version }} name: test-search-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: test-search-screenshots name: test-search-screenshots

View File

@ -31,7 +31,7 @@ const config: StorybookConfig = {
viteFinal(config) { viteFinal(config) {
// For an unknown reason, Storybook does not use the root // For an unknown reason, Storybook does not use the root
// from the Vite config so we need to set it manually. // 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; return config;
}, },
}; };

View File

@ -114,7 +114,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-http', '~> 0.27.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-http_client', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 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-rack', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false

View File

@ -128,7 +128,7 @@ GEM
blurhash (0.1.8) blurhash (0.1.8)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (7.1.1)
racc racc
browser (6.2.0) browser (6.2.0)
builder (3.3.0) builder (3.3.0)
@ -512,9 +512,9 @@ GEM
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10) opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.2.0) opentelemetry-helpers-sql (0.3.0)
opentelemetry-api (~> 1.7) opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-obfuscation (0.4.0) opentelemetry-helpers-sql-obfuscation (0.5.0)
opentelemetry-common (~> 0.21) opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_support (~> 0.10)
@ -548,7 +548,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.26.0) opentelemetry-instrumentation-net_http (0.26.0)
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-pg (0.32.0) opentelemetry-instrumentation-pg (0.33.0)
opentelemetry-helpers-sql opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
@ -621,7 +621,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) rack (3.2.4)
rack-attack (6.8.0) rack-attack (6.8.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-cors (3.0.0) rack-cors (3.0.0)
@ -803,9 +803,9 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
shoulda-matchers (6.5.0) shoulda-matchers (7.0.1)
activesupport (>= 5.2.0) activesupport (>= 7.1)
sidekiq (8.0.8) sidekiq (8.0.9)
connection_pool (>= 2.5.0) connection_pool (>= 2.5.0)
json (>= 2.9.0) json (>= 2.9.0)
logger (>= 1.6.2) logger (>= 1.6.2)
@ -883,7 +883,7 @@ GEM
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.1.0)
uri (1.0.4) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
@ -1018,7 +1018,7 @@ DEPENDENCIES
opentelemetry-instrumentation-http (~> 0.27.0) opentelemetry-instrumentation-http (~> 0.27.0)
opentelemetry-instrumentation-http_client (~> 0.26.0) opentelemetry-instrumentation-http_client (~> 0.26.0)
opentelemetry-instrumentation-net_http (~> 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-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0) opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-redis (~> 0.28.0) opentelemetry-instrumentation-redis (~> 0.28.0)

View File

@ -56,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_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_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_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) { export function insertEmojiCompose(position, emoji, needsSpace) {
return { return {
type: COMPOSE_EMOJI_INSERT, type: COMPOSE_EMOJI_INSERT,

View File

@ -13,10 +13,11 @@ import {
} from 'mastodon/store/typed_functions'; } from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes'; 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 { showAlert } from './alerts';
import { focusCompose } from './compose'; import { changeCompose, focusCompose } from './compose';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses } from './importer';
import { openModal } from './modal'; import { openModal } from './modal';
@ -41,6 +42,10 @@ const messages = defineMessages({
id: 'quote_error.unauthorized', id: 'quote_error.unauthorized',
defaultMessage: 'You are not authorized to quote this post.', 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 & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
return data; 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( export const changeUploadCompose = createDataLoadingThunk(
'compose/changeUpload', 'compose/changeUpload',
async ( async (
@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
if (composeState.get('id')) { if (composeState.get('id')) {
dispatch(showAlert({ message: messages.quoteErrorEdit })); dispatch(showAlert({ message: messages.quoteErrorEdit }));
} else if (composeState.get('privacy') === 'direct') {
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
} else if (composeState.get('poll')) { } else if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll })); dispatch(showAlert({ message: messages.quoteErrorPoll }));
} else if ( } 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( export const pasteLinkCompose = createDataLoadingThunk(
'compose/pasteLink', 'compose/pasteLink',
async ({ url }: { url: string }) => { async ({ url }: { url: string }) => {
@ -183,16 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
limit: 2, limit: 2,
}); });
}, },
(data, { dispatch, getState }) => { (data, { dispatch, getState, requestId }) => {
const composeState = getState().compose; const composeState = getState().compose;
if ( if (
composeState.get('quoted_status_id') || composeStateForbidsLink(composeState) ||
composeState.get('is_submitting') || composeState.get('fetching_link') !== requestId // Request has been cancelled
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id') ||
composeState.get('privacy') === 'direct'
) )
return; return;
@ -208,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
dispatch(quoteComposeById(data.statuses[0].id)); 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 quoteComposeCancel = createAction('compose/quoteComposeCancel');

View File

@ -10,7 +10,7 @@ import ModalRoot from 'mastodon/components/modal_root';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { Audio } from 'mastodon/features/audio'; import { Audio } from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card'; 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 { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll'; import { createPollFromServerJSON } from 'mastodon/models/poll';

View File

@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
return; 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) { if (e) {
e.preventDefault(); e.preventDefault();

View File

@ -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 (
<div className='status__quote'>
<div className='status'>
<div className='status__info'>
<div className='status__avatar'>
<Skeleton width='32px' height='32px' />
</div>
<div className='status__display-name'>
<DisplayName />
</div>
<IconButton
onClick={handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon='cancel-fill'
iconComponent={CancelFillIcon}
/>
</div>
<div className='status__content'>
<Skeleton />
</div>
</div>
</div>
);
};

View File

@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted'; import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { QuotePlaceholder } from './quote_placeholder';
export const ComposeQuotedStatus: FC = () => { export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector( const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null, (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 isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo( const quote = useMemo(
@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
dispatch(quoteComposeCancel()); dispatch(quoteComposeCancel());
}, [dispatch]); }, [dispatch]);
if (!quote) { if (isFetchingLink && !quote) {
return <QuotePlaceholder />;
} else if (!quote) {
return null; return null;
} }

View File

@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose'; import {
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed'; changeComposeVisibility,
setComposeQuotePolicy,
} from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import type { StatusVisibility } from '@/mastodon/api_types/statuses';

View File

@ -12,6 +12,7 @@ import {
} from 'mastodon/actions/compose'; } from 'mastodon/actions/compose';
import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
import { openModal } from 'mastodon/actions/modal'; 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'; import ComposeForm from '../components/compose_form';
@ -32,6 +33,10 @@ const mapStateToProps = state => ({
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, 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), 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, isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']), lang: state.getIn(['compose', 'language']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit (missingAltText) { onSubmit ({ missingAltText, quoteToPrivate }) {
if (missingAltText) { if (missingAltText) {
dispatch(openModal({ dispatch(openModal({
modalType: 'CONFIRM_MISSING_ALT_TEXT', modalType: 'CONFIRM_MISSING_ALT_TEXT',
modalProps: {}, modalProps: {},
})); }));
} else if (quoteToPrivate) {
dispatch(openModal({
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
modalProps: {},
}));
} else { } else {
dispatch(submitCompose((status) => { dispatch(submitCompose((status) => {
if (props.redirectOnSuccess) { if (props.redirectOnSuccess) {

View File

@ -1,8 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose'; import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
import PrivacyDropdown from '../components/privacy_dropdown'; import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View File

@ -299,6 +299,12 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } })); dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
}; };
handleQuote = (status) => {
const { dispatch } = this.props;
dispatch(quoteComposeById(status.get('id')));
};
handleEditClick = (status) => { handleEditClick = (status) => {
const { dispatch, askReplyConfirmation } = this.props; const { dispatch, askReplyConfirmation } = this.props;
@ -592,7 +598,7 @@ class Status extends ImmutablePureComponent {
showBackButton showBackButton
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={( extraButton={(
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} icon={status.get('hidden') ? VisibilityOffIcon : VisibilityIcon} /></button> <button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye' : 'eye-slash'} icon={status.get('hidden') ? VisibilityIcon : VisibilityOffIcon} /></button>
)} )}
/> />
@ -625,6 +631,7 @@ class Status extends ImmutablePureComponent {
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onRevokeQuote={this.handleRevokeQuoteClick} onRevokeQuote={this.handleRevokeQuoteClick}
onQuotePolicyChange={this.handleQuotePolicyChange} onQuotePolicyChange={this.handleQuotePolicyChange}
onQuote={this.handleQuote}
onEdit={this.handleEditClick} onEdit={this.handleEditClick}
onDirect={this.handleDirectClick} onDirect={this.handleDirectClick}
onMention={this.handleMentionClick} onMention={this.handleMentionClick}

View File

@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC<
onSecondary?: () => void; onSecondary?: () => void;
onConfirm: () => void; onConfirm: () => void;
closeWhenConfirm?: boolean; closeWhenConfirm?: boolean;
extraContent?: React.ReactNode;
} & BaseConfirmationModalProps } & BaseConfirmationModalProps
> = ({ > = ({
title, title,
@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC<
secondary, secondary,
onSecondary, onSecondary,
closeWhenConfirm = true, closeWhenConfirm = true,
extraContent,
}) => { }) => {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (closeWhenConfirm) { if (closeWhenConfirm) {
@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__confirmation'> <div className='safety-action-modal__confirmation'>
<h1>{title}</h1> <h1>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}
{extraContent}
</div> </div>
</div> </div>

View File

@ -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 (
<ConfirmationModal
title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.confirm)}
cancel={intl.formatMessage(messages.cancel)}
onConfirm={handleConfirm}
onClose={onClose}
extraContent={
<label className={classes.checkbox_wrapper}>
<CheckBox
value='hide'
checked={dismiss}
onChange={handleDismissToggle}
/>{' '}
<FormattedMessage
id='confirmations.private_quote_notify.do_not_show_again'
defaultMessage="Don't show me this message again"
/>
</label>
}
/>
);
},
);
PrivateQuoteNotify.displayName = 'PrivateQuoteNotify';

View File

@ -0,0 +1,7 @@
.checkbox_wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
cursor: pointer;
}

View File

@ -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 && <button tabIndex={0} className='media-modal__nav media-modal__nav--prev' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--next' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
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 (
<ZoomableImage
src={image.get('url')}
blurhash={image.get('blurhash')}
width={width}
height={height}
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.handleToggleNavigation}
onDoubleClick={this.handleZoomClick}
onClose={onClose}
onZoomChange={this.handleZoomChange}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (image.get('type') === 'video') {
const { currentTime, autoPlay, volume } = this.props;
return (
<Video
preview={image.get('preview_url')}
blurhash={image.get('blurhash')}
src={image.get('url')}
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
startTime={currentTime || 0}
startPlaying={autoPlay || false}
startVolume={volume || 1}
onCloseVideo={onClose}
detailed
alt={description}
lang={lang}
key={image.get('url')}
/>
);
} else if (image.get('type') === 'gifv') {
return (
<GIFV
src={image.get('url')}
key={image.get('url')}
alt={description}
lang={lang}
onClick={this.toggleNavigation}
/>
);
}
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) => (
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
{i + 1}
</button>
));
}
const currentMedia = media.get(index);
const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
return (
<div className='modal-root__modal media-modal' ref={this.setRef}>
<div className='media-modal__closer' role='presentation' onClick={onClose}>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index}
disabled={disableSwiping || zoomedIn}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<div className='media-modal__buttons'>
{zoomable && <IconButton title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)} iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon} onClick={this.handleZoomClick} />}
<IconButton title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} />
</div>
{leftNav}
{rightNav}
<div className='media-modal__overlay'>
{pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
</div>
</div>
</div>
);
}
}
export default injectIntl(MediaModal);

View File

@ -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<MediaAttachment>;
statusId?: string;
lang?: string;
index: number;
onClose: () => void;
onChangeBackgroundColor: (color: RGB | null) => void;
currentTime?: number;
autoPlay?: boolean;
volume?: number;
}
export const MediaModal: FC<MediaModalProps> = 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<HTMLDivElement> = 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 (
<ZoomableImage
src={url}
blurhash={blurhash}
width={width}
height={height}
alt={description}
lang={lang}
key={url}
onClick={handleToggleNavigation}
onDoubleClick={handleZoomClick}
onClose={onClose}
onZoomChange={setZoomedIn}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (item.get('type') === 'video') {
return (
<Video
preview={item.get('preview_url') as string | undefined}
blurhash={blurhash}
src={url}
frameRate={
item.getIn(['meta', 'original', 'frame_rate']) as
| string
| undefined
}
aspectRatio={`${width} / ${height}`}
startTime={currentTime ?? 0}
startPlaying={autoPlay ?? false}
startVolume={volume ?? 1}
onCloseVideo={onClose}
detailed
alt={description}
lang={lang}
key={url}
/>
);
} else if (item.get('type') === 'gifv') {
return (
<GIFV
src={url}
key={url}
alt={description}
lang={lang}
onClick={handleToggleNavigation}
/>
);
}
return null;
}),
[
autoPlay,
currentTime,
handleToggleNavigation,
handleZoomClick,
index,
lang,
media,
onClose,
volume,
zoomedIn,
],
);
const intl = useIntl();
const leftNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--prev'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
>
<Icon id='chevron-left' icon={ChevronLeftIcon} />
</button>
);
const rightNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--next'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
>
<Icon id='chevron-right' icon={ChevronRightIcon} />
</button>
);
return (
<div
{...bind()}
className='modal-root__modal media-modal'
ref={handleRef}
>
<animated.div
style={wrapperStyles}
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
{content}
</animated.div>
<div
className={classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
})}
>
<div className='media-modal__buttons'>
{zoomable && (
<IconButton
title={intl.formatMessage(
zoomedIn ? messages.zoomOut : messages.zoomIn,
)}
icon=''
iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon}
onClick={handleZoomClick}
/>
)}
<IconButton
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</div>
{leftNav}
{rightNav}
<div className='media-modal__overlay'>
<MediaPagination
itemsCount={media.size}
index={index}
onChangeIndex={handleChangeIndex}
/>
{statusId && (
<Footer statusId={statusId} withOpenButton onClose={onClose} />
)}
</div>
</div>
</div>
);
},
);
MediaModal.displayName = 'MediaModal';
interface MediaPaginationProps {
itemsCount: number;
index: number;
onChangeIndex: (newIndex: number) => void;
}
const MediaPagination: FC<MediaPaginationProps> = ({
itemsCount,
index,
onChangeIndex,
}) => {
const handleChangeIndex = useCallback(
(curIndex: number) => {
return () => {
onChangeIndex(curIndex);
};
},
[onChangeIndex],
);
if (itemsCount <= 1) {
return null;
}
return (
<ul className='media-modal__pagination'>
{Array.from({ length: itemsCount }).map((_, i) => (
<button
key={i}
className={classNames('media-modal__page-dot', {
active: i === index,
})}
onClick={handleChangeIndex(i)}
>
{i + 1}
</button>
))}
</ul>
);
};

View File

@ -43,10 +43,11 @@ import {
QuietPostQuoteInfoModal, QuietPostQuoteInfoModal,
} from './confirmation_modals'; } from './confirmation_modals';
import { ImageModal } from './image_modal'; import { ImageModal } from './image_modal';
import MediaModal from './media_modal'; import { MediaModal } from './media_modal';
import { ModalPlaceholder } from './modal_placeholder'; import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal'; import VideoModal from './video_modal';
import { VisibilityModal } from './visibility_modal'; import { VisibilityModal } from './visibility_modal';
import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify';
export const MODAL_COMPONENTS = { export const MODAL_COMPONENTS = {
'MEDIA': () => Promise.resolve({ default: MediaModal }), 'MEDIA': () => Promise.resolve({ default: MediaModal }),
@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), 'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }), 'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), 'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }), 'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
'MUTE': MuteModal, 'MUTE': MuteModal,

View File

@ -128,9 +128,12 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
const disableVisibility = !!statusId; const disableVisibility = !!statusId;
const disableQuotePolicy = const disableQuotePolicy =
visibility === 'private' || visibility === 'direct'; visibility === 'private' || visibility === 'direct';
const disablePublicVisibilities: boolean = useAppSelector( const disablePublicVisibilities = useAppSelector(
selectDisablePublicVisibilities, selectDisablePublicVisibilities,
); );
const isQuotePost = useAppSelector(
(state) => state.compose.get('quoted_status_id') !== null,
);
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(() => { const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(() => {
const items: SelectItem<StatusVisibility>[] = [ const items: SelectItem<StatusVisibility>[] = [
@ -315,6 +318,21 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
id={quoteDescriptionId} id={quoteDescriptionId}
/> />
</div> </div>
{isQuotePost && visibility === 'direct' && (
<div className='visibility-modal__quote-warning'>
<FormattedMessage
id='visibility_modal.direct_quote_warning.title'
defaultMessage="Quotes can't be embedded in private mentions"
tagName='h3'
/>
<FormattedMessage
id='visibility_modal.direct_quote_warning.text'
defaultMessage='If you save the current settings, the embedded quote will be converted to a link.'
tagName='p'
/>
</div>
)}
</div> </div>
<div className='dialog-modal__content__actions'> <div className='dialog-modal__content__actions'>
<Button onClick={onClose} secondary> <Button onClick={onClose} secondary>

View File

@ -35,7 +35,7 @@ interface InitialStateMeta {
streaming_api_base_url: string; streaming_api_base_url: string;
local_live_feed_access: 'public' | 'authenticated' | 'disabled'; local_live_feed_access: 'public' | 'authenticated' | 'disabled';
remote_live_feed_access: 'public' | 'authenticated' | 'disabled'; remote_live_feed_access: 'public' | 'authenticated' | 'disabled';
local_topic_feed_access: 'public' | 'authenticated' | 'disabled'; local_topic_feed_access: 'public' | 'authenticated';
remote_topic_feed_access: 'public' | 'authenticated' | 'disabled'; remote_topic_feed_access: 'public' | 'authenticated' | 'disabled';
title: string; title: string;
show_trends: boolean; show_trends: boolean;

View File

@ -157,6 +157,8 @@
"bundle_modal_error.close": "Cau", "bundle_modal_error.close": "Cau",
"bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.", "bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.",
"bundle_modal_error.retry": "Ceisiwch eto", "bundle_modal_error.retry": "Ceisiwch eto",
"carousel.current": "<sr>Sleid</sr> {current, number} / {max, number}",
"carousel.slide": "Sleid {current, number} o {max, number}",
"closed_registrations.other_server_instructions": "Gan fod Mastodon yn ddatganoledig, gallwch greu cyfrif ar weinydd arall a dal i ryngweithio gyda hwn.", "closed_registrations.other_server_instructions": "Gan fod Mastodon yn ddatganoledig, gallwch greu cyfrif ar weinydd arall a dal i ryngweithio gyda hwn.",
"closed_registrations_modal.description": "Ar hyn o bryd nid yw'n bosib creu cyfrif ar {domain}, ond cadwch mewn cof nad oes raid i chi gael cyfrif yn benodol ar {domain} i ddefnyddio Mastodon.", "closed_registrations_modal.description": "Ar hyn o bryd nid yw'n bosib creu cyfrif ar {domain}, ond cadwch mewn cof nad oes raid i chi gael cyfrif yn benodol ar {domain} i ddefnyddio Mastodon.",
"closed_registrations_modal.find_another_server": "Dod o hyd i weinydd arall", "closed_registrations_modal.find_another_server": "Dod o hyd i weinydd arall",
@ -173,6 +175,8 @@
"column.edit_list": "Golygu rhestr", "column.edit_list": "Golygu rhestr",
"column.favourites": "Ffefrynnau", "column.favourites": "Ffefrynnau",
"column.firehose": "Ffrydiau byw", "column.firehose": "Ffrydiau byw",
"column.firehose_local": "Ffrwd fyw ar gyfer y gweinydd hwn",
"column.firehose_singular": "Ffrwd fyw",
"column.follow_requests": "Ceisiadau dilyn", "column.follow_requests": "Ceisiadau dilyn",
"column.home": "Cartref", "column.home": "Cartref",
"column.list_members": "Rheoli aelodau rhestr", "column.list_members": "Rheoli aelodau rhestr",
@ -192,6 +196,7 @@
"community.column_settings.local_only": "Lleol yn unig", "community.column_settings.local_only": "Lleol yn unig",
"community.column_settings.media_only": "Cyfryngau yn unig", "community.column_settings.media_only": "Cyfryngau yn unig",
"community.column_settings.remote_only": "Pell yn unig", "community.column_settings.remote_only": "Pell yn unig",
"compose.error.blank_post": "Gall postiad ddim bod yn wag.",
"compose.language.change": "Newid iaith", "compose.language.change": "Newid iaith",
"compose.language.search": "Chwilio ieithoedd...", "compose.language.search": "Chwilio ieithoedd...",
"compose.published.body": "Postiad wedi ei gyhoeddi.", "compose.published.body": "Postiad wedi ei gyhoeddi.",
@ -358,7 +363,9 @@
"explore.trending_links": "Newyddion", "explore.trending_links": "Newyddion",
"explore.trending_statuses": "Postiadau", "explore.trending_statuses": "Postiadau",
"explore.trending_tags": "Hashnodau", "explore.trending_tags": "Hashnodau",
"featured_carousel.current": "<sr>Postiad</sr> {current, number} / {max, number}",
"featured_carousel.header": "{count, plural, one {Postiad wedi'i binio} other {Postiadau wedi'u pinio}}", "featured_carousel.header": "{count, plural, one {Postiad wedi'i binio} other {Postiadau wedi'u pinio}}",
"featured_carousel.slide": "Postiad {current, number} of {max, number}",
"filter_modal.added.context_mismatch_explanation": "Dyw'r categori hidlo hwn ddim yn berthnasol i'r cyd-destun yr ydych wedi cyrchu'r postiad hwn ynddo. Os ydych chi am i'r postiad gael ei hidlo yn y cyd-destun hwn hefyd, bydd yn rhaid i chi olygu'r hidlydd.", "filter_modal.added.context_mismatch_explanation": "Dyw'r categori hidlo hwn ddim yn berthnasol i'r cyd-destun yr ydych wedi cyrchu'r postiad hwn ynddo. Os ydych chi am i'r postiad gael ei hidlo yn y cyd-destun hwn hefyd, bydd yn rhaid i chi olygu'r hidlydd.",
"filter_modal.added.context_mismatch_title": "Diffyg cyfatebiaeth cyd-destun!", "filter_modal.added.context_mismatch_title": "Diffyg cyfatebiaeth cyd-destun!",
"filter_modal.added.expired_explanation": "Mae'r categori hidlydd hwn wedi dod i ben, bydd angen i chi newid y dyddiad dod i ben er mwyn iddo fod yn berthnasol.", "filter_modal.added.expired_explanation": "Mae'r categori hidlydd hwn wedi dod i ben, bydd angen i chi newid y dyddiad dod i ben er mwyn iddo fod yn berthnasol.",
@ -401,6 +408,7 @@
"follow_suggestions.who_to_follow": "Pwy i ddilyn", "follow_suggestions.who_to_follow": "Pwy i ddilyn",
"followed_tags": "Hashnodau rydych yn eu dilyn", "followed_tags": "Hashnodau rydych yn eu dilyn",
"footer.about": "Ynghylch", "footer.about": "Ynghylch",
"footer.about_this_server": "Ynghylch",
"footer.directory": "Cyfeiriadur proffiliau", "footer.directory": "Cyfeiriadur proffiliau",
"footer.get_app": "Llwytho'r ap i lawr", "footer.get_app": "Llwytho'r ap i lawr",
"footer.keyboard_shortcuts": "Bysellau brys", "footer.keyboard_shortcuts": "Bysellau brys",
@ -905,9 +913,12 @@
"status.pin": "Pinio ar y proffil", "status.pin": "Pinio ar y proffil",
"status.quote": "Dyfynnu", "status.quote": "Dyfynnu",
"status.quote.cancel": "Diddymu'r dyfyniad", "status.quote.cancel": "Diddymu'r dyfyniad",
"status.quote_error.blocked_account_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi rhwystro @{name}.",
"status.quote_error.blocked_domain_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi rhwystro {domain}.",
"status.quote_error.filtered": "Wedi'i guddio oherwydd un o'ch hidlwyr", "status.quote_error.filtered": "Wedi'i guddio oherwydd un o'ch hidlwyr",
"status.quote_error.limited_account_hint.action": "Dangos beth bynnag", "status.quote_error.limited_account_hint.action": "Dangos beth bynnag",
"status.quote_error.limited_account_hint.title": "Mae'r cyfrif hwn wedi'i guddio gan gymedrolwyr {domain}.", "status.quote_error.limited_account_hint.title": "Mae'r cyfrif hwn wedi'i guddio gan gymedrolwyr {domain}.",
"status.quote_error.muted_account_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi mudo @{name}.",
"status.quote_error.not_available": "Postiad ddim ar gael", "status.quote_error.not_available": "Postiad ddim ar gael",
"status.quote_error.pending_approval": "Postiad yn yr arfaeth", "status.quote_error.pending_approval": "Postiad yn yr arfaeth",
"status.quote_error.pending_approval_popout.body": "Ar Mastodon, gallwch reoli os yw rhywun yn gallu eich dyfynnu. Mae'r postiad hwn yn cael ei ddal nôl tra'n bod yn cael cymeradwyaeth yr awdur gwreiddiol.", "status.quote_error.pending_approval_popout.body": "Ar Mastodon, gallwch reoli os yw rhywun yn gallu eich dyfynnu. Mae'r postiad hwn yn cael ei ddal nôl tra'n bod yn cael cymeradwyaeth yr awdur gwreiddiol.",

View File

@ -284,7 +284,7 @@
"directory.recently_active": "Aktive for nyligt", "directory.recently_active": "Aktive for nyligt",
"disabled_account_banner.account_settings": "Kontoindstillinger", "disabled_account_banner.account_settings": "Kontoindstillinger",
"disabled_account_banner.text": "Din konto {disabledAccount} er pt. deaktiveret.", "disabled_account_banner.text": "Din konto {disabledAccount} er pt. deaktiveret.",
"dismissable_banner.community_timeline": "Disse er de seneste offentlige indlæg fra personer med konti hostet af {domain}.", "dismissable_banner.community_timeline": "Dette er de seneste offentlige indlæg fra personer med konti hostet af {domain}.",
"dismissable_banner.dismiss": "Afvis", "dismissable_banner.dismiss": "Afvis",
"dismissable_banner.public_timeline": "Dette er de seneste offentlige indlæg fra personer på fediverset, som folk på {domain} følger.", "dismissable_banner.public_timeline": "Dette er de seneste offentlige indlæg fra personer på fediverset, som folk på {domain} følger.",
"domain_block_modal.block": "Blokér server", "domain_block_modal.block": "Blokér server",

View File

@ -249,6 +249,11 @@
"confirmations.missing_alt_text.secondary": "Post anyway", "confirmations.missing_alt_text.secondary": "Post anyway",
"confirmations.missing_alt_text.title": "Add alt text?", "confirmations.missing_alt_text.title": "Add alt text?",
"confirmations.mute.confirm": "Mute", "confirmations.mute.confirm": "Mute",
"confirmations.private_quote_notify.cancel": "Back to editing",
"confirmations.private_quote_notify.confirm": "Publish post",
"confirmations.private_quote_notify.do_not_show_again": "Don't show me this message again",
"confirmations.private_quote_notify.message": "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.",
"confirmations.private_quote_notify.title": "Share with followers and mentioned users?",
"confirmations.quiet_post_quote_info.dismiss": "Don't remind me again", "confirmations.quiet_post_quote_info.dismiss": "Don't remind me again",
"confirmations.quiet_post_quote_info.got_it": "Got it", "confirmations.quiet_post_quote_info.got_it": "Got it",
"confirmations.quiet_post_quote_info.message": "When quoting a quiet public post, your post will be hidden from trending timelines.", "confirmations.quiet_post_quote_info.message": "When quoting a quiet public post, your post will be hidden from trending timelines.",
@ -760,6 +765,7 @@
"privacy_policy.title": "Privacy Policy", "privacy_policy.title": "Privacy Policy",
"quote_error.edit": "Quotes cannot be added when editing a post.", "quote_error.edit": "Quotes cannot be added when editing a post.",
"quote_error.poll": "Quoting is not allowed with polls.", "quote_error.poll": "Quoting is not allowed with polls.",
"quote_error.private_mentions": "Quoting is not allowed with direct mentions.",
"quote_error.quote": "Only one quote at a time is allowed.", "quote_error.quote": "Only one quote at a time is allowed.",
"quote_error.unauthorized": "You are not authorized to quote this post.", "quote_error.unauthorized": "You are not authorized to quote this post.",
"quote_error.upload": "Quoting is not allowed with media attachments.", "quote_error.upload": "Quoting is not allowed with media attachments.",
@ -1013,6 +1019,8 @@
"video.volume_down": "Volume down", "video.volume_down": "Volume down",
"video.volume_up": "Volume up", "video.volume_up": "Volume up",
"visibility_modal.button_title": "Set visibility", "visibility_modal.button_title": "Set visibility",
"visibility_modal.direct_quote_warning.text": "If you save the current settings, the embedded quote will be converted to a link.",
"visibility_modal.direct_quote_warning.title": "Quotes can't be embedded in private mentions",
"visibility_modal.header": "Visibility and interaction", "visibility_modal.header": "Visibility and interaction",
"visibility_modal.helper.direct_quoting": "Private mentions authored on Mastodon can't be quoted by others.", "visibility_modal.helper.direct_quoting": "Private mentions authored on Mastodon can't be quoted by others.",
"visibility_modal.helper.privacy_editing": "Visibility can't be changed after a post is published.", "visibility_modal.helper.privacy_editing": "Visibility can't be changed after a post is published.",

View File

@ -743,7 +743,7 @@
"poll.votes": "{votes, plural, one {# stem} other {# stemmen}}", "poll.votes": "{votes, plural, one {# stem} other {# stemmen}}",
"poll_button.add_poll": "Peiling toevoegen", "poll_button.add_poll": "Peiling toevoegen",
"poll_button.remove_poll": "Peiling verwijderen", "poll_button.remove_poll": "Peiling verwijderen",
"privacy.change": "Privacy voor een bericht aanpassen", "privacy.change": "Privacy van dit bericht aanpassen",
"privacy.direct.long": "Alleen voor mensen die specifiek in het bericht worden vermeld", "privacy.direct.long": "Alleen voor mensen die specifiek in het bericht worden vermeld",
"privacy.direct.short": "Privébericht", "privacy.direct.short": "Privébericht",
"privacy.private.long": "Alleen jouw volgers", "privacy.private.long": "Alleen jouw volgers",

View File

@ -192,6 +192,7 @@
"community.column_settings.local_only": "Vetëm vendore", "community.column_settings.local_only": "Vetëm vendore",
"community.column_settings.media_only": "Vetëm Media", "community.column_settings.media_only": "Vetëm Media",
"community.column_settings.remote_only": "Vetëm të largëta", "community.column_settings.remote_only": "Vetëm të largëta",
"compose.error.blank_post": "Postimi smund të jetë i zbrazët.",
"compose.language.change": "Ndryshoni gjuhën", "compose.language.change": "Ndryshoni gjuhën",
"compose.language.search": "Kërkoni te gjuhët…", "compose.language.search": "Kërkoni te gjuhët…",
"compose.published.body": "Postimi u botua.", "compose.published.body": "Postimi u botua.",
@ -403,6 +404,7 @@
"follow_suggestions.who_to_follow": "Cilët të ndiqen", "follow_suggestions.who_to_follow": "Cilët të ndiqen",
"followed_tags": "Hashtag-ë të ndjekur", "followed_tags": "Hashtag-ë të ndjekur",
"footer.about": "Mbi", "footer.about": "Mbi",
"footer.about_this_server": "Mbi",
"footer.directory": "Drejtori profilesh", "footer.directory": "Drejtori profilesh",
"footer.get_app": "Merreni aplikacionin", "footer.get_app": "Merreni aplikacionin",
"footer.keyboard_shortcuts": "Shkurtore tastiere", "footer.keyboard_shortcuts": "Shkurtore tastiere",

View File

@ -606,8 +606,8 @@
"notification.annual_report.view": "檢視 #Wrapstodon", "notification.annual_report.view": "檢視 #Wrapstodon",
"notification.favourite": "{name} 已將您的嘟文加入最愛", "notification.favourite": "{name} 已將您的嘟文加入最愛",
"notification.favourite.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的嘟文加入最愛", "notification.favourite.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的嘟文加入最愛",
"notification.favourite_pm": "{name} 將您的私人提及加入最愛", "notification.favourite_pm": "{name} 將您的私加入最愛",
"notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私人提及加入最愛", "notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私加入最愛",
"notification.follow": "{name} 已跟隨您", "notification.follow": "{name} 已跟隨您",
"notification.follow.name_and_others": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已跟隨您", "notification.follow.name_and_others": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已跟隨您",
"notification.follow_request": "{name} 要求跟隨您", "notification.follow_request": "{name} 要求跟隨您",
@ -1014,7 +1014,7 @@
"video.volume_up": "提高音量", "video.volume_up": "提高音量",
"visibility_modal.button_title": "設定可見性", "visibility_modal.button_title": "設定可見性",
"visibility_modal.header": "可見性與互動", "visibility_modal.header": "可見性與互動",
"visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私人提及嘟文無法被其他使用者引用。", "visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私嘟文無法被其他使用者引用。",
"visibility_modal.helper.privacy_editing": "嘟文發布後無法變更可見性。", "visibility_modal.helper.privacy_editing": "嘟文發布後無法變更可見性。",
"visibility_modal.helper.privacy_private_self_quote": "自我引用之私嘟無法設為公開可見。", "visibility_modal.helper.privacy_private_self_quote": "自我引用之私嘟無法設為公開可見。",
"visibility_modal.helper.private_quoting": "Mastodon 上發佈之僅限跟隨者嘟文無法被其他使用者引用。", "visibility_modal.helper.private_quoting": "Mastodon 上發佈之僅限跟隨者嘟文無法被其他使用者引用。",

View File

@ -1,11 +1,14 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { import {
changeComposeVisibility,
changeUploadCompose, changeUploadCompose,
quoteCompose, quoteCompose,
quoteComposeCancel, quoteComposeCancel,
setComposeQuotePolicy, setComposeQuotePolicy,
} from 'mastodon/actions/compose_typed'; pasteLinkCompose,
cancelPasteLinkCompose,
} from '@/mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed'; import { timelineDelete } from 'mastodon/actions/timelines_typed';
import { import {
@ -38,7 +41,6 @@ import {
COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_CHANGE,
COMPOSE_COMPOSING_CHANGE, COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT, COMPOSE_EMOJI_INSERT,
@ -93,6 +95,7 @@ const initialState = ImmutableMap({
quoted_status_id: null, quoted_status_id: null,
quote_policy: 'public', quote_policy: 'public',
default_quote_policy: 'public', // Set in hydration. default_quote_policy: 'public', // Set in hydration.
fetching_link: null,
}); });
const initialPoll = ImmutableMap({ const initialPoll = ImmutableMap({
@ -315,7 +318,11 @@ const calculateProgress = (loaded, total) => Math.min(Math.round((loaded / total
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */ /** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
export const composeReducer = (state = initialState, action) => { export const composeReducer = (state = initialState, action) => {
if (changeUploadCompose.fulfilled.match(action)) { if (changeComposeVisibility.match(action)) {
return state
.set('privacy', action.payload)
.set('idempotencyKey', uuid());
} else if (changeUploadCompose.fulfilled.match(action)) {
return state return state
.set('is_changing_upload', false) .set('is_changing_upload', false)
.update('media_attachments', list => list.map(item => { .update('media_attachments', list => list.map(item => {
@ -331,15 +338,27 @@ export const composeReducer = (state = initialState, action) => {
return state.set('is_changing_upload', false); return state.set('is_changing_upload', false);
} else if (quoteCompose.match(action)) { } else if (quoteCompose.match(action)) {
const status = action.payload; const status = action.payload;
const isDirect = state.get('privacy') === 'direct';
return state return state
.set('quoted_status_id', status.get('id')) .set('quoted_status_id', isDirect ? null : status.get('id'))
.set('spoiler', status.get('sensitive')) .set('spoiler', status.get('sensitive'))
.set('spoiler_text', status.get('spoiler_text')) .set('spoiler_text', status.get('spoiler_text'))
.update('privacy', (visibility) => ['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private' ? 'private' : visibility); .update('privacy', (visibility) => {
if (['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private') {
return 'private';
}
return visibility;
});
} else if (quoteComposeCancel.match(action)) { } else if (quoteComposeCancel.match(action)) {
return state.set('quoted_status_id', null); return state.set('quoted_status_id', null);
} else if (setComposeQuotePolicy.match(action)) { } else if (setComposeQuotePolicy.match(action)) {
return state.set('quote_policy', action.payload); return state.set('quote_policy', action.payload);
} else if (pasteLinkCompose.pending.match(action)) {
return state.set('fetching_link', action.meta.requestId);
} else if (pasteLinkCompose.fulfilled.match(action) || pasteLinkCompose.rejected.match(action)) {
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
} else if (cancelPasteLinkCompose.match(action)) {
return state.set('fetching_link', null);
} }
switch(action.type) { switch(action.type) {
@ -383,10 +402,6 @@ export const composeReducer = (state = initialState, action) => {
return state return state
.set('spoiler_text', action.text) .set('spoiler_text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid());
case COMPOSE_VISIBILITY_CHANGE:
return state
.set('privacy', action.value)
.set('idempotencyKey', uuid());
case COMPOSE_CHANGE: case COMPOSE_CHANGE:
return state return state
.set('text', action.text) .set('text', action.text)

View File

@ -42,7 +42,7 @@ interface AppThunkConfig {
} }
export type AppThunkApi = Pick< export type AppThunkApi = Pick<
GetThunkAPI<AppThunkConfig>, GetThunkAPI<AppThunkConfig>,
'getState' | 'dispatch' 'getState' | 'dispatch' | 'requestId'
>; >;
interface AppThunkOptions<Arg> { interface AppThunkOptions<Arg> {
@ -60,7 +60,7 @@ type AppThunk<Arg = void, Returned = void> = (
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = ( type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
arg: Arg, arg: Arg,
api: AppThunkApi, api: Pick<AppThunkApi, 'getState' | 'dispatch'>,
extra?: ExtraArg, extra?: ExtraArg,
) => Returned; ) => Returned;
@ -143,10 +143,10 @@ export function createAsyncThunk<Arg = void, Returned = void>(
name, name,
async ( async (
arg: Arg, arg: Arg,
{ getState, dispatch, fulfillWithValue, rejectWithValue }, { getState, dispatch, requestId, fulfillWithValue, rejectWithValue },
) => { ) => {
try { try {
const result = await creator(arg, { dispatch, getState }); const result = await creator(arg, { dispatch, getState, requestId });
return fulfillWithValue(result, { return fulfillWithValue(result, {
useLoadingBar: options.useLoadingBar, useLoadingBar: options.useLoadingBar,
@ -280,10 +280,11 @@ export function createDataLoadingThunk<
return createAsyncThunk<Args, Returned>( return createAsyncThunk<Args, Returned>(
name, name,
async (arg, { getState, dispatch }) => { async (arg, { getState, dispatch, requestId }) => {
const data = await loadData(arg, { const data = await loadData(arg, {
dispatch, dispatch,
getState, getState,
requestId,
}); });
if (!onData) return data as Returned; if (!onData) return data as Returned;
@ -291,6 +292,7 @@ export function createDataLoadingThunk<
const result = await onData(data, { const result = await onData(data, {
dispatch, dispatch,
getState, getState,
requestId,
discardLoadData: discardLoadDataInPayload, discardLoadData: discardLoadDataInPayload,
actionArg: arg, actionArg: arg,
}); });

View File

@ -1325,6 +1325,10 @@ a.sparkline {
line-height: 1; line-height: 1;
width: 100%; width: 100%;
animation: skeleton 1.2s ease-in-out infinite; animation: skeleton 1.2s ease-in-out infinite;
.reduce-motion & {
animation: none;
}
} }
@keyframes skeleton { @keyframes skeleton {

View File

@ -2685,6 +2685,7 @@ a.account__display-name {
outline-offset: -1px; outline-offset: -1px;
border-radius: 8px; border-radius: 8px;
touch-action: none; touch-action: none;
user-select: none;
} }
&--zoomed-in { &--zoomed-in {
@ -5743,6 +5744,34 @@ a.status-card {
} }
} }
.visibility-modal {
&__quote-warning {
color: var(--nested-card-text);
background:
/* This is a bit of a silly hack for layering two background colours
* since --nested-card-background is too transparent for a tooltip */
linear-gradient(
var(--nested-card-background),
var(--nested-card-background)
),
linear-gradient(var(--background-color), var(--background-color));
border: var(--nested-card-border);
padding: 16px;
border-radius: 4px;
h3 {
font-weight: 500;
margin-bottom: 4px;
color: $darker-text-color;
}
p {
font-size: 0.8em;
color: $dark-text-color;
}
}
}
.visibility-dropdown { .visibility-dropdown {
&__overlay[data-popper-placement] { &__overlay[data-popper-placement] {
z-index: 9999; z-index: 9999;
@ -6096,6 +6125,7 @@ a.status-card {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
touch-action: pan-y;
&__buttons { &__buttons {
position: absolute; position: absolute;
@ -6131,11 +6161,17 @@ a.status-card {
} }
.media-modal__closer { .media-modal__closer {
display: flex;
position: absolute; position: absolute;
top: 0; top: 0;
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
> div {
flex-shrink: 0;
overflow: auto;
}
} }
.media-modal__navigation { .media-modal__navigation {
@ -8936,6 +8972,10 @@ noscript {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
.layout-multiple-columns & {
width: 100%;
}
@media screen and (max-width: (124px + 300px)) { @media screen and (max-width: (124px + 300px)) {
width: 100%; width: 100%;
} }

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class SignedRequest class SignedRequest
include DomainControlHelper
EXPIRATION_WINDOW_LIMIT = 12.hours EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour CLOCK_SKEW_MARGIN = 1.hour

View File

@ -102,6 +102,7 @@ class Form::AdminSettings
DOMAIN_BLOCK_AUDIENCES = %w(disabled users all).freeze DOMAIN_BLOCK_AUDIENCES = %w(disabled users all).freeze
REGISTRATION_MODES = %w(open approved none).freeze REGISTRATION_MODES = %w(open approved none).freeze
FEED_ACCESS_MODES = %w(public authenticated disabled).freeze FEED_ACCESS_MODES = %w(public authenticated disabled).freeze
ALTERNATE_FEED_ACCESS_MODES = %w(public authenticated).freeze
LANDING_PAGE = %w(trends about local_feed).freeze LANDING_PAGE = %w(trends about local_feed).freeze
attr_accessor(*KEYS) attr_accessor(*KEYS)
@ -114,7 +115,7 @@ class Form::AdminSettings
validates :show_domain_blocks_rationale, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks_rationale) } validates :show_domain_blocks_rationale, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks_rationale) }
validates :local_live_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@local_live_feed_access) } validates :local_live_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@local_live_feed_access) }
validates :remote_live_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@remote_live_feed_access) } validates :remote_live_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@remote_live_feed_access) }
validates :local_topic_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@local_topic_feed_access) } validates :local_topic_feed_access, inclusion: { in: ALTERNATE_FEED_ACCESS_MODES }, if: -> { defined?(@local_topic_feed_access) }
validates :remote_topic_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@remote_topic_feed_access) } validates :remote_topic_feed_access, inclusion: { in: FEED_ACCESS_MODES }, if: -> { defined?(@remote_topic_feed_access) }
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }

View File

@ -46,7 +46,7 @@
.fields-row .fields-row
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :local_topic_feed_access, = f.input :local_topic_feed_access,
collection: f.object.class::FEED_ACCESS_MODES, collection: f.object.class::ALTERNATE_FEED_ACCESS_MODES,
include_blank: false, include_blank: false,
label_method: ->(mode) { I18n.t("admin.settings.feed_access.modes.#{mode}") }, label_method: ->(mode) { I18n.t("admin.settings.feed_access.modes.#{mode}") },
wrapper: :with_label wrapper: :with_label

View File

@ -5,7 +5,7 @@ class ActivityPub::RefetchAndVerifyQuoteWorker
include ExponentialBackoff include ExponentialBackoff
include JsonLdHelper include JsonLdHelper
sidekiq_options queue: 'pull', retry: 3 sidekiq_options queue: 'pull', retry: 5
def perform(quote_id, quoted_uri, options = {}) def perform(quote_id, quoted_uri, options = {})
quote = Quote.find(quote_id) quote = Quote.find(quote_id)

View File

@ -2015,6 +2015,7 @@ be:
errors: errors:
in_reply_not_found: Здаецца, допіс, на які вы спрабуеце адказаць, не існуе. in_reply_not_found: Здаецца, допіс, на які вы спрабуеце адказаць, не існуе.
quoted_status_not_found: Выглядае, што допісу, які Вы спрабуеце цытаваць, не існуе. quoted_status_not_found: Выглядае, што допісу, які Вы спрабуеце цытаваць, не існуе.
quoted_user_not_mentioned: Немагчыма цытаваць незгаданага карыстальніка ў допісе прыватнага згадвання.
over_character_limit: перавышаная колькасць сімвалаў у %{max} over_character_limit: перавышаная колькасць сімвалаў у %{max}
pin_errors: pin_errors:
direct: Допісы, бачныя толькі згаданым карыстальнікам, нельга замацаваць direct: Допісы, бачныя толькі згаданым карыстальнікам, нельга замацаваць

View File

@ -911,6 +911,11 @@ cy:
authenticated: Defnyddwyr dilys yn unig authenticated: Defnyddwyr dilys yn unig
disabled: Gofyn am rôl defnyddiwr penodol disabled: Gofyn am rôl defnyddiwr penodol
public: Pawb public: Pawb
landing_page:
values:
about: Ynghylch
local_feed: Ffrwd leol
trends: Trendiau
registrations: registrations:
moderation_recommandation: Gwnewch yn siŵr bod gennych chi dîm cymedroli digonol ac adweithiol cyn i chi agor cofrestriadau i bawb! moderation_recommandation: Gwnewch yn siŵr bod gennych chi dîm cymedroli digonol ac adweithiol cyn i chi agor cofrestriadau i bawb!
preamble: Rheoli pwy all greu cyfrif ar eich gweinydd. preamble: Rheoli pwy all greu cyfrif ar eich gweinydd.

View File

@ -1929,6 +1929,7 @@ da:
errors: errors:
in_reply_not_found: Indlægget, der forsøges besvaret, ser ikke ud til at eksistere. in_reply_not_found: Indlægget, der forsøges besvaret, ser ikke ud til at eksistere.
quoted_status_not_found: Indlægget, du forsøger at citere, ser ikke ud til at eksistere. quoted_status_not_found: Indlægget, du forsøger at citere, ser ikke ud til at eksistere.
quoted_user_not_mentioned: Kan ikke citere en ikke-omtalt bruger i et privat omtale-indlæg.
over_character_limit: grænsen på %{max} tegn overskredet over_character_limit: grænsen på %{max} tegn overskredet
pin_errors: pin_errors:
direct: Indlæg, som kun kan ses af omtalte brugere, kan ikke fastgøres direct: Indlæg, som kun kan ses af omtalte brugere, kan ikke fastgøres

View File

@ -1929,6 +1929,7 @@ de:
errors: errors:
in_reply_not_found: Der Beitrag, auf den du antworten möchtest, scheint nicht zu existieren. in_reply_not_found: Der Beitrag, auf den du antworten möchtest, scheint nicht zu existieren.
quoted_status_not_found: Der Beitrag, den du zitieren möchtest, scheint nicht zu existieren. quoted_status_not_found: Der Beitrag, den du zitieren möchtest, scheint nicht zu existieren.
quoted_user_not_mentioned: Ein nicht erwähnter Nutzer kann in einem privaten Erwähnungsbeitrag nicht zitiert werden.
over_character_limit: Begrenzung von %{max} Zeichen überschritten over_character_limit: Begrenzung von %{max} Zeichen überschritten
pin_errors: pin_errors:
direct: Beiträge, die nur für erwähnte Profile sichtbar sind, können nicht angeheftet werden direct: Beiträge, die nur für erwähnte Profile sichtbar sind, können nicht angeheftet werden

View File

@ -1929,6 +1929,7 @@ el:
errors: errors:
in_reply_not_found: Η ανάρτηση στην οποία προσπαθείς να απαντήσεις δεν φαίνεται να υπάρχει. in_reply_not_found: Η ανάρτηση στην οποία προσπαθείς να απαντήσεις δεν φαίνεται να υπάρχει.
quoted_status_not_found: Η ανάρτηση την οποία προσπαθείς να παραθέσεις δεν φαίνεται να υπάρχει. quoted_status_not_found: Η ανάρτηση την οποία προσπαθείς να παραθέσεις δεν φαίνεται να υπάρχει.
quoted_user_not_mentioned: Δεν είναι δυνατή η παράθεση ενός μη επισημασμένου χρήστη σε μια ανάρτηση Ιδιωτικής επισήμανσης.
over_character_limit: υπέρβαση μέγιστου ορίου %{max} χαρακτήρων over_character_limit: υπέρβαση μέγιστου ορίου %{max} χαρακτήρων
pin_errors: pin_errors:
direct: Αναρτήσεις που είναι ορατές μόνο στους αναφερόμενους χρήστες δεν μπορούν να καρφιτσωθούν direct: Αναρτήσεις που είναι ορατές μόνο στους αναφερόμενους χρήστες δεν μπορούν να καρφιτσωθούν

View File

@ -1929,6 +1929,7 @@ es-AR:
errors: errors:
in_reply_not_found: El mensaje al que intentás responder no existe. in_reply_not_found: El mensaje al que intentás responder no existe.
quoted_status_not_found: El mensaje al que intentás citar parece que no existe. quoted_status_not_found: El mensaje al que intentás citar parece que no existe.
quoted_user_not_mentioned: No se puede citar a un usuario no mencionado en un mensaje de mención privada.
over_character_limit: se excedió el límite de %{max} caracteres over_character_limit: se excedió el límite de %{max} caracteres
pin_errors: pin_errors:
direct: Los mensajes que sólo son visibles para los usuarios mencionados no pueden ser fijados direct: Los mensajes que sólo son visibles para los usuarios mencionados no pueden ser fijados

View File

@ -1929,6 +1929,7 @@ fi:
errors: errors:
in_reply_not_found: Julkaisua, johon yrität vastata, ei näytä olevan olemassa. in_reply_not_found: Julkaisua, johon yrität vastata, ei näytä olevan olemassa.
quoted_status_not_found: Julkaisua, jota yrität lainata, ei näytä olevan olemassa. quoted_status_not_found: Julkaisua, jota yrität lainata, ei näytä olevan olemassa.
quoted_user_not_mentioned: Mainitsematonta käyttäjää ei voi lainata yksityismaininnassa.
over_character_limit: merkkimäärän rajoitus %{max} ylitetty over_character_limit: merkkimäärän rajoitus %{max} ylitetty
pin_errors: pin_errors:
direct: Vain mainituille käyttäjille näkyviä julkaisuja ei voi kiinnittää direct: Vain mainituille käyttäjille näkyviä julkaisuja ei voi kiinnittää

View File

@ -2015,6 +2015,7 @@ he:
errors: errors:
in_reply_not_found: נראה שההודעה שנסית להגיב לה לא קיימת. in_reply_not_found: נראה שההודעה שנסית להגיב לה לא קיימת.
quoted_status_not_found: נראה שההודעה שנסית לצטט לא קיימת. quoted_status_not_found: נראה שההודעה שנסית לצטט לא קיימת.
quoted_user_not_mentioned: לא ניתן לצטט משתמש שאיננו מאוזכר בהודעה פרטית.
over_character_limit: חריגה מגבול התווים של %{max} over_character_limit: חריגה מגבול התווים של %{max}
pin_errors: pin_errors:
direct: לא ניתן לקבע הודעות שנראותן מוגבלת למכותבים בלבד direct: לא ניתן לקבע הודעות שנראותן מוגבלת למכותבים בלבד

View File

@ -1933,6 +1933,7 @@ is:
errors: errors:
in_reply_not_found: Færslan sem þú ert að reyna að svara að er líklega ekki til. in_reply_not_found: Færslan sem þú ert að reyna að svara að er líklega ekki til.
quoted_status_not_found: Færslan sem þú ert að reyna að vitna í virðist ekki vera til. quoted_status_not_found: Færslan sem þú ert að reyna að vitna í virðist ekki vera til.
quoted_user_not_mentioned: Ekki er hægt að vitna í aðila sem ekki er minnst á í einkaspjalli.
over_character_limit: hámarksfjölda stafa (%{max}) náð over_character_limit: hámarksfjölda stafa (%{max}) náð
pin_errors: pin_errors:
direct: Ekki er hægt að festa færslur sem einungis eru sýnilegar þeim notendum sem minnst er á direct: Ekki er hægt að festa færslur sem einungis eru sýnilegar þeim notendum sem minnst er á

View File

@ -1929,6 +1929,7 @@ nl:
errors: errors:
in_reply_not_found: Het bericht waarop je probeert te reageren lijkt niet te bestaan. in_reply_not_found: Het bericht waarop je probeert te reageren lijkt niet te bestaan.
quoted_status_not_found: Het bericht die je probeert te citeren lijkt niet te bestaan. quoted_status_not_found: Het bericht die je probeert te citeren lijkt niet te bestaan.
quoted_user_not_mentioned: Een niet-vermelde gebruiker kan niet in een privébericht worden geciteerd.
over_character_limit: Limiet van %{max} tekens overschreden over_character_limit: Limiet van %{max} tekens overschreden
pin_errors: pin_errors:
direct: Berichten die alleen zichtbaar zijn voor vermelde gebruikers, kunnen niet worden vastgezet direct: Berichten die alleen zichtbaar zijn voor vermelde gebruikers, kunnen niet worden vastgezet

View File

@ -93,6 +93,7 @@ cy:
content_cache_retention_period: Bydd yr holl bostiadau gan weinyddion eraill (gan gynnwys hwb ac atebion) yn cael eu dileu ar ôl y nifer penodedig o ddyddiau, heb ystyried unrhyw ryngweithio defnyddiwr lleol â'r postiadau hynny. Mae hyn yn cynnwys postiadau lle mae defnyddiwr lleol wedi ei farcio fel nodau tudalen neu ffefrynnau. Bydd cyfeiriadau preifat rhwng defnyddwyr o wahanol achosion hefyd yn cael eu colli ac yn amhosibl eu hadfer. Mae'r defnydd o'r gosodiad hwn wedi'i fwriadu ar gyfer achosion pwrpas arbennig ac mae'n torri llawer o ddisgwyliadau defnyddwyr pan gaiff ei weithredu at ddibenion cyffredinol. content_cache_retention_period: Bydd yr holl bostiadau gan weinyddion eraill (gan gynnwys hwb ac atebion) yn cael eu dileu ar ôl y nifer penodedig o ddyddiau, heb ystyried unrhyw ryngweithio defnyddiwr lleol â'r postiadau hynny. Mae hyn yn cynnwys postiadau lle mae defnyddiwr lleol wedi ei farcio fel nodau tudalen neu ffefrynnau. Bydd cyfeiriadau preifat rhwng defnyddwyr o wahanol achosion hefyd yn cael eu colli ac yn amhosibl eu hadfer. Mae'r defnydd o'r gosodiad hwn wedi'i fwriadu ar gyfer achosion pwrpas arbennig ac mae'n torri llawer o ddisgwyliadau defnyddwyr pan gaiff ei weithredu at ddibenion cyffredinol.
custom_css: Gallwch gymhwyso arddulliau cyfaddas ar fersiwn gwe Mastodon. custom_css: Gallwch gymhwyso arddulliau cyfaddas ar fersiwn gwe Mastodon.
favicon: WEBP, PNG, GIF neu JPG. Yn diystyru'r favicon Mastodon rhagosodedig gydag eicon cyfaddas. favicon: WEBP, PNG, GIF neu JPG. Yn diystyru'r favicon Mastodon rhagosodedig gydag eicon cyfaddas.
landing_page: Yn dewis pa dudalen y mae ymwelwyr newydd yn ei gweld pan fyddan nhw'n cyrraedd eich gweinydd am y tro cyntaf. Os dewiswch "Trendio", yna mae angen galluogi tueddiadau yn y Gosodiadau Darganfod. Os dewiswch "Ffrydiau lleol", yna mae angen gosod "Mynediad i ffrydiau byw sy'n cynnwys postiadau lleol" i "Pawb" yn y Gosodiadau Darganfod.
mascot: Yn diystyru'r darlun yn y rhyngwyneb gwe uwch. mascot: Yn diystyru'r darlun yn y rhyngwyneb gwe uwch.
media_cache_retention_period: Mae ffeiliau cyfryngau o bostiadau a wneir gan ddefnyddwyr o bell yn cael eu storio ar eich gweinydd. Pan gaiff ei osod i werth positif, bydd y cyfryngau yn cael eu dileu ar ôl y nifer penodedig o ddyddiau. Os gofynnir am y data cyfryngau ar ôl iddo gael ei ddileu, caiff ei ail-lwytho i lawr, os yw'r cynnwys ffynhonnell yn dal i fod ar gael. Oherwydd cyfyngiadau ar ba mor aml y mae cardiau rhagolwg cyswllt yn pleidleisio i wefannau trydydd parti, argymhellir gosod y gwerth hwn i o leiaf 14 diwrnod, neu ni fydd cardiau rhagolwg cyswllt yn cael eu diweddaru ar alw cyn yr amser hwnnw. media_cache_retention_period: Mae ffeiliau cyfryngau o bostiadau a wneir gan ddefnyddwyr o bell yn cael eu storio ar eich gweinydd. Pan gaiff ei osod i werth positif, bydd y cyfryngau yn cael eu dileu ar ôl y nifer penodedig o ddyddiau. Os gofynnir am y data cyfryngau ar ôl iddo gael ei ddileu, caiff ei ail-lwytho i lawr, os yw'r cynnwys ffynhonnell yn dal i fod ar gael. Oherwydd cyfyngiadau ar ba mor aml y mae cardiau rhagolwg cyswllt yn pleidleisio i wefannau trydydd parti, argymhellir gosod y gwerth hwn i o leiaf 14 diwrnod, neu ni fydd cardiau rhagolwg cyswllt yn cael eu diweddaru ar alw cyn yr amser hwnnw.
min_age: Mae gofyn i ddefnyddwyr gadarnhau eu dyddiad geni wrth gofrestru min_age: Mae gofyn i ddefnyddwyr gadarnhau eu dyddiad geni wrth gofrestru
@ -290,6 +291,7 @@ cy:
content_cache_retention_period: Cyfnod cadw cynnwys o bell content_cache_retention_period: Cyfnod cadw cynnwys o bell
custom_css: CSS cyfaddas custom_css: CSS cyfaddas
favicon: Favicon favicon: Favicon
landing_page: Tudalen cychwyn ar gyfer ymwelwyr newydd
local_live_feed_access: Mynediad i ffrydiau byw sy'n cynnwys postiadau lleol local_live_feed_access: Mynediad i ffrydiau byw sy'n cynnwys postiadau lleol
local_topic_feed_access: Mynediad i ffrydiau hashnod a dolenni sy'n cynnwys postiadau lleol local_topic_feed_access: Mynediad i ffrydiau hashnod a dolenni sy'n cynnwys postiadau lleol
mascot: Mascot cyfaddas (hen) mascot: Mascot cyfaddas (hen)

View File

@ -79,7 +79,7 @@ en:
featured_tag: featured_tag:
name: 'Here are some of the hashtags you used the most recently:' name: 'Here are some of the hashtags you used the most recently:'
filters: filters:
action: Chose which action to perform when a post matches the filter action: Choose which action to perform when a post matches the filter
actions: actions:
blur: Hide media behind a warning, without hiding the text itself blur: Hide media behind a warning, without hiding the text itself
hide: Completely hide the filtered content, behaving as if it did not exist hide: Completely hide the filtered content, behaving as if it did not exist

View File

@ -1914,6 +1914,7 @@ sq:
errors: errors:
in_reply_not_found: Gjendja të cilës po provoni ti përgjigjeni sduket se ekziston. in_reply_not_found: Gjendja të cilës po provoni ti përgjigjeni sduket se ekziston.
quoted_status_not_found: Postimi që po rrekeni të citoni nuk duket se ekziston. quoted_status_not_found: Postimi që po rrekeni të citoni nuk duket se ekziston.
quoted_user_not_mentioned: Smund të citohet një përdorues që sështë përmendur në një postim Përmendje Private.
over_character_limit: u tejkalua kufi shenjash prej %{max} over_character_limit: u tejkalua kufi shenjash prej %{max}
pin_errors: pin_errors:
direct: Postimet që janë të dukshme vetëm për përdoruesit e përmendur smund të fiksohen direct: Postimet që janë të dukshme vetëm për përdoruesit e përmendur smund të fiksohen

View File

@ -1886,6 +1886,7 @@ vi:
errors: errors:
in_reply_not_found: Bạn đang trả lời một tút không còn tồn tại. in_reply_not_found: Bạn đang trả lời một tút không còn tồn tại.
quoted_status_not_found: Bạn đang trích dẫn một tút không còn tồn tại. quoted_status_not_found: Bạn đang trích dẫn một tút không còn tồn tại.
quoted_user_not_mentioned: Không thể trích dẫn người dùng không được nhắc đến trong tút Nhắn riêng.
over_character_limit: vượt quá giới hạn %{max} ký tự over_character_limit: vượt quá giới hạn %{max} ký tự
pin_errors: pin_errors:
direct: Không thể ghim những tút nhắn riêng direct: Không thể ghim những tút nhắn riêng

View File

@ -1888,6 +1888,7 @@ zh-TW:
errors: errors:
in_reply_not_found: 您嘗試回覆之嘟文似乎不存在。 in_reply_not_found: 您嘗試回覆之嘟文似乎不存在。
quoted_status_not_found: 您嘗試引用之嘟文似乎不存在。 quoted_status_not_found: 您嘗試引用之嘟文似乎不存在。
quoted_user_not_mentioned: 無法於私訊嘟文中引用無提及之使用者。
over_character_limit: 已超過 %{max} 字的限制 over_character_limit: 已超過 %{max} 字的限制
pin_errors: pin_errors:
direct: 無法釘選只有僅提及使用者可見之嘟文 direct: 無法釘選只有僅提及使用者可見之嘟文

View File

@ -48,7 +48,7 @@
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1", "@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2", "@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.502", "@rails/ujs": "7.1.600",
"@react-spring/web": "^9.7.5", "@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
@ -103,7 +103,6 @@
"react-router-dom": "^5.3.4", "react-router-dom": "^5.3.4",
"react-select": "^5.7.3", "react-select": "^5.7.3",
"react-sparklines": "^1.7.0", "react-sparklines": "^1.7.0",
"react-swipeable-views": "^0.14.0",
"react-textarea-autosize": "^8.4.1", "react-textarea-autosize": "^8.4.1",
"react-toggle": "^4.1.3", "react-toggle": "^4.1.3",
"redux-immutable": "^4.0.0", "redux-immutable": "^4.0.0",
@ -135,10 +134,10 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.23.0", "@eslint/js": "^9.23.0",
"@formatjs/cli": "^6.1.1", "@formatjs/cli": "^6.1.1",
"@storybook/addon-a11y": "^9.1.1", "@storybook/addon-a11y": "^10.0.2",
"@storybook/addon-docs": "^9.1.1", "@storybook/addon-docs": "^10.0.2",
"@storybook/addon-vitest": "^9.1.1", "@storybook/addon-vitest": "^10.0.2",
"@storybook/react-vite": "^9.1.1", "@storybook/react-vite": "^10.0.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/debug": "^4", "@types/debug": "^4",
@ -160,14 +159,14 @@
"@types/react-router": "^5.1.20", "@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-sparklines": "^1.7.2", "@types/react-sparklines": "^1.7.2",
"@types/react-swipeable-views": "^0.13.1",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/redux-immutable": "^4.0.3", "@types/redux-immutable": "^4.0.3",
"@types/requestidlecallback": "^0.3.5", "@types/requestidlecallback": "^0.3.5",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^4.0.5",
"@vitest/coverage-v8": "^3.2.4", "@vitest/browser-playwright": "^4.0.5",
"@vitest/ui": "^3.2.4", "@vitest/coverage-v8": "^4.0.5",
"@vitest/ui": "^4.0.5",
"chromatic": "^13.1.3", "chromatic": "^13.1.3",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-import-resolver-typescript": "^4.2.5", "eslint-import-resolver-typescript": "^4.2.5",
@ -178,24 +177,24 @@
"eslint-plugin-promise": "~7.2.1", "eslint-plugin-promise": "~7.2.1",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-storybook": "^9.0.4", "eslint-plugin-storybook": "^10.0.2",
"fake-indexeddb": "^6.0.1", "fake-indexeddb": "^6.0.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^16.0.0", "lint-staged": "^16.2.6",
"msw": "^2.10.2", "msw": "^2.10.2",
"msw-storybook-addon": "^2.0.5", "msw-storybook-addon": "^2.0.5",
"playwright": "^1.56.1", "playwright": "^1.56.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"storybook": "^9.1.1", "storybook": "^10.0.2",
"stylelint": "^16.19.1", "stylelint": "^16.19.1",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0", "stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"typescript-plugin-css-modules": "^5.2.0", "typescript-plugin-css-modules": "^5.2.0",
"vitest": "^3.2.4" "vitest": "^4.0.5"
}, },
"resolutions": { "resolutions": {
"@types/react": "^18.2.7", "@types/react": "^18.2.7",

View File

@ -24,8 +24,8 @@
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"pg": "^8.5.0", "pg": "^8.5.0",
"pg-connection-string": "^2.6.0", "pg-connection-string": "^2.6.0",
"pino": "^9.0.0", "pino": "^10.0.0",
"pino-http": "^10.0.0", "pino-http": "^11.0.0",
"prom-client": "^15.0.0", "prom-client": "^15.0.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"ws": "^8.12.1" "ws": "^8.12.1"

View File

@ -1,6 +1,7 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { import {
configDefaults, configDefaults,
defineConfig, defineConfig,
@ -23,7 +24,7 @@ const storybookTests: TestProjectInlineConfiguration = {
browser: { browser: {
enabled: true, enabled: true,
headless: true, headless: true,
provider: 'playwright', provider: playwright(),
instances: [{ browser: 'chromium' }], instances: [{ browser: 'chromium' }],
}, },
setupFiles: [resolve(__dirname, '.storybook/vitest.setup.ts')], setupFiles: [resolve(__dirname, '.storybook/vitest.setup.ts')],

1268
yarn.lock

File diff suppressed because it is too large Load Diff