Merge pull request #2850 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 7ed9c590b9
pull/2855/head
commit
5aebdc9bcb
1
Gemfile
1
Gemfile
|
@ -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'
|
||||
|
|
18
Gemfile.lock
18
Gemfile.lock
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 = []
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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() : {}),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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() : {}),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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": "احذفه مِن الفواصل المرجعية",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -312,7 +312,7 @@
|
|||
"follow_request.authorize": "Autoriser",
|
||||
"follow_request.reject": "Rejeter",
|
||||
"follow_requests.unlocked_explanation": "Même si votre compte n’est 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",
|
||||
|
|
|
@ -312,7 +312,7 @@
|
|||
"follow_request.authorize": "Accepter",
|
||||
"follow_request.reject": "Rejeter",
|
||||
"follow_requests.unlocked_explanation": "Même si votre compte n’est 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",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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": "סנן הודעה זו",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "글타래에 답장",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Ответить всем",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -209,7 +209,6 @@ const KNOWN_EVENT_TYPES = [
|
|||
'notification',
|
||||
'conversation',
|
||||
'filters_changed',
|
||||
'encrypted_message',
|
||||
'announcement',
|
||||
'announcement.delete',
|
||||
'announcement.reaction',
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 |
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,8 +20,6 @@ class InlineRenderer
|
|||
serializer = REST::AnnouncementSerializer
|
||||
when :reaction
|
||||
serializer = REST::ReactionSerializer
|
||||
when :encrypted_message
|
||||
serializer = REST::EncryptedMessageSerializer
|
||||
else
|
||||
return
|
||||
end
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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('.')
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::Keys::DeviceSerializer < ActiveModel::Serializer
|
||||
attributes :device_id, :name, :identity_key,
|
||||
:fingerprint_key
|
||||
end
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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?
|
||||
·
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue