Merge pull request #2850 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 7ed9c590b9
pull/2855/head
Claire 2024-09-21 20:27:48 +02:00 committed by GitHub
commit 5aebdc9bcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
323 changed files with 2457 additions and 2369 deletions

View File

@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1'
gem 'csv', '~> 3.2'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.6'
gem 'ed25519', '~> 1.3'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'hiredis', '~> 0.6'

View File

@ -100,8 +100,8 @@ GEM
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.974.0)
aws-sdk-core (3.205.0)
aws-partitions (1.977.0)
aws-sdk-core (3.206.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
@ -109,11 +109,11 @@ GEM
aws-sdk-kms (1.91.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.162.0)
aws-sdk-s3 (1.163.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@ -197,7 +197,7 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (5.1.0)
devise-two-factor (6.0.0)
activesupport (~> 7.0)
devise (~> 4.0)
railties (~> 7.0)
@ -212,9 +212,8 @@ GEM
domain_name (0.6.20240107)
doorkeeper (5.7.1)
railties (>= 5)
dotenv (3.1.2)
dotenv (3.1.4)
drb (2.2.1)
ed25519 (1.3.0)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
@ -429,7 +428,7 @@ GEM
addressable (~> 2.5)
azure-storage-blob (~> 2.0.1)
hashie (~> 5.0)
memory_profiler (1.0.2)
memory_profiler (1.1.0)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2024.0820)
@ -610,7 +609,7 @@ GEM
psych (5.1.2)
stringio
public_suffix (6.0.1)
puma (6.4.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
@ -937,7 +936,6 @@ DEPENDENCIES
discard (~> 1.2)
doorkeeper (~> 5.6)
dotenv
ed25519 (~> 1.3)
email_spec
fabrication (~> 2.30)
faker (~> 3.2)

View File

@ -1,18 +0,0 @@
# frozen_string_literal: true
class ActivityPub::ClaimsController < ActivityPub::BaseController
skip_before_action :authenticate_user!
before_action :require_account_signature!
before_action :set_claim_result
def create
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
end
private
def set_claim_result
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
end
end

View File

@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
when 'tags'
@items = for_signed_account { @account.featured_tags }
when 'devices'
@items = @account.devices
else
not_found
end
@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
def set_size
case params[:id]
when 'featured', 'devices', 'tags'
when 'featured', 'tags'
@size = @items.size
else
not_found
@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
case params[:id]
when 'featured'
@type = :ordered
when 'devices', 'tags'
when 'tags'
@type = :unordered
else
not_found

View File

@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController
before_action :require_public_status!
def show
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight]
end
private
@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController
def status_finder
StatusFinder.new(params[:url])
end
def maxwidth_or_default
(params[:maxwidth].presence || 400).to_i
end
def maxheight_or_default
params[:maxheight].present? ? params[:maxheight].to_i : nil
end
end

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::DeliveriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def create
devices.each do |device_params|
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
end
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def resource_params
params.require(:device)
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -1,47 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
LIMIT = 80
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
before_action :set_encrypted_messages, only: :index
after_action :insert_pagination_headers, only: :index
def index
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
end
def clear
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def set_encrypted_messages
@encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def next_path
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end
def pagination_collection
@encrypted_messages
end
def records_continue?
@encrypted_messages.size == limit_param(LIMIT)
end
end

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_claim_results
def create
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
end
private
def set_claim_results
@claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }
end
def resource_params
params.permit(device: [:account_id, :device_id])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::CountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def show
render json: { one_time_keys: @current_device.one_time_keys.count }
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
end

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_accounts
before_action :set_query_results
def create
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
end
private
def set_accounts
@accounts = Account.where(id: account_ids).includes(:devices)
end
def set_query_results
@query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) }
end
def account_ids
Array(params[:id]).map(&:to_i)
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
def create
device = Device.find_or_initialize_by(access_token: doorkeeper_token)
device.transaction do
device.account = current_account
device.update!(resource_params[:device])
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
resource_params[:one_time_keys].each do |one_time_key_params|
device.one_time_keys.create!(one_time_key_params)
end
end
end
render json: device, serializer: REST::Keys::DeviceSerializer
end
private
def resource_params
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
end
end

View File

@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
skip_around_action :set_locale
LIMIT = 10
vary_by ''
def index
@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController
field: 'accounts_count',
modifier: 'log2p',
},
}).limit(10).pluck(:domain)
}).limit(LIMIT).pluck(:domain)
else
domain = normalized_domain
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
@domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain)
end
rescue Addressable::URI::InvalidURIError
@domains = []

View File

@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
return not_found if @status.hidden?
if @status.local?
render json: @status, serializer: OEmbedSerializer, width: 400
render json: @status, serializer: OEmbedSerializer
else
return not_found unless user_signed_in?

View File

@ -20,11 +20,6 @@ class Auth::SessionsController < Devise::SessionsController
p.form_action(false)
end
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
end
def create
super do |resource|
# We only need to call this if this hasn't already been
@ -101,6 +96,11 @@ class Auth::SessionsController < Devise::SessionsController
private
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
end
def home_paths(resource)
paths = [about_path, '/explore']

View File

@ -24,23 +24,6 @@ module ContextHelper
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: {
'toot' => 'http://joinmastodon.org/ns#',
'Device' => 'toot:Device',
'Ed25519Signature' => 'toot:Ed25519Signature',
'Ed25519Key' => 'toot:Ed25519Key',
'Curve25519Key' => 'toot:Curve25519Key',
'EncryptedMessage' => 'toot:EncryptedMessage',
'publicKeyBase64' => 'toot:publicKeyBase64',
'deviceId' => 'toot:deviceId',
'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
'messageFranking' => 'toot:messageFranking',
'messageType' => 'toot:messageType',
'cipherText' => 'toot:cipherText',
},
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
}.freeze

View File

@ -1,5 +1,7 @@
import { defineMessages } from 'react-intl';
import { AxiosError } from 'axios';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
});
}
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error);
return showAlert({

View File

@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) {
return axios.create({
transitional: {
clarifyTimeoutError: true,
},
headers: {
...csrfHeader,
...(withAuthorization ? authorizationTokenFromInitialState() : {}),

View File

@ -153,7 +153,7 @@ class ModalRoot extends PureComponent {
return (
<div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
<div role='dialog' className='modal-root__container'>{children}</div>
</div>
</div>

View File

@ -51,7 +51,8 @@ function normalizePath(
if (
layoutFromWindow() === 'multi-column' &&
!location.pathname?.startsWith('/deck')
location.pathname &&
!location.pathname.startsWith('/deck')
) {
location.pathname = `/deck${location.pathname}`;
}

View File

@ -1,5 +1,5 @@
import type { ChangeEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -26,6 +26,8 @@ import { RadioButton } from 'flavours/glitch/components/radio_button';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { useSearchParam } from '../../hooks/useSearchParam';
import { AccountCard } from './components/account_card';
const messages = defineMessages({
@ -50,18 +52,19 @@ export const Directory: React.FC<{
const intl = useIntl();
const dispatch = useAppDispatch();
const [state, setState] = useState<{
order: string | null;
local: boolean | null;
}>({
order: null,
local: null,
});
const column = useRef<Column>(null);
const order = state.order ?? params?.order ?? 'active';
const local = state.local ?? params?.local ?? false;
const [orderParam, setOrderParam] = useSearchParam('order');
const [localParam, setLocalParam] = useSearchParam('local');
let localParamBool: boolean | undefined;
if (localParam === 'false') {
localParamBool = false;
}
const order = orderParam ?? params?.order ?? 'active';
const local = localParamBool ?? params?.local ?? true;
const handlePin = useCallback(() => {
if (columnId) {
@ -104,10 +107,10 @@ export const Directory: React.FC<{
if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else {
setState((s) => ({ order: e.target.value, local: s.local }));
setOrderParam(e.target.value);
}
},
[dispatch, columnId],
[dispatch, columnId, setOrderParam],
);
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
@ -116,11 +119,13 @@ export const Directory: React.FC<{
dispatch(
changeColumnParams(columnId, ['local'], e.target.value === '1'),
);
} else if (e.target.value === '1') {
setLocalParam('true');
} else {
setState((s) => ({ local: e.target.value === '1', order: s.order }));
setLocalParam('false');
}
},
[dispatch, columnId],
[dispatch, columnId, setLocalParam],
);
const handleLoadMore = useCallback(() => {

View File

@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
import BundleContainer from '../containers/bundle_container';
import {
@ -72,10 +70,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
};
componentDidMount() {
if (!this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
if (this.mediaQuery) {
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
@ -88,23 +82,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
}
UNSAFE_componentWillUpdate(nextProps) {
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
}
componentDidUpdate(prevProps) {
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
}
componentWillUnmount () {
if (!this.props.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
@ -117,7 +95,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
handleChildrenContentChange() {
if (!this.props.singleColumn) {
const modifier = this.isRtlLayout ? -1 : 1;
this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
}
}
@ -125,14 +103,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.setState({ renderComposePanel: !e.matches });
};
handleWheel = () => {
if (typeof this._interruptScrollAnimation !== 'function') {
return;
}
this._interruptScrollAnimation();
};
setRef = (node) => {
this.node = node;
};

View File

@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent {
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
};
render () {
const { alt, lang, src, width, height, onClick } = this.props;
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} />
</div>
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
onClick={onClick}
width={width}
height={height}
zoomButtonHidden={this.props.zoomButtonHidden}
zoomedIn={zoomedIn}
/>
)}
</div>

View File

@ -12,6 +12,8 @@ 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 'flavours/glitch/blurhash';
import { GIFV } from 'flavours/glitch/components/gifv';
import { Icon } from 'flavours/glitch/components/icon';
@ -26,6 +28,8 @@ 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 {
@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
state = {
index: null,
navigationHidden: false,
zoomButtonHidden: false,
zoomedIn: false,
};
handleZoomClick = () => {
this.setState(prevState => ({
zoomedIn: !prevState.zoomedIn,
}));
};
handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size });
this.setState({
index: index % this.props.media.size,
zoomedIn: false,
});
};
handleTransitionEnd = () => {
this.setState({
zoomButtonHidden: false,
zoomedIn: false,
});
};
handleNextClick = () => {
this.setState({
index: (this.getIndex() + 1) % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
handlePrevClick = () => {
this.setState({
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent {
this.setState({
index: index % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
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 } = this.state;
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
const index = this.getIndex();
@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.toggleNavigation}
zoomButtonHidden={this.state.zoomButtonHidden}
onClick={this.handleToggleNavigation}
zoomedIn={zoomedIn}
/>
);
} else if (image.get('type') === 'video') {
@ -229,9 +249,12 @@ class MediaModal extends ImmutablePureComponent {
));
}
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'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<div className='modal-root__modal media-modal' ref={this.setRef}>
<div className='media-modal__closer' role='presentation' onClick={onClose}>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
@ -245,7 +268,10 @@ class MediaModal extends ImmutablePureComponent {
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
<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}

View File

@ -1,17 +1,6 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
const messages = defineMessages({
compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
});
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent {
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent {
translateX: null,
translateY: null,
},
zoomState: 'expand', // 'expand' 'compress'
navigationHidden: false,
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent {
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this.initZoomMatrix();
this._initZoomMatrix();
}
componentWillUnmount () {
this.removeEventListeners();
this._removeEventListeners();
}
componentDidUpdate () {
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
if (this.state.scale === MIN_SCALE) {
this.container.style.removeProperty('cursor');
componentDidUpdate (prevProps) {
if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
}
}
UNSAFE_componentWillReceiveProps () {
// reset when slide to next image
if (this.props.zoomButtonHidden) {
this.setState({
scale: MIN_SCALE,
lockTranslate: { x: 0, y: 0 },
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
}
}
removeEventListeners () {
_removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent {
};
mouseDownHandler = e => {
this.container.style.cursor = 'grabbing';
this.container.style.userSelect = 'none';
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent {
};
mouseUpHandler = () => {
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
};
@ -276,13 +241,13 @@ class ZoomableImage extends PureComponent {
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this.zoom(scale, midpoint);
this._zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
};
zoom(nextScale, midpoint) {
_zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = this.state;
const { scrollLeft, scrollTop } = this.container;
@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent {
if (dragged) return;
const handler = this.props.onClick;
if (handler) handler();
this.setState({ navigationHidden: !this.state.navigationHidden });
};
handleMouseDown = e => {
e.preventDefault();
};
initZoomMatrix = () => {
_initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent {
});
};
handleZoomClick = e => {
e.preventDefault();
e.stopPropagation();
_toggleZoom () {
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent {
this.container.scrollTop = zoomMatrix.scrollTop;
});
}
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
};
}
setContainerRef = c => {
this.container = c;
@ -408,52 +366,37 @@ class ZoomableImage extends PureComponent {
};
render () {
const { alt, lang, src, width, height, intl } = this.props;
const { scale, lockTranslate } = this.state;
const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
return (
<>
<IconButton
className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
title={zoomButtonTitle}
icon={this.state.zoomState}
iconComponent={this.state.zoomState === 'compress' ? FullscreenExitIcon : RectangleIcon}
onClick={this.handleZoomClick}
size={40}
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow, cursor, userSelect: 'none' }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
</>
</div>
);
}
}
export default injectIntl(ZoomableImage);
export default ZoomableImage;

View File

@ -0,0 +1,31 @@
import { useMemo, useCallback } from 'react';
import { useLocation, useHistory } from 'react-router';
export function useSearchParams() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
export function useSearchParam(name: string, defaultValue?: string) {
const searchParams = useSearchParams();
const history = useHistory();
const value = searchParams.get(name) ?? defaultValue;
const setValue = useCallback(
(value: string | null) => {
if (value === null) {
searchParams.delete(name);
} else {
searchParams.set(name, value);
}
history.push({ search: searchParams.toString() });
},
[history, name, searchParams],
);
return [value, setValue] as const;
}

View File

@ -38,13 +38,20 @@ const scroll = (
const isScrollBehaviorSupported =
'scrollBehavior' in document.documentElement.style;
export const scrollRight = (node: Element, position: number) => {
if (isScrollBehaviorSupported)
node.scrollTo({ left: position, behavior: 'smooth' });
else scroll(node, 'scrollLeft', position);
};
export const scrollRight = (node: Element, position: number) =>
requestIdleCallback(() => {
if (isScrollBehaviorSupported) {
node.scrollTo({ left: position, behavior: 'smooth' });
} else {
scroll(node, 'scrollLeft', position);
}
});
export const scrollTop = (node: Element) => {
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' });
else scroll(node, 'scrollTop', 0);
};
export const scrollTop = (node: Element) =>
requestIdleCallback(() => {
if (isScrollBehaviorSupported) {
node.scrollTo({ top: 0, behavior: 'smooth' });
} else {
scroll(node, 'scrollTop', 0);
}
});

View File

@ -2196,13 +2196,14 @@ body > [data-popper-placement] {
display: block;
position: relative;
border-radius: var(--avatar-border-radius);
background-color: var(--surface-background-color);
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--avatar-border-radius);
display: inline-block; // to not show broken images
}
&-inline {
@ -6211,19 +6212,34 @@ a.status-card {
height: 100%;
position: relative;
&__close,
&__zoom-button {
color: rgba($white, 0.7);
&__buttons {
position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
display: flex;
gap: 8px;
align-items: center;
&:hover,
&:focus,
&:active {
color: $white;
background-color: rgba($white, 0.15);
}
.icon-button {
color: rgba($white, 0.7);
padding: 8px;
&:focus {
background-color: rgba($white, 0.3);
.icon {
width: 24px;
height: 24px;
}
&:hover,
&:focus,
&:active {
color: $white;
background-color: rgba($white, 0.15);
}
&:focus {
background-color: rgba($white, 0.3);
}
}
}
}
@ -6384,28 +6400,6 @@ a.status-card {
}
}
.media-modal__close {
position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
}
.media-modal__zoom-button {
position: absolute;
inset-inline-end: 64px;
top: 8px;
z-index: 100;
pointer-events: auto;
transition: opacity 0.3s linear;
will-change: opacity;
}
.media-modal__zoom-button--hidden {
pointer-events: none;
opacity: 0;
}
.onboarding-modal,
.error-modal,
.embed-modal {

View File

@ -1,3 +1,5 @@
@use 'sass:color';
// Dependent colors
$black: #000000;
$white: #ffffff;
@ -47,11 +49,19 @@ $account-background-color: $white !default;
// Invert darkened and lightened colors
@function darken($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) + $amount);
@return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) + $amount
);
}
@function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
@return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) - $amount
);
}
$emojis-requiring-inversion: 'chains';

View File

@ -0,0 +1,31 @@
import { useMemo, useCallback } from 'react';
import { useLocation, useHistory } from 'react-router';
export function useSearchParams() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
export function useSearchParam(name: string, defaultValue?: string) {
const searchParams = useSearchParams();
const history = useHistory();
const value = searchParams.get(name) ?? defaultValue;
const setValue = useCallback(
(value: string | null) => {
if (value === null) {
searchParams.delete(name);
} else {
searchParams.set(name, value);
}
history.push({ search: searchParams.toString() });
},
[history, name, searchParams],
);
return [value, setValue] as const;
}

View File

@ -1,5 +1,7 @@
import { defineMessages } from 'react-intl';
import { AxiosError } from 'axios';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
});
}
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error);
return showAlert({

View File

@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) {
return axios.create({
transitional: {
clarifyTimeoutError: true,
},
headers: {
...csrfHeader,
...(withAuthorization ? authorizationTokenFromInitialState() : {}),

View File

@ -148,7 +148,7 @@ class ModalRoot extends PureComponent {
return (
<div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
<div role='dialog' className='modal-root__container'>{children}</div>
</div>
</div>

View File

@ -51,7 +51,8 @@ function normalizePath(
if (
layoutFromWindow() === 'multi-column' &&
!location.pathname?.startsWith('/deck')
location.pathname &&
!location.pathname.startsWith('/deck')
) {
location.pathname = `/deck${location.pathname}`;
}

View File

@ -1,5 +1,5 @@
import type { ChangeEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -23,6 +23,8 @@ import { RadioButton } from 'mastodon/components/radio_button';
import ScrollContainer from 'mastodon/containers/scroll_container';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useSearchParam } from '../../../hooks/useSearchParam';
import { AccountCard } from './components/account_card';
const messages = defineMessages({
@ -47,18 +49,19 @@ export const Directory: React.FC<{
const intl = useIntl();
const dispatch = useAppDispatch();
const [state, setState] = useState<{
order: string | null;
local: boolean | null;
}>({
order: null,
local: null,
});
const column = useRef<Column>(null);
const order = state.order ?? params?.order ?? 'active';
const local = state.local ?? params?.local ?? false;
const [orderParam, setOrderParam] = useSearchParam('order');
const [localParam, setLocalParam] = useSearchParam('local');
let localParamBool: boolean | undefined;
if (localParam === 'false') {
localParamBool = false;
}
const order = orderParam ?? params?.order ?? 'active';
const local = localParamBool ?? params?.local ?? true;
const handlePin = useCallback(() => {
if (columnId) {
@ -101,10 +104,10 @@ export const Directory: React.FC<{
if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else {
setState((s) => ({ order: e.target.value, local: s.local }));
setOrderParam(e.target.value);
}
},
[dispatch, columnId],
[dispatch, columnId, setOrderParam],
);
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
@ -113,11 +116,13 @@ export const Directory: React.FC<{
dispatch(
changeColumnParams(columnId, ['local'], e.target.value === '1'),
);
} else if (e.target.value === '1') {
setLocalParam('true');
} else {
setState((s) => ({ local: e.target.value === '1', order: s.order }));
setLocalParam('false');
}
},
[dispatch, columnId],
[dispatch, columnId, setLocalParam],
);
const handleLoadMore = useCallback(() => {

View File

@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
import BundleContainer from '../containers/bundle_container';
import {
@ -71,10 +69,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
};
componentDidMount() {
if (!this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
if (this.mediaQuery) {
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
@ -87,23 +81,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
}
UNSAFE_componentWillUpdate(nextProps) {
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
}
componentDidUpdate(prevProps) {
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
}
componentWillUnmount () {
if (!this.props.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
@ -116,7 +94,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
handleChildrenContentChange() {
if (!this.props.singleColumn) {
const modifier = this.isRtlLayout ? -1 : 1;
this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
}
}
@ -124,14 +102,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.setState({ renderComposePanel: !e.matches });
};
handleWheel = () => {
if (typeof this._interruptScrollAnimation !== 'function') {
return;
}
this._interruptScrollAnimation();
};
setRef = (node) => {
this.node = node;
};

View File

@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent {
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
};
render () {
const { alt, lang, src, width, height, onClick } = this.props;
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} />
</div>
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
onClick={onClick}
width={width}
height={height}
zoomButtonHidden={this.props.zoomButtonHidden}
zoomedIn={zoomedIn}
/>
)}
</div>

View File

@ -12,6 +12,8 @@ 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';
@ -26,6 +28,8 @@ 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 {
@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
state = {
index: null,
navigationHidden: false,
zoomButtonHidden: false,
zoomedIn: false,
};
handleZoomClick = () => {
this.setState(prevState => ({
zoomedIn: !prevState.zoomedIn,
}));
};
handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size });
this.setState({
index: index % this.props.media.size,
zoomedIn: false,
});
};
handleTransitionEnd = () => {
this.setState({
zoomButtonHidden: false,
zoomedIn: false,
});
};
handleNextClick = () => {
this.setState({
index: (this.getIndex() + 1) % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
handlePrevClick = () => {
this.setState({
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent {
this.setState({
index: index % this.props.media.size,
zoomButtonHidden: true,
zoomedIn: false,
});
};
@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
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 } = this.state;
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
const index = this.getIndex();
@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.toggleNavigation}
zoomButtonHidden={this.state.zoomButtonHidden}
onClick={this.handleToggleNavigation}
zoomedIn={zoomedIn}
/>
);
} else if (image.get('type') === 'video') {
@ -230,9 +250,12 @@ class MediaModal extends ImmutablePureComponent {
));
}
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'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<div className='modal-root__modal media-modal' ref={this.setRef}>
<div className='media-modal__closer' role='presentation' onClick={onClose}>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
@ -246,7 +269,10 @@ class MediaModal extends ImmutablePureComponent {
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
<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}

View File

@ -1,17 +1,6 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
});
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent {
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent {
translateX: null,
translateY: null,
},
zoomState: 'expand', // 'expand' 'compress'
navigationHidden: false,
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent {
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this.initZoomMatrix();
this._initZoomMatrix();
}
componentWillUnmount () {
this.removeEventListeners();
this._removeEventListeners();
}
componentDidUpdate () {
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
if (this.state.scale === MIN_SCALE) {
this.container.style.removeProperty('cursor');
componentDidUpdate (prevProps) {
if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
}
}
UNSAFE_componentWillReceiveProps () {
// reset when slide to next image
if (this.props.zoomButtonHidden) {
this.setState({
scale: MIN_SCALE,
lockTranslate: { x: 0, y: 0 },
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
}
}
removeEventListeners () {
_removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent {
};
mouseDownHandler = e => {
this.container.style.cursor = 'grabbing';
this.container.style.userSelect = 'none';
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent {
};
mouseUpHandler = () => {
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
};
@ -276,13 +241,13 @@ class ZoomableImage extends PureComponent {
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this.zoom(scale, midpoint);
this._zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
};
zoom(nextScale, midpoint) {
_zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = this.state;
const { scrollLeft, scrollTop } = this.container;
@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent {
if (dragged) return;
const handler = this.props.onClick;
if (handler) handler();
this.setState({ navigationHidden: !this.state.navigationHidden });
};
handleMouseDown = e => {
e.preventDefault();
};
initZoomMatrix = () => {
_initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent {
});
};
handleZoomClick = e => {
e.preventDefault();
e.stopPropagation();
_toggleZoom () {
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent {
this.container.scrollTop = zoomMatrix.scrollTop;
});
}
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
};
}
setContainerRef = c => {
this.container = c;
@ -408,52 +366,37 @@ class ZoomableImage extends PureComponent {
};
render () {
const { alt, lang, src, width, height, intl } = this.props;
const { scale, lockTranslate } = this.state;
const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
return (
<>
<IconButton
className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
title={zoomButtonTitle}
icon={this.state.zoomState}
iconComponent={this.state.zoomState === 'compress' ? FullscreenExitIcon : RectangleIcon}
onClick={this.handleZoomClick}
size={40}
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow, cursor, userSelect: 'none' }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
</>
</div>
);
}
}
export default injectIntl(ZoomableImage);
export default ZoomableImage;

View File

@ -36,6 +36,7 @@
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
"account.following": "الاشتراكات",
"account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
"account.go_to_profile": "اذهب إلى الملف الشخصي",
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
@ -309,7 +310,7 @@
"follow_request.authorize": "ترخيص",
"follow_request.reject": "رفض",
"follow_requests.unlocked_explanation": "حتى وإن كان حسابك غير مقفل، يعتقد فريق {domain} أنك قد ترغب في مراجعة طلبات المتابعة من هذه الحسابات يدوياً.",
"follow_suggestions.curated_suggestion": "اختيار الموظفين",
"follow_suggestions.curated_suggestion": "انتقاه الفريق",
"follow_suggestions.dismiss": "لا تُظهرها مجدّدًا",
"follow_suggestions.featured_longer": "مختار يدوياً من قِبل فريق {domain}",
"follow_suggestions.friends_of_friends_longer": "مشهور بين الأشخاص الذين تتابعهم",
@ -752,7 +753,7 @@
"status.edit": "تعديل",
"status.edited": "آخر تعديل يوم {date}",
"status.edited_x_times": "عُدّل {count, plural, zero {} one {مرةً واحدة} two {مرّتان} few {{count} مرات} many {{count} مرة} other {{count} مرة}}",
"status.embed": "الحصول على شفرة الإدماج",
"status.embed": "الحصول على شيفرة الدمج",
"status.favourite": "فضّل",
"status.favourites": "{count, plural, zero {}one {مفضلة واحدة} two {مفضلتان} few {# مفضلات} many {# مفضلات} other {# مفضلات}}",
"status.filter": "تصفية هذا المنشور",
@ -773,7 +774,7 @@
"status.reblog": "إعادة النشر",
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
"status.reblogged_by": "شارَكَه {name}",
"status.reblogs": "{count, plural, one {تعزيز واحد} two {تعزيزتان} few {# تعزيزات} many {# تعزيزات} other {# تعزيزات}}",
"status.reblogs": "{count, plural, one {إعادة نشر واحدة} two {معاد نشرها مرتان} few {# إعادات نشر} many {# إعادات نشر} other {# إعادة نشر}}",
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
"status.redraft": "إزالة وإعادة الصياغة",
"status.remove_bookmark": "احذفه مِن الفواصل المرجعية",

View File

@ -432,10 +432,10 @@
"keyboard_shortcuts.unfocus": "Unfocus compose textarea/search",
"keyboard_shortcuts.up": "Move up in the list",
"lightbox.close": "Close",
"lightbox.compress": "Compress image view box",
"lightbox.expand": "Expand image view box",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"lightbox.zoom_in": "Zoom to actual size",
"lightbox.zoom_out": "Zoom to fit",
"limited_account_hint.action": "Show profile anyway",
"limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
"link_preview.author": "By {name}",

View File

@ -97,6 +97,8 @@
"block_modal.title": "Ĉu bloki uzanton?",
"block_modal.you_wont_see_mentions": "Vi ne vidos afiŝojn, ke mencii ilin.",
"boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
"boost_modal.reblog": "Ĉu diskonigi afiŝon?",
"boost_modal.undo_reblog": "Ĉu ĉesi diskonigi afiŝon?",
"bundle_column_error.copy_stacktrace": "Kopii la eraran raporton",
"bundle_column_error.error.body": "La petita paĝo ne povas redonitis. Eble estas eraro.",
"bundle_column_error.error.title": "Ho, ve!",
@ -188,8 +190,12 @@
"confirmations.redraft.title": "Ĉu forigi kaj redakcii afiŝon?",
"confirmations.reply.confirm": "Respondi",
"confirmations.reply.message": "Respondi nun anstataŭigos la skribatan afiŝon. Ĉu vi certas, ke vi volas daŭrigi?",
"confirmations.reply.title": "Ĉu superskribi afiŝon?",
"confirmations.unfollow.confirm": "Ne plu sekvi",
"confirmations.unfollow.message": "Ĉu vi certas, ke vi volas ĉesi sekvi {name}?",
"confirmations.unfollow.title": "Ĉu ĉesi sekvi uzanton?",
"content_warning.hide": "Kaŝi afiŝon",
"content_warning.show": "Montri ĉiukaze",
"conversation.delete": "Forigi konversacion",
"conversation.mark_as_read": "Marki legita",
"conversation.open": "Vidi konversacion",
@ -209,6 +215,8 @@
"dismissable_banner.explore_statuses": "Ĉi tioj estas afiŝoj de socia reto kiu populariĝas hodiau.",
"dismissable_banner.explore_tags": "Ĉi tiuj kradvostoj populariĝas en ĉi tiu kaj aliaj serviloj en la malcentraliza reto nun.",
"dismissable_banner.public_timeline": "Ĉi tioj estas plej lastaj publikaj afiŝoj de personoj ĉe socia reto kiu personoj ĉe {domain} sekvas.",
"domain_block_modal.they_cant_follow": "Neniu el ĉi tiu servilo povas sekvi vin.",
"domain_pill.username": "Uzantnomo",
"embed.instructions": "Enkorpigu ĉi tiun afiŝon en vian retejon per kopio de la suba kodo.",
"embed.preview": "Ĝi aperos tiel:",
"emoji_button.activity": "Agadoj",
@ -281,6 +289,12 @@
"follow_request.authorize": "Rajtigi",
"follow_request.reject": "Rifuzi",
"follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la dungitaro de {domain} opinias, ke vi eble volas revizii petojn pri sekvado de ĉi tiuj kontoj permane.",
"follow_suggestions.dismiss": "Ne montri denove",
"follow_suggestions.hints.friends_of_friends": "Ĉi tiu profilo estas populara inter la homoj, kiujn vi sekvas.",
"follow_suggestions.hints.most_followed": "Ĉi tiu profilo estas unu el la plej sekvataj en {domain}.",
"follow_suggestions.popular_suggestion_longer": "Populara en {domain}",
"follow_suggestions.view_all": "Vidi ĉiujn",
"follow_suggestions.who_to_follow": "Kiun sekvi",
"followed_tags": "Sekvataj kradvortoj",
"footer.about": "Pri",
"footer.directory": "Profilujo",
@ -374,6 +388,7 @@
"limited_account_hint.action": "Montru profilon ĉiukaze",
"limited_account_hint.title": "La profilo estas kaŝita de la moderigantoj de {domain}.",
"link_preview.author": "De {name}",
"link_preview.shares": "{count, plural, one {{counter} afiŝo} other {{counter} afiŝoj}}",
"lists.account.add": "Aldoni al la listo",
"lists.account.remove": "Forigi de la listo",
"lists.delete": "Forigi la liston",
@ -390,8 +405,12 @@
"lists.subheading": "Viaj listoj",
"load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}",
"loading_indicator.label": "Ŝargado…",
"media_gallery.hide": "Kaŝi",
"moved_to_account_banner.text": "Via konto {disabledAccount} estas malvalidigita ĉar vi movis ĝin al {movedToAccount}.",
"mute_modal.show_options": "Montri agordojn",
"mute_modal.they_can_mention_and_follow": "Ili povas mencii kaj sekvi vin, sed vi ne vidos ilin.",
"navigation_bar.about": "Pri",
"navigation_bar.administration": "Administrado",
"navigation_bar.advanced_interface": "Malfermi altnivelan retpaĝan interfacon",
"navigation_bar.blocks": "Blokitaj uzantoj",
"navigation_bar.bookmarks": "Legosignoj",
@ -422,10 +441,18 @@
"notification.favourite": "{name} stelumis vian afiŝon",
"notification.follow": "{name} eksekvis vin",
"notification.follow_request": "{name} petis sekvi vin",
"notification.label.mention": "Mencii",
"notification.label.private_mention": "Privata mencio",
"notification.label.private_reply": "Privata respondo",
"notification.label.reply": "Respondi",
"notification.mention": "Mencii",
"notification.moderation-warning.learn_more": "Lerni pli",
"notification.own_poll": "Via enketo finiĝis",
"notification.reblog": "{name} diskonigis vian afiŝon",
"notification.relationships_severance_event.learn_more": "Lerni pli",
"notification.status": "{name} ĵus afiŝis",
"notification.update": "{name} redaktis afiŝon",
"notification_requests.accept": "Akcepti",
"notifications.clear": "Forviŝi sciigojn",
"notifications.clear_confirmation": "Ĉu vi certas, ke vi volas porĉiame forviŝi ĉiujn viajn sciigojn?",
"notifications.column_settings.admin.report": "Novaj raportoj:",
@ -457,6 +484,8 @@
"notifications.permission_denied": "Labortablaj sciigoj ne disponeblas pro peto antaŭe rifuzita de retumiloj",
"notifications.permission_denied_alert": "Labortablaj sciigoj ne povas esti ebligitaj, ĉar retumilpermeso antaŭe estis rifuzita",
"notifications.permission_required": "Labortablaj sciigoj ne disponeblas ĉar la bezonata permeso ne estis donita.",
"notifications.policy.accept": "Akcepti",
"notifications.policy.filter_new_accounts_title": "Novaj kontoj",
"notifications_permission_banner.enable": "Ŝalti retumilajn sciigojn",
"notifications_permission_banner.how_to_control": "Por ricevi sciigojn kiam Mastodon ne estas malfermita, ebligu labortablajn sciigojn. Vi povas regi precize kiuj specoj de interagoj generas labortablajn sciigojn per la supra butono {icon} post kiam ili estas ebligitaj.",
"notifications_permission_banner.title": "Neniam preterlasas iun ajn",
@ -581,6 +610,7 @@
"report_notification.attached_statuses": "{count, plural, one {{count} afiŝo almetita} other {{count} afiŝoj almetitaj}}",
"report_notification.categories.legal": "Laŭleĝa",
"report_notification.categories.other": "Alia",
"report_notification.categories.other_sentence": "alia",
"report_notification.categories.spam": "Trudmesaĝo",
"report_notification.categories.violation": "Malobservo de la regulo",
"report_notification.open": "Malfermi la raporton",

View File

@ -39,11 +39,11 @@
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
"account.go_to_profile": "Ir al perfil",
"account.hide_reblogs": "Ocultar retoots de @{name}",
"account.hide_reblogs": "Ocultar impulsos de @{name}",
"account.in_memoriam": "En memoria.",
"account.joined_short": "Se unió",
"account.languages": "Cambiar idiomas suscritos",
"account.link_verified_on": "El proprietario de este link fue comprobado el {date}",
"account.link_verified_on": "El proprietario de este enlace fue comprobado el {date}",
"account.locked_info": "El estado de privacidad de esta cuenta està configurado como bloqueado. El proprietario debe revisar manualmente quien puede seguirle.",
"account.media": "Multimedia",
"account.mention": "Mencionar a @{name}",
@ -61,7 +61,7 @@
"account.requested": "Esperando aprobación. Haga clic para cancelar la solicitud de seguimiento",
"account.requested_follow": "{name} ha solicitado seguirte",
"account.share": "Compartir el perfil de @{name}",
"account.show_reblogs": "Mostrar retoots de @{name}",
"account.show_reblogs": "Mostrar impulsos de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Mostrar a {domain}",
@ -70,8 +70,8 @@
"account.unfollow": "Dejar de seguir",
"account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
"account.unmute_short": "Desmutear",
"account_note.placeholder": "Clic para añadir nota",
"account.unmute_short": "Dejar de silenciar",
"account_note.placeholder": "Haz clic para agregar una nota",
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después de unirse",
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después de unirse",
"admin.dashboard.retention.average": "Promedio",
@ -97,7 +97,7 @@
"block_modal.title": "¿Bloquear usuario?",
"block_modal.you_wont_see_mentions": "No verás publicaciones que los mencionen.",
"boost_modal.combo": "Puedes hacer clic en {combo} para saltar este aviso la próxima vez",
"boost_modal.reblog": "¿Impulsar la publicación?",
"boost_modal.reblog": "¿Deseas impulsar la publicación?",
"boost_modal.undo_reblog": "¿Dejar de impulsar la publicación?",
"bundle_column_error.copy_stacktrace": "Copiar informe de error",
"bundle_column_error.error.body": "La página solicitada no pudo ser renderizada. Podría deberse a un error en nuestro código o a un problema de compatibilidad con el navegador.",
@ -130,7 +130,7 @@
"column.lists": "Listas",
"column.mutes": "Usuarios silenciados",
"column.notifications": "Notificaciones",
"column.pins": "Toots fijados",
"column.pins": "Publicaciones fijadas",
"column.public": "Línea de tiempo federada",
"column_back_button.label": "Atrás",
"column_header.hide_settings": "Ocultar configuración",
@ -148,10 +148,10 @@
"compose.published.body": "Publicado.",
"compose.published.open": "Abrir",
"compose.saved.body": "Publicación guardada.",
"compose_form.direct_message_warning_learn_more": "Aprender mas",
"compose_form.direct_message_warning_learn_more": "Saber más",
"compose_form.encryption_warning": "Las publicaciones en Mastodon no están cifradas de extremo a extremo. No comparta ninguna información sensible en Mastodon.",
"compose_form.hashtag_warning": "Este toot no será listado bajo ningún hashtag dado que no es público. Solo toots públicos pueden ser buscados por hashtag.",
"compose_form.lock_disclaimer": "Tu cuenta no está bloqueada. Todos pueden seguirte para ver tus toots solo para seguidores.",
"compose_form.hashtag_warning": "Esta publicación no será listada bajo ninguna etiqueta dado que no es pública. Solo publicaciones públicas pueden ser buscadas por etiqueta.",
"compose_form.lock_disclaimer": "Tu cuenta no está {locked}. Todos pueden seguirte para ver tus publicaciones solo para seguidores.",
"compose_form.lock_disclaimer.lock": "bloqueado",
"compose_form.placeholder": "¿En qué estás pensando?",
"compose_form.poll.duration": "Duración de la encuesta",
@ -165,32 +165,32 @@
"compose_form.publish_form": "Publicar",
"compose_form.reply": "Respuesta",
"compose_form.save_changes": "Actualización",
"compose_form.spoiler.marked": "Texto oculto tras la advertencia",
"compose_form.spoiler.unmarked": "Texto no oculto",
"compose_form.spoiler.marked": "Quitar advertencia de contenido",
"compose_form.spoiler.unmarked": "Añadir advertencia de contenido",
"compose_form.spoiler_placeholder": "Advertencia de contenido (opcional)",
"confirmation_modal.cancel": "Cancelar",
"confirmations.block.confirm": "Bloquear",
"confirmations.delete.confirm": "Eliminar",
"confirmations.delete.message": "¿Estás seguro de que quieres borrar este toot?",
"confirmations.delete.message": "¿Estás seguro de que quieres borrar esta publicación?",
"confirmations.delete.title": "¿Eliminar publicación?",
"confirmations.delete_list.confirm": "Eliminar",
"confirmations.delete_list.message": "¿Seguro que quieres borrar esta lista permanentemente?",
"confirmations.delete_list.title": "¿Eliminar lista?",
"confirmations.delete_list.title": "¿Deseas eliminar la lista?",
"confirmations.discard_edit_media.confirm": "Descartar",
"confirmations.discard_edit_media.message": "Tienes cambios sin guardar en la descripción o vista previa del archivo, ¿deseas descartarlos de cualquier manera?",
"confirmations.edit.confirm": "Editar",
"confirmations.edit.message": "Editar sobrescribirá el mensaje que estás escribiendo. ¿Estás seguro de que deseas continuar?",
"confirmations.edit.title": "¿Sobrescribir publicación?",
"confirmations.edit.title": "¿Sobreescribir publicación?",
"confirmations.logout.confirm": "Cerrar sesión",
"confirmations.logout.message": "¿Estás seguro de querer cerrar la sesión?",
"confirmations.logout.title": "¿Cerrar sesión?",
"confirmations.logout.message": "¿Estás seguro de que quieres cerrar la sesión?",
"confirmations.logout.title": "¿Deseas cerrar sesión?",
"confirmations.mute.confirm": "Silenciar",
"confirmations.redraft.confirm": "Borrar y volver a borrador",
"confirmations.redraft.message": "¿Estás seguro que quieres borrar esta publicación y editarla? Los favoritos e impulsos se perderán, y las respuestas a la publicación original quedarán separadas.",
"confirmations.redraft.title": "¿Borrar y volver a redactar la publicación?",
"confirmations.reply.confirm": "Responder",
"confirmations.reply.message": "Responder sobrescribirá el mensaje que estás escribiendo. ¿Estás seguro de que deseas continuar?",
"confirmations.reply.title": "¿Sobrescribir publicación?",
"confirmations.reply.title": "¿Sobreescribir publicación?",
"confirmations.unfollow.confirm": "Dejar de seguir",
"confirmations.unfollow.message": "¿Estás seguro de que quieres dejar de seguir a {name}?",
"confirmations.unfollow.title": "¿Dejar de seguir al usuario?",
@ -213,8 +213,8 @@
"dismissable_banner.dismiss": "Descartar",
"dismissable_banner.explore_links": "Estas noticias están siendo discutidas por personas en este y otros servidores de la red descentralizada en este momento.",
"dismissable_banner.explore_statuses": "Estas son las publicaciones que están en tendencia en la red ahora. Las publicaciones recientes con más impulsos y favoritos se muestran más arriba.",
"dismissable_banner.explore_tags": "Se trata de hashtags que están ganando adeptos en las redes sociales hoy en día. Los hashtags que son utilizados por más personas diferentes se clasifican mejor.",
"dismissable_banner.public_timeline": "Estos son los toots públicos más recientes de personas en la web social a las que sigue la gente en {domain}.",
"dismissable_banner.explore_tags": "Se trata de etiquetas que están ganando adeptos en las redes sociales hoy en día. Las etiquetas que son utilizadas por más personas diferentes se clasifican mejor.",
"dismissable_banner.public_timeline": "Estas son las publicaciones públicas más recientes de personas en la web social a las que sigue la gente en {domain}.",
"domain_block_modal.block": "Bloquear servidor",
"domain_block_modal.block_account_instead": "Bloquear @{name} en su lugar",
"domain_block_modal.they_can_interact_with_old_posts": "Las personas de este servidor pueden interactuar con tus publicaciones antiguas.",
@ -236,7 +236,7 @@
"domain_pill.your_handle": "Tu alias:",
"domain_pill.your_server": "Tu hogar digital, donde residen todas tus publicaciones. ¿No te gusta este sitio? Muévete a otro servidor en cualquier momento y llévate a tus seguidores.",
"domain_pill.your_username": "Tu identificador único en este servidor. Es posible encontrar usuarios con el mismo nombre de usuario en diferentes servidores.",
"embed.instructions": "Añade este toot a tu sitio web con el siguiente código.",
"embed.instructions": "Añade esta publicación a tu sitio web con el siguiente código.",
"embed.preview": "Así es como se verá:",
"emoji_button.activity": "Actividad",
"emoji_button.clear": "Borrar",
@ -249,16 +249,16 @@
"emoji_button.objects": "Objetos",
"emoji_button.people": "Gente",
"emoji_button.recent": "Usados frecuentemente",
"emoji_button.search": "Buscar",
"emoji_button.search": "Buscar...",
"emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares",
"empty_column.account_hides_collections": "Este usuario ha elegido no hacer disponible esta información",
"empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay toots aquí!",
"empty_column.account_timeline": "¡No hay publicaciones aquí!",
"empty_column.account_unavailable": "Perfil no disponible",
"empty_column.blocks": "Aún no has bloqueado a ningún usuario.",
"empty_column.bookmarked_statuses": "Aún no tienes ningún toot guardado como marcador. Cuando guardes uno, se mostrará aquí.",
"empty_column.bookmarked_statuses": "Aún no tienes ninguna publicación guardada como marcador. Cuando guardes una, se mostrará aquí.",
"empty_column.community": "La línea de tiempo local está vacía. ¡Escribe algo para empezar la fiesta!",
"empty_column.direct": "Aún no tienes menciones privadas. Cuando envíes o recibas una, aparecerán aquí.",
"empty_column.domain_blocks": "Todavía no hay dominios ocultos.",
@ -266,8 +266,8 @@
"empty_column.favourited_statuses": "Todavía no tienes publicaciones favoritas. Cuando le des favorito a una publicación se mostrarán acá.",
"empty_column.favourites": "Todavía nadie marcó como favorito esta publicación. Cuando alguien lo haga, se mostrará aquí.",
"empty_column.follow_requests": "No tienes ninguna petición de seguidor. Cuando recibas una, se mostrará aquí.",
"empty_column.followed_tags": "No estás siguiendo ningún hashtag todavía. Cuando lo hagas, aparecerá aquí.",
"empty_column.hashtag": "No hay nada en este hashtag aún.",
"empty_column.followed_tags": "No estás siguiendo ninguna etiqueta todavía. Cuando lo hagas, aparecerá aquí.",
"empty_column.hashtag": "No hay nada en esta etiqueta aún.",
"empty_column.home": "No estás siguiendo a nadie aún. Visita {public} o haz búsquedas para empezar y conocer gente nueva.",
"empty_column.list": "No hay nada en esta lista aún. Cuando miembros de esta lista publiquen nuevos estatus, estos aparecerán qui.",
"empty_column.lists": "No tienes ninguna lista. cuando crees una, se mostrará aquí.",
@ -304,7 +304,7 @@
"filter_modal.select_filter.title": "Filtrar esta publicación",
"filter_modal.title.status": "Filtrar una publicación",
"filter_warning.matches_filter": "Coincide con el filtro “{title}”",
"filtered_notifications_banner.pending_requests": "De {count, plural, =0 {nadie} one {una persona} other {# personas}} que puede que conozcas",
"filtered_notifications_banner.pending_requests": "De {count, plural, =0 {nadie} one {una persona} other {# people}} que puede que tú conozcas",
"filtered_notifications_banner.title": "Notificaciones filtradas",
"firehose.all": "Todas",
"firehose.local": "Este servidor",
@ -315,7 +315,7 @@
"follow_suggestions.curated_suggestion": "Recomendaciones del equipo",
"follow_suggestions.dismiss": "No mostrar de nuevo",
"follow_suggestions.featured_longer": "Escogidos por el equipo de {domain}",
"follow_suggestions.friends_of_friends_longer": "Populares entre las personas a las que sigues",
"follow_suggestions.friends_of_friends_longer": "Popular entre las personas a las que sigues",
"follow_suggestions.hints.featured": "Este perfil ha sido seleccionado a mano por el equipo de {domain}.",
"follow_suggestions.hints.friends_of_friends": "Este perfil es popular entre las personas que sigues.",
"follow_suggestions.hints.most_followed": "Este perfil es uno de los más seguidos en {domain}.",
@ -323,11 +323,11 @@
"follow_suggestions.hints.similar_to_recently_followed": "Este perfil es similar a los perfiles que has seguido recientemente.",
"follow_suggestions.personalized_suggestion": "Sugerencia personalizada",
"follow_suggestions.popular_suggestion": "Sugerencia popular",
"follow_suggestions.popular_suggestion_longer": "Populares en {domain}",
"follow_suggestions.popular_suggestion_longer": "Popular en {domain}",
"follow_suggestions.similar_to_recently_followed_longer": "Similares a los perfiles que has seguido recientemente",
"follow_suggestions.view_all": "Ver todo",
"follow_suggestions.who_to_follow": "Recomendamos seguir",
"followed_tags": "Hashtags seguidos",
"followed_tags": "Etiquetas seguidas",
"footer.about": "Acerca de",
"footer.directory": "Directorio de perfiles",
"footer.get_app": "Obtener la aplicación",
@ -344,8 +344,8 @@
"hashtag.column_settings.select.no_options_message": "No se encontraron sugerencias",
"hashtag.column_settings.select.placeholder": "Introducir etiquetas…",
"hashtag.column_settings.tag_mode.all": "Todos estos",
"hashtag.column_settings.tag_mode.any": "Cualquiera de estos",
"hashtag.column_settings.tag_mode.none": "Ninguno de estos",
"hashtag.column_settings.tag_mode.any": "Cualquiera de estas",
"hashtag.column_settings.tag_mode.none": "Ninguna de estas",
"hashtag.column_settings.tag_toggle": "Incluye etiquetas adicionales para esta columna",
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
@ -361,7 +361,7 @@
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
"hints.threads.replies_may_be_missing": "Puede que no se muestren algunas respuestas de otros servidores.",
"hints.threads.see_more": "Ver más respuestas en {domain}",
"home.column_settings.show_reblogs": "Mostrar retoots",
"home.column_settings.show_reblogs": "Mostrar impulsos",
"home.column_settings.show_replies": "Mostrar respuestas",
"home.hide_announcements": "Ocultar anuncios",
"home.pending_critical_update.body": "¡Por favor actualiza tu servidor Mastodon lo antes posible!",
@ -369,7 +369,7 @@
"home.pending_critical_update.title": "¡Actualización de seguridad crítica disponible!",
"home.show_announcements": "Mostrar anuncios",
"ignore_notifications_modal.disclaimer": "Mastodon no puede informar a los usuarios que has ignorado sus notificaciones. Ignorar notificaciones no impedirá que se sigan enviando los mensajes.",
"ignore_notifications_modal.filter_instead": "Filtrar en vez de ignorar",
"ignore_notifications_modal.filter_instead": "Filtrar en su lugar",
"ignore_notifications_modal.filter_to_act_users": "Aún podrás aceptar, rechazar o reportar usuarios",
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtrar ayuda a evitar confusiones potenciales",
"ignore_notifications_modal.filter_to_review_separately": "Puedes revisar las notificaciones filtradas por separado",
@ -399,13 +399,13 @@
"intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
"keyboard_shortcuts.back": "volver atrás",
"keyboard_shortcuts.blocked": "abrir una lista de usuarios bloqueados",
"keyboard_shortcuts.boost": "retootear",
"keyboard_shortcuts.boost": "Impulsar publicación",
"keyboard_shortcuts.column": "enfocar un estado en una de las columnas",
"keyboard_shortcuts.compose": "enfocar el área de texto de redacción",
"keyboard_shortcuts.description": "Descripción",
"keyboard_shortcuts.direct": "para abrir la columna de menciones privadas",
"keyboard_shortcuts.down": "mover hacia abajo en la lista",
"keyboard_shortcuts.enter": "abrir estado",
"keyboard_shortcuts.enter": "Abrir publicación",
"keyboard_shortcuts.favourite": "Marcar como favorita la publicación",
"keyboard_shortcuts.favourites": "Abrir lista de favoritos",
"keyboard_shortcuts.federated": "abrir el timeline federado",
@ -419,16 +419,16 @@
"keyboard_shortcuts.my_profile": "abrir tu perfil",
"keyboard_shortcuts.notifications": "abrir la columna de notificaciones",
"keyboard_shortcuts.open_media": "para abrir archivos multimedia",
"keyboard_shortcuts.pinned": "abrir la lista de toots destacados",
"keyboard_shortcuts.pinned": "Abrir la lista de publicaciones fijadas",
"keyboard_shortcuts.profile": "abrir el perfil del autor",
"keyboard_shortcuts.reply": "para responder",
"keyboard_shortcuts.reply": "Responder a la publicación",
"keyboard_shortcuts.requests": "abrir la lista de peticiones de seguidores",
"keyboard_shortcuts.search": "para poner el foco en la búsqueda",
"keyboard_shortcuts.spoilers": "para mostrar/ocultar el campo CW",
"keyboard_shortcuts.start": "abrir la columna \"comenzar\"",
"keyboard_shortcuts.toggle_hidden": "mostrar/ocultar texto tras aviso de contenido (CW)",
"keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar medios",
"keyboard_shortcuts.toot": "para comenzar un nuevo toot",
"keyboard_shortcuts.toot": "Comenzar una nueva publicación",
"keyboard_shortcuts.unfocus": "para retirar el foco de la caja de redacción/búsqueda",
"keyboard_shortcuts.up": "para ir hacia arriba en la lista",
"lightbox.close": "Cerrar",
@ -474,7 +474,7 @@
"navigation_bar.blocks": "Usuarios bloqueados",
"navigation_bar.bookmarks": "Marcadores",
"navigation_bar.community_timeline": "Historia local",
"navigation_bar.compose": "Escribir un nuevo toot",
"navigation_bar.compose": "Redactar una nueva publicación",
"navigation_bar.direct": "Menciones privadas",
"navigation_bar.discover": "Descubrir",
"navigation_bar.domain_blocks": "Dominios ocultos",
@ -482,7 +482,7 @@
"navigation_bar.favourites": "Favoritos",
"navigation_bar.filters": "Palabras silenciadas",
"navigation_bar.follow_requests": "Solicitudes para seguirte",
"navigation_bar.followed_tags": "Hashtags seguidos",
"navigation_bar.followed_tags": "Etiquetas seguidas",
"navigation_bar.follows_and_followers": "Siguiendo y seguidores",
"navigation_bar.lists": "Listas",
"navigation_bar.logout": "Cerrar sesión",
@ -490,25 +490,25 @@
"navigation_bar.mutes": "Usuarios silenciados",
"navigation_bar.opened_in_classic_interface": "Publicaciones, cuentas y otras páginas específicas se abren por defecto en la interfaz web clásica.",
"navigation_bar.personal": "Personal",
"navigation_bar.pins": "Toots fijados",
"navigation_bar.pins": "Publicaciones fijadas",
"navigation_bar.preferences": "Preferencias",
"navigation_bar.public_timeline": "Historia federada",
"navigation_bar.search": "Buscar",
"navigation_bar.security": "Seguridad",
"not_signed_in_indicator.not_signed_in": "Necesitas iniciar sesión para acceder a este recurso.",
"notification.admin.report": "{name} denunció a {target}",
"notification.admin.report_account": "{name} informó de {count, plural, one {una publicación} other {# publicaciones}} de {target} por {category}",
"notification.admin.report_account_other": "{name} informó de {count, plural, one {una publicación} other {# publicaciones}} de {target}",
"notification.admin.report_statuses": "{name} informó de {target} por {category}",
"notification.admin.report_statuses_other": "{name} informó de {target}",
"notification.admin.report_account": "{name} reportó {count, plural, one {una publicación} other {# publicaciones}} de {target} por {category}",
"notification.admin.report_account_other": "{name} reportó {count, plural, one {una publicación} other {# publicaciones}} de {target}",
"notification.admin.report_statuses": "{name} reportó {target} por {category}",
"notification.admin.report_statuses_other": "{name} reportó {target}",
"notification.admin.sign_up": "{name} se unio",
"notification.admin.sign_up.name_and_others": "{name} y {count, plural, one {# más} other {# más}} se registraron",
"notification.admin.sign_up.name_and_others": "{name} y {count, plural, one {# otro} other {# otros}} se registraron",
"notification.favourite": "{name} marcó como favorita tu publicación",
"notification.favourite.name_and_others_with_link": "{name} y <a>{count, plural, one {# más} other {# más}}</a> marcaron tu publicación como favorita",
"notification.favourite.name_and_others_with_link": "{name} y <a>{count, plural, one {# otro} other {# otros}}</a> marcaron tu publicación como favorita",
"notification.follow": "{name} te empezó a seguir",
"notification.follow.name_and_others": "{name} y {count, plural, one {# más} other {# más}} te siguieron",
"notification.follow.name_and_others": "{name} y {count, plural, one {# otro} other {# otros}} te siguieron",
"notification.follow_request": "{name} ha solicitado seguirte",
"notification.follow_request.name_and_others": "{name} y {count, plural, one {# más} other {# más}} han solicitado seguirte",
"notification.follow_request.name_and_others": "{name} y {count, plural, one {# otro} other {# otros}} han solicitado seguirte",
"notification.label.mention": "Mención",
"notification.label.private_mention": "Mención privada",
"notification.label.private_reply": "Respuesta privada",
@ -519,14 +519,14 @@
"notification.moderation_warning.action_delete_statuses": "Se han eliminado algunas de tus publicaciones.",
"notification.moderation_warning.action_disable": "Tu cuenta ha sido desactivada.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Se han marcado como sensibles algunas de tus publicaciones.",
"notification.moderation_warning.action_none": "Tu cuenta ha recibido un aviso de moderación.",
"notification.moderation_warning.action_none": "Tu cuenta ha recibido una advertencia de moderación.",
"notification.moderation_warning.action_sensitive": "De ahora en adelante, todas tus publicaciones se marcarán como sensibles.",
"notification.moderation_warning.action_silence": "Tu cuenta ha sido limitada.",
"notification.moderation_warning.action_suspend": "Tu cuenta ha sido suspendida.",
"notification.own_poll": "Tu encuesta ha terminado",
"notification.poll": "Una encuesta ha terminado",
"notification.reblog": "{name} ha retooteado tu estado",
"notification.reblog.name_and_others_with_link": "{name} y <a>{count, plural, one {# más} other {# más}}</a> impulsaron tu publicación",
"notification.poll": "Una encuesta en la que has votado ha terminado",
"notification.reblog": "{name} ha impulsado tu publicación",
"notification.reblog.name_and_others_with_link": "{name} y <a>{count, plural, one {# otro} other {# otros}}</a> impulsaron tu publicación",
"notification.relationships_severance_event": "Conexiones perdidas con {name}",
"notification.relationships_severance_event.account_suspension": "Un administrador de {from} ha suspendido {target}, lo que significa que ya no puedes recibir actualizaciones de sus cuentas o interactuar con ellas.",
"notification.relationships_severance_event.domain_block": "Un administrador de {from} ha bloqueado {target}, incluyendo {followersCount} de tus seguidores y {followingCount, plural, one {# cuenta} other {# cuentas}} que sigues.",
@ -536,18 +536,18 @@
"notification.update": "{name} editó una publicación",
"notification_requests.accept": "Aceptar",
"notification_requests.accept_multiple": "{count, plural, one {Aceptar # solicitud…} other {Aceptar # solicitudes…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Aceptar solicitud} other {Aceptar solicitudes}}",
"notification_requests.confirm_accept_multiple.message": "Vas a aceptar {count, plural, one {una solicitud} other {# solicitudes}}. ¿Quieres continuar?",
"notification_requests.confirm_accept_multiple.title": "¿Aceptar las solicitudes?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Descartar solicitud} other {Descartar solicitudes}}",
"notification_requests.confirm_dismiss_multiple.message": "Vas a descartar {count, plural, one {una solicitud} other {# solicitudes}}. No podrás volver a acceder fácilmente a {count, plural, one {ella} other {ellas}} de nuevo. ¿Seguro que quieres continuar?",
"notification_requests.confirm_dismiss_multiple.title": "¿Descartar las solicitudes?",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Solicitud aceptada} other {Solicitudes aceptadas}}",
"notification_requests.confirm_accept_multiple.message": "Estás por aceptar {count, plural, one {una solicitud de notificación} other {# solicitudes de notificación}}. ¿Estás seguro de que quieres continuar?",
"notification_requests.confirm_accept_multiple.title": "¿Deseas aceptar las solicitudes de notificación?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Solicitud descartada} other {Solicitudes descartadas}}",
"notification_requests.confirm_dismiss_multiple.message": "Estás por descartar {count, plural, one {una solicitud de notificación} other {# solicitudes de notificación}}. No serás capaz de acceder fácilmente a {count, plural, one {ella} other {ellas}} de nuevo. ¿Estás seguro de que quieres continuar?",
"notification_requests.confirm_dismiss_multiple.title": "¿Deseas descartar las solicitudes de notificación?",
"notification_requests.dismiss": "Descartar",
"notification_requests.dismiss_multiple": "{count, plural, one {Descartar # solicitud…} other {Descartar # solicitudes…}}",
"notification_requests.edit_selection": "Editar",
"notification_requests.exit_selection": "Hecho",
"notification_requests.explainer_for_limited_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta ha sido limitada por un moderador.",
"notification_requests.explainer_for_limited_remote_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta o su servidor ha sido limitada por un moderador.",
"notification_requests.explainer_for_limited_account": "Las notificaciones de esta cuenta han sido filtradas, ya que la cuenta ha sido limitada por un moderador.",
"notification_requests.explainer_for_limited_remote_account": "Las notificaciones de esta cuenta han sido filtradas, ya que la cuenta o su servidor ha sido limitada por un moderador.",
"notification_requests.maximize": "Maximizar",
"notification_requests.minimize_banner": "Minimizar banner de notificaciones filtradas",
"notification_requests.notifications_from": "Notificaciones de {name}",
@ -555,7 +555,7 @@
"notification_requests.view": "Ver notificaciones",
"notifications.clear": "Limpiar notificaciones",
"notifications.clear_confirmation": "¿Seguro de querer borrar permanentemente todas tus notificaciones?",
"notifications.clear_title": "¿Borrar notificaciones?",
"notifications.clear_title": "¿Limpiar notificaciones?",
"notifications.column_settings.admin.report": "Nuevas denuncias:",
"notifications.column_settings.admin.sign_up": "Registros nuevos:",
"notifications.column_settings.alert": "Notificaciones de escritorio",
@ -567,7 +567,7 @@
"notifications.column_settings.mention": "Menciones:",
"notifications.column_settings.poll": "Resultados de la votación:",
"notifications.column_settings.push": "Notificaciones push",
"notifications.column_settings.reblog": "Retoots:",
"notifications.column_settings.reblog": "Impulsos:",
"notifications.column_settings.show": "Mostrar en columna",
"notifications.column_settings.sound": "Reproducir sonido",
"notifications.column_settings.status": "Nuevas publicaciones:",
@ -575,7 +575,7 @@
"notifications.column_settings.unread_notifications.highlight": "Destacar notificaciones no leídas",
"notifications.column_settings.update": "Ediciones:",
"notifications.filter.all": "Todos",
"notifications.filter.boosts": "Retoots",
"notifications.filter.boosts": "Impulsos",
"notifications.filter.favourites": "Favoritos",
"notifications.filter.follows": "Seguidores",
"notifications.filter.mentions": "Menciones",
@ -621,7 +621,7 @@
"onboarding.profile.display_name_hint": "Tu nombre completo o tu apodo…",
"onboarding.profile.lead": "Siempre puedes completar esto más tarde en los ajustes, donde hay aún más opciones de personalización disponibles.",
"onboarding.profile.note": "Biografía",
"onboarding.profile.note_hint": "Puedes @mencionar a otras personas o #hashtags…",
"onboarding.profile.note_hint": "Puedes @mencionar a otras personas o #etiquetas…",
"onboarding.profile.save_and_continue": "Guardar y continuar",
"onboarding.profile.title": "Configuración del perfil",
"onboarding.profile.upload_avatar": "Subir foto de perfil",
@ -639,7 +639,7 @@
"onboarding.steps.publish_status.title": "Escribe tu primera publicación",
"onboarding.steps.setup_profile.body": "Si rellenas tu perfil tendrás más posibilidades de que otros interactúen contigo.",
"onboarding.steps.setup_profile.title": "Personaliza tu perfil",
"onboarding.steps.share_profile.body": "¡Dile a tus amigos cómo encontrarte en Mastodon!",
"onboarding.steps.share_profile.body": "Dile a tus amigos cómo encontrarte en Mastodon",
"onboarding.steps.share_profile.title": "Comparte tu perfil",
"onboarding.tips.2fa": "<strong>¿Sabías que?</strong> Puedes proteger tu cuenta configurando la autenticación de dos factores en la configuración de su cuenta. Funciona con cualquier aplicación TOTP de su elección, ¡no necesitas número de teléfono!",
"onboarding.tips.accounts_from_other_servers": "<strong>¿Sabías que?</strong> Como Mastodon es descentralizado, algunos perfiles que encuentras están alojados en servidores distintos del tuyo. Y sin embargo, ¡puedes interactuar con ellos! ¡Su servidor corresponde a la segunda mitad de su nombre de usuario!",
@ -665,7 +665,7 @@
"privacy.private.short": "Seguidores",
"privacy.public.long": "Cualquiera dentro y fuera de Mastodon",
"privacy.public.short": "Público",
"privacy.unlisted.additional": "Esto se comporta exactamente igual que el público, excepto que el post no aparecerá en las cronologías en directo o en los hashtags, la exploración o busquedas en Mastodon, incluso si está optado por activar la cuenta de usuario.",
"privacy.unlisted.additional": "Esto se comporta exactamente igual que el público, excepto que el post no aparecerá en las cronologías en directo o en las etiquetas, la exploración o busquedas en Mastodon, incluso si está optado por activar la cuenta de usuario.",
"privacy.unlisted.long": "Menos fanfares algorítmicos",
"privacy.unlisted.short": "Público silencioso",
"privacy_policy.last_updated": "Actualizado por última vez {date}",
@ -699,7 +699,7 @@
"report.category.title_account": "perfil",
"report.category.title_status": "publicación",
"report.close": "Realizado",
"report.comment.title": "¿Hay algo más que usted cree que debamos saber?",
"report.comment.title": "¿Hay algo más que creas que deberíamos saber?",
"report.forward": "Reenviar a {target}",
"report.forward_hint": "Esta cuenta es de otro servidor. ¿Enviar una copia anonimizada del informe allí también?",
"report.mute": "Silenciar",
@ -776,9 +776,9 @@
"status.admin_status": "Abrir este estado en la interfaz de moderación",
"status.block": "Bloquear a @{name}",
"status.bookmark": "Añadir marcador",
"status.cancel_reblog_private": "Eliminar retoot",
"status.cannot_reblog": "Este toot no puede retootearse",
"status.continued_thread": "Continuó el hilo",
"status.cancel_reblog_private": "Deshacer impulso",
"status.cannot_reblog": "Esta publicación no puede ser impulsada",
"status.continued_thread": "Hilo continuado",
"status.copy": "Copiar enlace al estado",
"status.delete": "Borrar",
"status.detailed_status": "Vista de conversación detallada",
@ -803,16 +803,16 @@
"status.mute_conversation": "Silenciar conversación",
"status.open": "Expandir estado",
"status.pin": "Fijar",
"status.pinned": "Toot fijado",
"status.pinned": "Publicación fijada",
"status.read_more": "Leer más",
"status.reblog": "Retootear",
"status.reblog": "Impulsar",
"status.reblog_private": "Implusar a la audiencia original",
"status.reblogged_by": "Retooteado por {name}",
"status.reblogged_by": "Impulsado por {name}",
"status.reblogs": "{count, plural, one {impulso} other {impulsos}}",
"status.reblogs.empty": "Nadie retooteó este toot todavía. Cuando alguien lo haga, aparecerá aquí.",
"status.reblogs.empty": "Nadie impulsó esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
"status.redraft": "Borrar y volver a borrador",
"status.remove_bookmark": "Eliminar marcador",
"status.replied_in_thread": "Respondió en el hilo",
"status.replied_in_thread": "Respondido en el hilo",
"status.replied_to": "Respondió a {name}",
"status.reply": "Responder",
"status.replyAll": "Responder al hilo",

View File

@ -312,7 +312,7 @@
"follow_request.authorize": "Autoriser",
"follow_request.reject": "Rejeter",
"follow_requests.unlocked_explanation": "Même si votre compte nest pas privé, léquipe de {domain} a pensé que vous pourriez vouloir peut-être consulter manuellement les demandes d'abonnement de ces comptes.",
"follow_suggestions.curated_suggestion": "Choix du staff",
"follow_suggestions.curated_suggestion": "Sélectionné par l'équipe",
"follow_suggestions.dismiss": "Ne plus afficher",
"follow_suggestions.featured_longer": "Sélectionné par l'équipe de {domain}",
"follow_suggestions.friends_of_friends_longer": "Populaire dans le cercle des personnes que vous suivez",

View File

@ -312,7 +312,7 @@
"follow_request.authorize": "Accepter",
"follow_request.reject": "Rejeter",
"follow_requests.unlocked_explanation": "Même si votre compte nest pas privé, léquipe de {domain} a pensé que vous pourriez vouloir consulter manuellement les demandes de suivi de ces comptes.",
"follow_suggestions.curated_suggestion": "Choix du staff",
"follow_suggestions.curated_suggestion": "Sélectionné par l'équipe",
"follow_suggestions.dismiss": "Ne plus afficher",
"follow_suggestions.featured_longer": "Sélectionné par l'équipe de {domain}",
"follow_suggestions.friends_of_friends_longer": "Populaire dans le cercle des personnes que vous suivez",

View File

@ -36,7 +36,7 @@
"account.followers.empty": "Chan eil neach sam bith a leantainn air a chleachdaiche seo fhathast.",
"account.followers_counter": "{count, plural, one {{counter} neach-leantainn} other {{counter} luchd-leantainn}}",
"account.following": "A leantainn",
"account.following_counter": "{count, plural, one {Tha {counter} a leantainn} other {Tha {counter} a leantainn}}",
"account.following_counter": "{count, plural, one {A leantainn {counter}} other {A leantainn {counter}}}",
"account.follows.empty": "Chan eil an cleachdaiche seo a leantainn neach sam bith fhathast.",
"account.go_to_profile": "Tadhail air a phròifil",
"account.hide_reblogs": "Falaich na brosnachaidhean o @{name}",

View File

@ -787,6 +787,7 @@
"status.edit": "עריכה",
"status.edited": "נערך לאחרונה {date}",
"status.edited_x_times": "נערך {count, plural, one {פעם {count}} other {{count} פעמים}}",
"status.embed": "העתקת קוד להטמעה",
"status.favourite": "חיבוב",
"status.favourites": "{count, plural, one {חיבוב אחד} two {זוג חיבובים} other {# חיבובים}}",
"status.filter": "סנן הודעה זו",

View File

@ -2,12 +2,14 @@
"about.blocks": "Ulac agbur",
"about.contact": "Anermis:",
"about.disclaimer": "Mastodon d aseɣẓan ilelli, d aseɣẓan n uɣbalu yeldin, d tnezzut n Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Ulac taɣẓint",
"about.domain_blocks.preamble": "Maṣṭudun s umata yeḍmen-ak ad teẓreḍ agbur, ad tesdemreḍ akked yimseqdacen-nniḍen seg yal aqeddac deg fedivers. Ha-tent-an ɣur-k tsuraf i yellan deg uqeddac-agi.",
"about.domain_blocks.silenced.title": "Ɣur-s talast",
"about.domain_blocks.suspended.title": "Yeḥbes",
"about.not_available": "Talɣut-a ur tettwabder ara deg uqeddac-a.",
"about.powered_by": "Azeṭṭa inmetti yettwasɣelsen sɣur {mastodon}",
"about.rules": "Ilugan n uqeddac",
"account.account_note_header": "Tamawt tudmawant",
"account.add_or_remove_from_list": "Rnu neɣ kkes seg tebdarin",
"account.badges.bot": "Aṛubut",
"account.badges.group": "Agraw",
@ -46,6 +48,7 @@
"account.mute_notifications_short": "Susem alɣuten",
"account.mute_short": "Sgugem",
"account.muted": "Yettwasgugem",
"account.mutual": "Temṭafarem",
"account.no_bio": "Ulac aglam i d-yettunefken.",
"account.open_original_page": "Ldi asebter anasli",
"account.posts": "Tisuffaɣ",
@ -62,6 +65,7 @@
"account.unendorse": "Ur ttwellih ara fell-as deg umaɣnu-inek",
"account.unfollow": "Ur ṭṭafaṛ ara",
"account.unmute": "Kkes asgugem ɣef @{name}",
"account.unmute_notifications_short": "Serreḥ i yilɣa",
"account.unmute_short": "Kkes asgugem",
"account_note.placeholder": "Ulac iwenniten",
"admin.dashboard.retention.cohort_size": "Iseqdacen imaynuten",
@ -152,6 +156,7 @@
"confirmations.edit.message": "Abeddel tura ad d-yaru izen-nni i d-tegreḍ akka tura. Tetḥeqqeḍ tebɣiḍ ad tkemmleḍ?",
"confirmations.logout.confirm": "Ffeɣ",
"confirmations.logout.message": "D tidet tebɣiḍ ad teffɣeḍ?",
"confirmations.logout.title": "Tebɣiḍ ad teffɣeḍ ssya?",
"confirmations.mute.confirm": "Sgugem",
"confirmations.redraft.confirm": "Kkes sakin ɛiwed tira",
"confirmations.reply.confirm": "Err",
@ -351,6 +356,7 @@
"lists.subheading": "Tibdarin-ik·im",
"load_pending": "{count, plural, one {# n uferdis amaynut} other {# n yiferdisen imaynuten}}",
"loading_indicator.label": "Yessalay-d …",
"media_gallery.hide": "Ffer-it",
"mute_modal.hide_from_notifications": "Ffer-it deg ulɣuten",
"mute_modal.hide_options": "Ffer tinefrunin",
"mute_modal.indefinite": "Alamma ssnesreɣ asgugem fell-as",
@ -405,6 +411,7 @@
"notification.status": "{name} akken i d-yessufeɣ",
"notification_requests.accept": "Qbel",
"notification_requests.dismiss": "Agi",
"notification_requests.edit_selection": "Ẓreg",
"notification_requests.exit_selection": "Immed",
"notification_requests.notifications_from": "Alɣuten sɣur {name}",
"notifications.clear": "Sfeḍ alɣuten",

View File

@ -778,6 +778,7 @@
"status.bookmark": "북마크",
"status.cancel_reblog_private": "부스트 취소",
"status.cannot_reblog": "이 게시물은 부스트 할 수 없습니다",
"status.continued_thread": "이어지는 글타래",
"status.copy": "게시물 링크 복사",
"status.delete": "삭제",
"status.detailed_status": "대화 자세히 보기",
@ -786,6 +787,7 @@
"status.edit": "수정",
"status.edited": "{date}에 마지막으로 편집됨",
"status.edited_x_times": "{count, plural, other {{count}}} 번 수정됨",
"status.embed": "임베드 코드 받기",
"status.favourite": "좋아요",
"status.favourites": "{count, plural, other {좋아요}}",
"status.filter": "이 게시물을 필터",
@ -810,6 +812,7 @@
"status.reblogs.empty": "아직 아무도 이 게시물을 부스트하지 않았습니다. 부스트 한 사람들이 여기에 표시 됩니다.",
"status.redraft": "지우고 다시 쓰기",
"status.remove_bookmark": "북마크 삭제",
"status.replied_in_thread": "글타래에 답장",
"status.replied_to": "{name} 님에게",
"status.reply": "답장",
"status.replyAll": "글타래에 답장",

View File

@ -409,6 +409,7 @@
"lists.subheading": "Tavi saraksti",
"load_pending": "{count, plural, one {# jauna lieta} other {# jaunas lietas}}",
"loading_indicator.label": "Ielādē…",
"media_gallery.hide": "Paslēpt",
"moved_to_account_banner.text": "Tavs konts {disabledAccount} pašlaik ir atspējots, jo Tu pārcēlies uz kontu {movedToAccount}.",
"mute_modal.hide_from_notifications": "Paslēpt paziņojumos",
"mute_modal.hide_options": "Paslēpt iespējas",

View File

@ -34,7 +34,9 @@
"account.follow_back": "Подписаться в ответ",
"account.followers": "Подписчики",
"account.followers.empty": "На этого пользователя пока никто не подписан.",
"account.followers_counter": "{count, plural, one {{counter} последователя} other {{counter} последователей}}",
"account.following": "Подписки",
"account.following_counter": "{count, plural, one {{counter} последующий} other {{counter} последующие}}",
"account.follows.empty": "Этот пользователь пока ни на кого не подписался.",
"account.go_to_profile": "Перейти к профилю",
"account.hide_reblogs": "Скрыть продвижения от @{name}",
@ -48,7 +50,7 @@
"account.moved_to": "У {name} теперь новый аккаунт:",
"account.mute": "Игнорировать @{name}",
"account.mute_notifications_short": "Отключить уведомления",
"account.mute_short": "Глохни!",
"account.mute_short": "Приглушить",
"account.muted": "Игнорируется",
"account.mutual": "Взаимно",
"account.no_bio": "Описание не предоставлено.",
@ -94,6 +96,8 @@
"block_modal.title": "Заблокировать пользователя?",
"block_modal.you_wont_see_mentions": "Вы не увидите записи, которые упоминают его.",
"boost_modal.combo": "{combo}, чтобы пропустить это в следующий раз",
"boost_modal.reblog": "Повысить пост?",
"boost_modal.undo_reblog": "Разгрузить пост?",
"bundle_column_error.copy_stacktrace": "Скопировать отчет об ошибке",
"bundle_column_error.error.body": "Запрошенная страница не может быть отображена. Это может быть вызвано ошибкой в нашем коде или проблемой совместимости браузера.",
"bundle_column_error.error.title": "О нет!",
@ -298,6 +302,8 @@
"filter_modal.select_filter.subtitle": "Используйте существующую категорию или создайте новую",
"filter_modal.select_filter.title": "Фильтровать этот пост",
"filter_modal.title.status": "Фильтровать пост",
"filter_warning.matches_filter": "Соответствует фильтру \"{title}\"",
"filtered_notifications_banner.pending_requests": "Вы можете знать {count, plural, =0 {ни один} one {один человек} other {# люди}}",
"filtered_notifications_banner.title": "Отфильтрованные уведомления",
"firehose.all": "Все",
"firehose.local": "Текущий сервер",
@ -346,6 +352,14 @@
"hashtag.follow": "Подписаться на новые посты",
"hashtag.unfollow": "Отписаться",
"hashtags.and_other": "...и {count, plural, other {# ещё}}",
"hints.profiles.followers_may_be_missing": "Последователи для этого профиля могут отсутствовать.",
"hints.profiles.follows_may_be_missing": "Фолловеры для этого профиля могут отсутствовать.",
"hints.profiles.posts_may_be_missing": "Некоторые сообщения из этого профиля могут отсутствовать.",
"hints.profiles.see_more_followers": "Посмотреть больше подписчиков на {domain}",
"hints.profiles.see_more_follows": "Смотрите другие материалы по теме {domain}",
"hints.profiles.see_more_posts": "Посмотреть другие сообщения на {domain}",
"hints.threads.replies_may_be_missing": "Ответы с других серверов могут отсутствовать.",
"hints.threads.see_more": "Посмотреть другие ответы на {domain}",
"home.column_settings.show_reblogs": "Показывать продвижения",
"home.column_settings.show_replies": "Показывать ответы",
"home.hide_announcements": "Скрыть объявления",
@ -353,7 +367,17 @@
"home.pending_critical_update.link": "Посмотреть обновления",
"home.pending_critical_update.title": "Доступно критическое обновление безопасности!",
"home.show_announcements": "Показать объявления",
"ignore_notifications_modal.disclaimer": "Mastodon не может сообщить пользователям, что вы проигнорировали их уведомления. Игнорирование уведомлений не остановит отправку самих сообщений.",
"ignore_notifications_modal.filter_instead": "Фильтр вместо",
"ignore_notifications_modal.filter_to_act_users": "Вы и далее сможете принять, отвергнуть и жаловаться на пользователей",
"ignore_notifications_modal.filter_to_avoid_confusion": "Фильтрация помогает избежать потенциальной путаницы",
"ignore_notifications_modal.filter_to_review_separately": "Вы можете просматривать отфильтрованные уведомления отдельно",
"ignore_notifications_modal.ignore": "Игнорировать уведомления",
"ignore_notifications_modal.limited_accounts_title": "Игнорировать уведомления от модерируемых аккаунтов?",
"ignore_notifications_modal.new_accounts_title": "Игнорировать уведомления от новых аккаунтов?",
"ignore_notifications_modal.not_followers_title": "Игнорировать уведомления от людей, которые не следят за вами?",
"ignore_notifications_modal.not_following_title": "Игнорировать уведомления от людей, за которыми вы не следите?",
"ignore_notifications_modal.private_mentions_title": "Игнорировать уведомления о нежелательных личных сообщениях?",
"interaction_modal.description.favourite": "С учётной записью Mastodon, вы можете добавить этот пост в избранное, чтобы сохранить его на будущее и дать автору знать, что пост вам понравился.",
"interaction_modal.description.follow": "С учётной записью Mastodon вы можете подписаться на {name}, чтобы получать их посты в своей домашней ленте.",
"interaction_modal.description.reblog": "С учётной записью Mastodon, вы можете продвинуть этот пост, чтобы поделиться им со своими подписчиками.",
@ -432,6 +456,7 @@
"lists.subheading": "Ваши списки",
"load_pending": "{count, plural, one {# новый элемент} few {# новых элемента} other {# новых элементов}}",
"loading_indicator.label": "Загрузка…",
"media_gallery.hide": "Скрыть",
"moved_to_account_banner.text": "Ваша учетная запись {disabledAccount} в настоящее время заморожена, потому что вы переехали на {movedToAccount}.",
"mute_modal.hide_from_notifications": "Скрыть из уведомлений",
"mute_modal.hide_options": "Скрыть параметры",
@ -443,6 +468,7 @@
"mute_modal.you_wont_see_mentions": "Вы не увидите постов, которые их упоминают.",
"mute_modal.you_wont_see_posts": "Они по-прежнему смогут видеть ваши посты, но вы не сможете видеть их посты.",
"navigation_bar.about": "О проекте",
"navigation_bar.administration": "Администрация",
"navigation_bar.advanced_interface": "Включить многоколоночный интерфейс",
"navigation_bar.blocks": "Заблокированные пользователи",
"navigation_bar.bookmarks": "Закладки",
@ -459,6 +485,7 @@
"navigation_bar.follows_and_followers": "Подписки и подписчики",
"navigation_bar.lists": "Списки",
"navigation_bar.logout": "Выйти",
"navigation_bar.moderation": "Модерация",
"navigation_bar.mutes": "Игнорируемые пользователи",
"navigation_bar.opened_in_classic_interface": "Сообщения, учётные записи и другие специфические страницы по умолчанию открываются в классическом веб-интерфейсе.",
"navigation_bar.personal": "Личное",
@ -469,10 +496,22 @@
"navigation_bar.security": "Безопасность",
"not_signed_in_indicator.not_signed_in": "Вам нужно войти, чтобы иметь доступ к этому ресурсу.",
"notification.admin.report": "{name} сообщил о {target}",
"notification.admin.report_account": "{name} сообщил {count, plural, one {один пост} other {# постов}} от {target} для {category}",
"notification.admin.report_account_other": "{name} сообщил {count, plural, one {одно сообщение} other {# сообщений}} от {target}",
"notification.admin.report_statuses": "{name} сообщил {target} для {category}",
"notification.admin.report_statuses_other": "{name} сообщает {target}",
"notification.admin.sign_up": "{name} зарегистрирован",
"notification.admin.sign_up.name_and_others": "{name} и {count, plural, one {# другой} other {# другие}} подписались",
"notification.favourite": "{name} добавил(а) ваш пост в избранное",
"notification.favourite.name_and_others_with_link": "{name} и <a>{count, plural, one {# другие} other {# другие}}</a> отдали предпочтение вашему посту",
"notification.follow": "{name} подписался (-лась) на вас",
"notification.follow_request": "{name} отправил запрос на подписку",
"notification.follow_request.name_and_others": "{name} и {count, plural, one {# другие} other {# другие}} последовали за тобой",
"notification.label.mention": "Упоминание",
"notification.label.private_mention": "Частное упоминание",
"notification.label.private_reply": "Частный ответ",
"notification.label.reply": "Ответить",
"notification.mention": "Упоминание",
"notification.moderation-warning.learn_more": "Узнать больше",
"notification.moderation_warning": "Вы получили предупреждение от модерации",
"notification.moderation_warning.action_delete_statuses": "Некоторые из ваших публикаций были удалены.",
@ -483,7 +522,9 @@
"notification.moderation_warning.action_silence": "Ваша учётная запись была ограничена.",
"notification.moderation_warning.action_suspend": "Действие вашей учётной записи приостановлено.",
"notification.own_poll": "Ваш опрос закончился",
"notification.poll": "Голосование, в котором вы приняли участие, завершилось",
"notification.reblog": "{name} продвинул(а) ваш пост",
"notification.reblog.name_and_others_with_link": "{name} и <a>{count, plural, one {# other} other {# others}}</a> увеличили ваш пост",
"notification.relationships_severance_event": "Потеряно соединение с {name}",
"notification.relationships_severance_event.account_suspension": "Администратор {from} заблокировал {target}, что означает, что вы больше не сможете получать обновления от них или взаймодествовать с ними.",
"notification.relationships_severance_event.domain_block": "Администратор {from} заблокировал {target} включая {followersCount} ваших подписчиков и {followingCount, plural, one {# аккаунт} few {# аккаунта} other {# аккаунтов}}, на которые вы подписаны.",
@ -492,10 +533,19 @@
"notification.status": "{name} только что запостил",
"notification.update": "{name} изменил(а) пост",
"notification_requests.accept": "Принять",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Принять запрос} other {Принять запросы}}",
"notification_requests.confirm_accept_multiple.title": "Принимать запросы на уведомления?",
"notification_requests.confirm_dismiss_multiple.title": "Отклонять запросы на уведомления?",
"notification_requests.dismiss": "Отклонить",
"notification_requests.edit_selection": "Редактировать",
"notification_requests.exit_selection": "Готово",
"notification_requests.explainer_for_limited_account": "Уведомления от этой учетной записи были отфильтрованы, поскольку учетная запись была ограничена модератором.",
"notification_requests.explainer_for_limited_remote_account": "Уведомления от этой учетной записи были отфильтрованы, поскольку учетная запись или ее сервер были ограничены модератором.",
"notification_requests.maximize": "Развернуть",
"notification_requests.minimize_banner": "Минимизация баннера отфильтрованных уведомлений",
"notification_requests.notifications_from": "Уведомления от {name}",
"notification_requests.title": "Отфильтрованные уведомления",
"notification_requests.view": "Просмотр уведомлений",
"notifications.clear": "Очистить уведомления",
"notifications.clear_confirmation": "Вы уверены, что хотите очистить все уведомления?",
"notifications.clear_title": "Сбросить уведомления?",
@ -530,7 +580,14 @@
"notifications.permission_denied": "Уведомления на рабочем столе недоступны, так как вы запретили их отправку в браузере. Проверьте настройки для сайта, чтобы включить их обратно.",
"notifications.permission_denied_alert": "Уведомления на рабочем столе недоступны, так как вы ранее отклонили запрос на их отправку.",
"notifications.permission_required": "Чтобы включить уведомления на рабочем столе, необходимо разрешить их в браузере.",
"notifications.policy.accept": "Принять",
"notifications.policy.accept_hint": "Показать в уведомлениях",
"notifications.policy.drop": "Игнорируем",
"notifications.policy.drop_hint": "Отправить в пустоту, чтобы никогда больше не увидеть",
"notifications.policy.filter": "Фильтр",
"notifications.policy.filter_hint": "Отправка в папку фильтрованных уведомлений",
"notifications.policy.filter_limited_accounts_hint": "Ограничено модераторами сервера",
"notifications.policy.filter_limited_accounts_title": "Модерируемые аккаунты",
"notifications.policy.filter_new_accounts.hint": "Создано в течение последних {days, plural, one {один день} few {# дней} many {# дней} other {# дня}}",
"notifications.policy.filter_new_accounts_title": "Новые учётные записи",
"notifications.policy.filter_not_followers_title": "Люди, не подписанные на вас",
@ -538,6 +595,7 @@
"notifications.policy.filter_not_following_title": "Люди, на которых вы не подписаны",
"notifications.policy.filter_private_mentions_hint": "Фильтруется, если только это не ответ на ваше собственное упоминание или если вы подписаны на отправителя",
"notifications.policy.filter_private_mentions_title": "Нежелательные личные упоминания",
"notifications.policy.title": "………Управлять уведомлениями от…",
"notifications_permission_banner.enable": "Включить уведомления",
"notifications_permission_banner.how_to_control": "Получайте уведомления даже когда Mastodon закрыт, включив уведомления на рабочем столе. А чтобы лишний шум не отвлекал, вы можете настроить какие уведомления вы хотите получать, нажав на кнопку {icon} выше.",
"notifications_permission_banner.title": "Будьте в курсе происходящего",
@ -666,6 +724,7 @@
"report_notification.categories.legal": "Правовая информация",
"report_notification.categories.legal_sentence": "срамной контент",
"report_notification.categories.other": "Прочее",
"report_notification.categories.other_sentence": "другое",
"report_notification.categories.spam": "Спам",
"report_notification.categories.spam_sentence": "спам",
"report_notification.categories.violation": "Нарушение правил",
@ -696,8 +755,11 @@
"server_banner.about_active_users": "Люди, заходившие на этот сервер за последние 30 дней (ежемесячные активные пользователи)",
"server_banner.active_users": "активные пользователи",
"server_banner.administered_by": "Управляется:",
"server_banner.is_one_of_many": "{domain} - это один из многих независимых серверов Mastodon, которые вы можете использовать для участия в fediverse.",
"server_banner.server_stats": "Статистика сервера:",
"sign_in_banner.create_account": "Создать учётную запись",
"sign_in_banner.follow_anyone": "Следите за любым человеком в федеральной вселенной и смотрите все в хронологическом порядке. Никаких алгоритмов, рекламы или клик бейта.",
"sign_in_banner.mastodon_is": "Mastodon - лучший способ быть в курсе всего происходящего.",
"sign_in_banner.sign_in": "Войти",
"sign_in_banner.sso_redirect": "Войдите или Зарегистрируйтесь",
"status.admin_account": "Открыть интерфейс модератора для @{name}",
@ -707,6 +769,7 @@
"status.bookmark": "Сохранить в закладки",
"status.cancel_reblog_private": "Не продвигать",
"status.cannot_reblog": "Этот пост не может быть продвинут",
"status.continued_thread": "Продолжение темы",
"status.copy": "Скопировать ссылку на пост",
"status.delete": "Удалить",
"status.detailed_status": "Подробный просмотр обсуждения",
@ -715,6 +778,7 @@
"status.edit": "Изменить",
"status.edited": "Дата последнего изменения: {date}",
"status.edited_x_times": "{count, plural, one {{count} изменение} many {{count} изменений} other {{count} изменения}}",
"status.embed": "Получить код для встраивания",
"status.favourite": "Избранное",
"status.filter": "Фильтровать этот пост",
"status.history.created": "{name} создал {date}",
@ -737,6 +801,7 @@
"status.reblogs.empty": "Никто ещё не продвинул этот пост. Как только кто-то это сделает, они появятся здесь.",
"status.redraft": "Создать заново",
"status.remove_bookmark": "Убрать из закладок",
"status.replied_in_thread": "Ответил в теме",
"status.replied_to": "Ответил(а) {name}",
"status.reply": "Ответить",
"status.replyAll": "Ответить всем",

View File

@ -38,13 +38,20 @@ const scroll = (
const isScrollBehaviorSupported =
'scrollBehavior' in document.documentElement.style;
export const scrollRight = (node: Element, position: number) => {
if (isScrollBehaviorSupported)
node.scrollTo({ left: position, behavior: 'smooth' });
else scroll(node, 'scrollLeft', position);
};
export const scrollRight = (node: Element, position: number) =>
requestIdleCallback(() => {
if (isScrollBehaviorSupported) {
node.scrollTo({ left: position, behavior: 'smooth' });
} else {
scroll(node, 'scrollLeft', position);
}
});
export const scrollTop = (node: Element) => {
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' });
else scroll(node, 'scrollTop', 0);
};
export const scrollTop = (node: Element) =>
requestIdleCallback(() => {
if (isScrollBehaviorSupported) {
node.scrollTo({ top: 0, behavior: 'smooth' });
} else {
scroll(node, 'scrollTop', 0);
}
});

View File

@ -209,7 +209,6 @@ const KNOWN_EVENT_TYPES = [
'notification',
'conversation',
'filters_changed',
'encrypted_message',
'announcement',
'announcement.delete',
'announcement.reaction',

View File

@ -8,6 +8,14 @@ import { render as rtlRender } from '@testing-library/react';
import { IdentityContext } from './identity_context';
beforeEach(() => {
global.requestIdleCallback = jest
.fn()
.mockImplementation((fn: () => void) => {
fn();
});
});
function render(
ui: React.ReactElement,
{

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M800-600v-120H680v-80h120q33 0 56.5 23.5T880-720v120h-80Zm-720 0v-120q0-33 23.5-56.5T160-800h120v80H160v120H80Zm600 440v-80h120v-120h80v120q0 33-23.5 56.5T800-160H680Zm-520 0q-33 0-56.5-23.5T80-240v-120h80v120h120v80H160Zm80-160v-320h480v320H240Z"/></svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M800-600v-120H680v-80h120q33 0 56.5 23.5T880-720v120h-80Zm-720 0v-120q0-33 23.5-56.5T160-800h120v80H160v120H80Zm600 440v-80h120v-120h80v120q0 33-23.5 56.5T800-160H680Zm-520 0q-33 0-56.5-23.5T80-240v-120h80v120h120v80H160Zm80-160v-320h480v320H240Zm80-80h320v-160H320v160Zm0 0v-160 160Z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@ -1,3 +1,5 @@
@use 'sass:color';
// Dependent colors
$black: #000000;
$white: #ffffff;
@ -47,11 +49,19 @@ $account-background-color: $white !default;
// Invert darkened and lightened colors
@function darken($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) + $amount);
@return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) + $amount
);
}
@function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
@return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) - $amount
);
}
$emojis-requiring-inversion: 'chains';

View File

@ -2032,13 +2032,14 @@ body > [data-popper-placement] {
display: block;
position: relative;
border-radius: var(--avatar-border-radius);
background-color: var(--surface-background-color);
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--avatar-border-radius);
display: inline-block; // to not show broken images
}
&-inline {
@ -5763,19 +5764,34 @@ a.status-card {
height: 100%;
position: relative;
&__close,
&__zoom-button {
color: rgba($white, 0.7);
&__buttons {
position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
display: flex;
gap: 8px;
align-items: center;
&:hover,
&:focus,
&:active {
color: $white;
background-color: rgba($white, 0.15);
}
.icon-button {
color: rgba($white, 0.7);
padding: 8px;
&:focus {
background-color: rgba($white, 0.3);
.icon {
width: 24px;
height: 24px;
}
&:hover,
&:focus,
&:active {
color: $white;
background-color: rgba($white, 0.15);
}
&:focus {
background-color: rgba($white, 0.3);
}
}
}
}
@ -5936,28 +5952,6 @@ a.status-card {
}
}
.media-modal__close {
position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
}
.media-modal__zoom-button {
position: absolute;
inset-inline-end: 64px;
top: 8px;
z-index: 100;
pointer-events: auto;
transition: opacity 0.3s linear;
will-change: opacity;
}
.media-modal__zoom-button--hidden {
pointer-events: none;
opacity: 0;
}
.onboarding-modal,
.error-modal,
.embed-modal {

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.1002 20.2C2.46686 20.2 1.9252 19.9833 1.4752 19.55C1.04186 19.1 0.825195 18.5583 0.825195 17.925V6.07499C0.825195 5.44165 1.04186 4.90832 1.4752 4.47499C1.9252 4.02499 2.46686 3.79999 3.1002 3.79999H20.9002C21.5335 3.79999 22.0669 4.02499 22.5002 4.47499C22.9502 4.90832 23.1752 5.44165 23.1752 6.07499V17.925C23.1752 18.5583 22.9502 19.1 22.5002 19.55C22.0669 19.9833 21.5335 20.2 20.9002 20.2H3.1002ZM3.1002 17.925H20.9002V6.07499H3.1002V17.925Z" fill="black"/>
<path d="M8.12522 16V9.85782H6.25043V8H10V16H8.12522ZM11.1461 16V14.1422H13.0209V16H11.1461ZM15.1252 16V9.85782H13.2313V8H17V16H15.1252ZM11.1461 12.8578V11H13.0209V12.8578H11.1461Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 776 B

View File

@ -8,44 +8,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
dereference_object!
case @object['type']
when 'EncryptedMessage'
create_encrypted_message
else
create_status
end
create_status
end
private
def create_encrypted_message
return reject_payload! if non_matching_uri_hosts?(@account.uri, object_uri) || @options[:delivered_to_account_id].blank?
target_account = Account.find(@options[:delivered_to_account_id])
target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId'))
return if target_device.nil?
target_device.encrypted_messages.create!(
from_account: @account,
from_device_id: @object.dig('attributedTo', 'deviceId'),
type: @object['messageType'],
body: @object['cipherText'],
digest: @object.dig('digest', 'digestValue'),
message_franking: message_franking.to_token
)
end
def message_franking
MessageFranking.new(
hmac: @object.dig('digest', 'digestValue'),
original_franking: @object['messageFranking'],
source_account_id: @account.id,
target_account_id: @options[:delivered_to_account_id],
timestamp: Time.now.utc
)
end
def create_status
return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity?

View File

@ -618,7 +618,7 @@ class FeedManager
arr = crutches[:active_mentions][s.id] || []
arr.push(s.account_id)
if s.reblog?
if s.reblog? && s.reblog.present?
arr.push(s.reblog.account_id)
arr.concat(crutches[:active_mentions][s.reblog_of_id] || [])
end

View File

@ -20,8 +20,6 @@ class InlineRenderer
serializer = REST::AnnouncementSerializer
when :reaction
serializer = REST::ReactionSerializer
when :encrypted_message
serializer = REST::EncryptedMessageSerializer
else
return
end

View File

@ -77,7 +77,7 @@ class Request
@url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client)
@allow_local = options.delete(:allow_local)
@full_path = options.delete(:with_query_string)
@full_path = !options.delete(:omit_query_string)
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
@options = @options.merge(proxy_url) if use_proxy?

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
class Vacuum::SystemKeysVacuum
def perform
vacuum_expired_system_keys!
end
private
def vacuum_expired_system_keys!
SystemKey.expired.delete_all
end
end

View File

@ -56,9 +56,11 @@ class AdminMailer < ApplicationMailer
def new_critical_software_updates
@software_updates = SoftwareUpdate.where(urgent: true).to_a.sort_by(&:gem_version)
headers['Priority'] = 'urgent'
headers['X-Priority'] = '1'
headers['Importance'] = 'high'
headers(
'Importance' => 'high',
'Priority' => 'urgent',
'X-Priority' => '1'
)
locale_for_account(@me) do
mail subject: default_i18n_subject(instance: @instance)

View File

@ -16,8 +16,10 @@ class ApplicationMailer < ActionMailer::Base
end
def set_autoreply_headers!
headers['Precedence'] = 'list'
headers['X-Auto-Response-Suppress'] = 'All'
headers['Auto-Submitted'] = 'auto-generated'
headers(
'Auto-Submitted' => 'auto-generated',
'Precedence' => 'list',
'X-Auto-Response-Suppress' => 'All'
)
end
end

View File

@ -6,7 +6,10 @@ class NotificationMailer < ApplicationMailer
:routing
before_action :process_params
before_action :set_status, only: [:mention, :favourite, :reblog]
with_options only: %i(mention favourite reblog) do
before_action :set_status
after_action :thread_by_conversation!
end
before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
after_action :set_list_headers!
@ -18,7 +21,6 @@ class NotificationMailer < ApplicationMailer
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail subject: default_i18n_subject(name: @status.account.acct)
end
end
@ -35,7 +37,6 @@ class NotificationMailer < ApplicationMailer
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail subject: default_i18n_subject(name: @account.acct)
end
end
@ -44,7 +45,6 @@ class NotificationMailer < ApplicationMailer
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail subject: default_i18n_subject(name: @account.acct)
end
end
@ -76,17 +76,21 @@ class NotificationMailer < ApplicationMailer
end
def set_list_headers!
headers['List-ID'] = "<#{@type}.#{@me.username}.#{Rails.configuration.x.local_domain}>"
headers['List-Unsubscribe'] = "<#{@unsubscribe_url}>"
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
headers(
'List-ID' => "<#{@type}.#{@me.username}.#{Rails.configuration.x.local_domain}>",
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
'List-Unsubscribe' => "<#{@unsubscribe_url}>"
)
end
def thread_by_conversation(conversation)
return if conversation.nil?
def thread_by_conversation!
return if @status.conversation.nil?
msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>"
conversation_message_id = "<conversation-#{@status.conversation.id}.#{@status.conversation.created_at.to_date}@#{Rails.configuration.x.local_domain}>"
headers['In-Reply-To'] = msg_id
headers['References'] = msg_id
headers(
'In-Reply-To' => conversation_message_id,
'References' => conversation_message_id
)
end
end

View File

@ -44,7 +44,6 @@
# hide_collections :boolean
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# devices_url :string
# suspension_origin :integer
# sensitized_at :datetime
# trendable :boolean
@ -56,11 +55,12 @@
class Account < ApplicationRecord
self.ignored_columns += %w(
subscription_expires_at
secret
devices_url
hub_url
remote_url
salmon_url
hub_url
secret
subscription_expires_at
trust_level
)

View File

@ -7,9 +7,6 @@ module Account::Associations
# Local users
has_one :user, inverse_of: :account, dependent: :destroy
# E2EE
has_many :devices, dependent: :destroy, inverse_of: :account
# Timelines
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy

View File

@ -3,6 +3,11 @@
module Reviewable
extend ActiveSupport::Concern
included do
scope :reviewed, -> { where.not(reviewed_at: nil) }
scope :unreviewed, -> { where(reviewed_at: nil) }
end
def requires_review?
reviewed_at.nil?
end

View File

@ -1,36 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: devices
#
# id :bigint(8) not null, primary key
# access_token_id :bigint(8)
# account_id :bigint(8)
# device_id :string default(""), not null
# name :string default(""), not null
# fingerprint_key :text default(""), not null
# identity_key :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Device < ApplicationRecord
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken'
belongs_to :account
has_many :one_time_keys, dependent: :destroy, inverse_of: :device
has_many :encrypted_messages, dependent: :destroy, inverse_of: :device
validates :name, :fingerprint_key, :identity_key, presence: true
validates :fingerprint_key, :identity_key, ed25519_key: true
before_save :invalidate_associations, if: -> { device_id_changed? || fingerprint_key_changed? || identity_key_changed? }
private
def invalidate_associations
one_time_keys.destroy_all
encrypted_messages.destroy_all
end
end

View File

@ -1,49 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_messages
#
# id :bigint(8) not null, primary key
# device_id :bigint(8)
# from_account_id :bigint(8)
# from_device_id :string default(""), not null
# type :integer default(0), not null
# body :text default(""), not null
# digest :text default(""), not null
# message_franking :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class EncryptedMessage < ApplicationRecord
self.inheritance_column = nil
include Paginable
include Redisable
scope :up_to, ->(id) { where(arel_table[:id].lteq(id)) }
belongs_to :device
belongs_to :from_account, class_name: 'Account'
around_create Mastodon::Snowflake::Callbacks
after_commit :push_to_streaming_api
private
def push_to_streaming_api
return if destroyed? || !subscribed_to_timeline?
PushEncryptedMessageWorker.perform_async(id)
end
def subscribed_to_timeline?
redis.exists?("subscribed:#{streaming_channel}")
end
def streaming_channel
"timeline:#{device.account_id}:#{device.device_id}"
end
end

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
class MessageFranking
attr_reader :hmac, :source_account_id, :target_account_id,
:timestamp, :original_franking
def initialize(attributes = {})
@hmac = attributes[:hmac]
@source_account_id = attributes[:source_account_id]
@target_account_id = attributes[:target_account_id]
@timestamp = attributes[:timestamp]
@original_franking = attributes[:original_franking]
end
def to_token
crypt = ActiveSupport::MessageEncryptor.new(SystemKey.current_key, serializer: Oj)
crypt.encrypt_and_sign(self)
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: one_time_keys
#
# id :bigint(8) not null, primary key
# device_id :bigint(8)
# key_id :string default(""), not null
# key :text default(""), not null
# signature :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class OneTimeKey < ApplicationRecord
belongs_to :device
validates :key_id, :key, :signature, presence: true
validates :key, ed25519_key: true
validates :signature, ed25519_signature: { message: :key, verify_key: ->(one_time_key) { one_time_key.device.fingerprint_key } }
end

View File

@ -34,8 +34,6 @@ class PreviewCardProvider < ApplicationRecord
scope :trendable, -> { where(trendable: true) }
scope :not_trendable, -> { where(trendable: false) }
scope :reviewed, -> { where.not(reviewed_at: nil) }
scope :pending_review, -> { where(reviewed_at: nil) }
def self.matching_domain(domain)
segments = domain.split('.')

View File

@ -1,41 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: system_keys
#
# id :bigint(8) not null, primary key
# key :binary
# created_at :datetime not null
# updated_at :datetime not null
#
class SystemKey < ApplicationRecord
ROTATION_PERIOD = 1.week.freeze
before_validation :set_key
scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - (ROTATION_PERIOD * 3))) }
class << self
def current_key
previous_key = order(id: :asc).last
if previous_key && previous_key.created_at >= ROTATION_PERIOD.ago
previous_key.key
else
create.key
end
end
end
private
def set_key
return if key.present?
cipher = OpenSSL::Cipher.new('AES-256-GCM')
cipher.encrypt
self.key = cipher.random_key
end
end

View File

@ -50,8 +50,6 @@ class Tag < ApplicationRecord
validate :validate_name_change, if: -> { !new_record? && name_changed? }
validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? }
scope :reviewed, -> { where.not(reviewed_at: nil) }
scope :unreviewed, -> { where(reviewed_at: nil) }
scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) }
scope :usable, -> { where(usable: [true, nil]) }
scope :not_usable, -> { where(usable: false) }
@ -127,7 +125,7 @@ class Tag < ApplicationRecord
query = Tag.matches_name(stripped_term)
query = query.merge(Tag.listable) if options[:exclude_unlistable]
query = query.merge(matching_name(stripped_term).or(where.not(reviewed_at: nil))) if options[:exclude_unreviewed]
query = query.merge(matching_name(stripped_term).or(reviewed)) if options[:exclude_unreviewed]
query.order(Arel.sql('length(name) ASC, name ASC'))
.limit(limit)

View File

@ -41,7 +41,7 @@ class Trends::PreviewCardProviderFilter
when 'rejected'
PreviewCardProvider.not_trendable
when 'pending_review'
PreviewCardProvider.pending_review
PreviewCardProvider.unreviewed
else
raise Mastodon::InvalidParameterError, "Unknown status: #{value}"
end

View File

@ -26,16 +26,5 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
end
end
end
def from_encrypted_message(encrypted_message)
new.tap do |presenter|
presenter.id = ActivityPub::TagManager.instance.generate_uri_for(nil)
presenter.type = 'Create'
presenter.actor = ActivityPub::TagManager.instance.uri_for(encrypted_message.source_account)
presenter.published = Time.now.utc
presenter.to = ActivityPub::TagManager.instance.uri_for(encrypted_message.target_account)
presenter.virtual_object = encrypted_message
end
end
end
end

View File

@ -5,8 +5,6 @@ class ActivityPub::ActivitySerializer < ActivityPub::Serializer
case model.class.name
when 'Status'
ActivityPub::NoteSerializer
when 'DeliverToDeviceService::EncryptedMessage'
ActivityPub::EncryptedMessageSerializer
else
super
end

View File

@ -7,7 +7,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context :security
context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :olm, :suspended,
:moved_to, :property_value, :discoverable, :suspended,
:memorial, :indexable, :attribution_domains
attributes :id, :type, :following, :followers,
@ -21,7 +21,6 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
has_many :virtual_tags, key: :tag
has_many :virtual_attachments, key: :attachment
attribute :devices, unless: :instance_actor?
attribute :moved_to, if: :moved?
attribute :also_known_as, if: :also_known_as?
attribute :suspended, if: :suspended?
@ -72,10 +71,6 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object)
end
def devices
account_collection_url(object, :devices)
end
def outbox
object.instance_actor? ? instance_actor_outbox_url : account_outbox_url(object)
end

View File

@ -14,8 +14,6 @@ class ActivityPub::CollectionSerializer < ActivityPub::Serializer
case model.class.name
when 'Status'
ActivityPub::NoteSerializer
when 'Device'
ActivityPub::DeviceSerializer
when 'FeaturedTag'
ActivityPub::HashtagSerializer
when 'ActivityPub::CollectionPresenter'

View File

@ -1,52 +0,0 @@
# frozen_string_literal: true
class ActivityPub::DeviceSerializer < ActivityPub::Serializer
context_extensions :olm
include RoutingHelper
class FingerprintKeySerializer < ActivityPub::Serializer
attributes :type, :public_key_base64
def type
'Ed25519Key'
end
def public_key_base64
object.fingerprint_key
end
end
class IdentityKeySerializer < ActivityPub::Serializer
attributes :type, :public_key_base64
def type
'Curve25519Key'
end
def public_key_base64
object.identity_key
end
end
attributes :device_id, :type, :name, :claim
has_one :fingerprint_key, serializer: FingerprintKeySerializer
has_one :identity_key, serializer: IdentityKeySerializer
def type
'Device'
end
def claim
account_claim_url(object.account, id: object.device_id)
end
def fingerprint_key
object
end
def identity_key
object
end
end

View File

@ -1,61 +0,0 @@
# frozen_string_literal: true
class ActivityPub::EncryptedMessageSerializer < ActivityPub::Serializer
context :security
context_extensions :olm
class DeviceSerializer < ActivityPub::Serializer
attributes :type, :device_id
def type
'Device'
end
def device_id
object
end
end
class DigestSerializer < ActivityPub::Serializer
attributes :type, :digest_algorithm, :digest_value
def type
'Digest'
end
def digest_algorithm
'http://www.w3.org/2000/09/xmldsig#hmac-sha256'
end
def digest_value
object
end
end
attributes :type, :message_type, :cipher_text, :message_franking
has_one :attributed_to, serializer: DeviceSerializer
has_one :to, serializer: DeviceSerializer
has_one :digest, serializer: DigestSerializer
def type
'EncryptedMessage'
end
def attributed_to
object.source_device.device_id
end
def to
object.target_device_id
end
def message_type
object.type
end
def cipher_text
object.body
end
end

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
class ActivityPub::OneTimeKeySerializer < ActivityPub::Serializer
context :security
context_extensions :olm
class SignatureSerializer < ActivityPub::Serializer
attributes :type, :signature_value
def type
'Ed25519Signature'
end
def signature_value
object.signature
end
end
attributes :key_id, :type, :public_key_base64
has_one :signature, serializer: SignatureSerializer
def type
'Curve25519Key'
end
def public_key_base64
object.key
end
def signature
object
end
end

View File

@ -2,12 +2,40 @@
class OEmbedSerializer < ActiveModel::Serializer
INLINE_STYLES = {
blockquote: 'max-width: 540px; min-width: 270px; background:#FCF8FF; border: 1px solid #C9C4DA; border-radius: 8px; overflow: hidden; margin: 0; padding: 0;',
a: "color: #1C1A25; text-decoration: none; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 24px; font-size: 14px; line-height: 20px; letter-spacing: 0.25px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Roboto, sans-serif;", # rubocop:disable Layout/LineLength
div0: 'margin-top: 16px; color: #787588;',
div1: 'font-weight: 500;',
blockquote: <<~CSS.squish,
background: #FCF8FF;
border-radius: 8px;
border: 1px solid #C9C4DA;
margin: 0;
max-width: 540px;
min-width: 270px;
overflow: hidden;
padding: 0;
CSS
status_link: <<~CSS.squish,
align-items: center;
color: #1C1A25;
display: flex;
flex-direction: column;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Roboto, sans-serif;
font-size: 14px;
justify-content: center;
letter-spacing: 0.25px;
line-height: 20px;
padding: 24px;
text-decoration: none;
CSS
div_account: <<~CSS.squish,
color: #787588;
margin-top: 16px;
CSS
div_view: <<~CSS.squish,
font-weight: 500;
CSS
}.freeze
DEFAULT_WIDTH = 400
include RoutingHelper
include ActionView::Helpers::TagHelper
@ -46,10 +74,10 @@ class OEmbedSerializer < ActiveModel::Serializer
def html
<<~HTML.squish
<blockquote class="mastodon-embed" data-embed-url="#{embed_short_account_status_url(object.account, object)}" style="#{INLINE_STYLES[:blockquote]}">
<a href="#{short_account_status_url(object.account, object)}" target="_blank" style="#{INLINE_STYLES[:a]}">
<a href="#{short_account_status_url(object.account, object)}" target="_blank" style="#{INLINE_STYLES[:status_link]}">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 79 75"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></svg>
<div style="#{INLINE_STYLES[:div0]}">Post by @#{object.account.pretty_acct}@#{provider_name}</div>
<div style="#{INLINE_STYLES[:div1]}">View on Mastodon</div>
<div style="#{INLINE_STYLES[:div_account]}">Post by @#{object.account.pretty_acct}@#{provider_name}</div>
<div style="#{INLINE_STYLES[:div_view]}">View on Mastodon</div>
</a>
</blockquote>
<script data-allowed-prefixes="#{root_url}" async src="#{full_asset_url('embed.js', skip_pipeline: true)}"></script>
@ -57,10 +85,10 @@ class OEmbedSerializer < ActiveModel::Serializer
end
def width
instance_options[:width]
(instance_options[:width] || DEFAULT_WIDTH).to_i
end
def height
instance_options[:height]
instance_options[:height].presence&.to_i
end
end

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
class REST::EncryptedMessageSerializer < ActiveModel::Serializer
attributes :id, :account_id, :device_id,
:type, :body, :digest, :message_franking,
:created_at
def id
object.id.to_s
end
def account_id
object.from_account_id.to_s
end
def device_id
object.from_device_id
end
end

View File

@ -1,9 +0,0 @@
# frozen_string_literal: true
class REST::Keys::ClaimResultSerializer < ActiveModel::Serializer
attributes :account_id, :device_id, :key_id, :key, :signature
def account_id
object.account.id.to_s
end
end

View File

@ -1,6 +0,0 @@
# frozen_string_literal: true
class REST::Keys::DeviceSerializer < ActiveModel::Serializer
attributes :device_id, :name, :identity_key,
:fingerprint_key
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
class REST::Keys::QueryResultSerializer < ActiveModel::Serializer
attributes :account_id
has_many :devices, serializer: REST::Keys::DeviceSerializer
def account_id
object.account.id.to_s
end
end

View File

@ -49,7 +49,7 @@ class ActivityPub::FetchRepliesService < BaseService
rescue Mastodon::UnexpectedResponseError => e
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true })
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { omit_query_string: false })
end
end

View File

@ -108,7 +108,6 @@ class ActivityPub::ProcessAccountService < BaseService
def set_immediate_attributes!
@account.featured_collection_url = @json['featured'] || ''
@account.devices_url = @json['devices'] || ''
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@account.locked = @json['manuallyApprovesFollowers'] || false

View File

@ -13,7 +13,6 @@ class DeleteAccountService < BaseService
conversation_mutes
conversations
custom_filters
devices
domain_blocks
featured_tags
follow_requests
@ -40,7 +39,6 @@ class DeleteAccountService < BaseService
conversation_mutes
conversations
custom_filters
devices
domain_blocks
featured_tags
follow_requests

View File

@ -1,78 +0,0 @@
# frozen_string_literal: true
class DeliverToDeviceService < BaseService
include Payloadable
class EncryptedMessage < ActiveModelSerializers::Model
attributes :source_account, :target_account, :source_device,
:target_device_id, :type, :body, :digest,
:message_franking
end
def call(source_account, source_device, options = {})
@source_account = source_account
@source_device = source_device
@target_account = Account.find(options[:account_id])
@target_device_id = options[:device_id]
@body = options[:body]
@type = options[:type]
@hmac = options[:hmac]
set_message_franking!
if @target_account.local?
deliver_to_local!
else
deliver_to_remote!
end
end
private
def set_message_franking!
@message_franking = message_franking.to_token
end
def deliver_to_local!
target_device = @target_account.devices.find_by!(device_id: @target_device_id)
target_device.encrypted_messages.create!(
from_account: @source_account,
from_device_id: @source_device.device_id,
type: @type,
body: @body,
digest: @hmac,
message_franking: @message_franking
)
end
def deliver_to_remote!
ActivityPub::DeliveryWorker.perform_async(
Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_encrypted_message(encrypted_message), ActivityPub::ActivitySerializer)),
@source_account.id,
@target_account.inbox_url
)
end
def message_franking
MessageFranking.new(
source_account_id: @source_account.id,
target_account_id: @target_account.id,
hmac: @hmac,
timestamp: Time.now.utc
)
end
def encrypted_message
EncryptedMessage.new(
source_account: @source_account,
target_account: @target_account,
source_device: @source_device,
target_device_id: @target_device_id,
type: @type,
body: @body,
digest: @hmac,
message_franking: @message_franking
)
end
end

View File

@ -1,79 +0,0 @@
# frozen_string_literal: true
class Keys::ClaimService < BaseService
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
class Result < ActiveModelSerializers::Model
attributes :account, :device_id, :key_id,
:key, :signature
def initialize(account, device_id, key_attributes = {})
super(
account: account,
device_id: device_id,
key_id: key_attributes[:key_id],
key: key_attributes[:key],
signature: key_attributes[:signature],
)
end
end
def call(source_account, target_account_id, device_id)
@source_account = source_account
@target_account = Account.find(target_account_id)
@device_id = device_id
if @target_account.local?
claim_local_key!
else
claim_remote_key!
end
rescue ActiveRecord::RecordNotFound
nil
end
private
def claim_local_key!
device = @target_account.devices.find_by(device_id: @device_id)
key = nil
ApplicationRecord.transaction do
key = device.one_time_keys.order(Arel.sql('random()')).first!
key.destroy!
end
@result = Result.new(@target_account, @device_id, key)
end
def claim_remote_key!
query_result = QueryService.new.call(@target_account)
device = query_result.find(@device_id)
return unless device.present? && device.valid_claim_url?
json = fetch_resource_with_post(device.claim_url)
return unless json.present? && json['publicKeyBase64'].present?
@result = Result.new(@target_account, @device_id, key_id: json['id'], key: json['publicKeyBase64'], signature: json.dig('signature', 'signatureValue'))
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
Rails.logger.debug { "Claiming one-time key for #{@target_account.acct}:#{@device_id} failed: #{e}" }
nil
end
def fetch_resource_with_post(uri)
build_post_request(uri).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response)
body_to_json(response.body_with_limit) if response.code == 200
end
end
def build_post_request(uri)
Request.new(:post, uri).tap do |request|
request.on_behalf_of(@source_account)
request.add_headers(HEADERS)
end
end
end

View File

@ -1,79 +0,0 @@
# frozen_string_literal: true
class Keys::QueryService < BaseService
include JsonLdHelper
class Result < ActiveModelSerializers::Model
attributes :account, :devices
def initialize(account, devices)
super(
account: account,
devices: devices || [],
)
end
def find(device_id)
@devices.find { |device| device.device_id == device_id }
end
end
class Device < ActiveModelSerializers::Model
attributes :device_id, :name, :identity_key, :fingerprint_key
def initialize(attributes = {})
super(
device_id: attributes[:device_id],
name: attributes[:name],
identity_key: attributes[:identity_key],
fingerprint_key: attributes[:fingerprint_key],
)
@claim_url = attributes[:claim_url]
end
def valid_claim_url?
return false if @claim_url.blank?
begin
parsed_url = Addressable::URI.parse(@claim_url).normalize
rescue Addressable::URI::InvalidURIError
return false
end
%w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
end
end
def call(account)
@account = account
if @account.local?
query_local_devices!
else
query_remote_devices!
end
Result.new(@account, @devices)
end
private
def query_local_devices!
@devices = @account.devices.map { |device| Device.new(device) }
end
def query_remote_devices!
return if @account.devices_url.blank?
json = fetch_resource(@account.devices_url)
return if json['items'].blank?
@devices = as_array(json['items']).map do |device|
Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
end
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
Rails.logger.debug { "Querying devices for #{@account.acct} failed: #{e}" }
nil
end
end

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
class Ed25519KeyValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
key = Base64.decode64(value)
record.errors.add(attribute, I18n.t('crypto.errors.invalid_key')) unless verified?(key)
end
private
def verified?(key)
Ed25519.validate_key_bytes(key)
rescue ArgumentError
false
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
class Ed25519SignatureValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
verify_key = Ed25519::VerifyKey.new(Base64.decode64(option_to_value(record, :verify_key)))
signature = Base64.decode64(value)
message = option_to_value(record, :message)
record.errors.add(attribute, I18n.t('crypto.errors.invalid_signature')) unless verified?(verify_key, signature, message)
end
private
def verified?(verify_key, signature, message)
verify_key.verify(signature, message)
rescue Ed25519::VerifyError, ArgumentError
false
end
def option_to_value(record, key)
if options[key].is_a?(Proc)
options[key].call(record)
else
record.public_send(options[key])
end
end
end

View File

@ -18,7 +18,7 @@
- if status.application
= status.application.name
·
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
= link_to ActivityPub::TagManager.instance.url_for(status.proper), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- if status.edited?
·

View File

@ -12,7 +12,7 @@
%li= filter_link_to t('generic.all'), status: nil
%li= filter_link_to t('admin.trends.approved'), status: 'approved'
%li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{PreviewCardProvider.pending_review.count})"], ' '), status: 'pending_review'
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{PreviewCardProvider.unreviewed.count})"], ' '), status: 'pending_review'
.back-link
= link_to admin_trends_links_path do
= material_symbol 'chevron_left'

View File

@ -1,16 +0,0 @@
# frozen_string_literal: true
class PushEncryptedMessageWorker
include Sidekiq::Worker
include Redisable
def perform(encrypted_message_id)
encrypted_message = EncryptedMessage.find(encrypted_message_id)
message = InlineRenderer.render(encrypted_message, nil, :encrypted_message)
timeline_id = "timeline:#{encrypted_message.device.account_id}:#{encrypted_message.device.device_id}"
redis.publish(timeline_id, Oj.dump(event: :encrypted_message, payload: message))
rescue ActiveRecord::RecordNotFound
true
end
end

Some files were not shown because too many files have changed in this diff Show More