mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-12 23:38:20 +00:00
Merge commit '26e7fe97714d077930621f9111b7eaad2774df65' into glitch-soc/merge-upstream
This commit is contained in:
2
.github/actions/setup-javascript/action.yml
vendored
2
.github/actions/setup-javascript/action.yml
vendored
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
|
||||
8
.github/workflows/build-container-image.yml
vendored
8
.github/workflows/build-container-image.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- linux/arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare
|
||||
env:
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
- name: Upload digest
|
||||
if: ${{ inputs.push_to_images != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
# `hashFiles` is used to disambiguate between streaming and non-streaming images
|
||||
name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }}
|
||||
@@ -119,10 +119,10 @@ jobs:
|
||||
PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
# `hashFiles` is used to disambiguate between streaming and non-streaming images
|
||||
|
||||
2
.github/workflows/build-push-pr.yml
vendored
2
.github/workflows/build-push-pr.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
# Repository needs to be cloned so `git rev-parse` below works
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- id: version_vars
|
||||
run: |
|
||||
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT
|
||||
|
||||
2
.github/workflows/bundler-audit.yml
vendored
2
.github/workflows/bundler-audit.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
|
||||
2
.github/workflows/check-i18n.yml
vendored
2
.github/workflows/check-i18n.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Javascript environment
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run: yarn build-storybook
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@v12
|
||||
uses: chromaui/action@v13
|
||||
with:
|
||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -31,11 +31,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -61,6 +61,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
|
||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
|
||||
2
.github/workflows/crowdin-upload.yml
vendored
2
.github/workflows/crowdin-upload.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
2
.github/workflows/format-check.yml
vendored
2
.github/workflows/format-check.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
2
.github/workflows/lint-haml.yml
vendored
2
.github/workflows/lint-haml.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
|
||||
2
.github/workflows/lint-js.yml
vendored
2
.github/workflows/lint-js.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
2
.github/workflows/lint-ruby.yml
vendored
2
.github/workflows/lint-ruby.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
|
||||
2
.github/workflows/test-js.yml
vendored
2
.github/workflows/test-js.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
BUNDLE_RETRY: 3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
28
.github/workflows/test-ruby.yml
vendored
28
.github/workflows/test-ruby.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
SECRET_KEY_BASE_DUMMY: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
run: |
|
||||
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: matrix.mode == 'test'
|
||||
with:
|
||||
path: |-
|
||||
@@ -128,9 +128,9 @@ jobs:
|
||||
- '3.3'
|
||||
- '.ruby-version'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
@@ -230,9 +230,9 @@ jobs:
|
||||
- '3.3'
|
||||
- '.ruby-version'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
@@ -309,9 +309,9 @@ jobs:
|
||||
- '.ruby-version'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
@@ -350,14 +350,14 @@ jobs:
|
||||
- run: bin/rspec spec/system --tag streaming --tag js
|
||||
|
||||
- name: Archive logs
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-logs-${{ matrix.ruby-version }}
|
||||
path: log/
|
||||
|
||||
- name: Archive test screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-screenshots-${{ matrix.ruby-version }}
|
||||
@@ -447,9 +447,9 @@ jobs:
|
||||
search-image: opensearchproject/opensearch:2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
@@ -469,14 +469,14 @@ jobs:
|
||||
- run: bin/rspec --tag search
|
||||
|
||||
- name: Archive logs
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: test-search-logs-${{ matrix.ruby-version }}
|
||||
path: log/
|
||||
|
||||
- name: Archive test screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: test-search-screenshots
|
||||
|
||||
@@ -31,7 +31,7 @@ const config: StorybookConfig = {
|
||||
viteFinal(config) {
|
||||
// For an unknown reason, Storybook does not use the root
|
||||
// from the Vite config so we need to set it manually.
|
||||
config.root = resolve(__dirname, '../app/javascript');
|
||||
config.root = resolve(import.meta.dirname, '../app/javascript');
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -114,7 +114,7 @@ group :opentelemetry do
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.33.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||
|
||||
20
Gemfile.lock
20
Gemfile.lock
@@ -128,7 +128,7 @@ GEM
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.1)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
builder (3.3.0)
|
||||
@@ -512,9 +512,9 @@ GEM
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.10)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.2.0)
|
||||
opentelemetry-helpers-sql (0.3.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-obfuscation (0.4.0)
|
||||
opentelemetry-helpers-sql-obfuscation (0.5.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
@@ -548,7 +548,7 @@ GEM
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-net_http (0.26.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-pg (0.32.0)
|
||||
opentelemetry-instrumentation-pg (0.33.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
@@ -621,7 +621,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
@@ -803,9 +803,9 @@ GEM
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.8)
|
||||
shoulda-matchers (7.0.1)
|
||||
activesupport (>= 7.1)
|
||||
sidekiq (8.0.9)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
@@ -883,7 +883,7 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
@@ -1018,7 +1018,7 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.32.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.33.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||
|
||||
@@ -56,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
|
||||
@@ -796,13 +795,6 @@ export function changeComposeSpoilerText(text) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
import type { Status, StatusVisibility } from '../models/status';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { changeCompose, focusCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
quoteErrorPrivateMention: {
|
||||
id: 'quote_error.private_mentions',
|
||||
defaultMessage: 'Quoting is not allowed with direct mentions.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
@@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeComposeVisibility = createAppThunk(
|
||||
'compose/visibility_change',
|
||||
(visibility: StatusVisibility, { dispatch, getState }) => {
|
||||
if (visibility !== 'direct') {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const quotedStatusId = state.compose.get('quoted_status_id') as
|
||||
| string
|
||||
| null;
|
||||
if (!quotedStatusId) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Remove the quoted status
|
||||
dispatch(quoteComposeCancel());
|
||||
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
|
||||
if (!quotedStatus) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Append the quoted status URL to the compose text
|
||||
const url = quotedStatus.get('url') as string;
|
||||
const text = state.compose.get('text') as string;
|
||||
if (!text.includes(url)) {
|
||||
const newText = text.trim() ? `${text}\n\n${url}` : url;
|
||||
dispatch(changeCompose(newText));
|
||||
}
|
||||
return visibility;
|
||||
},
|
||||
);
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('privacy') === 'direct') {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
@@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const composeStateForbidsLink = (composeState: RootState['compose']) => {
|
||||
return (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
);
|
||||
};
|
||||
|
||||
export const pasteLinkCompose = createDataLoadingThunk(
|
||||
'compose/pasteLink',
|
||||
async ({ url }: { url: string }) => {
|
||||
@@ -183,16 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
limit: 2,
|
||||
});
|
||||
},
|
||||
(data, { dispatch, getState }) => {
|
||||
(data, { dispatch, getState, requestId }) => {
|
||||
const composeState = getState().compose;
|
||||
|
||||
if (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
composeStateForbidsLink(composeState) ||
|
||||
composeState.get('fetching_link') !== requestId // Request has been cancelled
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -208,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
dispatch(quoteComposeById(data.statuses[0].id));
|
||||
}
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
condition: (_, { getState }) =>
|
||||
!getState().compose.get('fetching_link') &&
|
||||
!composeStateForbidsLink(getState().compose),
|
||||
},
|
||||
);
|
||||
|
||||
// Ideally this would cancel the action and the HTTP request, but this is good enough
|
||||
export const cancelPasteLinkCompose = createAction(
|
||||
'compose/cancelPasteLinkCompose',
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
@@ -10,7 +10,7 @@ import ModalRoot from 'mastodon/components/modal_root';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import Card from 'mastodon/features/status/components/card';
|
||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
import { MediaModal } from 'mastodon/features/ui/components/media_modal';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { IntlProvider } from 'mastodon/locales';
|
||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
|
||||
@@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct');
|
||||
this.props.onSubmit({
|
||||
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
|
||||
quoteToPrivate: this.props.quoteToPrivate,
|
||||
});
|
||||
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
|
||||
import { QuotedStatus } from '@/mastodon/components/status_quoted';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { QuotePlaceholder } from './quote_placeholder';
|
||||
|
||||
export const ComposeQuotedStatus: FC = () => {
|
||||
const quotedStatusId = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') as string | null,
|
||||
);
|
||||
|
||||
const isFetchingLink = useAppSelector(
|
||||
(state) => !!state.compose.get('fetching_link'),
|
||||
);
|
||||
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const quote = useMemo(
|
||||
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
|
||||
dispatch(quoteComposeCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!quote) {
|
||||
if (isFetchingLink && !quote) {
|
||||
return <QuotePlaceholder />;
|
||||
} else if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { changeComposeVisibility } from '@/mastodon/actions/compose';
|
||||
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
setComposeQuotePolicy,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'mastodon/actions/compose';
|
||||
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify';
|
||||
|
||||
import ComposeForm from '../components/compose_form';
|
||||
|
||||
@@ -32,6 +33,10 @@ const mapStateToProps = state => ({
|
||||
isUploading: state.getIn(['compose', 'is_uploading']),
|
||||
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
|
||||
quoteToPrivate:
|
||||
!!state.getIn(['compose', 'quoted_status_id'])
|
||||
&& state.getIn(['compose', 'privacy']) === 'private'
|
||||
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
|
||||
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
||||
lang: state.getIn(['compose', 'language']),
|
||||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
@@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit (missingAltText) {
|
||||
onSubmit ({ missingAltText, quoteToPrivate }) {
|
||||
if (missingAltText) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_MISSING_ALT_TEXT',
|
||||
modalProps: {},
|
||||
}));
|
||||
} else if (quoteToPrivate) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
|
||||
modalProps: {},
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose((status) => {
|
||||
if (props.redirectOnSuccess) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposeVisibility } from '../../../actions/compose';
|
||||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import { isUserTouching } from '../../../is_mobile';
|
||||
import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
|
||||
|
||||
import PrivacyDropdown from '../components/privacy_dropdown';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
||||
@@ -299,6 +299,12 @@ class Status extends ImmutablePureComponent {
|
||||
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
|
||||
};
|
||||
|
||||
handleQuote = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
};
|
||||
|
||||
handleEditClick = (status) => {
|
||||
const { dispatch, askReplyConfirmation } = this.props;
|
||||
|
||||
@@ -592,7 +598,7 @@ class Status extends ImmutablePureComponent {
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<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}
|
||||
onRevokeQuote={this.handleRevokeQuoteClick}
|
||||
onQuotePolicyChange={this.handleQuotePolicyChange}
|
||||
onQuote={this.handleQuote}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMention={this.handleMentionClick}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC<
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
closeWhenConfirm?: boolean;
|
||||
extraContent?: React.ReactNode;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
@@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC<
|
||||
secondary,
|
||||
onSecondary,
|
||||
closeWhenConfirm = true,
|
||||
extraContent,
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (closeWhenConfirm) {
|
||||
@@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='safety-action-modal__confirmation'>
|
||||
<h1>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,7 @@
|
||||
.checkbox_wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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);
|
||||
363
app/javascript/mastodon/features/ui/components/media_modal.tsx
Normal file
363
app/javascript/mastodon/features/ui/components/media_modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -43,10 +43,11 @@ import {
|
||||
QuietPostQuoteInfoModal,
|
||||
} from './confirmation_modals';
|
||||
import { ImageModal } from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import { MediaModal } from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
import { VisibilityModal } from './visibility_modal';
|
||||
import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify';
|
||||
|
||||
export const MODAL_COMPONENTS = {
|
||||
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
||||
@@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = {
|
||||
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
|
||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
||||
'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
|
||||
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
||||
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
|
||||
'MUTE': MuteModal,
|
||||
|
||||
@@ -128,9 +128,12 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
const disableVisibility = !!statusId;
|
||||
const disableQuotePolicy =
|
||||
visibility === 'private' || visibility === 'direct';
|
||||
const disablePublicVisibilities: boolean = useAppSelector(
|
||||
const disablePublicVisibilities = useAppSelector(
|
||||
selectDisablePublicVisibilities,
|
||||
);
|
||||
const isQuotePost = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') !== null,
|
||||
);
|
||||
|
||||
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(() => {
|
||||
const items: SelectItem<StatusVisibility>[] = [
|
||||
@@ -315,6 +318,21 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
id={quoteDescriptionId}
|
||||
/>
|
||||
</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 className='dialog-modal__content__actions'>
|
||||
<Button onClick={onClose} secondary>
|
||||
|
||||
@@ -35,7 +35,7 @@ interface InitialStateMeta {
|
||||
streaming_api_base_url: string;
|
||||
local_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';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
"bundle_modal_error.close": "Cau",
|
||||
"bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.",
|
||||
"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_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",
|
||||
@@ -173,6 +175,8 @@
|
||||
"column.edit_list": "Golygu rhestr",
|
||||
"column.favourites": "Ffefrynnau",
|
||||
"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.home": "Cartref",
|
||||
"column.list_members": "Rheoli aelodau rhestr",
|
||||
@@ -192,6 +196,7 @@
|
||||
"community.column_settings.local_only": "Lleol yn unig",
|
||||
"community.column_settings.media_only": "Cyfryngau 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.search": "Chwilio ieithoedd...",
|
||||
"compose.published.body": "Postiad wedi ei gyhoeddi.",
|
||||
@@ -358,7 +363,9 @@
|
||||
"explore.trending_links": "Newyddion",
|
||||
"explore.trending_statuses": "Postiadau",
|
||||
"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.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_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.",
|
||||
@@ -401,6 +408,7 @@
|
||||
"follow_suggestions.who_to_follow": "Pwy i ddilyn",
|
||||
"followed_tags": "Hashnodau rydych yn eu dilyn",
|
||||
"footer.about": "Ynghylch",
|
||||
"footer.about_this_server": "Ynghylch",
|
||||
"footer.directory": "Cyfeiriadur proffiliau",
|
||||
"footer.get_app": "Llwytho'r ap i lawr",
|
||||
"footer.keyboard_shortcuts": "Bysellau brys",
|
||||
@@ -905,9 +913,12 @@
|
||||
"status.pin": "Pinio ar y proffil",
|
||||
"status.quote": "Dyfynnu",
|
||||
"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.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.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.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.",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"directory.recently_active": "Aktive for nyligt",
|
||||
"disabled_account_banner.account_settings": "Kontoindstillinger",
|
||||
"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.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",
|
||||
|
||||
@@ -249,6 +249,11 @@
|
||||
"confirmations.missing_alt_text.secondary": "Post anyway",
|
||||
"confirmations.missing_alt_text.title": "Add alt text?",
|
||||
"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.got_it": "Got it",
|
||||
"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",
|
||||
"quote_error.edit": "Quotes cannot be added when editing a post.",
|
||||
"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.unauthorized": "You are not authorized to quote this post.",
|
||||
"quote_error.upload": "Quoting is not allowed with media attachments.",
|
||||
@@ -1013,6 +1019,8 @@
|
||||
"video.volume_down": "Volume down",
|
||||
"video.volume_up": "Volume up",
|
||||
"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.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.",
|
||||
|
||||
@@ -743,7 +743,7 @@
|
||||
"poll.votes": "{votes, plural, one {# stem} other {# stemmen}}",
|
||||
"poll_button.add_poll": "Peiling toevoegen",
|
||||
"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.short": "Privébericht",
|
||||
"privacy.private.long": "Alleen jouw volgers",
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"community.column_settings.local_only": "Vetëm vendore",
|
||||
"community.column_settings.media_only": "Vetëm Media",
|
||||
"community.column_settings.remote_only": "Vetëm të largëta",
|
||||
"compose.error.blank_post": "Postimi s’mund të jetë i zbrazët.",
|
||||
"compose.language.change": "Ndryshoni gjuhën",
|
||||
"compose.language.search": "Kërkoni te gjuhët…",
|
||||
"compose.published.body": "Postimi u botua.",
|
||||
@@ -403,6 +404,7 @@
|
||||
"follow_suggestions.who_to_follow": "Cilët të ndiqen",
|
||||
"followed_tags": "Hashtag-ë të ndjekur",
|
||||
"footer.about": "Mbi",
|
||||
"footer.about_this_server": "Mbi",
|
||||
"footer.directory": "Drejtori profilesh",
|
||||
"footer.get_app": "Merreni aplikacionin",
|
||||
"footer.keyboard_shortcuts": "Shkurtore tastiere",
|
||||
|
||||
@@ -606,8 +606,8 @@
|
||||
"notification.annual_report.view": "檢視 #Wrapstodon",
|
||||
"notification.favourite": "{name} 已將您的嘟文加入最愛",
|
||||
"notification.favourite.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的嘟文加入最愛",
|
||||
"notification.favourite_pm": "{name} 將您的私人提及加入最愛",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私人提及加入最愛",
|
||||
"notification.favourite_pm": "{name} 將您的私訊加入最愛",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私訊加入最愛",
|
||||
"notification.follow": "{name} 已跟隨您",
|
||||
"notification.follow.name_and_others": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已跟隨您",
|
||||
"notification.follow_request": "{name} 要求跟隨您",
|
||||
@@ -1014,7 +1014,7 @@
|
||||
"video.volume_up": "提高音量",
|
||||
"visibility_modal.button_title": "設定可見性",
|
||||
"visibility_modal.header": "可見性與互動",
|
||||
"visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私人提及嘟文無法被其他使用者引用。",
|
||||
"visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私訊嘟文無法被其他使用者引用。",
|
||||
"visibility_modal.helper.privacy_editing": "嘟文發布後無法變更可見性。",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "自我引用之私嘟無法設為公開可見。",
|
||||
"visibility_modal.helper.private_quoting": "Mastodon 上發佈之僅限跟隨者嘟文無法被其他使用者引用。",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
changeUploadCompose,
|
||||
quoteCompose,
|
||||
quoteComposeCancel,
|
||||
setComposeQuotePolicy,
|
||||
} from 'mastodon/actions/compose_typed';
|
||||
pasteLinkCompose,
|
||||
cancelPasteLinkCompose,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
||||
|
||||
import {
|
||||
@@ -38,7 +41,6 @@ import {
|
||||
COMPOSE_SENSITIVITY_CHANGE,
|
||||
COMPOSE_SPOILERNESS_CHANGE,
|
||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||
COMPOSE_VISIBILITY_CHANGE,
|
||||
COMPOSE_LANGUAGE_CHANGE,
|
||||
COMPOSE_COMPOSING_CHANGE,
|
||||
COMPOSE_EMOJI_INSERT,
|
||||
@@ -93,6 +95,7 @@ const initialState = ImmutableMap({
|
||||
quoted_status_id: null,
|
||||
quote_policy: 'public',
|
||||
default_quote_policy: 'public', // Set in hydration.
|
||||
fetching_link: null,
|
||||
});
|
||||
|
||||
const initialPoll = ImmutableMap({
|
||||
@@ -315,7 +318,11 @@ const calculateProgress = (loaded, total) => Math.min(Math.round((loaded / total
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
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
|
||||
.set('is_changing_upload', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
@@ -331,15 +338,27 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state.set('is_changing_upload', false);
|
||||
} else if (quoteCompose.match(action)) {
|
||||
const status = action.payload;
|
||||
const isDirect = state.get('privacy') === 'direct';
|
||||
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_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)) {
|
||||
return state.set('quoted_status_id', null);
|
||||
} else if (setComposeQuotePolicy.match(action)) {
|
||||
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) {
|
||||
@@ -383,10 +402,6 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state
|
||||
.set('spoiler_text', action.text)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_VISIBILITY_CHANGE:
|
||||
return state
|
||||
.set('privacy', action.value)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_CHANGE:
|
||||
return state
|
||||
.set('text', action.text)
|
||||
|
||||
@@ -42,7 +42,7 @@ interface AppThunkConfig {
|
||||
}
|
||||
export type AppThunkApi = Pick<
|
||||
GetThunkAPI<AppThunkConfig>,
|
||||
'getState' | 'dispatch'
|
||||
'getState' | 'dispatch' | 'requestId'
|
||||
>;
|
||||
|
||||
interface AppThunkOptions<Arg> {
|
||||
@@ -60,7 +60,7 @@ type AppThunk<Arg = void, Returned = void> = (
|
||||
|
||||
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
|
||||
arg: Arg,
|
||||
api: AppThunkApi,
|
||||
api: Pick<AppThunkApi, 'getState' | 'dispatch'>,
|
||||
extra?: ExtraArg,
|
||||
) => Returned;
|
||||
|
||||
@@ -143,10 +143,10 @@ export function createAsyncThunk<Arg = void, Returned = void>(
|
||||
name,
|
||||
async (
|
||||
arg: Arg,
|
||||
{ getState, dispatch, fulfillWithValue, rejectWithValue },
|
||||
{ getState, dispatch, requestId, fulfillWithValue, rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const result = await creator(arg, { dispatch, getState });
|
||||
const result = await creator(arg, { dispatch, getState, requestId });
|
||||
|
||||
return fulfillWithValue(result, {
|
||||
useLoadingBar: options.useLoadingBar,
|
||||
@@ -280,10 +280,11 @@ export function createDataLoadingThunk<
|
||||
|
||||
return createAsyncThunk<Args, Returned>(
|
||||
name,
|
||||
async (arg, { getState, dispatch }) => {
|
||||
async (arg, { getState, dispatch, requestId }) => {
|
||||
const data = await loadData(arg, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (!onData) return data as Returned;
|
||||
@@ -291,6 +292,7 @@ export function createDataLoadingThunk<
|
||||
const result = await onData(data, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
discardLoadData: discardLoadDataInPayload,
|
||||
actionArg: arg,
|
||||
});
|
||||
|
||||
@@ -1325,6 +1325,10 @@ a.sparkline {
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
animation: skeleton 1.2s ease-in-out infinite;
|
||||
|
||||
.reduce-motion & {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
|
||||
@@ -2685,6 +2685,7 @@ a.account__display-name {
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&--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 {
|
||||
&__overlay[data-popper-placement] {
|
||||
z-index: 9999;
|
||||
@@ -6096,6 +6125,7 @@ a.status-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
|
||||
&__buttons {
|
||||
position: absolute;
|
||||
@@ -6131,11 +6161,17 @@ a.status-card {
|
||||
}
|
||||
|
||||
.media-modal__closer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
|
||||
> div {
|
||||
flex-shrink: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.media-modal__navigation {
|
||||
@@ -8936,6 +8972,10 @@ noscript {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-multiple-columns & {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: (124px + 300px)) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SignedRequest
|
||||
include DomainControlHelper
|
||||
|
||||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||
CLOCK_SKEW_MARGIN = 1.hour
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ class Form::AdminSettings
|
||||
DOMAIN_BLOCK_AUDIENCES = %w(disabled users all).freeze
|
||||
REGISTRATION_MODES = %w(open approved none).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
|
||||
|
||||
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 :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 :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 :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) }
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
.fields-row
|
||||
.fields-row__column.fields-row__column-6.fields-group
|
||||
= f.input :local_topic_feed_access,
|
||||
collection: f.object.class::FEED_ACCESS_MODES,
|
||||
collection: f.object.class::ALTERNATE_FEED_ACCESS_MODES,
|
||||
include_blank: false,
|
||||
label_method: ->(mode) { I18n.t("admin.settings.feed_access.modes.#{mode}") },
|
||||
wrapper: :with_label
|
||||
|
||||
@@ -5,7 +5,7 @@ class ActivityPub::RefetchAndVerifyQuoteWorker
|
||||
include ExponentialBackoff
|
||||
include JsonLdHelper
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 3
|
||||
sidekiq_options queue: 'pull', retry: 5
|
||||
|
||||
def perform(quote_id, quoted_uri, options = {})
|
||||
quote = Quote.find(quote_id)
|
||||
|
||||
@@ -2015,6 +2015,7 @@ be:
|
||||
errors:
|
||||
in_reply_not_found: Здаецца, допіс, на які вы спрабуеце адказаць, не існуе.
|
||||
quoted_status_not_found: Выглядае, што допісу, які Вы спрабуеце цытаваць, не існуе.
|
||||
quoted_user_not_mentioned: Немагчыма цытаваць незгаданага карыстальніка ў допісе прыватнага згадвання.
|
||||
over_character_limit: перавышаная колькасць сімвалаў у %{max}
|
||||
pin_errors:
|
||||
direct: Допісы, бачныя толькі згаданым карыстальнікам, нельга замацаваць
|
||||
|
||||
@@ -911,6 +911,11 @@ cy:
|
||||
authenticated: Defnyddwyr dilys yn unig
|
||||
disabled: Gofyn am rôl defnyddiwr penodol
|
||||
public: Pawb
|
||||
landing_page:
|
||||
values:
|
||||
about: Ynghylch
|
||||
local_feed: Ffrwd leol
|
||||
trends: Trendiau
|
||||
registrations:
|
||||
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.
|
||||
|
||||
@@ -1929,6 +1929,7 @@ da:
|
||||
errors:
|
||||
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_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
|
||||
pin_errors:
|
||||
direct: Indlæg, som kun kan ses af omtalte brugere, kan ikke fastgøres
|
||||
|
||||
@@ -1929,6 +1929,7 @@ de:
|
||||
errors:
|
||||
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_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
|
||||
pin_errors:
|
||||
direct: Beiträge, die nur für erwähnte Profile sichtbar sind, können nicht angeheftet werden
|
||||
|
||||
@@ -1929,6 +1929,7 @@ el:
|
||||
errors:
|
||||
in_reply_not_found: Η ανάρτηση στην οποία προσπαθείς να απαντήσεις δεν φαίνεται να υπάρχει.
|
||||
quoted_status_not_found: Η ανάρτηση την οποία προσπαθείς να παραθέσεις δεν φαίνεται να υπάρχει.
|
||||
quoted_user_not_mentioned: Δεν είναι δυνατή η παράθεση ενός μη επισημασμένου χρήστη σε μια ανάρτηση Ιδιωτικής επισήμανσης.
|
||||
over_character_limit: υπέρβαση μέγιστου ορίου %{max} χαρακτήρων
|
||||
pin_errors:
|
||||
direct: Αναρτήσεις που είναι ορατές μόνο στους αναφερόμενους χρήστες δεν μπορούν να καρφιτσωθούν
|
||||
|
||||
@@ -1929,6 +1929,7 @@ es-AR:
|
||||
errors:
|
||||
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_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
|
||||
pin_errors:
|
||||
direct: Los mensajes que sólo son visibles para los usuarios mencionados no pueden ser fijados
|
||||
|
||||
@@ -1929,6 +1929,7 @@ fi:
|
||||
errors:
|
||||
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_user_not_mentioned: Mainitsematonta käyttäjää ei voi lainata yksityismaininnassa.
|
||||
over_character_limit: merkkimäärän rajoitus %{max} ylitetty
|
||||
pin_errors:
|
||||
direct: Vain mainituille käyttäjille näkyviä julkaisuja ei voi kiinnittää
|
||||
|
||||
@@ -2015,6 +2015,7 @@ he:
|
||||
errors:
|
||||
in_reply_not_found: נראה שההודעה שנסית להגיב לה לא קיימת.
|
||||
quoted_status_not_found: נראה שההודעה שנסית לצטט לא קיימת.
|
||||
quoted_user_not_mentioned: לא ניתן לצטט משתמש שאיננו מאוזכר בהודעה פרטית.
|
||||
over_character_limit: חריגה מגבול התווים של %{max}
|
||||
pin_errors:
|
||||
direct: לא ניתן לקבע הודעות שנראותן מוגבלת למכותבים בלבד
|
||||
|
||||
@@ -1933,6 +1933,7 @@ is:
|
||||
errors:
|
||||
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_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áð
|
||||
pin_errors:
|
||||
direct: Ekki er hægt að festa færslur sem einungis eru sýnilegar þeim notendum sem minnst er á
|
||||
|
||||
@@ -1929,6 +1929,7 @@ nl:
|
||||
errors:
|
||||
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_user_not_mentioned: Een niet-vermelde gebruiker kan niet in een privébericht worden geciteerd.
|
||||
over_character_limit: Limiet van %{max} tekens overschreden
|
||||
pin_errors:
|
||||
direct: Berichten die alleen zichtbaar zijn voor vermelde gebruikers, kunnen niet worden vastgezet
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
@@ -290,6 +291,7 @@ cy:
|
||||
content_cache_retention_period: Cyfnod cadw cynnwys o bell
|
||||
custom_css: CSS cyfaddas
|
||||
favicon: Favicon
|
||||
landing_page: Tudalen cychwyn ar gyfer ymwelwyr newydd
|
||||
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
|
||||
mascot: Mascot cyfaddas (hen)
|
||||
|
||||
@@ -79,7 +79,7 @@ en:
|
||||
featured_tag:
|
||||
name: 'Here are some of the hashtags you used the most recently:'
|
||||
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:
|
||||
blur: Hide media behind a warning, without hiding the text itself
|
||||
hide: Completely hide the filtered content, behaving as if it did not exist
|
||||
|
||||
@@ -1914,6 +1914,7 @@ sq:
|
||||
errors:
|
||||
in_reply_not_found: Gjendja të cilës po provoni t’i përgjigjeni s’duket se ekziston.
|
||||
quoted_status_not_found: Postimi që po rrekeni të citoni nuk duket se ekziston.
|
||||
quoted_user_not_mentioned: S’mund 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}
|
||||
pin_errors:
|
||||
direct: Postimet që janë të dukshme vetëm për përdoruesit e përmendur s’mund të fiksohen
|
||||
|
||||
@@ -1886,6 +1886,7 @@ vi:
|
||||
errors:
|
||||
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_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ự
|
||||
pin_errors:
|
||||
direct: Không thể ghim những tút nhắn riêng
|
||||
|
||||
@@ -1888,6 +1888,7 @@ zh-TW:
|
||||
errors:
|
||||
in_reply_not_found: 您嘗試回覆之嘟文似乎不存在。
|
||||
quoted_status_not_found: 您嘗試引用之嘟文似乎不存在。
|
||||
quoted_user_not_mentioned: 無法於私訊嘟文中引用無提及之使用者。
|
||||
over_character_limit: 已超過 %{max} 字的限制
|
||||
pin_errors:
|
||||
direct: 無法釘選只有僅提及使用者可見之嘟文
|
||||
|
||||
27
package.json
27
package.json
@@ -48,7 +48,7 @@
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@optimize-lodash/rollup-plugin": "^5.0.2",
|
||||
"@rails/ujs": "7.1.502",
|
||||
"@rails/ujs": "7.1.600",
|
||||
"@react-spring/web": "^9.7.5",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
@@ -103,7 +103,6 @@
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-select": "^5.7.3",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-swipeable-views": "^0.14.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-toggle": "^4.1.3",
|
||||
"redux-immutable": "^4.0.0",
|
||||
@@ -135,10 +134,10 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@formatjs/cli": "^6.1.1",
|
||||
"@storybook/addon-a11y": "^9.1.1",
|
||||
"@storybook/addon-docs": "^9.1.1",
|
||||
"@storybook/addon-vitest": "^9.1.1",
|
||||
"@storybook/react-vite": "^9.1.1",
|
||||
"@storybook/addon-a11y": "^10.0.2",
|
||||
"@storybook/addon-docs": "^10.0.2",
|
||||
"@storybook/addon-vitest": "^10.0.2",
|
||||
"@storybook/react-vite": "^10.0.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/debug": "^4",
|
||||
@@ -160,14 +159,14 @@
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-sparklines": "^1.7.2",
|
||||
"@types/react-swipeable-views": "^0.13.1",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"@types/react-toggle": "^4.0.3",
|
||||
"@types/redux-immutable": "^4.0.3",
|
||||
"@types/requestidlecallback": "^0.3.5",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"@vitest/browser": "^4.0.5",
|
||||
"@vitest/browser-playwright": "^4.0.5",
|
||||
"@vitest/coverage-v8": "^4.0.5",
|
||||
"@vitest/ui": "^4.0.5",
|
||||
"chromatic": "^13.1.3",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-import-resolver-typescript": "^4.2.5",
|
||||
@@ -178,24 +177,24 @@
|
||||
"eslint-plugin-promise": "~7.2.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-storybook": "^9.0.4",
|
||||
"eslint-plugin-storybook": "^10.0.2",
|
||||
"fake-indexeddb": "^6.0.1",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^16.0.0",
|
||||
"lint-staged": "^16.2.6",
|
||||
"msw": "^2.10.2",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.3.3",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"storybook": "^9.1.1",
|
||||
"storybook": "^10.0.2",
|
||||
"stylelint": "^16.19.1",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"typescript-plugin-css-modules": "^5.2.0",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18.2.7",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"jsdom": "^27.0.0",
|
||||
"pg": "^8.5.0",
|
||||
"pg-connection-string": "^2.6.0",
|
||||
"pino": "^9.0.0",
|
||||
"pino-http": "^10.0.0",
|
||||
"pino": "^10.0.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"prom-client": "^15.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.12.1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import {
|
||||
configDefaults,
|
||||
defineConfig,
|
||||
@@ -23,7 +24,7 @@ const storybookTests: TestProjectInlineConfiguration = {
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: 'playwright',
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
setupFiles: [resolve(__dirname, '.storybook/vitest.setup.ts')],
|
||||
|
||||
Reference in New Issue
Block a user