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

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

View File

@@ -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'

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }}

View File

@@ -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}}'

View File

@@ -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?

View File

@@ -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?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
},
};

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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');

View File

@@ -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';

View File

@@ -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();

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 { 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;
}

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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 => ({

View File

@@ -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}

View File

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

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,
} 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,

View File

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

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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 smund 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",

View File

@@ -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 上發佈之僅限跟隨者嘟文無法被其他使用者引用。",

View File

@@ -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)

View File

@@ -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,
});

View File

@@ -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 {

View File

@@ -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%;
}

View File

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

View File

@@ -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) }

View File

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

View File

@@ -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)

View File

@@ -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: Допісы, бачныя толькі згаданым карыстальнікам, нельга замацаваць

View File

@@ -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.

View File

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

View File

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

View File

@@ -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: Αναρτήσεις που είναι ορατές μόνο στους αναφερόμενους χρήστες δεν μπορούν να καρφιτσωθούν

View File

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

View File

@@ -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ää

View File

@@ -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: לא ניתן לקבע הודעות שנראותן מוגבלת למכותבים בלבד

View File

@@ -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 á

View File

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

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.
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)

View File

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

View File

@@ -1914,6 +1914,7 @@ sq:
errors:
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_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}
pin_errors:
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:
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

View File

@@ -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: 無法釘選只有僅提及使用者可見之嘟文

View File

@@ -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",

View File

@@ -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"

View File

@@ -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')],

1268
yarn.lock

File diff suppressed because it is too large Load Diff