mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Merge branch 'main' into compose-language-detection
This commit is contained in:
commit
c1cd312146
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
|
@ -23,7 +23,6 @@
|
|||
matchManagers: ['npm'],
|
||||
matchPackageNames: [
|
||||
'tesseract.js', // Requires code changes
|
||||
'react-hotkeys', // Requires code changes
|
||||
|
||||
// react-router: Requires manual upgrade
|
||||
'history',
|
||||
|
|
81
Gemfile.lock
81
Gemfile.lock
|
@ -95,8 +95,8 @@ GEM
|
|||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1103.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1131.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
@ -109,9 +109,9 @@ GEM
|
|||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.8)
|
||||
azure-blob (0.5.9.1)
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
|
@ -228,12 +228,12 @@ GEM
|
|||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (1.2.5)
|
||||
excon (1.2.8)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday (2.13.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
@ -241,7 +241,7 @@ GEM
|
|||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.0)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.0)
|
||||
|
@ -266,14 +266,14 @@ GEM
|
|||
fog-openstack (1.1.5)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.0)
|
||||
formatador (1.1.1)
|
||||
forwardable (1.3.3)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.31.0)
|
||||
google-protobuf (4.31.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
|
@ -287,21 +287,21 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.64.0)
|
||||
haml_lint (0.65.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.1.2)
|
||||
hashdiff (1.2.0)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.24.0)
|
||||
redis-client (= 0.24.0)
|
||||
hiredis-client (0.25.1)
|
||||
redis-client (= 0.25.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
@ -345,7 +345,7 @@ GEM
|
|||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.2)
|
||||
json (2.13.0)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
|
@ -362,14 +362,14 @@ GEM
|
|||
rack (>= 2.2, < 4)
|
||||
rdf (~> 3.3)
|
||||
rexml (~> 3.2)
|
||||
json-ld-preloaded (3.3.1)
|
||||
json-ld-preloaded (3.3.2)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.2.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.10.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
|
@ -433,21 +433,21 @@ GEM
|
|||
marcel (1.0.4)
|
||||
mario-redis-lock (1.2.1)
|
||||
redis (>= 3.0.5)
|
||||
matrix (0.4.2)
|
||||
matrix (0.4.3)
|
||||
memory_profiler (1.1.0)
|
||||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2025.0514)
|
||||
mime-types-data (3.2025.0715)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_json (1.17.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.8)
|
||||
net-imap (0.5.9)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
|
@ -458,7 +458,7 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.11)
|
||||
|
@ -515,7 +515,7 @@ GEM
|
|||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-action_pack (0.12.1)
|
||||
opentelemetry-instrumentation-action_pack (0.12.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
|
@ -597,7 +597,7 @@ GEM
|
|||
opentelemetry-semantic_conventions (1.11.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
ostruct (0.6.3)
|
||||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
|
@ -610,7 +610,7 @@ GEM
|
|||
pg (1.5.9)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.52.0)
|
||||
playwright-ruby-client (1.54.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
|
@ -627,7 +627,7 @@ GEM
|
|||
prism (1.4.0)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
propshaft (1.2.0)
|
||||
propshaft (1.2.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
|
@ -701,23 +701,28 @@ GEM
|
|||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdf (3.3.2)
|
||||
rdf (3.3.4)
|
||||
bcp47_spec (~> 0.2)
|
||||
bigdecimal (~> 3.1, >= 3.1.5)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
logger (~> 1.5)
|
||||
ostruct (~> 0.6)
|
||||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
readline (0.0.4)
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.25.1)
|
||||
connection_pool
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
@ -732,11 +737,11 @@ GEM
|
|||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec (3.13.0)
|
||||
rspec (3.13.1)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.4)
|
||||
rspec-core (3.13.5)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
|
@ -754,7 +759,7 @@ GEM
|
|||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-sidekiq (5.1.0)
|
||||
rspec-sidekiq (5.2.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
|
@ -771,7 +776,7 @@ GEM
|
|||
rubocop-ast (>= 1.45.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.45.1)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
|
@ -845,7 +850,7 @@ GEM
|
|||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov-lcov (0.8.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
stackprof (0.2.27)
|
||||
|
@ -865,11 +870,11 @@ GEM
|
|||
temple (0.10.3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.1.0)
|
||||
terrapin (1.1.1)
|
||||
climate_control
|
||||
test-prof (1.4.4)
|
||||
thor (1.3.2)
|
||||
tilt (2.6.0)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.1)
|
||||
timeout (0.4.3)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
|
@ -931,7 +936,7 @@ GEM
|
|||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
@ -1101,4 +1106,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
2.7.0
|
||||
|
|
|
@ -16,11 +16,14 @@ module Admin
|
|||
def batch
|
||||
authorize :account, :index?
|
||||
|
||||
@form = Form::AccountBatch.new(form_account_batch_params)
|
||||
@form.current_account = current_account
|
||||
@form.action = action_from_button
|
||||
@form.select_all_matching = params[:select_all_matching]
|
||||
@form.query = filtered_accounts
|
||||
@form = Form::AccountBatch.new(
|
||||
form_account_batch_params.merge(
|
||||
action: action_from_button,
|
||||
current_account:,
|
||||
query: filtered_accounts,
|
||||
select_all_matching: params[:select_all_matching]
|
||||
)
|
||||
)
|
||||
@form.save
|
||||
rescue ActionController::ParameterMissing
|
||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||
|
|
|
@ -5,6 +5,7 @@ module Admin
|
|||
before_action :set_tag, except: [:index]
|
||||
|
||||
PER_PAGE = 20
|
||||
PERIOD_DAYS = 6.days
|
||||
|
||||
def index
|
||||
authorize :tag, :index?
|
||||
|
@ -15,7 +16,7 @@ module Admin
|
|||
def show
|
||||
authorize @tag, :show?
|
||||
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@time_period = report_range
|
||||
end
|
||||
|
||||
def update
|
||||
|
@ -24,7 +25,7 @@ module Admin
|
|||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
||||
else
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@time_period = report_range
|
||||
|
||||
render :show
|
||||
end
|
||||
|
@ -36,6 +37,10 @@ module Admin
|
|||
@tag = Tag.find(params[:id])
|
||||
end
|
||||
|
||||
def report_range
|
||||
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params
|
||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||
|
|
|
@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
|
|||
skip_around_action :set_locale
|
||||
|
||||
before_action :set_invite
|
||||
before_action :check_valid_usage!
|
||||
before_action :check_enabled_registrations!
|
||||
|
||||
# Override `current_user` to avoid reading session cookies
|
||||
|
@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
|
|||
@invite = Invite.find_by!(code: params[:invite_code])
|
||||
end
|
||||
|
||||
def check_enabled_registrations!
|
||||
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||
def check_valid_usage!
|
||||
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||
end
|
||||
|
||||
def check_enabled_registrations!
|
||||
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::StatusesController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||
|
@ -57,9 +58,17 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
refresh_key = "context:#{@status.id}:refresh"
|
||||
async_refresh = AsyncRefresh.new(refresh_key)
|
||||
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
def create
|
||||
|
|
|
@ -5,6 +5,18 @@ module Auth::CaptchaConcern
|
|||
|
||||
include Hcaptcha::Adapters::ViewMethods
|
||||
|
||||
CAPTCHA_DIRECTIVES = %w(
|
||||
connect_src
|
||||
frame_src
|
||||
script_src
|
||||
style_src
|
||||
).freeze
|
||||
|
||||
CAPTCHA_SOURCES = %w(
|
||||
https://*.hcaptcha.com
|
||||
https://hcaptcha.com
|
||||
).freeze
|
||||
|
||||
included do
|
||||
helper_method :render_captcha
|
||||
end
|
||||
|
@ -42,20 +54,9 @@ module Auth::CaptchaConcern
|
|||
end
|
||||
|
||||
def extend_csp_for_captcha!
|
||||
policy = request.content_security_policy&.clone
|
||||
return unless captcha_required? && request.content_security_policy.present?
|
||||
|
||||
return unless captcha_required? && policy.present?
|
||||
|
||||
%w(script_src frame_src style_src connect_src).each do |directive|
|
||||
values = policy.send(directive)
|
||||
|
||||
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
||||
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
||||
|
||||
policy.send(directive, *values)
|
||||
end
|
||||
|
||||
request.content_security_policy = policy
|
||||
request.content_security_policy = captcha_adjusted_policy
|
||||
end
|
||||
|
||||
def render_captcha
|
||||
|
@ -63,4 +64,24 @@ module Auth::CaptchaConcern
|
|||
|
||||
hcaptcha_tags
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captcha_adjusted_policy
|
||||
request.content_security_policy.clone.tap do |policy|
|
||||
populate_captcha_policy(policy)
|
||||
end
|
||||
end
|
||||
|
||||
def populate_captcha_policy(policy)
|
||||
CAPTCHA_DIRECTIVES.each do |directive|
|
||||
values = policy.send(directive)
|
||||
|
||||
CAPTCHA_SOURCES.each do |source|
|
||||
values << source unless values.include?(source) || values.include?('https:')
|
||||
end
|
||||
|
||||
policy.send(directive, *values)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetContext } from 'mastodon/api/statuses';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
|
@ -6,13 +8,18 @@ import { importFetchedStatuses } from './importer';
|
|||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
(context, { dispatch }) => {
|
||||
({ context, refresh }, { dispatch }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
|
||||
return {
|
||||
context,
|
||||
refresh,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||
'status/context/complete',
|
||||
);
|
||||
|
|
|
@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => {
|
|||
return LinkHeader.parse(value);
|
||||
};
|
||||
|
||||
export interface AsyncRefreshHeader {
|
||||
id: string;
|
||||
retry: number;
|
||||
}
|
||||
|
||||
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
|
||||
'id' in obj && 'retry' in obj;
|
||||
|
||||
export const getAsyncRefreshHeader = (
|
||||
response: AxiosResponse,
|
||||
): AsyncRefreshHeader | null => {
|
||||
const value = response.headers['mastodon-async-refresh'] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asyncRefreshHeader: Record<string, unknown> = {};
|
||||
|
||||
value.split(/,\s*/).forEach((pair) => {
|
||||
const [key, val] = pair.split('=', 2);
|
||||
|
||||
let typedValue: string | number;
|
||||
|
||||
if (key && ['id', 'retry'].includes(key) && val) {
|
||||
if (val.startsWith('"')) {
|
||||
typedValue = val.slice(1, -1);
|
||||
} else {
|
||||
typedValue = parseInt(val);
|
||||
}
|
||||
|
||||
asyncRefreshHeader[key] = typedValue;
|
||||
}
|
||||
});
|
||||
|
||||
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
|
||||
return asyncRefreshHeader;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const csrfHeader: RawAxiosRequestHeaders = {};
|
||||
|
||||
const setCSRFHeader = () => {
|
||||
|
@ -83,7 +127,7 @@ export default function api(withAuthorization = true) {
|
|||
return instance;
|
||||
}
|
||||
|
||||
type ApiUrl = `v${1 | 2}/${string}`;
|
||||
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
|
||||
export async function apiRequest<ApiResponse = unknown>(
|
||||
|
|
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
|
||||
|
||||
export const apiGetAsyncRefresh = (id: string) =>
|
||||
apiRequestGet<ApiAsyncRefreshJSON>(`v1_alpha/async_refreshes/${id}`);
|
|
@ -1,5 +1,14 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||
|
||||
export const apiGetContext = (statusId: string) =>
|
||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
||||
export const apiGetContext = async (statusId: string) => {
|
||||
const response = await api().request<ApiContextJSON>({
|
||||
method: 'GET',
|
||||
url: `/api/v1/statuses/${statusId}/context`,
|
||||
});
|
||||
|
||||
return {
|
||||
context: response.data,
|
||||
refresh: getAsyncRefreshHeader(response),
|
||||
};
|
||||
};
|
||||
|
|
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface ApiAsyncRefreshJSON {
|
||||
async_refresh: {
|
||||
id: string;
|
||||
status: 'running' | 'finished';
|
||||
result_count: number;
|
||||
};
|
||||
}
|
|
@ -2,27 +2,44 @@ import { useCallback } from 'react';
|
|||
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
|
||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||
import { isFeatureEnabled } from '../initial_state';
|
||||
import { useAppSelector } from '../store';
|
||||
|
||||
interface AccountBioProps {
|
||||
note: string;
|
||||
className: string;
|
||||
dropdownAccountId?: string;
|
||||
accountId: string;
|
||||
showDropdown?: boolean;
|
||||
}
|
||||
|
||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
note,
|
||||
className,
|
||||
dropdownAccountId,
|
||||
accountId,
|
||||
showDropdown = false,
|
||||
}) => {
|
||||
const handleClick = useLinks(!!dropdownAccountId);
|
||||
const handleClick = useLinks(showDropdown);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
addDropdownToHashtags(node, dropdownAccountId);
|
||||
addDropdownToHashtags(node, accountId);
|
||||
},
|
||||
[dropdownAccountId],
|
||||
[showDropdown, accountId],
|
||||
);
|
||||
const note = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
if (!account) {
|
||||
return '';
|
||||
}
|
||||
return isFeatureEnabled('modern_emojis')
|
||||
? account.note
|
||||
: account.note_emojified;
|
||||
});
|
||||
const extraEmojis = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
return account?.emojis;
|
||||
});
|
||||
|
||||
if (note.length === 0) {
|
||||
return null;
|
||||
|
@ -31,10 +48,11 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
/>
|
||||
>
|
||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
import type { HandlerMap } from '.';
|
||||
import { Hotkeys } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Hotkeys',
|
||||
component: Hotkeys,
|
||||
args: {
|
||||
global: undefined,
|
||||
focusable: undefined,
|
||||
handlers: {},
|
||||
},
|
||||
tags: ['test'],
|
||||
} satisfies Meta<typeof Hotkeys>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
|
||||
async function confirmHotkey(name: string, shouldFind = true) {
|
||||
// 'status' is the role of the 'output' element
|
||||
const output = await canvas.findByRole('status');
|
||||
if (shouldFind) {
|
||||
await expect(output).toHaveTextContent(name);
|
||||
} else {
|
||||
await expect(output).not.toHaveTextContent(name);
|
||||
}
|
||||
}
|
||||
|
||||
const button = await canvas.findByRole('button');
|
||||
await userEvent.click(button);
|
||||
|
||||
await userEvent.keyboard('n');
|
||||
await confirmHotkey('new');
|
||||
|
||||
await userEvent.keyboard('/');
|
||||
await confirmHotkey('search');
|
||||
|
||||
await userEvent.keyboard('o');
|
||||
await confirmHotkey('open');
|
||||
|
||||
await userEvent.keyboard('{Alt>}N{/Alt}');
|
||||
await confirmHotkey('forceNew');
|
||||
|
||||
await userEvent.keyboard('gh');
|
||||
await confirmHotkey('goToHome');
|
||||
|
||||
await userEvent.keyboard('gn');
|
||||
await confirmHotkey('goToNotifications');
|
||||
|
||||
await userEvent.keyboard('gf');
|
||||
await confirmHotkey('goToFavourites');
|
||||
|
||||
/**
|
||||
* Ensure that hotkeys are not triggered when certain
|
||||
* interactive elements are focused:
|
||||
*/
|
||||
|
||||
await userEvent.keyboard('{enter}');
|
||||
await confirmHotkey('open', false);
|
||||
|
||||
const input = await canvas.findByRole('textbox');
|
||||
await userEvent.click(input);
|
||||
|
||||
await userEvent.keyboard('n');
|
||||
await confirmHotkey('new', false);
|
||||
|
||||
await userEvent.keyboard('{backspace}');
|
||||
await confirmHotkey('None', false);
|
||||
|
||||
/**
|
||||
* Reset playground:
|
||||
*/
|
||||
|
||||
await userEvent.click(button);
|
||||
await userEvent.keyboard('{backspace}');
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
render: function Render() {
|
||||
const [matchedHotkey, setMatchedHotkey] = useState<keyof HandlerMap | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handlers = {
|
||||
back: () => {
|
||||
setMatchedHotkey(null);
|
||||
},
|
||||
new: () => {
|
||||
setMatchedHotkey('new');
|
||||
},
|
||||
forceNew: () => {
|
||||
setMatchedHotkey('forceNew');
|
||||
},
|
||||
search: () => {
|
||||
setMatchedHotkey('search');
|
||||
},
|
||||
open: () => {
|
||||
setMatchedHotkey('open');
|
||||
},
|
||||
goToHome: () => {
|
||||
setMatchedHotkey('goToHome');
|
||||
},
|
||||
goToNotifications: () => {
|
||||
setMatchedHotkey('goToNotifications');
|
||||
},
|
||||
goToFavourites: () => {
|
||||
setMatchedHotkey('goToFavourites');
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '1em',
|
||||
border: '1px dashed #ccc',
|
||||
fontSize: 14,
|
||||
color: '#222',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
marginBottom: '0.3em',
|
||||
}}
|
||||
>
|
||||
Hotkey playground
|
||||
</h1>
|
||||
<p>
|
||||
Last pressed hotkey: <output>{matchedHotkey ?? 'None'}</output>
|
||||
</p>
|
||||
<p>
|
||||
Click within the dashed border and press the "<kbd>n</kbd>
|
||||
" or "<kbd>/</kbd>" key. Press "
|
||||
<kbd>Backspace</kbd>" to clear the displayed hotkey.
|
||||
</p>
|
||||
<p>
|
||||
Try typing a sequence, like "<kbd>g</kbd>" shortly
|
||||
followed by "<kbd>h</kbd>", "<kbd>n</kbd>", or
|
||||
"<kbd>f</kbd>"
|
||||
</p>
|
||||
<p>
|
||||
Note that this playground doesn't support all hotkeys we use in
|
||||
the app.
|
||||
</p>
|
||||
<p>
|
||||
When a <button>Button</button> is focused, "
|
||||
<kbd>Enter</kbd>
|
||||
" should not trigger "open", but "<kbd>o</kbd>
|
||||
" should.
|
||||
</p>
|
||||
<p>
|
||||
When an input element is focused, hotkeys should not interfere with
|
||||
regular typing:
|
||||
</p>
|
||||
<input type='text' />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
},
|
||||
play: hotkeyTest,
|
||||
};
|
282
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
282
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
|
@ -0,0 +1,282 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { normalizeKey, isKeyboardEvent } from './utils';
|
||||
|
||||
/**
|
||||
* In case of multiple hotkeys matching the pressed key(s),
|
||||
* the hotkey with a higher priority is selected. All others
|
||||
* are ignored.
|
||||
*/
|
||||
const hotkeyPriority = {
|
||||
singleKey: 0,
|
||||
combo: 1,
|
||||
sequence: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* This type of function receives a keyboard event and an array of
|
||||
* previously pressed keys (within the last second), and returns
|
||||
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
|
||||
* (a weighting used to resolve conflicts when two hotkeys match the
|
||||
* pressed keys)
|
||||
*/
|
||||
type KeyMatcher = (
|
||||
event: KeyboardEvent,
|
||||
bufferedKeys?: string[],
|
||||
) => {
|
||||
/**
|
||||
* Whether the event.key matches the hotkey
|
||||
*/
|
||||
isMatch: boolean;
|
||||
/**
|
||||
* If there are multiple matching hotkeys, the
|
||||
* first one with the highest priority will be handled
|
||||
*/
|
||||
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches a single key
|
||||
*/
|
||||
function just(keyName: string): KeyMatcher {
|
||||
return (event) => ({
|
||||
isMatch: normalizeKey(event.key) === keyName,
|
||||
priority: hotkeyPriority.singleKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches any single key out of those provided
|
||||
*/
|
||||
function any(...keys: string[]): KeyMatcher {
|
||||
return (event) => ({
|
||||
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
|
||||
priority: hotkeyPriority.singleKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a single key combined with the option/alt modifier
|
||||
*/
|
||||
function optionPlus(key: string): KeyMatcher {
|
||||
return (event) => ({
|
||||
// Matching against event.code here as alt combos are often
|
||||
// mapped to other characters
|
||||
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
|
||||
priority: hotkeyPriority.combo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches when all provided keys are pressed in sequence.
|
||||
*/
|
||||
function sequence(...sequence: string[]): KeyMatcher {
|
||||
return (event, bufferedKeys) => {
|
||||
const lastKeyInSequence = sequence.at(-1);
|
||||
const startOfSequence = sequence.slice(0, -1);
|
||||
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
|
||||
|
||||
const bufferMatchesStartOfSequence =
|
||||
!!relevantBufferedKeys &&
|
||||
startOfSequence.join('') === relevantBufferedKeys.join('');
|
||||
|
||||
return {
|
||||
isMatch:
|
||||
bufferMatchesStartOfSequence &&
|
||||
normalizeKey(event.key) === lastKeyInSequence,
|
||||
priority: hotkeyPriority.sequence,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a map of all global hotkeys we support.
|
||||
* To trigger a hotkey, a handler with a matching name must be
|
||||
* provided to the `useHotkeys` hook or `Hotkeys` component.
|
||||
*/
|
||||
const hotkeyMatcherMap = {
|
||||
help: just('?'),
|
||||
search: any('s', '/'),
|
||||
back: just('backspace'),
|
||||
new: just('n'),
|
||||
forceNew: optionPlus('n'),
|
||||
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
openProfile: just('p'),
|
||||
moveDown: any('down', 'j'),
|
||||
moveUp: any('up', 'k'),
|
||||
toggleHidden: just('x'),
|
||||
toggleSensitive: just('h'),
|
||||
toggleComposeSpoilers: optionPlus('x'),
|
||||
openMedia: just('e'),
|
||||
onTranslate: just('t'),
|
||||
goToHome: sequence('g', 'h'),
|
||||
goToNotifications: sequence('g', 'n'),
|
||||
goToLocal: sequence('g', 'l'),
|
||||
goToFederated: sequence('g', 't'),
|
||||
goToDirect: sequence('g', 'd'),
|
||||
goToStart: sequence('g', 's'),
|
||||
goToFavourites: sequence('g', 'f'),
|
||||
goToPinned: sequence('g', 'p'),
|
||||
goToProfile: sequence('g', 'u'),
|
||||
goToBlocked: sequence('g', 'b'),
|
||||
goToMuted: sequence('g', 'm'),
|
||||
goToRequests: sequence('g', 'r'),
|
||||
cheat: sequence(
|
||||
'up',
|
||||
'up',
|
||||
'down',
|
||||
'down',
|
||||
'left',
|
||||
'right',
|
||||
'left',
|
||||
'right',
|
||||
'b',
|
||||
'a',
|
||||
'enter',
|
||||
),
|
||||
} as const;
|
||||
|
||||
type HotkeyName = keyof typeof hotkeyMatcherMap;
|
||||
|
||||
export type HandlerMap = Partial<
|
||||
Record<HotkeyName, (event: KeyboardEvent) => void>
|
||||
>;
|
||||
|
||||
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
const ref = useRef<T>(null);
|
||||
const bufferedKeys = useRef<string[]>([]);
|
||||
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/**
|
||||
* Store the latest handlers object in a ref so we don't need to
|
||||
* add it as a dependency to the main event listener effect
|
||||
*/
|
||||
const handlersRef = useRef(handlers);
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current ?? document;
|
||||
|
||||
function listener(event: Event) {
|
||||
// Ignore key presses from input, textarea, or select elements
|
||||
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
|
||||
const shouldHandleEvent =
|
||||
isKeyboardEvent(event) &&
|
||||
!event.defaultPrevented &&
|
||||
!['input', 'textarea', 'select'].includes(tagName) &&
|
||||
!(
|
||||
['a', 'button'].includes(tagName) &&
|
||||
normalizeKey(event.key) === 'enter'
|
||||
);
|
||||
|
||||
if (shouldHandleEvent) {
|
||||
const matchCandidates: {
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
priority: number;
|
||||
}[] = [];
|
||||
|
||||
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||
(handlerName) => {
|
||||
const handler = handlersRef.current[handlerName];
|
||||
|
||||
if (handler) {
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sort all matches by priority
|
||||
matchCandidates.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const bestMatchingHandler = matchCandidates.at(0)?.handler;
|
||||
if (bestMatchingHandler) {
|
||||
bestMatchingHandler(event);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Add last keypress to buffer
|
||||
bufferedKeys.current.push(normalizeKey(event.key));
|
||||
|
||||
// Reset the timeout
|
||||
if (sequenceTimer.current) {
|
||||
clearTimeout(sequenceTimer.current);
|
||||
}
|
||||
sequenceTimer.current = setTimeout(() => {
|
||||
bufferedKeys.current = [];
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('keydown', listener);
|
||||
if (sequenceTimer.current) {
|
||||
clearTimeout(sequenceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hotkeys component allows us to globally register keyboard combinations
|
||||
* under a name and assign actions to them, either globally or scoped to a portion
|
||||
* of the app.
|
||||
*
|
||||
* ### How to use
|
||||
*
|
||||
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
|
||||
* and give it a name.
|
||||
*
|
||||
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
|
||||
* where you want to handle the action, and pass in a handlers object.
|
||||
*
|
||||
* ```tsx
|
||||
* <Hotkeys handlers={{open: openStatus}} />
|
||||
* ```
|
||||
*
|
||||
* Now this function will be called when the 'open' hotkey is pressed by the user.
|
||||
*/
|
||||
export const Hotkeys: React.FC<{
|
||||
/**
|
||||
* An object containing functions to be run when a hotkey is pressed.
|
||||
* The key must be the name of a registered hotkey, e.g. "help" or "search"
|
||||
*/
|
||||
handlers: HandlerMap;
|
||||
/**
|
||||
* When enabled, hotkeys will be matched against the document root
|
||||
* rather than only inside of this component's DOM node.
|
||||
*/
|
||||
global?: boolean;
|
||||
/**
|
||||
* Allow the rendered `div` to be focused
|
||||
*/
|
||||
focusable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}> = ({ handlers, global, focusable = true, children }) => {
|
||||
const ref = useHotkeys<HTMLDivElement>(handlers);
|
||||
|
||||
return (
|
||||
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export function isKeyboardEvent(event: Event): event is KeyboardEvent {
|
||||
return 'key' in event;
|
||||
}
|
||||
|
||||
export function normalizeKey(key: string): string {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
switch (lowerKey) {
|
||||
case ' ':
|
||||
case 'spacebar': // for older browsers
|
||||
return 'space';
|
||||
|
||||
case 'arrowup':
|
||||
return 'up';
|
||||
case 'arrowdown':
|
||||
return 'down';
|
||||
case 'arrowleft':
|
||||
return 'left';
|
||||
case 'arrowright':
|
||||
return 'right';
|
||||
|
||||
case 'esc':
|
||||
case 'escape':
|
||||
return 'escape';
|
||||
|
||||
default:
|
||||
return lowerKey;
|
||||
}
|
||||
}
|
|
@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
|
|||
<>
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
accountId={account.id}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
|
|
|
@ -8,10 +8,9 @@ import { Link } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar';
|
|||
import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { VisibilityIcon } from './visibility_icon';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
|
@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyMoveDown = e => {
|
||||
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyToggleHidden = () => {
|
||||
|
@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (hidden) {
|
||||
return (
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||
{expanded && <span>{status.get('content')}</span>}
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent {
|
|||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{!skipPrepend && prepend}
|
||||
|
||||
|
@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
import { autoPlayGif, isFeatureEnabled, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||
|
||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||
|
||||
|
@ -23,6 +24,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
|||
* @returns {string}
|
||||
*/
|
||||
export function getStatusContent(status) {
|
||||
if (isFeatureEnabled('modern_emojis')) {
|
||||
return status.getIn(['translation', 'content']) || status.get('content');
|
||||
}
|
||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||
}
|
||||
|
||||
|
@ -228,7 +232,7 @@ class StatusContent extends PureComponent {
|
|||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const content = statusContent ?? getStatusContent(status);
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.props.history,
|
||||
|
@ -253,7 +257,12 @@ class StatusContent extends PureComponent {
|
|||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
/>
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
|
@ -265,7 +274,12 @@ class StatusContent extends PureComponent {
|
|||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
/>
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
|
|
|
@ -40,6 +40,12 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
trackScroll: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.columnHeaderHeight = parseFloat(
|
||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||
) || 0;
|
||||
}
|
||||
|
||||
getFeaturedStatusCount = () => {
|
||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||
};
|
||||
|
@ -53,34 +59,68 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||
this._selectChild(elementIndex, true);
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, -1);
|
||||
};
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, 1);
|
||||
};
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||
this._selectChild(elementIndex, false);
|
||||
};
|
||||
_selectChild = (id, index, direction) => {
|
||||
const listContainer = this.node.node;
|
||||
let listItem = listContainer.querySelector(
|
||||
// :nth-child uses 1-based indexing
|
||||
`.item-list > :nth-child(${index + 1 + direction})`
|
||||
);
|
||||
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If selected container element is empty, we skip it
|
||||
if (listItem.matches(':empty')) {
|
||||
this._selectChild(id, index + direction, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the list item is a post
|
||||
let targetElement = listItem.querySelector('.focusable');
|
||||
|
||||
// Otherwise, check if the item contains follow suggestions or
|
||||
// is a 'load more' button.
|
||||
if (
|
||||
!targetElement && (
|
||||
listItem.querySelector('.inline-follow-suggestions') ||
|
||||
listItem.matches('.load-more')
|
||||
)
|
||||
) {
|
||||
targetElement = listItem;
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
const elementRect = targetElement.getBoundingClientRect();
|
||||
|
||||
const isFullyVisible =
|
||||
elementRect.top >= this.columnHeaderHeight &&
|
||||
elementRect.bottom <= window.innerHeight;
|
||||
|
||||
if (!isFullyVisible) {
|
||||
targetElement.scrollIntoView({
|
||||
block: direction === 1 ? 'start' : 'center',
|
||||
});
|
||||
}
|
||||
|
||||
targetElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const { statusIds, lastId, onLoadMore } = this.props;
|
||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||
}, 300, { leading: true });
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
|
|
@ -898,8 +898,7 @@ export const AccountHeader: React.FC<{
|
|||
)}
|
||||
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
dropdownAccountId={accountId}
|
||||
accountId={accountId}
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
|
|
|
@ -91,10 +91,29 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.handleSubmit();
|
||||
blurOnEscape = (e) => {
|
||||
if (['esc', 'escape'].includes(e.key.toLowerCase())) {
|
||||
e.target.blur();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDownPost = (e) => {
|
||||
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
|
||||
this.handleSubmit();
|
||||
}
|
||||
this.blurOnEscape(e);
|
||||
};
|
||||
|
||||
handleKeyDownSpoiler = (e) => {
|
||||
if (e.key.toLowerCase() === 'enter') {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
this.handleSubmit();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.textareaRef.current?.focus();
|
||||
}
|
||||
}
|
||||
this.blurOnEscape(e);
|
||||
};
|
||||
|
||||
getFulltextForCharacterCounting = () => {
|
||||
|
@ -247,7 +266,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
value={this.props.spoilerText}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyDown={this.handleKeyDownSpoiler}
|
||||
ref={this.setSpoilerText}
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
|
@ -272,7 +291,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyDown={this.handleKeyDownPost}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
|
|
|
@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
||||
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||
<AvatarComposite accounts={accounts} size={48} />
|
||||
|
@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -15,6 +15,16 @@ export const SKIN_TONE_CODES = [
|
|||
0x1f3ff, // Dark skin tone
|
||||
] as const;
|
||||
|
||||
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
||||
export const EMOJI_MODE_NATIVE = 'native';
|
||||
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
||||
export const EMOJI_MODE_TWEMOJI = 'twemoji';
|
||||
|
||||
export const EMOJI_TYPE_UNICODE = 'unicode';
|
||||
export const EMOJI_TYPE_CUSTOM = 'custom';
|
||||
|
||||
export const EMOJI_STATE_MISSING = 'missing';
|
||||
|
||||
export const EMOJIS_WITH_DARK_BORDER = [
|
||||
'🎱', // 1F3B1
|
||||
'🐜', // 1F41C
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
import type { DBSchema } from 'idb';
|
||||
import type { Locale } from 'emojibase';
|
||||
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import type { LocaleOrCustom } from './locale';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type {
|
||||
CustomEmojiData,
|
||||
UnicodeEmojiData,
|
||||
LocaleOrCustom,
|
||||
} from './types';
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: ApiCustomEmojiJSON;
|
||||
value: CustomEmojiData;
|
||||
indexes: {
|
||||
category: string;
|
||||
};
|
||||
|
@ -24,7 +26,7 @@ interface EmojiDB extends LocaleTables, DBSchema {
|
|||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: FlatCompactEmoji;
|
||||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
group: number;
|
||||
label: string;
|
||||
|
@ -36,63 +38,114 @@ type LocaleTables = Record<Locale, LocaleTable>;
|
|||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
let db: IDBPDatabase<EmojiDB> | null = null;
|
||||
|
||||
database.createObjectStore('etags');
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
async function loadDB() {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
|
||||
export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) {
|
||||
database.createObjectStore('etags');
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction(locale, 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) {
|
||||
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction('custom', 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
export function putLatestEtag(etag: string, localeString: string) {
|
||||
export async function putLatestEtag(etag: string, localeString: string) {
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
const db = await loadDB();
|
||||
return db.put('etags', etag, locale);
|
||||
}
|
||||
|
||||
export function searchEmojiByHexcode(hexcode: string, localeString: string) {
|
||||
export async function searchEmojiByHexcode(
|
||||
hexcode: string,
|
||||
localeString: string,
|
||||
) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
return db.get(locale, hexcode);
|
||||
}
|
||||
|
||||
export function searchEmojiByTag(tag: string, localeString: string) {
|
||||
export async function searchEmojisByHexcodes(
|
||||
hexcodes: string[],
|
||||
localeString: string,
|
||||
) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
return db.getAll(
|
||||
locale,
|
||||
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchEmojiByTag(tag: string, localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const range = IDBKeyRange.only(tag.toLowerCase());
|
||||
const db = await loadDB();
|
||||
return db.getAllFromIndex(locale, 'tags', range);
|
||||
}
|
||||
|
||||
export function searchCustomEmojiByShortcode(shortcode: string) {
|
||||
export async function searchCustomEmojiByShortcode(shortcode: string) {
|
||||
const db = await loadDB();
|
||||
return db.get('custom', shortcode);
|
||||
}
|
||||
|
||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
const db = await loadDB();
|
||||
return db.getAll(
|
||||
'custom',
|
||||
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMissingLocales(localeStrings: string[]) {
|
||||
const locales = new Set(localeStrings.map(toSupportedLocale));
|
||||
const missingLocales: Locale[] = [];
|
||||
const db = await loadDB();
|
||||
for (const locale of locales) {
|
||||
const rowCount = await db.count(locale);
|
||||
if (!rowCount) {
|
||||
missingLocales.push(locale);
|
||||
}
|
||||
}
|
||||
return missingLocales;
|
||||
}
|
||||
|
||||
export async function loadLatestEtag(localeString: string) {
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
const db = await loadDB();
|
||||
const rowCount = await db.count(locale);
|
||||
if (!rowCount) {
|
||||
return null; // No data for this locale, return null even if there is an etag.
|
||||
|
|
81
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
81
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { isFeatureEnabled } from '@/mastodon/initial_state';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
|
||||
import { useEmojiAppState } from './hooks';
|
||||
import { emojifyElement } from './render';
|
||||
import type { ExtraCustomEmojiMap } from './types';
|
||||
|
||||
type EmojiHTMLProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'dangerouslySetInnerHTML'
|
||||
> & {
|
||||
htmlString: string;
|
||||
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
|
||||
};
|
||||
|
||||
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||
htmlString,
|
||||
extraEmojis,
|
||||
...props
|
||||
}) => {
|
||||
if (isFeatureEnabled('modern_emojis')) {
|
||||
return (
|
||||
<ModernEmojiHTML
|
||||
htmlString={htmlString}
|
||||
extraEmojis={extraEmojis}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
|
||||
};
|
||||
|
||||
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||
extraEmojis: rawEmojis,
|
||||
htmlString: text,
|
||||
...props
|
||||
}) => {
|
||||
const appState = useEmojiAppState();
|
||||
const [innerHTML, setInnerHTML] = useState('');
|
||||
|
||||
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
|
||||
if (!rawEmojis) {
|
||||
return {};
|
||||
}
|
||||
if (isList(rawEmojis)) {
|
||||
return (
|
||||
rawEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||
).reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return rawEmojis;
|
||||
}, [rawEmojis]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const cb = async () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
const ele = await emojifyElement(div, appState, extraEmojis);
|
||||
setInnerHTML(ele.innerHTML);
|
||||
};
|
||||
void cb();
|
||||
}, [text, appState, extraEmojis]);
|
||||
|
||||
if (!innerHTML) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
|
||||
};
|
45
app/javascript/mastodon/features/emoji/emoji_text.tsx
Normal file
45
app/javascript/mastodon/features/emoji/emoji_text.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEmojiAppState } from './hooks';
|
||||
import { emojifyText } from './render';
|
||||
|
||||
interface EmojiTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
|
||||
const appState = useEmojiAppState();
|
||||
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = async () => {
|
||||
const rendered = await emojifyText(text, appState);
|
||||
setRendered(rendered ?? []);
|
||||
};
|
||||
void cb();
|
||||
}, [text, appState]);
|
||||
|
||||
if (rendered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rendered.map((fragment, index) => {
|
||||
if (typeof fragment === 'string') {
|
||||
return <span key={index}>{fragment}</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
draggable='false'
|
||||
src={fragment.src}
|
||||
alt={fragment.alt}
|
||||
title={fragment.title}
|
||||
className={fragment.className}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
16
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
16
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import type { EmojiAppState } from './types';
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
toSupportedLocale(state.meta.get('locale') as string),
|
||||
);
|
||||
const mode = useAppSelector((state) =>
|
||||
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||
);
|
||||
|
||||
return { currentLocale: locale, locales: [locale], mode };
|
||||
}
|
|
@ -2,27 +2,44 @@ import initialState from '@/mastodon/initial_state';
|
|||
|
||||
import { toSupportedLocale } from './locale';
|
||||
|
||||
const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
|
||||
const worker =
|
||||
'Worker' in window
|
||||
? new Worker(new URL('./worker', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
: null;
|
||||
let worker: Worker | null = null;
|
||||
|
||||
export async function initializeEmoji() {
|
||||
if (!worker && 'Worker' in window) {
|
||||
try {
|
||||
worker = new Worker(new URL('./worker', import.meta.url), {
|
||||
type: 'module',
|
||||
credentials: 'omit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Error creating web worker:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (worker) {
|
||||
worker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
// Assign worker to const to make TS happy inside the event listener.
|
||||
const thisWorker = worker;
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
worker.postMessage(serverLocale);
|
||||
worker.postMessage('custom');
|
||||
thisWorker.postMessage('custom');
|
||||
void loadEmojiLocale(userLocale);
|
||||
// Load English locale as well, because people are still used to
|
||||
// using it from before we supported other locales.
|
||||
if (userLocale !== 'en') {
|
||||
void loadEmojiLocale('en');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const { importCustomEmojiData, importEmojiData } = await import('./loader');
|
||||
await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]);
|
||||
const { importCustomEmojiData } = await import('./loader');
|
||||
await importCustomEmojiData();
|
||||
await loadEmojiLocale(userLocale);
|
||||
if (userLocale !== 'en') {
|
||||
await loadEmojiLocale('en');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
putLatestEtag,
|
||||
} from './database';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { LocaleOrCustom } from './locale';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
|
||||
export async function importEmojiData(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { Locale } from 'emojibase';
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
|
||||
export type LocaleOrCustom = Locale | 'custom';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
|
||||
export function toSupportedLocale(localeBase: string): Locale {
|
||||
const locale = localeBase.toLowerCase();
|
||||
|
|
119
app/javascript/mastodon/features/emoji/mode.ts
Normal file
119
app/javascript/mastodon/features/emoji/mode.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
// Credit to Nolan Lawson for the original implementation.
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import type { EmojiMode } from './types';
|
||||
|
||||
type Feature = Uint8ClampedArray;
|
||||
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
|
||||
const FONT_FAMILY =
|
||||
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
|
||||
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
|
||||
|
||||
function getTextFeature(text: string, color: string) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
|
||||
const ctx = canvas.getContext('2d', {
|
||||
// Improves the performance of `getImageData()`
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas context not available');
|
||||
}
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = `100px ${FONT_FAMILY}`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.scale(0.01, 0.01);
|
||||
ctx.fillText(text, 0, 0);
|
||||
|
||||
return ctx.getImageData(0, 0, 1, 1).data satisfies Feature;
|
||||
}
|
||||
|
||||
function compareFeatures(feature1: Feature, feature2: Feature) {
|
||||
const feature1Str = [...feature1].join(',');
|
||||
const feature2Str = [...feature2].join(',');
|
||||
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
|
||||
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
|
||||
// 0,0,0,61 - there is a transparency here.
|
||||
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,');
|
||||
}
|
||||
|
||||
function testEmojiSupport(text: string) {
|
||||
// Render white and black and then compare them to each other and ensure they're the same
|
||||
// color, and neither one is black. This shows that the emoji was rendered in color.
|
||||
const feature1 = getTextFeature(text, '#000');
|
||||
const feature2 = getTextFeature(text, '#fff');
|
||||
return compareFeatures(feature1, feature2);
|
||||
}
|
||||
|
||||
const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15
|
||||
const EMOJI_FLAG_TEST_EMOJI = '🇨🇭';
|
||||
|
||||
export function determineEmojiMode(style: string): EmojiMode {
|
||||
if (style === EMOJI_MODE_NATIVE) {
|
||||
// If flags are not supported, we replace them with Twemoji.
|
||||
if (shouldReplaceFlags()) {
|
||||
return EMOJI_MODE_NATIVE_WITH_FLAGS;
|
||||
}
|
||||
return EMOJI_MODE_NATIVE;
|
||||
}
|
||||
if (style === EMOJI_MODE_TWEMOJI) {
|
||||
return EMOJI_MODE_TWEMOJI;
|
||||
}
|
||||
|
||||
// Auto style so determine based on browser capabilities.
|
||||
if (shouldUseTwemoji()) {
|
||||
return EMOJI_MODE_TWEMOJI;
|
||||
} else if (shouldReplaceFlags()) {
|
||||
return EMOJI_MODE_NATIVE_WITH_FLAGS;
|
||||
}
|
||||
return EMOJI_MODE_NATIVE;
|
||||
}
|
||||
|
||||
export function shouldUseTwemoji(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Test a known color emoji to see if 15.1 is supported.
|
||||
return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI);
|
||||
} catch (err: unknown) {
|
||||
// If an error occurs, fall back to Twemoji to be safe.
|
||||
if (isDevelopment()) {
|
||||
console.warn(
|
||||
'Emoji rendering test failed, defaulting to Twemoji. Error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19
|
||||
export function shouldReplaceFlags(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Test a known flag emoji to see if it is rendered in color.
|
||||
return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI);
|
||||
} catch (err: unknown) {
|
||||
// If an error occurs, assume flags should be replaced.
|
||||
if (isDevelopment()) {
|
||||
console.warn(
|
||||
'Flag emoji rendering test failed, defaulting to replacement. Error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -22,9 +22,9 @@ const emojiSVGFiles = await readdir(
|
|||
);
|
||||
const svgFileNames = emojiSVGFiles
|
||||
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
|
||||
.map((file) => basename(file.name, '.svg').toUpperCase());
|
||||
.map((file) => basename(file.name, '.svg'));
|
||||
const svgFileNamesWithoutBorder = svgFileNames.filter(
|
||||
(fileName) => !fileName.endsWith('_BORDER'),
|
||||
(fileName) => !fileName.endsWith('_border'),
|
||||
);
|
||||
|
||||
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
|
||||
|
@ -60,13 +60,13 @@ describe('unicodeToTwemojiHex', () => {
|
|||
describe('twemojiHasBorder', () => {
|
||||
test.concurrent.for(
|
||||
svgFileNames
|
||||
.filter((file) => file.endsWith('_BORDER'))
|
||||
.filter((file) => file.endsWith('_border'))
|
||||
.map((file) => {
|
||||
const hexCode = file.replace('_BORDER', '');
|
||||
const hexCode = file.replace('_border', '');
|
||||
return [
|
||||
hexCode,
|
||||
CODES_WITH_LIGHT_BORDER.includes(hexCode),
|
||||
CODES_WITH_DARK_BORDER.includes(hexCode),
|
||||
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
|
||||
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
|
||||
] as const;
|
||||
}),
|
||||
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
EMOJIS_WITH_DARK_BORDER,
|
||||
EMOJIS_WITH_LIGHT_BORDER,
|
||||
} from './constants';
|
||||
import type { TwemojiBorderInfo } from './types';
|
||||
|
||||
// Misc codes that have special handling
|
||||
const SKIER_CODE = 0x26f7;
|
||||
|
@ -51,13 +52,7 @@ export function unicodeToTwemojiHex(unicodeHex: string): string {
|
|||
normalizedCodes.push(code);
|
||||
}
|
||||
|
||||
return hexNumbersToString(normalizedCodes, 0);
|
||||
}
|
||||
|
||||
interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
return hexNumbersToString(normalizedCodes, 0).toLowerCase();
|
||||
}
|
||||
|
||||
export const CODES_WITH_DARK_BORDER =
|
||||
|
@ -77,7 +72,7 @@ export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
|
|||
hasDarkBorder = true;
|
||||
}
|
||||
return {
|
||||
hexCode: normalizedHex,
|
||||
hexCode: twemojiHex,
|
||||
hasLightBorder,
|
||||
hasDarkBorder,
|
||||
};
|
||||
|
|
163
app/javascript/mastodon/features/emoji/render.test.ts
Normal file
163
app/javascript/mastodon/features/emoji/render.test.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import { emojifyElement, tokenizeText } from './render';
|
||||
import type { CustomEmojiData, UnicodeEmojiData } from './types';
|
||||
|
||||
vitest.mock('./database', () => ({
|
||||
searchCustomEmojisByShortcodes: vitest.fn(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
shortcode: 'custom',
|
||||
static_url: 'emoji/static',
|
||||
url: 'emoji/custom',
|
||||
category: 'test',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
] satisfies CustomEmojiData[],
|
||||
),
|
||||
searchEmojisByHexcodes: vitest.fn(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
hexcode: '1F60A',
|
||||
group: 0,
|
||||
label: 'smiling face with smiling eyes',
|
||||
order: 0,
|
||||
tags: ['smile', 'happy'],
|
||||
unicode: '😊',
|
||||
},
|
||||
{
|
||||
hexcode: '1F1EA-1F1FA',
|
||||
group: 0,
|
||||
label: 'flag-eu',
|
||||
order: 0,
|
||||
tags: ['flag', 'european union'],
|
||||
unicode: '🇪🇺',
|
||||
},
|
||||
] satisfies UnicodeEmojiData[],
|
||||
),
|
||||
findMissingLocales: vitest.fn(() => []),
|
||||
}));
|
||||
|
||||
describe('emojifyElement', () => {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
const expectedCustomEmojiImage =
|
||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
|
||||
|
||||
function cloneTestElement() {
|
||||
return testElement.cloneNode(true) as HTMLElement;
|
||||
}
|
||||
|
||||
test('emojifies custom emoji in native mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_NATIVE,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('emojifies flag emoji in native-with-flags mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('emojifies everything in twemoji mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns empty array for string with only whitespace', () => {
|
||||
expect(tokenizeText(' \n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns an array of text to be a single token', () => {
|
||||
expect(tokenizeText('Hello')).toEqual(['Hello']);
|
||||
});
|
||||
|
||||
test('returns tokens for text with emoji', () => {
|
||||
expect(tokenizeText('Hello 😊 🇿🇼!!')).toEqual([
|
||||
'Hello ',
|
||||
{
|
||||
type: 'unicode',
|
||||
code: '😊',
|
||||
},
|
||||
' ',
|
||||
{
|
||||
type: 'unicode',
|
||||
code: '🇿🇼',
|
||||
},
|
||||
'!!',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns tokens for text with custom emoji', () => {
|
||||
expect(tokenizeText('Hello :smile:!!')).toEqual([
|
||||
'Hello ',
|
||||
{
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
},
|
||||
'!!',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles custom emoji with underscores and numbers', () => {
|
||||
expect(tokenizeText('Hello :smile_123:!!')).toEqual([
|
||||
'Hello ',
|
||||
{
|
||||
type: 'custom',
|
||||
code: 'smile_123',
|
||||
},
|
||||
'!!',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns tokens for text with mixed emoji', () => {
|
||||
expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([
|
||||
'Hello ',
|
||||
{
|
||||
type: 'unicode',
|
||||
code: '😊',
|
||||
},
|
||||
' ',
|
||||
{
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
},
|
||||
'!!',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not capture custom emoji with invalid characters', () => {
|
||||
expect(tokenizeText('Hello :smile-123:!!')).toEqual([
|
||||
'Hello :smile-123:!!',
|
||||
]);
|
||||
});
|
||||
});
|
331
app/javascript/mastodon/features/emoji/render.ts
Normal file
331
app/javascript/mastodon/features/emoji/render.ts
Normal file
|
@ -0,0 +1,331 @@
|
|||
import type { Locale } from 'emojibase';
|
||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
EMOJI_STATE_MISSING,
|
||||
} from './constants';
|
||||
import {
|
||||
findMissingLocales,
|
||||
searchCustomEmojisByShortcodes,
|
||||
searchEmojisByHexcodes,
|
||||
} from './database';
|
||||
import { loadEmojiLocale } from './index';
|
||||
import {
|
||||
emojiToUnicodeHex,
|
||||
twemojiHasBorder,
|
||||
unicodeToTwemojiHex,
|
||||
} from './normalize';
|
||||
import type {
|
||||
CustomEmojiToken,
|
||||
EmojiAppState,
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
EmojiState,
|
||||
EmojiStateMap,
|
||||
EmojiToken,
|
||||
ExtraCustomEmojiMap,
|
||||
LocaleOrCustom,
|
||||
UnicodeEmojiToken,
|
||||
} from './types';
|
||||
import { stringHasUnicodeFlags } from './utils';
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[EMOJI_TYPE_CUSTOM, new Map()],
|
||||
]);
|
||||
|
||||
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
export async function emojifyElement<Element extends HTMLElement>(
|
||||
element: Element,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<Element> {
|
||||
const queue: (HTMLElement | Text)[] = [element];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (
|
||||
!current ||
|
||||
current instanceof HTMLScriptElement ||
|
||||
current instanceof HTMLStyleElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current.textContent &&
|
||||
(current instanceof Text || !current.hasChildNodes())
|
||||
) {
|
||||
const renderedContent = await emojifyText(
|
||||
current.textContent,
|
||||
appState,
|
||||
extraEmojis,
|
||||
);
|
||||
if (renderedContent) {
|
||||
if (!(current instanceof Text)) {
|
||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||
}
|
||||
current.replaceWith(renderedToHTMLFragment(renderedContent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of current.childNodes) {
|
||||
if (child instanceof HTMLElement || child instanceof Text) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function emojifyText(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
) {
|
||||
// Exit if no text to convert.
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = tokenizeText(text);
|
||||
|
||||
// If only one token and it's a string, exit early.
|
||||
if (tokens.length === 1 && typeof tokens[0] === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all emoji from the state map, loading any missing ones.
|
||||
await ensureLocalesAreLoaded(appState.locales);
|
||||
await loadMissingEmojiIntoCache(tokens, appState.locales);
|
||||
|
||||
const renderedFragments: (string | HTMLImageElement)[] = [];
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||
let state: EmojiState | undefined;
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const extraEmojiData = extraEmojis[token.code];
|
||||
if (extraEmojiData) {
|
||||
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
|
||||
} else {
|
||||
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||
}
|
||||
} else {
|
||||
state = emojiForLocale(
|
||||
emojiToUnicodeHex(token.code),
|
||||
appState.currentLocale,
|
||||
);
|
||||
}
|
||||
|
||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||
if (state && typeof state !== 'string') {
|
||||
const image = stateToImage(state);
|
||||
renderedFragments.push(image);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const text = typeof token === 'string' ? token : token.code;
|
||||
renderedFragments.push(text);
|
||||
}
|
||||
|
||||
return renderedFragments;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
async function ensureLocalesAreLoaded(locales: Locale[]) {
|
||||
const missingLocales = await findMissingLocales(locales);
|
||||
for (const locale of missingLocales) {
|
||||
await loadEmojiLocale(locale);
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
const TOKENIZE_REGEX = new RegExp(
|
||||
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
type TokenizedText = (string | EmojiToken)[];
|
||||
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(TOKENIZE_REGEX)) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: code.slice(1, -1), // Remove the colons
|
||||
} satisfies CustomEmojiToken);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies UnicodeEmojiToken);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
|
||||
}
|
||||
|
||||
function emojiForLocale(
|
||||
code: string,
|
||||
locale: LocaleOrCustom,
|
||||
): EmojiState | undefined {
|
||||
const cache = cacheForLocale(locale);
|
||||
return cache.get(code);
|
||||
}
|
||||
|
||||
async function loadMissingEmojiIntoCache(
|
||||
tokens: TokenizedText,
|
||||
locales: Locale[],
|
||||
) {
|
||||
const missingUnicodeEmoji = new Set<string>();
|
||||
const missingCustomEmoji = new Set<string>();
|
||||
|
||||
// Iterate over tokens and check if they are in the cache already.
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
continue; // Skip plain strings.
|
||||
}
|
||||
|
||||
// If this is a custom emoji, check it separately.
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const code = token.code;
|
||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||
if (!emojiState) {
|
||||
missingCustomEmoji.add(code);
|
||||
}
|
||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||
} else {
|
||||
const code = emojiToUnicodeHex(token.code);
|
||||
if (missingUnicodeEmoji.has(code)) {
|
||||
continue; // Already marked as missing.
|
||||
}
|
||||
for (const locale of locales) {
|
||||
const emojiState = emojiForLocale(code, locale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUnicodeEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||
for (const locale of locales) {
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
|
||||
const cache = cacheForLocale(locale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||
}
|
||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||
emojis.every((emoji) => emoji.hexcode !== code),
|
||||
);
|
||||
for (const code of notFoundEmojis) {
|
||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||
}
|
||||
localeCacheMap.set(locale, cache);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingCustomEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
|
||||
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
|
||||
}
|
||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||
emojis.every((emoji) => emoji.shortcode !== code),
|
||||
);
|
||||
for (const code of notFoundEmojis) {
|
||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||
}
|
||||
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
||||
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||
// If the mode is native or native with flags for non-flag emoji
|
||||
// we can just append the text node directly.
|
||||
if (
|
||||
mode === EMOJI_MODE_NATIVE ||
|
||||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
|
||||
!stringHasUnicodeFlags(token.code))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function stateToImage(state: EmojiLoadedState) {
|
||||
const image = document.createElement('img');
|
||||
image.draggable = false;
|
||||
image.classList.add('emojione');
|
||||
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
||||
if (emojiInfo.hasLightBorder) {
|
||||
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
|
||||
} else if (emojiInfo.hasDarkBorder) {
|
||||
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
|
||||
}
|
||||
|
||||
image.alt = state.data.unicode;
|
||||
image.title = state.data.label;
|
||||
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
|
||||
} else {
|
||||
// Custom emoji
|
||||
const shortCode = `:${state.data.shortcode}:`;
|
||||
image.classList.add('custom-emoji');
|
||||
image.alt = shortCode;
|
||||
image.title = shortCode;
|
||||
image.src = autoPlayGif ? state.data.url : state.data.static_url;
|
||||
image.dataset.original = state.data.url;
|
||||
image.dataset.static = state.data.static_url;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const fragmentItem of renderedArray) {
|
||||
if (typeof fragmentItem === 'string') {
|
||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||
} else if (fragmentItem instanceof HTMLImageElement) {
|
||||
fragment.appendChild(fragmentItem);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
64
app/javascript/mastodon/features/emoji/types.ts
Normal file
64
app/javascript/mastodon/features/emoji/types.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import type {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
EMOJI_STATE_MISSING,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
EMOJI_TYPE_UNICODE,
|
||||
} from './constants';
|
||||
|
||||
export type EmojiMode =
|
||||
| typeof EMOJI_MODE_NATIVE
|
||||
| typeof EMOJI_MODE_NATIVE_WITH_FLAGS
|
||||
| typeof EMOJI_MODE_TWEMOJI;
|
||||
|
||||
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
|
||||
|
||||
export interface EmojiAppState {
|
||||
locales: Locale[];
|
||||
currentLocale: Locale;
|
||||
mode: EmojiMode;
|
||||
}
|
||||
|
||||
export interface UnicodeEmojiToken {
|
||||
type: typeof EMOJI_TYPE_UNICODE;
|
||||
code: string;
|
||||
}
|
||||
export interface CustomEmojiToken {
|
||||
type: typeof EMOJI_TYPE_CUSTOM;
|
||||
code: string;
|
||||
}
|
||||
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
|
||||
|
||||
export type CustomEmojiData = ApiCustomEmojiJSON;
|
||||
export type UnicodeEmojiData = FlatCompactEmoji;
|
||||
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
||||
|
||||
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
|
||||
export interface EmojiStateUnicode {
|
||||
type: typeof EMOJI_TYPE_UNICODE;
|
||||
data: UnicodeEmojiData;
|
||||
}
|
||||
export interface EmojiStateCustom {
|
||||
type: typeof EMOJI_TYPE_CUSTOM;
|
||||
data: CustomEmojiData;
|
||||
}
|
||||
export type EmojiState =
|
||||
| EmojiStateMissing
|
||||
| EmojiStateUnicode
|
||||
| EmojiStateCustom;
|
||||
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
||||
|
||||
export type EmojiStateMap = Map<string, EmojiState>;
|
||||
|
||||
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
|
||||
|
||||
export interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
}
|
47
app/javascript/mastodon/features/emoji/utils.test.ts
Normal file
47
app/javascript/mastodon/features/emoji/utils.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||
|
||||
describe('stringHasEmoji', () => {
|
||||
test.concurrent.for([
|
||||
['only text', false],
|
||||
['text with emoji 😀', true],
|
||||
['multiple emojis 😀😃😄', true],
|
||||
['emoji with skin tone 👍🏽', true],
|
||||
['emoji with ZWJ 👩❤️👨', true],
|
||||
['emoji with variation selector ✊️', true],
|
||||
['emoji with keycap 1️⃣', true],
|
||||
['emoji with flags 🇺🇸', true],
|
||||
['emoji with regional indicators 🇦🇺', true],
|
||||
['emoji with gender 👩⚕️', true],
|
||||
['emoji with family 👨👩👧👦', true],
|
||||
['emoji with zero width joiner 👩🔬', true],
|
||||
['emoji with non-BMP codepoint 🧑🚀', true],
|
||||
['emoji with combining marks 👨👩👧👦', true],
|
||||
['emoji with enclosing keycap #️⃣', true],
|
||||
['emoji with no visible glyph \u200D', false],
|
||||
] as const)(
|
||||
'stringHasEmoji has emojis in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasFlags', () => {
|
||||
test.concurrent.for([
|
||||
['EU 🇪🇺', true],
|
||||
['Germany 🇩🇪', true],
|
||||
['Canada 🇨🇦', true],
|
||||
['São Tomé & Príncipe 🇸🇹', true],
|
||||
['Scotland 🏴', true],
|
||||
['black flag 🏴', false],
|
||||
['arrr 🏴☠️', false],
|
||||
['rainbow flag 🏳️🌈', false],
|
||||
['non-flag 🔥', false],
|
||||
['only text', false],
|
||||
] as const)(
|
||||
'stringHasFlags has flag in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeFlags(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
13
app/javascript/mastodon/features/emoji/utils.ts
Normal file
13
app/javascript/mastodon/features/emoji/utils.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||
|
||||
export function stringHasUnicodeEmoji(text: string): boolean {
|
||||
return EMOJI_REGEX.test(text);
|
||||
}
|
||||
|
||||
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
|
||||
const EMOJIS_FLAGS_REGEX =
|
||||
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
|
||||
|
||||
export function stringHasUnicodeFlags(text: string): boolean {
|
||||
return EMOJIS_FLAGS_REGEX.test(text);
|
||||
}
|
|
@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
|
@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
|
@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user' icon={PersonIcon} />
|
||||
|
@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='star' icon={StarIcon} className='star-icon' />
|
||||
|
@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
|
@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
|
@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='pencil' icon={EditIcon} />
|
||||
|
@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='tasks' icon={InsertChartIcon} />
|
||||
|
@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
|
||||
<RelationshipsSeveranceEvent
|
||||
type={event.get('type')}
|
||||
|
@ -381,7 +381,7 @@ class Notification extends ImmutablePureComponent {
|
|||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
||||
<ModerationWarning
|
||||
action={warning.get('action')}
|
||||
|
@ -402,7 +402,7 @@ class Notification extends ImmutablePureComponent {
|
|||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
|
@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='flag' icon={FlagIcon} />
|
||||
|
@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { navigateToProfile } from 'mastodon/actions/accounts';
|
||||
import { mentionComposeById } from 'mastodon/actions/compose';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{
|
|||
return null;
|
||||
}
|
||||
|
||||
return <HotKeys handlers={handlers}>{content}</HotKeys>;
|
||||
return <Hotkeys handlers={handlers}>{content}</Hotkeys>;
|
||||
};
|
||||
|
|
|
@ -3,12 +3,11 @@ import type { JSX } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { replyComposeById } from 'mastodon/actions/compose';
|
||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{
|
|||
);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div
|
||||
role='button'
|
||||
className={classNames(
|
||||
|
@ -149,6 +148,6 @@ export const NotificationGroupWithStatus: React.FC<{
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,14 +2,13 @@ import { useMemo } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { replyComposeById } from 'mastodon/actions/compose';
|
||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||
import {
|
||||
navigateToStatus,
|
||||
toggleStatusSpoilers,
|
||||
} from 'mastodon/actions/statuses';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||
|
@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{
|
|||
if (!statusId || isFiltered) return null;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div
|
||||
role='button'
|
||||
className={classNames(
|
||||
|
@ -111,6 +110,6 @@ export const NotificationWithStatus: React.FC<{
|
|||
unfocusable
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
fetchContext,
|
||||
completeContextRefresh,
|
||||
} from 'mastodon/actions/statuses';
|
||||
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'status.context.loading',
|
||||
defaultMessage: 'Checking for more replies',
|
||||
},
|
||||
});
|
||||
|
||||
export const RefreshController: React.FC<{
|
||||
statusId: string;
|
||||
withBorder?: boolean;
|
||||
}> = ({ statusId, withBorder }) => {
|
||||
const refresh = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||
if (result.async_refresh.status === 'finished') {
|
||||
dispatch(completeContextRefresh({ statusId }));
|
||||
|
||||
if (result.async_refresh.result_count > 0) {
|
||||
setReady(true);
|
||||
}
|
||||
} else {
|
||||
scheduleRefresh(refresh);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
if (refresh) {
|
||||
scheduleRefresh(refresh);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [dispatch, setReady, statusId, refresh]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoading(true);
|
||||
setReady(false);
|
||||
|
||||
dispatch(fetchContext({ statusId }))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [dispatch, setReady, statusId]);
|
||||
|
||||
if (ready && !loading) {
|
||||
return (
|
||||
<button
|
||||
className={classNames('load-more load-gap', {
|
||||
'timeline-hint--with-descendants': withBorder,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='status.context.load_new_replies'
|
||||
defaultMessage='New replies available'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!refresh && !loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('load-more load-gap', {
|
||||
'timeline-hint--with-descendants': withBorder,
|
||||
})}
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
|
@ -69,7 +68,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
|
|||
|
||||
import ActionBar from './components/action_bar';
|
||||
import { DetailedStatus } from './components/detailed_status';
|
||||
|
||||
import { RefreshController } from './components/refresh_controller';
|
||||
|
||||
const messages = defineMessages({
|
||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||
|
@ -549,7 +548,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
render () {
|
||||
let ancestors, descendants, remoteHint;
|
||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { isLoading, status, ancestorsIds, descendantsIds, refresh, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (isLoading) {
|
||||
|
@ -579,11 +578,9 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (!isLocal) {
|
||||
remoteHint = (
|
||||
<TimelineHint
|
||||
className={classNames(!!descendants && 'timeline-hint--with-descendants')}
|
||||
url={status.get('url')}
|
||||
message={<FormattedMessage id='hints.threads.replies_may_be_missing' defaultMessage='Replies from other servers may be missing.' />}
|
||||
label={<FormattedMessage id='hints.threads.see_more' defaultMessage='See more replies on {domain}' values={{ domain: <strong>{status.getIn(['account', 'acct']).split('@')[1]}</strong> }} />}
|
||||
<RefreshController
|
||||
statusId={status.get('id')}
|
||||
withBorder={!!descendants}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -616,7 +613,7 @@ class Status extends ImmutablePureComponent {
|
|||
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
|
||||
{ancestors}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false)} ref={this.setStatusRef}>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
|
@ -654,7 +651,7 @@ class Status extends ImmutablePureComponent {
|
|||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
|
|
|
@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||
import { AlertsController } from 'mastodon/components/alerts_controller';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
@ -98,40 +98,6 @@ const mapStateToProps = state => ({
|
|||
username: state.getIn(['accounts', me, 'username']),
|
||||
});
|
||||
|
||||
const keyMap = {
|
||||
help: '?',
|
||||
new: 'n',
|
||||
search: ['s', '/'],
|
||||
forceNew: 'option+n',
|
||||
toggleComposeSpoilers: 'option+x',
|
||||
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
||||
reply: 'r',
|
||||
favourite: 'f',
|
||||
boost: 'b',
|
||||
mention: 'm',
|
||||
open: ['enter', 'o'],
|
||||
openProfile: 'p',
|
||||
moveDown: ['down', 'j'],
|
||||
moveUp: ['up', 'k'],
|
||||
back: 'backspace',
|
||||
goToHome: 'g h',
|
||||
goToNotifications: 'g n',
|
||||
goToLocal: 'g l',
|
||||
goToFederated: 'g t',
|
||||
goToDirect: 'g d',
|
||||
goToStart: 'g s',
|
||||
goToFavourites: 'g f',
|
||||
goToPinned: 'g p',
|
||||
goToProfile: 'g u',
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
goToRequests: 'g r',
|
||||
toggleHidden: 'x',
|
||||
toggleSensitive: 'h',
|
||||
openMedia: 'e',
|
||||
onTranslate: 't',
|
||||
};
|
||||
|
||||
class SwitchingColumnsArea extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
|
@ -400,6 +366,10 @@ class UI extends PureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleDonate = () => {
|
||||
location.href = 'https://joinmastodon.org/sponsors#donate'
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
|
@ -426,10 +396,6 @@ class UI extends PureComponent {
|
|||
|
||||
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
|
||||
}
|
||||
|
||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
|
@ -509,10 +475,6 @@ class UI extends PureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
setHotkeysRef = c => {
|
||||
this.hotkeys = c;
|
||||
};
|
||||
|
||||
handleHotkeyToggleHelp = () => {
|
||||
if (this.props.location.pathname === '/keyboard-shortcuts') {
|
||||
this.props.history.goBack();
|
||||
|
@ -593,10 +555,11 @@ class UI extends PureComponent {
|
|||
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||
goToMuted: this.handleHotkeyGoToMuted,
|
||||
goToRequests: this.handleHotkeyGoToRequests,
|
||||
cheat: this.handleDonate,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
|
||||
<Hotkeys global handlers={handlers}>
|
||||
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
|
||||
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
|
||||
{children}
|
||||
|
@ -611,7 +574,7 @@ class UI extends PureComponent {
|
|||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
* @property {string} sso_redirect
|
||||
* @property {string} status_page_url
|
||||
* @property {boolean} terms_of_service_enabled
|
||||
* @property {string?} emoji_style
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -95,6 +96,7 @@ export const disableHoverCards = getMeta('disable_hover_cards');
|
|||
export const disabledAccountId = getMeta('disabled_account_id');
|
||||
export const displayMedia = getMeta('display_media');
|
||||
export const domain = getMeta('domain');
|
||||
export const emojiStyle = getMeta('emoji_style') || 'auto';
|
||||
export const expandSpoilers = getMeta('expand_spoilers');
|
||||
export const forceSingleColumn = !getMeta('advanced_layout');
|
||||
export const limitedFederationMode = getMeta('limited_federation_mode');
|
||||
|
|
|
@ -424,8 +424,6 @@
|
|||
"hints.profiles.see_more_followers": "See more followers on {domain}",
|
||||
"hints.profiles.see_more_follows": "See more follows on {domain}",
|
||||
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
|
||||
"hints.threads.see_more": "See more replies on {domain}",
|
||||
"home.column_settings.show_quotes": "Show quotes",
|
||||
"home.column_settings.show_reblogs": "Show boosts",
|
||||
"home.column_settings.show_replies": "Show replies",
|
||||
|
@ -847,6 +845,8 @@
|
|||
"status.bookmark": "Bookmark",
|
||||
"status.cancel_reblog_private": "Unboost",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.context.load_new_replies": "New replies available",
|
||||
"status.context.loading": "Checking for more replies",
|
||||
"status.continued_thread": "Continued thread",
|
||||
"status.copy": "Copy link to post",
|
||||
"status.delete": "Delete",
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
"account.mute_notifications_short": "خموشی آگاهیها",
|
||||
"account.mute_short": "خموشی",
|
||||
"account.muted": "خموش",
|
||||
"account.muting": "خموش کرده",
|
||||
"account.muting": "خموشش کردهاید",
|
||||
"account.mutual": "یکدیگر را پی میگیرید",
|
||||
"account.no_bio": "شرحی فراهم نشده.",
|
||||
"account.open_original_page": "گشودن صفحهٔ اصلی",
|
||||
|
@ -129,14 +129,14 @@
|
|||
"annual_report.summary.thanks": "سپاس که بخشی از ماستودون هستید!",
|
||||
"attachments_list.unprocessed": "(پردازش نشده)",
|
||||
"audio.hide": "نهفتن صدا",
|
||||
"block_modal.remote_users_caveat": "ما از کارساز {domain} خواهیم خواست که به تصمیم شما احترام بگذارد. با این حال، تضمینی برای رعایت آن وجود ندارد زیرا برخی کارسازها ممکن است بلوکها را بهطور متفاوتی مدیریت کنند. فرستههای عمومی ممکن است همچنان برای کاربران که وارد نشده قابل مشاهده باشند.",
|
||||
"block_modal.remote_users_caveat": "از کارساز {domain} خواهیم خواست که به تصمیمتان احترام بگذارد. با این حال تضمینی برای رعایتش وجود ندارد؛ زیرا برخی کارسازها ممکن است مسدودی را متفاوت مدیریت کنند. ممکن است فرستههای عمومی همچنان برای کاربران وارد نشده نمایان باشند.",
|
||||
"block_modal.show_less": "نمایش کمتر",
|
||||
"block_modal.show_more": "نمایش بیشتر",
|
||||
"block_modal.they_cant_mention": "نمیتوانند نامتان را برده یا پیتان بگیرند.",
|
||||
"block_modal.they_cant_see_posts": "نمیتوانند فرستههایتان را دیده و فرستههایشان را نمیبینید.",
|
||||
"block_modal.they_will_know": "میتوانند ببینند که مسدود شدهاند.",
|
||||
"block_modal.they_cant_mention": "نمیتواند نامتان را برده یا پیتان بگیرد.",
|
||||
"block_modal.they_cant_see_posts": "نمیتواند فرستههایتان را ببیند و فرستههایش را نمیبینید.",
|
||||
"block_modal.they_will_know": "میتواند ببینند که مسدود شده.",
|
||||
"block_modal.title": "انسداد کاربر؟",
|
||||
"block_modal.you_wont_see_mentions": "فرستههایی که از اون نام برده را نخواهید دید.",
|
||||
"block_modal.you_wont_see_mentions": "فرستههایی که به او اشاره کردهاند را نخواهید دید.",
|
||||
"boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
|
||||
"boost_modal.reblog": "تقویت فرسته؟",
|
||||
"boost_modal.undo_reblog": "ناتقویت فرسته؟",
|
||||
|
@ -269,9 +269,9 @@
|
|||
"dismissable_banner.public_timeline": "اینها جدیدترین فرستههای عمومی از افرادی روی وب اجتماعیند که اعضای {domain} پی میگیرندشان.",
|
||||
"domain_block_modal.block": "انسداد کارساز",
|
||||
"domain_block_modal.block_account_instead": "انسداد @{name} به جایش",
|
||||
"domain_block_modal.they_can_interact_with_old_posts": "افزارد روی این کراساز میتوانند با فرستههای قدیمیتان تعامل داشته باشند.",
|
||||
"domain_block_modal.they_can_interact_with_old_posts": "افزارد روی این کارساز میتوانند با فرستههای قدیمیتان تعامل داشته باشند.",
|
||||
"domain_block_modal.they_cant_follow": "هیچکسی از این کارساز نمیتواند پیتان بگیرد.",
|
||||
"domain_block_modal.they_wont_know": "نخواهند دانست که مسدود شدهاند.",
|
||||
"domain_block_modal.they_wont_know": "نخواهد دانست که مسدود شده.",
|
||||
"domain_block_modal.title": "انسداد دامنه؟",
|
||||
"domain_block_modal.you_will_lose_num_followers": "شما {followersCount, plural, one {{followersCountDisplay} پیگیرنده} other {{followersCountDisplay} پیگیرنده}} و {followingCount, plural, one {{followingCountDisplay} فرد پیگرفتهشده} other {{followingCountDisplay} فرد پیگرفتهشده}} را از دست خواهید داد.",
|
||||
"domain_block_modal.you_will_lose_relationships": "شما تمام پیگیرکنندگان و افرادی که از این کارساز پیگیری میکنید را از دست خواهید داد.",
|
||||
|
@ -543,11 +543,11 @@
|
|||
"mute_modal.hide_options": "گزینههای نهفتن",
|
||||
"mute_modal.indefinite": "تا وقتی ناخموشش کنم",
|
||||
"mute_modal.show_options": "نمایش گزینهها",
|
||||
"mute_modal.they_can_mention_and_follow": "میتوانند به شما اشاره کرده و پیتان بگیرند، ولی نخواهید دیدشان.",
|
||||
"mute_modal.they_wont_know": "نخواهند دانست که خموش شدهاند.",
|
||||
"mute_modal.they_can_mention_and_follow": "میتواند به شما اشاره کرده و پیتان بگیرد؛ ولی نخواهید دیدش.",
|
||||
"mute_modal.they_wont_know": "نخواهد دانست که خموش شده.",
|
||||
"mute_modal.title": "خموشی کاربر؟",
|
||||
"mute_modal.you_wont_see_mentions": "فرستههایی که به او اشاره کردهاند را نخواهید دید.",
|
||||
"mute_modal.you_wont_see_posts": "هنوز میتوانند فرستههایتان را ببینند، ولی فرستههایشان را نمیبینید.",
|
||||
"mute_modal.you_wont_see_posts": "همچنان میتواند فرستههایتان را ببینند؛ ولی فرستههایش را نمیبینید.",
|
||||
"navigation_bar.about": "درباره",
|
||||
"navigation_bar.account_settings": "گذرواژه و امنیت",
|
||||
"navigation_bar.administration": "مدیریت",
|
||||
|
@ -687,7 +687,7 @@
|
|||
"notifications.policy.filter_limited_accounts_title": "حسابهای مدیریت شده",
|
||||
"notifications.policy.filter_new_accounts.hint": "ساخته شده در {days, plural, one {یک} other {#}} روز اخیر",
|
||||
"notifications.policy.filter_new_accounts_title": "حسابهای جدید",
|
||||
"notifications.policy.filter_not_followers_hint": "از جمله کسانی که کمتر از {days, plural, one {یک} other {#}} روز است پیتان میگیرند",
|
||||
"notifications.policy.filter_not_followers_hint": "از جمله کسانی که کمتر از {days, plural, one {یک} other {#}} روز است پیتان میگیرند",
|
||||
"notifications.policy.filter_not_followers_title": "کسانی که شما را دنبال میکنند",
|
||||
"notifications.policy.filter_not_following_hint": "تا به صورت دستی تأییدشان کنید",
|
||||
"notifications.policy.filter_not_following_title": "کسانی که پی نمیگیرید",
|
||||
|
@ -756,7 +756,7 @@
|
|||
"reply_indicator.cancel": "لغو",
|
||||
"reply_indicator.poll": "نظرسنجی",
|
||||
"report.block": "انسداد",
|
||||
"report.block_explanation": "شما فرستههایشان را نخواهید دید. آنها نمیتوانند فرستههایتان را ببینند یا شما را پیبگیرند. آنها میتوانند بگویند که مسدود شدهاند.",
|
||||
"report.block_explanation": "فرستههایش را نخواهید دید. نخواهد توانست فرستههایتان را دیده یا پیتان بگیرد. قادر است تشخیص دهد مسدود شده.",
|
||||
"report.categories.legal": "حقوقی",
|
||||
"report.categories.other": "غیره",
|
||||
"report.categories.spam": "هرزنامه",
|
||||
|
@ -770,7 +770,7 @@
|
|||
"report.forward": "فرستادن به {target}",
|
||||
"report.forward_hint": "این حساب در کارساز دیگری ثبت شده. آیا میخواهید رونوشتی ناشناس از این گزارش به آنجا هم فرستاده شود؟",
|
||||
"report.mute": "خموش",
|
||||
"report.mute_explanation": "شما فرستههای آنها را نخواهید دید. آنها همچنان میتوانند شما را پیبگیرند و فرستههایتان را ببینند و نمیدانند که خموش شدهاند.",
|
||||
"report.mute_explanation": "فرستههایش را نخواهید دید. همچنان خواهد توانست پیتان گرفته و فرستههایتان را ببیند. نخواهد دانست که خموش شده.",
|
||||
"report.next": "بعدی",
|
||||
"report.placeholder": "توضیحات اضافه",
|
||||
"report.reasons.dislike": "من آن را دوست ندارم",
|
||||
|
|
|
@ -116,6 +116,7 @@
|
|||
"column.domain_blocks": "Taɣulin yeffren",
|
||||
"column.edit_list": "Ẓreg tabdart",
|
||||
"column.favourites": "Imenyafen",
|
||||
"column.firehose": "Isuddam usriden",
|
||||
"column.follow_requests": "Isuturen n teḍfeṛt",
|
||||
"column.home": "Agejdan",
|
||||
"column.lists": "Tibdarin",
|
||||
|
@ -224,6 +225,7 @@
|
|||
"empty_column.bookmarked_statuses": "Ulac kra n tsuffeɣt i terniḍ ɣer yismenyifen-ik·im ar tura. Ticki terniḍ yiwet, ad d-tettwasken da.",
|
||||
"empty_column.community": "Tasuddemt tazayezt tadigant n yisallen d tilemt. Aru ihi kra akken ad tt-teččareḍ!",
|
||||
"empty_column.domain_blocks": "Ulac kra n taɣult yettwaffren ar tura.",
|
||||
"empty_column.explore_statuses": "Ulac ayen yellan d anezzuɣ akka tura. Uɣal-d ticki!",
|
||||
"empty_column.follow_requests": "Ulac ɣur-k·m ula yiwen n usuter n teḍfeṛt. Ticki teṭṭfeḍ-d yiwen ad d-yettwasken da.",
|
||||
"empty_column.hashtag": "Ar tura ulac kra n ugbur yesɛan assaɣ ɣer uhacṭag-agi.",
|
||||
"empty_column.home": "Tasuddemt tagejdant n yisallen d tilemt! Ẓer {public} neɣ nadi ad tafeḍ imseqdacen-nniḍen ad ten-ḍefṛeḍ.",
|
||||
|
@ -235,6 +237,7 @@
|
|||
"errors.unexpected_crash.copy_stacktrace": "Nɣel stacktrace ɣef wafus",
|
||||
"errors.unexpected_crash.report_issue": "Mmel ugur",
|
||||
"explore.suggested_follows": "Imdanen",
|
||||
"explore.title": "Inezzaɣ",
|
||||
"explore.trending_links": "Isallen",
|
||||
"explore.trending_statuses": "Tisuffaɣ",
|
||||
"explore.trending_tags": "Ihacṭagen",
|
||||
|
@ -401,6 +404,8 @@
|
|||
"navigation_bar.followed_tags": "Ihacṭagen yettwaḍfaren",
|
||||
"navigation_bar.follows_and_followers": "Imeḍfaṛen akked wid i teṭṭafaṛeḍ",
|
||||
"navigation_bar.lists": "Tibdarin",
|
||||
"navigation_bar.live_feed_local": "Asuddem usrid (adigan)",
|
||||
"navigation_bar.live_feed_public": "Asuddem usrid (azayaz)",
|
||||
"navigation_bar.logout": "Ffeɣ",
|
||||
"navigation_bar.moderation": "Aseɣyed",
|
||||
"navigation_bar.more": "Ugar",
|
||||
|
@ -408,6 +413,7 @@
|
|||
"navigation_bar.opened_in_classic_interface": "Tisuffaɣ, imiḍanen akked isebtar-nniḍen igejdanen ldin-d s wudem amezwer deg ugrudem web aklasiki.",
|
||||
"navigation_bar.preferences": "Imenyafen",
|
||||
"navigation_bar.search": "Nadi",
|
||||
"navigation_bar.search_trends": "Anadi / Anezzuɣ",
|
||||
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
|
||||
"notification.admin.report": "Yemla-t-id {name} {target}",
|
||||
"notification.admin.sign_up": "Ijerred {name}",
|
||||
|
@ -512,6 +518,7 @@
|
|||
"recommended": "Yettuwelleh",
|
||||
"refresh": "Smiren",
|
||||
"regeneration_indicator.please_stand_by": "Ttxil rǧu.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Ha-tt-an tsuddemt-ik·im tagejdant tettwaheggay…",
|
||||
"relative_time.days": "{number}u",
|
||||
"relative_time.full.just_now": "tura kan",
|
||||
"relative_time.hours": "{number}isr",
|
||||
|
@ -552,6 +559,7 @@
|
|||
"report.thanks.title": "Ur tebɣiḍ ara ad twaliḍ aya?",
|
||||
"report.thanks.title_actionable": "Tanemmirt ɣef uneqqis, ad nwali deg waya.",
|
||||
"report.unfollow": "Seḥbes aḍfar n @{name}",
|
||||
"report.unfollow_explanation": "Aql-ik·ikem teṭṭafareḍ amiḍan-a. I wakken ur tettwaliḍ ara akk, akka d asawen, tisuffaɣ-is deg tsuddemt-ik·im tagejdant, ur teṭṭafar ara.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} n tsuffeɣt} other {{count} n tsuffiɣin}} ttwaqnent",
|
||||
"report_notification.categories.legal": "Azerfan",
|
||||
"report_notification.categories.other": "Ayen nniḍen",
|
||||
|
@ -654,7 +662,7 @@
|
|||
"time_remaining.moments": "Akuden i d-yeqqimen",
|
||||
"time_remaining.seconds": "Mazal {number, plural, one {# n tasint} other {# n tsinin}} id yugran",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} wemdan} other {{counter} medden}} deg {days, plural, one {ass} other {{days} wussan}} iɛeddan",
|
||||
"trends.trending_now": "Ayen mucaɛen tura",
|
||||
"trends.trending_now": "Anezzuɣ tura",
|
||||
"ui.beforeunload": "Arewway-ik·im ad iruḥ ma yella tefeɣ-d deg Maṣṭudun.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
{
|
||||
"about.contact": "ਸੰਪਰਕ:",
|
||||
"about.default_locale": "ਮੂਲ",
|
||||
"about.disclaimer": "ਮਸਟੋਡੋਨ ਇੱਕ ਆਜ਼ਾਦ, ਖੁੱਲ੍ਹੇ ਸਰੋਤ ਵਾਲਾ ਸਾਫਟਵੇਅਰ ਹੈ ਅਤੇ Mastodon gGmbH ਦਾ ਮਾਰਕਾ ਹੈ।",
|
||||
"about.domain_blocks.no_reason_available": "ਕਾਰਨ ਮੌਜੂਦ ਨਹੀਂ ਹੈ",
|
||||
"about.domain_blocks.silenced.title": "ਸੀਮਿਤ",
|
||||
"about.domain_blocks.suspended.title": "ਮੁਅੱਤਲ ਕੀਤੀ",
|
||||
"about.domain_blocks.suspended.title": "ਸਸਪੈਂਡ ਕੀਤਾ",
|
||||
"about.language_label": "ਭਾਸ਼ਾ",
|
||||
"about.not_available": "ਇਹ ਜਾਣਕਾਰੀ ਨੂੰ ਇਸ ਸਰਵਰ ਉੱਤੇ ਉਪਲੱਬਧ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ।",
|
||||
"about.rules": "ਸਰਵਰ ਨਿਯਮ",
|
||||
"account.account_note_header": "ਨਿੱਜੀ ਨੋਟ",
|
||||
"account.add_or_remove_from_list": "ਸੂਚੀ ਵਿੱਚ ਜੋੜੋ ਜਾਂ ਹਟਾਓ",
|
||||
|
@ -12,21 +16,33 @@
|
|||
"account.block_domain": "{domain} ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ ਲਾਓ",
|
||||
"account.block_short": "ਪਾਬੰਦੀ",
|
||||
"account.blocked": "ਪਾਬੰਦੀਸ਼ੁਦਾ",
|
||||
"account.blocking": "ਪਾਬੰਦੀ ਲਾਉਣੀ",
|
||||
"account.cancel_follow_request": "ਫ਼ਾਲੋ ਕਰਨ ਨੂੰ ਰੱਦ ਕਰੋ",
|
||||
"account.copy": "ਪਰੋਫਾਇਲ ਲਈ ਲਿੰਕ ਕਾਪੀ ਕਰੋ",
|
||||
"account.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ @{name}",
|
||||
"account.disable_notifications": "ਜਦੋਂ {name} ਕੋਈ ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਨਾ ਦਿਓ",
|
||||
"account.domain_blocking": "ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ",
|
||||
"account.edit_profile": "ਪਰੋਫਾਈਲ ਨੂੰ ਸੋਧੋ",
|
||||
"account.enable_notifications": "ਜਦੋਂ {name} ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਦਿਓ",
|
||||
"account.endorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ",
|
||||
"account.familiar_followers_one": "{name1} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ",
|
||||
"account.familiar_followers_two": "{name1} ਅਤੇ {name2} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ",
|
||||
"account.featured": "ਫ਼ੀਚਰ",
|
||||
"account.featured.accounts": "ਪਰੋਫਾਈਲ",
|
||||
"account.featured.hashtags": "ਹੈਸ਼ਟੈਗ",
|
||||
"account.featured_tags.last_status_at": "{date} ਨੂੰ ਆਖਰੀ ਪੋਸਟ",
|
||||
"account.featured_tags.last_status_never": "ਕੋਈ ਪੋਸਟ ਨਹੀਂ",
|
||||
"account.follow": "ਫ਼ਾਲੋ",
|
||||
"account.follow_back": "ਵਾਪਸ ਫਾਲ਼ੋ ਕਰੋ",
|
||||
"account.followers": "ਫ਼ਾਲੋਅਰ",
|
||||
"account.followers.empty": "ਇਸ ਵਰਤੋਂਕਾਰ ਨੂੰ ਹਾਲੇ ਕੋਈ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।",
|
||||
"account.followers_counter": "{count, plural, one {{counter} ਫ਼ਾਲੋਅਰ} other {{counter} ਫ਼ਾਲੋਅਰ}}",
|
||||
"account.followers_you_know_counter": "{counter} ਤੁਸੀਂ ਜਾਣਦੇ ਹੋ",
|
||||
"account.following": "ਫ਼ਾਲੋ ਕੀਤਾ",
|
||||
"account.follows.empty": "ਇਹ ਵਰਤੋਂਕਾਰ ਹਾਲੇ ਕਿਸੇ ਨੂੰ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।",
|
||||
"account.follows_you": "ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹਨ",
|
||||
"account.go_to_profile": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਜਾਓ",
|
||||
"account.hide_reblogs": "{name} ਵਲੋਂ ਬੂਸਟ ਨੂੰ ਲੁਕਾਓ",
|
||||
"account.joined_short": "ਜੁਆਇਨ ਕੀਤਾ",
|
||||
"account.media": "ਮੀਡੀਆ",
|
||||
"account.mention": "@{name} ਦਾ ਜ਼ਿਕਰ",
|
||||
|
@ -34,6 +50,7 @@
|
|||
"account.mute_notifications_short": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਮੌਨ ਕਰੋ",
|
||||
"account.mute_short": "ਮੌਨ ਕਰੋ",
|
||||
"account.muted": "ਮੌਨ ਕੀਤੀਆਂ",
|
||||
"account.mutual": "ਤੁਸੀਂ ਇੱਕ ਦੂਜੇ ਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹੋ",
|
||||
"account.no_bio": "ਕੋਈ ਵਰਣਨ ਨਹੀਂ ਦਿੱਤਾ।",
|
||||
"account.open_original_page": "ਅਸਲ ਸਫ਼ੇ ਨੂੰ ਖੋਲ੍ਹੋ",
|
||||
"account.posts": "ਪੋਸਟਾਂ",
|
||||
|
@ -42,8 +59,10 @@
|
|||
"account.requested": "ਮਨਜ਼ੂਰੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ। ਫ਼ਾਲੋ ਬੇਨਤੀਆਂ ਨੂੰ ਰੱਦ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ",
|
||||
"account.requested_follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ",
|
||||
"account.share": "{name} ਦਾ ਪਰੋਫ਼ਾਇਲ ਸਾਂਝਾ ਕਰੋ",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} ਪੋਸਟ} other {{counter} ਪੋਸਟਾਂ}}",
|
||||
"account.unblock": "@{name} ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ",
|
||||
"account.unblock_domain": "{domain} ਡੋਮੇਨ ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ",
|
||||
"account.unblock_domain_short": "ਪਾਬੰਦੀ ਹਟਾਓ",
|
||||
"account.unblock_short": "ਪਾਬੰਦੀ ਹਟਾਓ",
|
||||
"account.unendorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ ਨਾ ਕਰੋ",
|
||||
"account.unfollow": "ਅਣ-ਫ਼ਾਲੋ",
|
||||
|
@ -148,6 +167,8 @@
|
|||
"confirmations.missing_alt_text.secondary": "ਕਿਵੇਂ ਵੀ ਪੋਸਟ ਕਰੋ",
|
||||
"confirmations.mute.confirm": "ਮੌਨ ਕਰੋ",
|
||||
"confirmations.redraft.confirm": "ਹਟਾਓ ਤੇ ਮੁੜ-ਡਰਾਫਟ",
|
||||
"confirmations.remove_from_followers.confirm": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਓ",
|
||||
"confirmations.remove_from_followers.title": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਉਣਾ ਹੈ?",
|
||||
"confirmations.unfollow.confirm": "ਅਣ-ਫ਼ਾਲੋ",
|
||||
"confirmations.unfollow.message": "ਕੀ ਤੁਸੀਂ {name} ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
|
||||
"confirmations.unfollow.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਹੈ?",
|
||||
|
@ -182,7 +203,9 @@
|
|||
"emoji_button.custom": "ਕਸਟਮ",
|
||||
"emoji_button.flags": "ਝੰਡੀਆਂ",
|
||||
"emoji_button.food": "ਖਾਣਾ-ਪੀਣਾ",
|
||||
"emoji_button.label": "ਇਮੋਜੀ ਪਾਓ",
|
||||
"emoji_button.nature": "ਕੁਦਰਤ",
|
||||
"emoji_button.not_found": "ਕੋਈ ਮਿਲਦਾ ਇਮੋਜ਼ੀ ਨਹੀਂ ਲੱਭਿਆ",
|
||||
"emoji_button.objects": "ਇਕਾਈ",
|
||||
"emoji_button.people": "ਲੋਕ",
|
||||
"emoji_button.recent": "ਅਕਸਰ ਵਰਤੇ",
|
||||
|
@ -199,9 +222,15 @@
|
|||
"empty_column.list": "ਇਸ ਸੂਚੀ ਵਿੱਚ ਹਾਲੇ ਕੁਝ ਵੀ ਨਹੀਂ ਹੈ। ਜਦੋਂ ਇਸ ਸੂਚੀ ਦੇ ਮੈਂਬਰ ਨਵੀਆਂ ਪੋਸਟਾਂ ਪਾਉਂਦੇ ਹਨ ਤਾਂ ਉਹ ਇੱਥੇ ਦਿਖਾਈ ਦੇਣਗੀਆਂ।",
|
||||
"errors.unexpected_crash.report_issue": "ਮੁੱਦੇ ਦੀ ਰਿਪੋਰਟ ਕਰੋ",
|
||||
"explore.suggested_follows": "ਲੋਕ",
|
||||
"explore.title": "ਰੁਝਾਨ",
|
||||
"explore.trending_links": "ਖ਼ਬਰਾਂ",
|
||||
"explore.trending_statuses": "ਪੋਸਟਾਂ",
|
||||
"explore.trending_tags": "ਹੈਸ਼ਟੈਗ",
|
||||
"featured_carousel.header": "{count, plural, one {ਟੰਗੀ ਹੋਈ ਪੋਸਟ} other {ਟੰਗੀਆਂ ਹੋਈਆਂ ਪੋਸਟਾਂ}}",
|
||||
"featured_carousel.next": "ਅੱਗੇ",
|
||||
"featured_carousel.post": "ਪੋਸਟ",
|
||||
"featured_carousel.previous": "ਪਿੱਛੇ",
|
||||
"featured_carousel.slide": "{total} ਵਿੱਚੋਂ {index}",
|
||||
"filter_modal.added.expired_title": "ਫਿਲਟਰ ਦੀ ਮਿਆਦ ਪੁੱਗੀ!",
|
||||
"filter_modal.added.review_and_configure_title": "ਫਿਲਟਰ ਸੈਟਿੰਗਾਂ",
|
||||
"filter_modal.added.settings_link": "ਸੈਟਿੰਗਾਂ ਸਫ਼ਾ",
|
||||
|
@ -252,6 +281,8 @@
|
|||
"home.column_settings.show_replies": "ਜਵਾਬਾਂ ਨੂੰ ਵੇਖੋ",
|
||||
"home.hide_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਓਹਲੇ ਕਰੋ",
|
||||
"home.pending_critical_update.link": "ਅੱਪਡੇਟ ਵੇਖੋ",
|
||||
"home.pending_critical_update.title": "ਗੰਭੀਰ ਸੁਰੱਖਿਆ ਅੱਪਡੇਟ ਮੌਜੂਦ ਹੈ!",
|
||||
"home.show_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਵੇਖਾਓ",
|
||||
"ignore_notifications_modal.ignore": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਅਣਡਿੱਠਾ ਕਰੋ",
|
||||
"info_button.label": "ਮਦਦ",
|
||||
"interaction_modal.go": "ਜਾਓ",
|
||||
|
@ -332,9 +363,12 @@
|
|||
"media_gallery.hide": "ਲੁਕਾਓ",
|
||||
"mute_modal.hide_from_notifications": "ਨੋਟੀਫਿਕੇਸ਼ਨਾਂ ਵਿੱਚੋਂ ਲੁਕਾਓ",
|
||||
"mute_modal.show_options": "ਚੋਣਾਂ ਨੂੰ ਵੇਖਾਓ",
|
||||
"mute_modal.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਮੌਨ ਕਰਨਾ ਹੈ?",
|
||||
"navigation_bar.about": "ਇਸ ਬਾਰੇ",
|
||||
"navigation_bar.account_settings": "ਪਾਸਵਰਡ ਅਤੇ ਸੁਰੱਖਿਆ",
|
||||
"navigation_bar.administration": "ਪਰਸ਼ਾਸ਼ਨ",
|
||||
"navigation_bar.advanced_interface": "ਤਕਨੀਕੀ ਵੈੱਬ ਇੰਟਰਫੇਸ ਵਿੱਚ ਖੋਲ੍ਹੋ",
|
||||
"navigation_bar.automated_deletion": "ਆਪਣੇ-ਆਪ ਹਟਾਈ ਪੋਸਟ",
|
||||
"navigation_bar.blocks": "ਪਾਬੰਦੀ ਲਾਏ ਵਰਤੋਂਕਾਰ",
|
||||
"navigation_bar.bookmarks": "ਬੁੱਕਮਾਰਕ",
|
||||
"navigation_bar.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ",
|
||||
|
@ -346,11 +380,16 @@
|
|||
"navigation_bar.follows_and_followers": "ਫ਼ਾਲੋ ਅਤੇ ਫ਼ਾਲੋ ਕਰਨ ਵਾਲੇ",
|
||||
"navigation_bar.lists": "ਸੂਚੀਆਂ",
|
||||
"navigation_bar.logout": "ਲਾਗ ਆਉਟ",
|
||||
"navigation_bar.more": "ਹੋਰ",
|
||||
"navigation_bar.mutes": "ਮੌਨ ਕੀਤੇ ਵਰਤੋਂਕਾਰ",
|
||||
"navigation_bar.preferences": "ਪਸੰਦਾਂ",
|
||||
"navigation_bar.privacy_and_reach": "ਪਰਦੇਦਾਰੀ ਅਤੇ ਪਹੁੰਚ",
|
||||
"navigation_bar.search": "ਖੋਜੋ",
|
||||
"navigation_bar.search_trends": "ਖੋਜ / ਰੁਝਾਨ",
|
||||
"not_signed_in_indicator.not_signed_in": "ਇਹ ਸਰੋਤ ਵਰਤਣ ਲਈ ਤੁਹਾਨੂੰ ਲਾਗਇਨ ਕਰਨ ਦੀ ਲੋੜ ਹੈ।",
|
||||
"notification.admin.sign_up": "{name} ਨੇ ਸਾਈਨ ਅੱਪ ਕੀਤਾ",
|
||||
"notification.favourite": "{name} ਨੇ ਤੁਹਾਡੀ ਪੋਸਟ ਨੂੰ ਪਸੰਦ ਕੀਤਾ",
|
||||
"notification.favourite_pm": "{name} ਨੇ ਤੁਹਾਡੇ ਨਿੱਜੀ ਜ਼ਿਕਰ ਨੂੰ ਪਸੰਦ ਕੀਤਾ",
|
||||
"notification.follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ",
|
||||
"notification.follow.name_and_others": "{name} ਅਤੇ <a>{count, plural, one {# ਹੋਰ} other {# ਹੋਰਾਂ}}</a> ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ",
|
||||
"notification.follow_request": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ",
|
||||
|
@ -365,6 +404,7 @@
|
|||
"notification.moderation_warning.action_silence": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਸੀਮਿਤ ਕੀਤਾ ਗਿਆ ਹੈ।",
|
||||
"notification.moderation_warning.action_suspend": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਮੁਅੱਤਲ ਕੀਤਾ ਗਿਆ ਹੈ।",
|
||||
"notification.reblog": "{name} boosted your status",
|
||||
"notification.relationships_severance_event": "{name} ਨਾਲ ਕਨੈਕਸ਼ਨ ਗੁਆਚੇ",
|
||||
"notification.relationships_severance_event.learn_more": "ਹੋਰ ਜਾਣੋ",
|
||||
"notification.status": "{name} ਨੇ ਹੁਣੇ ਪੋਸਟ ਕੀਤਾ",
|
||||
"notification.update": "{name} ਨੋ ਪੋਸਟ ਨੂੰ ਸੋਧਿਆ",
|
||||
|
@ -540,7 +580,10 @@
|
|||
"status.unpin": "ਪਰੋਫਾਈਲ ਤੋਂ ਲਾਹੋ",
|
||||
"subscribed_languages.save": "ਤਬਦੀਲੀਆਂ ਸੰਭਾਲੋ",
|
||||
"tabs_bar.home": "ਘਰ",
|
||||
"tabs_bar.menu": "ਮੇਨੂ",
|
||||
"tabs_bar.notifications": "ਸੂਚਨਾਵਾਂ",
|
||||
"tabs_bar.publish": "ਨਵੀਂ ਪੋਸਟ",
|
||||
"tabs_bar.search": "ਖੋਜੋ",
|
||||
"terms_of_service.title": "ਸੇਵਾ ਦੀਆਂ ਸ਼ਰਤਾਂ",
|
||||
"time_remaining.days": "{number, plural, one {# ਦਿਨ} other {# ਦਿਨ}} ਬਾਕੀ",
|
||||
"time_remaining.hours": "{number, plural, one {# ਘੰਟਾ} other {# ਘੰਟੇ}} ਬਾਕੀ",
|
||||
|
@ -561,6 +604,9 @@
|
|||
"video.expand": "ਵੀਡੀਓ ਨੂੰ ਫੈਲਾਓ",
|
||||
"video.fullscreen": "ਪੂਰੀ ਸਕਰੀਨ",
|
||||
"video.hide": "ਵੀਡੀਓ ਨੂੰ ਲੁਕਾਓ",
|
||||
"video.mute": "ਮੌਨ",
|
||||
"video.pause": "ਠਹਿਰੋ",
|
||||
"video.play": "ਚਲਾਓ"
|
||||
"video.play": "ਚਲਾਓ",
|
||||
"video.volume_down": "ਅਵਾਜ਼ ਘਟਾਓ",
|
||||
"video.volume_up": "ਅਵਾਜ਼ ਵਧਾਓ"
|
||||
}
|
||||
|
|
|
@ -537,8 +537,10 @@
|
|||
"mute_modal.you_wont_see_mentions": "你看不到提及对方的嘟文。",
|
||||
"mute_modal.you_wont_see_posts": "对方可以看到你的嘟文,但是你看不到对方的。",
|
||||
"navigation_bar.about": "关于",
|
||||
"navigation_bar.account_settings": "密码与安全",
|
||||
"navigation_bar.administration": "管理",
|
||||
"navigation_bar.advanced_interface": "在高级网页界面中打开",
|
||||
"navigation_bar.automated_deletion": "自动删除嘟文",
|
||||
"navigation_bar.blocks": "已屏蔽的用户",
|
||||
"navigation_bar.bookmarks": "书签",
|
||||
"navigation_bar.direct": "私下提及",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Globals } from '@react-spring/web';
|
|||
|
||||
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
|
||||
import Mastodon from 'mastodon/containers/mastodon';
|
||||
import { me, reduceMotion } from 'mastodon/initial_state';
|
||||
import { isFeatureEnabled, me, reduceMotion } from 'mastodon/initial_state';
|
||||
import * as perf from 'mastodon/performance';
|
||||
import ready from 'mastodon/ready';
|
||||
import { store } from 'mastodon/store';
|
||||
|
@ -29,6 +29,11 @@ function main() {
|
|||
});
|
||||
}
|
||||
|
||||
if (isFeatureEnabled('modern_emojis')) {
|
||||
const { initializeEmoji } = await import('@/mastodon/features/emoji');
|
||||
await initializeEmoji();
|
||||
}
|
||||
|
||||
const root = createRoot(mountNode);
|
||||
root.render(<Mastodon {...props} />);
|
||||
store.dispatch(setupBrowserNotifications());
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { Draft, UnknownAction } from '@reduxjs/toolkit';
|
|||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
||||
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import type {
|
||||
ApiStatusJSON,
|
||||
|
@ -12,7 +13,7 @@ import type {
|
|||
import type { Status } from 'mastodon/models/status';
|
||||
|
||||
import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts';
|
||||
import { fetchContext } from '../actions/statuses';
|
||||
import { fetchContext, completeContextRefresh } from '../actions/statuses';
|
||||
import { TIMELINE_UPDATE } from '../actions/timelines';
|
||||
import { compareId } from '../compare_id';
|
||||
|
||||
|
@ -25,11 +26,13 @@ interface TimelineUpdateAction extends UnknownAction {
|
|||
interface State {
|
||||
inReplyTos: Record<string, string>;
|
||||
replies: Record<string, string[]>;
|
||||
refreshing: Record<string, AsyncRefreshHeader>;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
inReplyTos: {},
|
||||
replies: {},
|
||||
refreshing: {},
|
||||
};
|
||||
|
||||
const normalizeContext = (
|
||||
|
@ -127,6 +130,13 @@ export const contextsReducer = createReducer(initialState, (builder) => {
|
|||
builder
|
||||
.addCase(fetchContext.fulfilled, (state, action) => {
|
||||
normalizeContext(state, action.meta.arg.statusId, action.payload.context);
|
||||
|
||||
if (action.payload.refresh) {
|
||||
state.refreshing[action.meta.arg.statusId] = action.payload.refresh;
|
||||
}
|
||||
})
|
||||
.addCase(completeContextRefresh, (state, action) => {
|
||||
delete state.refreshing[action.payload.statusId];
|
||||
})
|
||||
.addCase(blockAccountSuccess, (state, action) => {
|
||||
filterContexts(
|
||||
|
|
|
@ -2868,6 +2868,8 @@ a.account__display-name {
|
|||
}
|
||||
|
||||
&__main {
|
||||
--column-header-height: 62px;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
flex: 0 1 auto;
|
||||
|
@ -8815,6 +8817,10 @@ noscript {
|
|||
.conversation {
|
||||
position: relative;
|
||||
|
||||
// When scrolling these elements into view, take into account
|
||||
// the column header height
|
||||
scroll-margin-top: var(--column-header-height, 0);
|
||||
|
||||
&.unread {
|
||||
&::before {
|
||||
content: '';
|
||||
|
|
|
@ -3,9 +3,6 @@
|
|||
module Status::FetchRepliesConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# enable/disable fetching all replies
|
||||
FETCH_REPLIES_ENABLED = ENV['FETCH_REPLIES_ENABLED'] == 'true'
|
||||
|
||||
# debounce fetching all replies to minimize DoS
|
||||
FETCH_REPLIES_COOLDOWN_MINUTES = (ENV['FETCH_REPLIES_COOLDOWN_MINUTES'] || 15).to_i.minutes
|
||||
FETCH_REPLIES_INITIAL_WAIT_MINUTES = (ENV['FETCH_REPLIES_INITIAL_WAIT_MINUTES'] || 5).to_i.minutes
|
||||
|
@ -36,7 +33,7 @@ module Status::FetchRepliesConcern
|
|||
|
||||
def should_fetch_replies?
|
||||
# we aren't brand new, and we haven't fetched replies since the debounce window
|
||||
FETCH_REPLIES_ENABLED && !local? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
|
||||
!local? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
|
||||
fetched_replies_at.nil? || fetched_replies_at <= FETCH_REPLIES_COOLDOWN_MINUTES.ago
|
||||
)
|
||||
end
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::AccountBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
class Form::AccountBatch < Form::BaseBatch
|
||||
include Payloadable
|
||||
|
||||
attr_accessor :account_ids, :action, :current_account,
|
||||
:select_all_matching, :query
|
||||
attr_accessor :account_ids,
|
||||
:query,
|
||||
:select_all_matching
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
14
app/models/form/base_batch.rb
Normal file
14
app/models/form/base_batch.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::BaseBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :action,
|
||||
:current_account
|
||||
|
||||
def save
|
||||
raise 'Override in subclass'
|
||||
end
|
||||
end
|
|
@ -1,12 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::CustomEmojiBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :custom_emoji_ids, :action, :current_account,
|
||||
:category_id, :category_name, :visible_in_picker
|
||||
class Form::CustomEmojiBatch < Form::BaseBatch
|
||||
attr_accessor :category_id,
|
||||
:category_name,
|
||||
:visible_in_picker,
|
||||
:custom_emoji_ids
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::DomainBlockBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :domain_blocks_attributes, :action, :current_account
|
||||
class Form::DomainBlockBatch < Form::BaseBatch
|
||||
attr_accessor :domain_blocks_attributes
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::EmailDomainBlockBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :email_domain_block_ids, :action, :current_account
|
||||
class Form::EmailDomainBlockBatch < Form::BaseBatch
|
||||
attr_accessor :email_domain_block_ids
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::IpBlockBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :ip_block_ids, :action, :current_account
|
||||
class Form::IpBlockBatch < Form::BaseBatch
|
||||
attr_accessor :ip_block_ids
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
79
app/models/worker_batch.rb
Normal file
79
app/models/worker_batch.rb
Normal file
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class WorkerBatch
|
||||
include Redisable
|
||||
|
||||
TTL = 3600
|
||||
|
||||
def initialize(id = nil)
|
||||
@id = id || SecureRandom.hex(12)
|
||||
end
|
||||
|
||||
attr_reader :id
|
||||
|
||||
# Connect the batch with an async refresh. When the number of processed jobs
|
||||
# passes the given threshold, the async refresh will be marked as finished.
|
||||
# @param [String] async_refresh_key
|
||||
# @param [Float] threshold
|
||||
def connect(async_refresh_key, threshold: 1.0)
|
||||
redis.hset(key, { 'async_refresh_key' => async_refresh_key, 'threshold' => threshold })
|
||||
end
|
||||
|
||||
# Add jobs to the batch. Usually when the batch is created.
|
||||
# @param [Array<String>] jids
|
||||
def add_jobs(jids)
|
||||
if jids.blank?
|
||||
async_refresh_key = redis.hget(key, 'async_refresh_key')
|
||||
|
||||
if async_refresh_key.present?
|
||||
async_refresh = AsyncRefresh.new(async_refresh_key)
|
||||
async_refresh.finish!
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
redis.multi do |pipeline|
|
||||
pipeline.sadd(key('jobs'), jids)
|
||||
pipeline.expire(key('jobs'), TTL)
|
||||
pipeline.hincrby(key, 'pending', jids.size)
|
||||
pipeline.expire(key, TTL)
|
||||
end
|
||||
end
|
||||
|
||||
# Remove a job from the batch, such as when it's been processed or it has failed.
|
||||
# @param [String] jid
|
||||
def remove_job(jid)
|
||||
_, pending, processed, async_refresh_key, threshold = redis.multi do |pipeline|
|
||||
pipeline.srem(key('jobs'), jid)
|
||||
pipeline.hincrby(key, 'pending', -1)
|
||||
pipeline.hincrby(key, 'processed', 1)
|
||||
pipeline.hget(key, 'async_refresh_key')
|
||||
pipeline.hget(key, 'threshold')
|
||||
end
|
||||
|
||||
if async_refresh_key.present?
|
||||
async_refresh = AsyncRefresh.new(async_refresh_key)
|
||||
async_refresh.increment_result_count(by: 1)
|
||||
async_refresh.finish! if pending.zero? || processed >= threshold.to_f * (processed + pending)
|
||||
end
|
||||
end
|
||||
|
||||
# Get pending jobs.
|
||||
# @returns [Array<String>]
|
||||
def jobs
|
||||
redis.smembers(key('jobs'))
|
||||
end
|
||||
|
||||
# Inspect the batch.
|
||||
# @returns [Hash]
|
||||
def info
|
||||
redis.hgetall(key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def key(suffix = nil)
|
||||
"worker_batch:#{@id}#{":#{suffix}" if suffix}"
|
||||
end
|
||||
end
|
|
@ -30,6 +30,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
store[:use_blurhash] = object_account_user.setting_use_blurhash
|
||||
store[:use_pending_items] = object_account_user.setting_use_pending_items
|
||||
store[:show_trends] = Setting.trends && object_account_user.setting_trends
|
||||
store[:emoji_style] = object_account_user.settings['web.emoji_style'] if Mastodon::Feature.modern_emojis_enabled?
|
||||
else
|
||||
store[:auto_play_gif] = Setting.auto_play_gif
|
||||
store[:display_media] = Setting.display_media
|
||||
|
|
|
@ -6,7 +6,7 @@ class ActivityPub::FetchAllRepliesService < ActivityPub::FetchRepliesService
|
|||
# Limit of replies to fetch per status
|
||||
MAX_REPLIES = (ENV['FETCH_REPLIES_MAX_SINGLE'] || 500).to_i
|
||||
|
||||
def call(status_uri, collection_or_uri, max_pages: 1, request_id: nil)
|
||||
def call(status_uri, collection_or_uri, max_pages: 1, async_refresh_key: nil, request_id: nil)
|
||||
@status_uri = status_uri
|
||||
|
||||
super
|
||||
|
|
|
@ -6,7 +6,7 @@ class ActivityPub::FetchRepliesService < BaseService
|
|||
# Limit of fetched replies
|
||||
MAX_REPLIES = 5
|
||||
|
||||
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, request_id: nil)
|
||||
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, async_refresh_key: nil, request_id: nil)
|
||||
@reference_uri = reference_uri
|
||||
@allow_synchronous_requests = allow_synchronous_requests
|
||||
|
||||
|
@ -14,7 +14,10 @@ class ActivityPub::FetchRepliesService < BaseService
|
|||
return if @items.nil?
|
||||
|
||||
@items = filter_replies(@items)
|
||||
FetchReplyWorker.push_bulk(@items) { |reply_uri| [reply_uri, { 'request_id' => request_id }] }
|
||||
|
||||
batch = WorkerBatch.new
|
||||
batch.connect(async_refresh_key) if async_refresh_key.present?
|
||||
batch.add_jobs(FetchReplyWorker.push_bulk(@items) { |reply_uri| [reply_uri, { 'request_id' => request_id, 'batch_id' => batch.id }] })
|
||||
|
||||
[@items, n_pages]
|
||||
end
|
||||
|
|
|
@ -55,7 +55,7 @@ class ActivityPub::FetchAllRepliesWorker
|
|||
replies_collection_or_uri = get_replies_uri(status)
|
||||
return if replies_collection_or_uri.nil?
|
||||
|
||||
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, **options.deep_symbolize_keys)
|
||||
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, async_refresh_key: "context:#{@root_status.id}:refresh", **options.deep_symbolize_keys)
|
||||
end
|
||||
|
||||
# Get the URI of the replies collection of a status
|
||||
|
|
|
@ -7,6 +7,9 @@ class FetchReplyWorker
|
|||
sidekiq_options queue: 'pull', retry: 3
|
||||
|
||||
def perform(child_url, options = {})
|
||||
batch = WorkerBatch.new(options.delete('batch_id')) if options['batch_id']
|
||||
FetchRemoteStatusService.new.call(child_url, **options.symbolize_keys)
|
||||
ensure
|
||||
batch&.remove_job(jid)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1425,6 +1425,9 @@ cy:
|
|||
basic_information: Gwybodaeth Sylfaenol
|
||||
hint_html: "<strong>Addaswch yr hyn y mae pobl yn ei weld ar eich proffil cyhoeddus ac wrth ymyl eich postiadau.</strong> Mae pobl eraill yn fwy tebygol o'ch dilyn yn ôl a rhyngweithio â chi pan fydd gennych broffil wedi'i lenwi a llun proffil."
|
||||
other: Arall
|
||||
emoji_styles:
|
||||
auto: Awto
|
||||
native: Cynhenid
|
||||
errors:
|
||||
'400': Roedd y cais a gyflwynwyd gennych yn annilys neu wedi'i gamffurfio.
|
||||
'403': Nid oes gennych ganiatâd i weld y dudalen hon.
|
||||
|
@ -1590,7 +1593,7 @@ cy:
|
|||
domain_blocking_html:
|
||||
few: Rydych ar fin <strong>amnewid eich rhestr rhwystro parthau </strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
many: Rydych ar fin <strong>amnewid eich rhestr rhwystro parthau </strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
one: Rydych ar fin <strong>disodli eich rhestr blociau parth</strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
one: Rydych ar fin <strong>amnewid eich rhestr blociau parth</strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
other: Rydych ar fin <strong>amnewid eich rhestr rhwystro parthau </strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
two: Rydych ar fin <strong>amnewid eich rhestr rhwystro parthau </strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong.
|
||||
zero: Rydych ar fin <strong>amnewid eich rhestr rhwystro parthau </strong> gyda hyd at <strong>%{count} parth</strong> o <strong>%{filename}</strong>.
|
||||
|
|
|
@ -318,6 +318,8 @@ et:
|
|||
new:
|
||||
create: Loo teadaanne
|
||||
title: Uus teadaanne
|
||||
preview:
|
||||
title: Info teavituse üle vaatamine
|
||||
publish: Postita
|
||||
published_msg: Teadaande avaldamine õnnestus!
|
||||
scheduled_for: Kavandatud ajaks %{time}
|
||||
|
@ -485,6 +487,7 @@ et:
|
|||
request_body: Päringu sisu
|
||||
providers:
|
||||
active: Aktiivne
|
||||
base_url: Baas-URL
|
||||
callback: Pöördliiklus
|
||||
delete: Kustuta
|
||||
edit: Muuda teenusepakkujat
|
||||
|
@ -499,6 +502,7 @@ et:
|
|||
reject: Keeldu
|
||||
title: Kinnita FASP-i registreerimine
|
||||
save: Salvesta
|
||||
select_capabilities: Vali oskused
|
||||
sign_in: Logi sisse
|
||||
status: Olek
|
||||
title: Täiendavad teenusepakkujad Födiversumis (FASP - Fediverse Auxiliary Service Providers)
|
||||
|
|
|
@ -182,7 +182,7 @@ fa:
|
|||
create_account_warning: ایجاد هشدار
|
||||
create_announcement: ایجاد اعلامیه
|
||||
create_canonical_email_block: ایجاد انسداد رایانامه
|
||||
create_custom_emoji: ایجاد اموجی سفارشی
|
||||
create_custom_emoji: ایجاد شکلک سفارشی
|
||||
create_domain_allow: ایجاد اجازهٔ دامنه
|
||||
create_domain_block: ایجاد انسداد دامنه
|
||||
create_email_domain_block: ایجاد انسداد دامنهٔ رایانامه
|
||||
|
@ -193,7 +193,7 @@ fa:
|
|||
demote_user: تنزل کاربر
|
||||
destroy_announcement: حذف اعلامیه
|
||||
destroy_canonical_email_block: حذف انسداد رایانامه
|
||||
destroy_custom_emoji: حذف اموجی سفارشی
|
||||
destroy_custom_emoji: حذف شکلک سفارشی
|
||||
destroy_domain_allow: حذف اجازهٔ دامنه
|
||||
destroy_domain_block: حذف انسداد دامنه
|
||||
destroy_email_domain_block: حذف انسداد دامنهٔ رایانامه
|
||||
|
@ -204,11 +204,11 @@ fa:
|
|||
destroy_unavailable_domain: حذف دامنهٔ ناموجود
|
||||
destroy_user_role: نابودی نقش
|
||||
disable_2fa_user: از کار انداختن ورود دومرحلهای
|
||||
disable_custom_emoji: از کار انداختن اموجی سفارشی
|
||||
disable_custom_emoji: از کار انداختن شکلک سفارشی
|
||||
disable_relay: غیرفعالسازی رله
|
||||
disable_sign_in_token_auth_user: از کار انداختن تأیید هویت ژتون رایانامهای برای کاربر
|
||||
disable_user: از کار انداختن کاربر
|
||||
enable_custom_emoji: به کار انداختن اموجی سفارشی
|
||||
enable_custom_emoji: به کار انداختن شکلک سفارشی
|
||||
enable_relay: فعالسازی رله
|
||||
enable_sign_in_token_auth_user: به کار انداختن تأیید هویت ژتون رایانامهای برای کاربر
|
||||
enable_user: به کار انداختن کاربر
|
||||
|
@ -231,7 +231,7 @@ fa:
|
|||
unsilence_account: رفع خموشی حساب
|
||||
unsuspend_account: رفع تعلیق حساب
|
||||
update_announcement: بهروز رسانی اعلامیه
|
||||
update_custom_emoji: بهروز رسانی اموجی سفارشی
|
||||
update_custom_emoji: بهروز رسانی شکلک سفارشی
|
||||
update_domain_block: بهروزرسانی مسدودسازی دامنه
|
||||
update_ip_block: بروزرسانی قاعدهٔ آیپی
|
||||
update_report: بهروز رسانی گزارش
|
||||
|
@ -247,7 +247,7 @@ fa:
|
|||
create_account_warning_html: "%{name} هشداری برای %{target} فرستاد"
|
||||
create_announcement_html: "%{name} اعلامیهای جدید ایجاد کرد %{target}"
|
||||
create_canonical_email_block_html: "%{name} رایانامه با درهمریزی %{target} را مسدود کرد"
|
||||
create_custom_emoji_html: "%{name} اموجی تازهٔ %{target} را بارگذاشت"
|
||||
create_custom_emoji_html: "%{name} شکلک تازهٔ %{target} را بارگذاشت"
|
||||
create_domain_allow_html: "%{name} دامنهٔ %{target} را مجاز کرد"
|
||||
create_domain_block_html: "%{name} دامنهٔ %{target} را مسدود کرد"
|
||||
create_email_domain_block_html: "%{name} دامنهٔ رایانامهٔ %{target} را مسدود کرد"
|
||||
|
@ -1351,6 +1351,8 @@ fa:
|
|||
other: سایر
|
||||
emoji_styles:
|
||||
auto: خودکار
|
||||
native: بومی
|
||||
twemoji: توییموجی
|
||||
errors:
|
||||
'400': درخواستی که فرستادید نامعتبر یا اشتباه بود.
|
||||
'403': شما اجازهٔ دیدن این صفحه را ندارید.
|
||||
|
|
|
@ -1220,9 +1220,9 @@ ga:
|
|||
confirmation_dialogs: Dialóga deimhnithe
|
||||
discovery: Fionnachtain
|
||||
localization:
|
||||
body: Aistríonn oibrithe deonacha Mastodon.
|
||||
body: Oibrithe deonacha a dhéanann aistriúchán Mastodon.
|
||||
guide_link: https://crowdin.com/project/mastodon
|
||||
guide_link_text: Is féidir le gach duine rannchuidiú.
|
||||
guide_link_text: Is féidir le gach duine cur leis.
|
||||
sensitive_content: Ábhar íogair
|
||||
application_mailer:
|
||||
notification_preferences: Athraigh roghanna ríomhphoist
|
||||
|
|
|
@ -444,7 +444,7 @@ kab:
|
|||
media:
|
||||
title: Amidya
|
||||
open: Ldi tasuffeɣt
|
||||
trending: Ayen mucaɛen
|
||||
trending: Inezzaɣ
|
||||
visibility: Abani
|
||||
with_media: S umidya
|
||||
system_checks:
|
||||
|
@ -475,12 +475,16 @@ kab:
|
|||
title: Tadbelt
|
||||
trends:
|
||||
allow: Sireg
|
||||
links:
|
||||
title: Iseɣwan inezzaɣ
|
||||
statuses:
|
||||
title: Tisuffaɣ mucaɛen
|
||||
title: Tisuffaɣ tinezzaɣ
|
||||
tags:
|
||||
dashboard:
|
||||
tag_languages_dimension: Tutlayin ifazen
|
||||
trending: Ayen mucaɛen
|
||||
title: Ihacṭagen inezzaɣ
|
||||
trending_rank: 'Anezzuɣ #%{rank}'
|
||||
trending: Inezzaɣ
|
||||
warning_presets:
|
||||
add_new: Rnu amaynut
|
||||
delete: Kkes
|
||||
|
@ -492,8 +496,12 @@ kab:
|
|||
body: "%{reporter} yettwazen ɣef %{target}"
|
||||
subject: Aneqqis amaynut i %{instance} (#%{id})
|
||||
new_trends:
|
||||
new_trending_links:
|
||||
title: Iseɣwan inezzaɣ
|
||||
new_trending_statuses:
|
||||
title: Tisuffaɣ mucaɛen
|
||||
title: Tisuffaɣ tinezzaɣ
|
||||
new_trending_tags:
|
||||
title: Ihacṭagen inezzaɣ
|
||||
appearance:
|
||||
advanced_web_interface: Agrudem n web leqqayen
|
||||
discovery: Asnirem
|
||||
|
@ -916,12 +924,13 @@ kab:
|
|||
edit_profile_title: Sagen amaɣnu-inek·inem
|
||||
feature_action: Issin ugar
|
||||
follow_action: Ḍfeṛ
|
||||
follow_title: Sagen isuddam n yisallen n wejgu-k·m agejdan
|
||||
follows_subtitle: Ḍfer imiḍanen yettwassnen mliḥ
|
||||
follows_title: Anwa ara ḍefṛeḍ
|
||||
follows_view_more: Ssken-d ugar n medden ay tzemred ad tḍefred
|
||||
hashtags_subtitle: Wali ayen ileḥḥun seg sin wussan-a iεeddan
|
||||
hashtags_title: Ihacṭagen mucaɛen
|
||||
hashtags_view_more: Sken-d ugar n yihacṭagen mucaɛen
|
||||
hashtags_subtitle: Snirem ayen yellan d anezzuɣ deg 2 n wussan-a iεeddan
|
||||
hashtags_title: Ihacṭagen inezzaɣ
|
||||
hashtags_view_more: Sken-d ugar n yihacṭagen inezzaɣ
|
||||
post_step: Ini-as azul i umaḍal s uḍris, s tiwlafin, s tividyutin neɣ s tefranin.
|
||||
post_title: Aru tasuffeɣt-inek·inem tamezwarut
|
||||
share_step: Init-asen i yimeddukal-nwen amek ara ken-id-afen deg Mastodon.
|
||||
|
|
|
@ -454,6 +454,7 @@ nan:
|
|||
title: 封鎖新ê電子phue網域
|
||||
no_email_domain_block_selected: 因為無揀任何電子phue域名封鎖,所以lóng無改變
|
||||
not_permitted: 無允准
|
||||
resolved_dns_records_hint_html: 域名解析做下kha ê MX域名,tsiah ê域名上後負責收電子phue。封鎖MX域名ē封任何有siâng款MX域名ê電子郵件ê註冊,就算通看見ê域名無kâng,mā án-ne。<strong>Tio̍h細膩,m̄通封鎖主要ê電子phue提供者。</strong>
|
||||
resolved_through_html: 通過 %{domain} 解析
|
||||
title: 封鎖ê電子phue網域
|
||||
export_domain_allows:
|
||||
|
@ -468,9 +469,78 @@ nan:
|
|||
private_comment_template: 佇 %{date} tuì %{source} 輸入
|
||||
title: 輸入域名封鎖
|
||||
invalid_domain_block: 因為下kha ê錯誤,làng過tsi̍t ê以上ê域名封鎖:%{error}
|
||||
new:
|
||||
title: 輸入域名封鎖
|
||||
no_file: Iáu bē揀檔案
|
||||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: 建立佇
|
||||
delete: Thâi掉
|
||||
ip: IP地址
|
||||
request_body: 請求主文
|
||||
title: 除蟲callback
|
||||
providers:
|
||||
active: 有效
|
||||
base_url: 基本URL
|
||||
callback: Callback
|
||||
delete: Thâi掉
|
||||
edit: 編輯提供者
|
||||
finish_registration: 完成註冊
|
||||
name: 名
|
||||
providers: 提供者
|
||||
public_key_fingerprint: 公開鎖匙ê指頭仔螺(public key fingerprint)
|
||||
registration_requested: 註冊請求ah
|
||||
registrations:
|
||||
confirm: 確認
|
||||
description: Lí收著FASP ê註冊ah。nā準lí bô啟動,請拒絕。若有啟動,請佇確認註冊以前,細膩比較名kap鎖匙ê指頭仔螺。
|
||||
reject: 拒絕
|
||||
title: 確認FASP註冊
|
||||
save: 儲存
|
||||
select_capabilities: 揀功能
|
||||
sign_in: 登入
|
||||
status: 狀態
|
||||
title: 聯邦宇宙輔助服務提供者 (FASP)
|
||||
title: FASP
|
||||
follow_recommendations:
|
||||
description_html: "<strong>跟tuè建議幫tsān新用者緊tshuē著心適ê內容</strong>。Nā使用者無hām別lâng有夠額ê互動,來形成個人化ê跟tuè建議,就ē推薦tsiah ê口座。In是佇指定語言內底,由最近上tsia̍p參與ê,kap上tsē lâng跟tuè ê口座,用ta̍k kang做基礎,相濫koh計算出來ê。"
|
||||
language: 揀語言
|
||||
status: 狀態
|
||||
suppress: Khàm掉跟tuè建議
|
||||
suppressed: Khàm掉ê
|
||||
title: 跟tuè建議
|
||||
unsuppress: 恢復跟tuè建議
|
||||
instances:
|
||||
audit_log:
|
||||
title: 最近ê審核日誌
|
||||
view_all: 看完整ê審核日誌
|
||||
availability:
|
||||
description_html:
|
||||
other: Nā佇 <strong>%{count} kang</strong>內,寄送kàu hit ê域名lóng失敗,除非收著hit ê域名<em>來ê</em>寄送,a̍h無buē koh試寄送。
|
||||
failure_threshold_reached: 佇 %{date} kàu失敗ê底限。
|
||||
failures_recorded:
|
||||
other: 連suà %{count} kang lóng寄失敗。
|
||||
no_failures_recorded: 報告內底無失敗。
|
||||
title: 可用性
|
||||
warning: 頂kái試連接tsit臺服侍器是無成功
|
||||
back_to_all: 全部
|
||||
back_to_limited: 受限制
|
||||
back_to_warning: 警告
|
||||
by_domain: 域名
|
||||
confirm_purge: Lí kám確定beh永永thâi掉tsit ê域名來ê資料?
|
||||
content_policies:
|
||||
comment: 內部ê筆記
|
||||
description_html: Lí ē當定義用tī所有tuì tsit ê域名kap伊ê子域名來ê口座ê內容政策。
|
||||
dashboard:
|
||||
instance_languages_dimension: Tsia̍p用ê語言
|
||||
invites:
|
||||
filter:
|
||||
available: 通用ê
|
||||
expired: 過期ê
|
||||
title: 過濾器
|
||||
title: 邀請
|
||||
ip_blocks:
|
||||
add_new: 建立規則
|
||||
statuses:
|
||||
language: 語言
|
||||
trends:
|
||||
|
|
|
@ -7,7 +7,13 @@ pa:
|
|||
hosted_on: "%{domain} ਉੱਤੇ ਹੋਸਟ ਕੀਤਾ ਮਸਟਾਡੋਨ"
|
||||
title: ਇਸ ਬਾਰੇ
|
||||
accounts:
|
||||
followers:
|
||||
one: ਫ਼ਾਲੋਅਰ
|
||||
other: ਫ਼ਾਲੋਅਰ
|
||||
following: ਫ਼ਾਲੋ ਕੀਤੇ ਜਾ ਰਹੇ
|
||||
posts:
|
||||
one: ਪੋਸਟ
|
||||
other: ਪੋਸਟਾਂ
|
||||
posts_tab_heading: ਪੋਸਟਾਂ
|
||||
admin:
|
||||
account_moderation_notes:
|
||||
|
@ -126,6 +132,9 @@ pa:
|
|||
thread: ਗੱਲਾਂਬਾਤਾਂ
|
||||
index:
|
||||
delete: ਹਟਾਓ
|
||||
statuses:
|
||||
one: "%{count} ਪੋਸਟ"
|
||||
other: "%{count} ਪੋਸਟ"
|
||||
generic:
|
||||
all: ਸਭ
|
||||
copy: ਕਾਪੀ ਕਰੋ
|
||||
|
|
|
@ -1471,10 +1471,10 @@ ru:
|
|||
other: Выберите все %{count} предмет(-ов), совпадающий(-их) вашему поисковому запросу.
|
||||
today: сегодня
|
||||
validation_errors:
|
||||
few: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже
|
||||
many: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже
|
||||
one: Что-то здесь не так! Пожалуйста, прочитайте об ошибке ниже
|
||||
other: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже
|
||||
few: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщения об ошибке
|
||||
many: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщений об ошибке
|
||||
one: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщение об ошибке
|
||||
other: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщений об ошибке
|
||||
imports:
|
||||
errors:
|
||||
empty: Пустой CSV-файл
|
||||
|
|
|
@ -61,6 +61,7 @@ cy:
|
|||
setting_display_media_default: Cuddio cyfryngau wedi eu marcio'n sensitif
|
||||
setting_display_media_hide_all: Cuddio cyfryngau bob tro
|
||||
setting_display_media_show_all: Dangos cyfryngau bob tro
|
||||
setting_emoji_style: Sut i arddangos emojis. Bydd "Awto" yn ceisio defnyddio emoji cynhenid, ond mae'n disgyn yn ôl i Twemoji ar gyfer porwyr traddodiadol.
|
||||
setting_system_scrollbars_ui: Yn berthnasol i borwyr bwrdd gwaith yn seiliedig ar Safari a Chrome yn unig
|
||||
setting_use_blurhash: Mae graddiannau wedi'u seilio ar liwiau'r delweddau cudd ond maen nhw'n cuddio unrhyw fanylion
|
||||
setting_use_pending_items: Cuddio diweddariadau llinell amser y tu ôl i glic yn lle sgrolio'n awtomatig
|
||||
|
@ -149,6 +150,13 @@ cy:
|
|||
min_age: Ni ddylai fod yn is na'r isafswm oedran sy'n ofynnol gan gyfreithiau eich awdurdodaeth.
|
||||
user:
|
||||
chosen_languages: Wedi eu dewis, dim ond tŵtiau yn yr ieithoedd hyn bydd yn cael eu harddangos mewn ffrydiau cyhoeddus
|
||||
date_of_birth:
|
||||
few: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
many: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
one: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
other: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
two: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
zero: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn.
|
||||
role: Mae'r rôl yn rheoli pa ganiatâd sydd gan y defnyddiwr.
|
||||
user_role:
|
||||
color: Lliw i'w ddefnyddio ar gyfer y rôl drwy'r UI, fel RGB mewn fformat hecs
|
||||
|
@ -238,6 +246,7 @@ cy:
|
|||
setting_display_media_default: Rhagosodiad
|
||||
setting_display_media_hide_all: Cuddio popeth
|
||||
setting_display_media_show_all: Dangos popeth
|
||||
setting_emoji_style: Arddull Emojis
|
||||
setting_expand_spoilers: Dangos postiadau wedi'u marcio â rhybudd cynnwys bob tro
|
||||
setting_hide_network: Cuddio eich graff cymdeithasol
|
||||
setting_missing_alt_text_modal: Dangos deialog cadarnhau cyn postio cyfrwng heb destun amgen
|
||||
|
|
|
@ -61,7 +61,7 @@ es-MX:
|
|||
setting_display_media_default: Ocultar contenido multimedia marcado como sensible
|
||||
setting_display_media_hide_all: Siempre ocultar todo el contenido multimedia
|
||||
setting_display_media_show_all: Mostrar siempre contenido multimedia marcado como sensible
|
||||
setting_emoji_style: Cómo se mostrarán los emojis. "Auto" intentará usar emojis nativos, cambiando a Twemoji en navegadores antiguos.
|
||||
setting_emoji_style: Cómo se muestran los emojis. «Automático» intentará usar emojis nativos, pero vuelve a Twemoji para los navegadores antiguos.
|
||||
setting_system_scrollbars_ui: Solo se aplica a los navegadores de escritorio basados en Safari y Chrome
|
||||
setting_use_blurhash: Los degradados se basan en los colores de los elementos visuales ocultos, pero ocultan cualquier detalle
|
||||
setting_use_pending_items: Ocultar las publicaciones de la línea de tiempo tras un clic en lugar de desplazar automáticamente el feed
|
||||
|
@ -151,8 +151,8 @@ es-MX:
|
|||
user:
|
||||
chosen_languages: Cuando se marca, solo se mostrarán las publicaciones en los idiomas seleccionados en las líneas de tiempo públicas
|
||||
date_of_birth:
|
||||
one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información.
|
||||
other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información.
|
||||
one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información.
|
||||
other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información.
|
||||
role: El rol controla qué permisos tiene el usuario.
|
||||
user_role:
|
||||
color: Color que se usará para el rol en toda la interfaz de usuario, como RGB en formato hexadecimal
|
||||
|
|
|
@ -61,6 +61,7 @@ fa:
|
|||
setting_display_media_default: تصویرهایی را که به عنوان حساس علامت زده شدهاند پنهان کن
|
||||
setting_display_media_hide_all: همیشه همهٔ عکسها و ویدیوها را پنهان کن
|
||||
setting_display_media_show_all: همیشه تصویرهایی را که به عنوان حساس علامت زده شدهاند را نشان بده
|
||||
setting_emoji_style: چگونگی نمایش شکلکها. «خودکار» تلاش خواهد کرد از شکلکهای بومی استفاده کند؛ ولی برای مرورگرهای قدیمی به توییموجی برخواهد گشت.
|
||||
setting_system_scrollbars_ui: فقط برای مرورگرهای دسکتاپ مبتنی بر سافاری و کروم اعمال می شود
|
||||
setting_use_blurhash: سایهها بر اساس رنگهای بهکاررفته در تصویر پنهانشده ساخته میشوند ولی جزئیات تصویر در آنها آشکار نیست
|
||||
setting_use_pending_items: به جای پیشرفتن خودکار در فهرست، بهروزرسانی فهرست نوشتهها را پشت یک کلیک پنهان کن
|
||||
|
@ -238,6 +239,7 @@ fa:
|
|||
setting_display_media_default: پیشفرض
|
||||
setting_display_media_hide_all: نهفتن همه
|
||||
setting_display_media_show_all: نمایش همه
|
||||
setting_emoji_style: سبک شکلک
|
||||
setting_expand_spoilers: همیشه فرستههایی را که هشدار محتوا دارند کامل نشان بده
|
||||
setting_hide_network: نهفتن شبکهٔ ارتباطی
|
||||
setting_missing_alt_text_modal: نمایش گفتگوی تایید قبل از ارسال رسانه بدون متن جایگزین
|
||||
|
|
|
@ -150,6 +150,9 @@ pt-PT:
|
|||
min_age: Não deve ter menos do que a idade mínima exigida pela legislação da sua jurisdição.
|
||||
user:
|
||||
chosen_languages: Quando selecionado, só serão mostradas nas cronologias públicas as publicações nos idiomas escolhidos
|
||||
date_of_birth:
|
||||
one: Temos de nos certificar que tem pelo menos %{count} para utilizar %{domain}. Não vamos guardar esta informação.
|
||||
other: Temos de nos certificar que tem pelo menos %{count} para utilizar %{domain}. Não vamos guardar esta informação.
|
||||
role: A função controla as permissões que o utilizador tem.
|
||||
user_role:
|
||||
color: Cor a ser utilizada para a função em toda a interface de utilizador, como RGB no formato hexadecimal
|
||||
|
|
84
config/vite/plugin-assets-manifest.ts
Normal file
84
config/vite/plugin-assets-manifest.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
// Heavily inspired by https://github.com/ElMassimo/vite_ruby
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import glob from 'fast-glob';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
interface AssetManifestChunk {
|
||||
file: string;
|
||||
integrity: string;
|
||||
}
|
||||
|
||||
const ALGORITHM = 'sha384';
|
||||
|
||||
export function MastodonAssetsManifest(): Plugin {
|
||||
let manifest: string | boolean = true;
|
||||
let jsRoot = '';
|
||||
|
||||
return {
|
||||
name: 'mastodon-assets-manifest',
|
||||
applyToEnvironment(environment) {
|
||||
return !!environment.config.build.manifest;
|
||||
},
|
||||
configResolved(resolvedConfig) {
|
||||
manifest = resolvedConfig.build.manifest;
|
||||
jsRoot = resolvedConfig.root;
|
||||
},
|
||||
async generateBundle() {
|
||||
// Glob all assets and return an array of absolute paths.
|
||||
const assetPaths = await glob('{fonts,icons,images}/**/*', {
|
||||
cwd: jsRoot,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const assetManifest: Record<string, AssetManifestChunk> = {};
|
||||
const excludeExts = ['', '.md'];
|
||||
for (const file of assetPaths) {
|
||||
// Exclude files like markdown or README files with no extension.
|
||||
const ext = path.extname(file);
|
||||
if (excludeExts.includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read the file and emit it as an asset.
|
||||
const contents = await fs.readFile(file);
|
||||
const ref = this.emitFile({
|
||||
name: path.basename(file),
|
||||
type: 'asset',
|
||||
source: contents,
|
||||
});
|
||||
const hashedFilename = this.getFileName(ref);
|
||||
|
||||
// With the emitted file information, hash the contents and store in manifest.
|
||||
const name = path.relative(jsRoot, file);
|
||||
const hash = createHash(ALGORITHM)
|
||||
.update(contents)
|
||||
.digest()
|
||||
.toString('base64');
|
||||
assetManifest[name] = {
|
||||
file: hashedFilename,
|
||||
integrity: `${ALGORITHM}-${hash}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(assetManifest).length === 0) {
|
||||
console.warn('Asset manifest is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get manifest location and emit the manifest.
|
||||
const manifestDir =
|
||||
typeof manifest === 'string' ? path.dirname(manifest) : '.vite';
|
||||
const fileName = `${manifestDir}/manifest-assets.json`;
|
||||
|
||||
this.emitFile({
|
||||
fileName,
|
||||
type: 'asset',
|
||||
source: JSON.stringify(assetManifest, null, 2),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -350,6 +350,8 @@ export default tseslint.config([
|
|||
'import/no-default-export': 'warn',
|
||||
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
|
||||
'react/prefer-stateless-function': 'warn',
|
||||
'react/function-component-definition': [
|
||||
|
|
|
@ -68,6 +68,7 @@
|
|||
"emoji-mart": "npm:emoji-mart-lazyload@latest",
|
||||
"emojibase": "^16.0.0",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"emojibase-regex": "^16.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fuzzysort": "^3.0.0",
|
||||
|
@ -88,7 +89,6 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hotkeys": "^1.1.4",
|
||||
"react-immutable-proptypes": "^2.2.0",
|
||||
"react-immutable-pure-component": "^2.2.2",
|
||||
"react-intl": "^7.1.10",
|
||||
|
@ -163,12 +163,12 @@
|
|||
"@vitest/browser": "^3.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"@vitest/ui": "^3.2.1",
|
||||
"chromatic": "^12.1.0",
|
||||
"chromatic": "^13.0.0",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-import-resolver-typescript": "^4.2.5",
|
||||
"eslint-plugin-formatjs": "^5.3.1",
|
||||
"eslint-plugin-import": "~2.31.0",
|
||||
"eslint-plugin-jsdoc": "^50.6.9",
|
||||
"eslint-plugin-jsdoc": "^51.0.0",
|
||||
"eslint-plugin-jsx-a11y": "~6.10.2",
|
||||
"eslint-plugin-promise": "~7.2.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
|
@ -179,7 +179,7 @@
|
|||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.10.2",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"playwright": "^1.52.0",
|
||||
"playwright": "^1.54.1",
|
||||
"prettier": "^3.3.3",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"storybook": "^9.0.4",
|
||||
|
|
104
spec/models/worker_batch_spec.rb
Normal file
104
spec/models/worker_batch_spec.rb
Normal file
|
@ -0,0 +1,104 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe WorkerBatch do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:async_refresh_key) { 'test_refresh' }
|
||||
let(:async_refresh) { nil }
|
||||
|
||||
describe '#id' do
|
||||
it 'returns a string' do
|
||||
expect(subject.id).to be_a String
|
||||
end
|
||||
end
|
||||
|
||||
describe '#connect' do
|
||||
before do
|
||||
subject.connect(async_refresh_key, threshold: 0.75)
|
||||
end
|
||||
|
||||
it 'persists the async refresh key' do
|
||||
expect(subject.info['async_refresh_key']).to eq async_refresh_key
|
||||
end
|
||||
|
||||
it 'persists the threshold' do
|
||||
expect(subject.info['threshold']).to eq '0.75'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_jobs' do
|
||||
before do
|
||||
subject.connect(async_refresh_key, threshold: 0.5) if async_refresh.present?
|
||||
subject.add_jobs([])
|
||||
end
|
||||
|
||||
context 'when called with empty array' do
|
||||
it 'does not persist the number of pending jobs' do
|
||||
expect(subject.info).to be_empty
|
||||
end
|
||||
|
||||
it 'does not persist the job IDs' do
|
||||
expect(subject.jobs).to eq []
|
||||
end
|
||||
|
||||
context 'when async refresh is connected' do
|
||||
let(:async_refresh) { AsyncRefresh.new(async_refresh_key) }
|
||||
|
||||
it 'immediately marks the async refresh as finished' do
|
||||
expect(async_refresh.reload.finished?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when called with an array of job IDs' do
|
||||
before do
|
||||
subject.add_jobs(%w(foo bar))
|
||||
end
|
||||
|
||||
it 'persists the number of pending jobs' do
|
||||
expect(subject.info['pending']).to eq '2'
|
||||
end
|
||||
|
||||
it 'persists the job IDs' do
|
||||
expect(subject.jobs).to eq %w(foo bar)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#remove_job' do
|
||||
before do
|
||||
subject.connect(async_refresh_key, threshold: 0.5) if async_refresh.present?
|
||||
subject.add_jobs(%w(foo bar baz))
|
||||
subject.remove_job('foo')
|
||||
end
|
||||
|
||||
it 'removes the job from pending jobs' do
|
||||
expect(subject.jobs).to eq %w(bar baz)
|
||||
end
|
||||
|
||||
it 'decrements the number of pending jobs' do
|
||||
expect(subject.info['pending']).to eq '2'
|
||||
end
|
||||
|
||||
context 'when async refresh is connected' do
|
||||
let(:async_refresh) { AsyncRefresh.new(async_refresh_key) }
|
||||
|
||||
it 'increments async refresh progress' do
|
||||
expect(async_refresh.reload.result_count).to eq 1
|
||||
end
|
||||
|
||||
it 'marks the async refresh as finished when the threshold is reached' do
|
||||
subject.remove_job('bar')
|
||||
expect(async_refresh.reload.finished?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#info' do
|
||||
it 'returns a hash' do
|
||||
expect(subject.info).to be_a Hash
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,13 +6,49 @@ RSpec.describe 'invites' do
|
|||
let(:invite) { Fabricate(:invite) }
|
||||
|
||||
context 'when requesting a JSON document' do
|
||||
it 'returns a JSON document with expected attributes' do
|
||||
get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' }
|
||||
subject { get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' } }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.media_type).to eq 'application/json'
|
||||
context 'when invite is valid' do
|
||||
it 'returns a JSON document with expected attributes' do
|
||||
subject
|
||||
|
||||
expect(response.parsed_body[:invite_code]).to eq invite.code
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.media_type)
|
||||
.to eq 'application/json'
|
||||
expect(response.parsed_body)
|
||||
.to include(invite_code: invite.code)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invite is expired' do
|
||||
before { invite.update(expires_at: 3.days.ago) }
|
||||
|
||||
it 'returns a JSON document with error details' do
|
||||
subject
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
expect(response.media_type)
|
||||
.to eq 'application/json'
|
||||
expect(response.parsed_body)
|
||||
.to include(error: I18n.t('invites.invalid'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user IP is blocked' do
|
||||
before { Fabricate :ip_block, severity: :sign_up_block, ip: '127.0.0.1' }
|
||||
|
||||
it 'returns a JSON document with error details' do
|
||||
subject
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(403)
|
||||
expect(response.media_type)
|
||||
.to eq 'application/json'
|
||||
expect(response.parsed_body)
|
||||
.to include(error: /This action is not allowed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -123,7 +123,6 @@ RSpec.describe ActivityPub::FetchAllRepliesWorker do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_const('Status::FetchRepliesConcern::FETCH_REPLIES_ENABLED', true)
|
||||
all_items.each do |item|
|
||||
next if [top_note_uri, reply_note_uri].include? item
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ import { readdir } from 'node:fs/promises';
|
|||
import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin';
|
||||
import legacy from '@vitejs/plugin-legacy';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import glob from 'fast-glob';
|
||||
import postcssPresetEnv from 'postcss-preset-env';
|
||||
import Compress from 'rollup-plugin-gzip';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
@ -24,6 +23,7 @@ import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales';
|
|||
import { MastodonEmojiCompressed } from './config/vite/plugin-emoji-compressed';
|
||||
import { MastodonThemes } from './config/vite/plugin-mastodon-themes';
|
||||
import { MastodonNameLookup } from './config/vite/plugin-name-lookup';
|
||||
import { MastodonAssetsManifest } from './config/vite/plugin-assets-manifest';
|
||||
|
||||
const jsRoot = path.resolve(__dirname, 'app/javascript');
|
||||
|
||||
|
@ -120,6 +120,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
|
|||
},
|
||||
}),
|
||||
MastodonThemes(),
|
||||
MastodonAssetsManifest(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
|
@ -144,7 +145,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
|
|||
isProdBuild && (Compress() as PluginOption),
|
||||
command === 'build' &&
|
||||
manifestSRI({
|
||||
manifestPaths: ['.vite/manifest.json', '.vite/manifest-assets.json'],
|
||||
manifestPaths: ['.vite/manifest.json'],
|
||||
}),
|
||||
VitePWA({
|
||||
srcDir: path.resolve(jsRoot, 'mastodon/service_worker'),
|
||||
|
@ -211,21 +212,6 @@ async function findEntrypoints() {
|
|||
}
|
||||
}
|
||||
|
||||
// Lastly other assets
|
||||
const assetEntrypoints = await glob('{fonts,icons,images}/**/*', {
|
||||
cwd: jsRoot,
|
||||
absolute: true,
|
||||
});
|
||||
const excludeExts = ['', '.md'];
|
||||
for (const file of assetEntrypoints) {
|
||||
const ext = path.extname(file);
|
||||
if (excludeExts.includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
const name = path.basename(file);
|
||||
entrypoints[name] = path.resolve(jsRoot, file);
|
||||
}
|
||||
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
|
|
250
yarn.lock
250
yarn.lock
|
@ -1932,14 +1932,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@es-joy/jsdoccomment@npm:~0.49.0":
|
||||
version: 0.49.0
|
||||
resolution: "@es-joy/jsdoccomment@npm:0.49.0"
|
||||
"@es-joy/jsdoccomment@npm:~0.52.0":
|
||||
version: 0.52.0
|
||||
resolution: "@es-joy/jsdoccomment@npm:0.52.0"
|
||||
dependencies:
|
||||
"@types/estree": "npm:^1.0.8"
|
||||
"@typescript-eslint/types": "npm:^8.34.1"
|
||||
comment-parser: "npm:1.4.1"
|
||||
esquery: "npm:^1.6.0"
|
||||
jsdoc-type-pratt-parser: "npm:~4.1.0"
|
||||
checksum: 10c0/16717507d557d37e7b59456fedeefbe0a3bc93aa2d9c043d5db91e24e076509b6fcb10ee6fd1dafcb0c5bbe50ae329b45de5b83541cb5994a98c9e862a45641e
|
||||
checksum: 10c0/4def78060ef58859f31757b9d30c4939fc33e7d9ee85637a7f568c1d209c33aa0abd2cf5a3a4f3662ec5b12b85ecff2f2035d809dc93b9382a31a6dfb200d83c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -2659,7 +2661,7 @@ __metadata:
|
|||
babel-plugin-formatjs: "npm:^10.5.37"
|
||||
babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24"
|
||||
blurhash: "npm:^2.0.5"
|
||||
chromatic: "npm:^12.1.0"
|
||||
chromatic: "npm:^13.0.0"
|
||||
classnames: "npm:^2.3.2"
|
||||
cocoon-js-vanilla: "npm:^1.5.1"
|
||||
color-blend: "npm:^4.0.0"
|
||||
|
@ -2669,12 +2671,13 @@ __metadata:
|
|||
emoji-mart: "npm:emoji-mart-lazyload@latest"
|
||||
emojibase: "npm:^16.0.0"
|
||||
emojibase-data: "npm:^16.0.3"
|
||||
emojibase-regex: "npm:^16.0.0"
|
||||
escape-html: "npm:^1.0.3"
|
||||
eslint: "npm:^9.23.0"
|
||||
eslint-import-resolver-typescript: "npm:^4.2.5"
|
||||
eslint-plugin-formatjs: "npm:^5.3.1"
|
||||
eslint-plugin-import: "npm:~2.31.0"
|
||||
eslint-plugin-jsdoc: "npm:^50.6.9"
|
||||
eslint-plugin-jsdoc: "npm:^51.0.0"
|
||||
eslint-plugin-jsx-a11y: "npm:~6.10.2"
|
||||
eslint-plugin-promise: "npm:~7.2.1"
|
||||
eslint-plugin-react: "npm:^7.37.4"
|
||||
|
@ -2698,7 +2701,7 @@ __metadata:
|
|||
msw: "npm:^2.10.2"
|
||||
msw-storybook-addon: "npm:^2.0.5"
|
||||
path-complete-extname: "npm:^1.0.0"
|
||||
playwright: "npm:^1.52.0"
|
||||
playwright: "npm:^1.54.1"
|
||||
postcss-preset-env: "npm:^10.1.5"
|
||||
prettier: "npm:^3.3.3"
|
||||
prop-types: "npm:^15.8.1"
|
||||
|
@ -2706,7 +2709,6 @@ __metadata:
|
|||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
react-helmet: "npm:^6.1.0"
|
||||
react-hotkeys: "npm:^1.1.4"
|
||||
react-immutable-proptypes: "npm:^2.2.0"
|
||||
react-immutable-pure-component: "npm:^2.2.2"
|
||||
react-intl: "npm:^7.1.10"
|
||||
|
@ -3089,13 +3091,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pkgr/core@npm:^0.1.0":
|
||||
version: 0.1.1
|
||||
resolution: "@pkgr/core@npm:0.1.1"
|
||||
checksum: 10c0/3f7536bc7f57320ab2cf96f8973664bef624710c403357429fbf680a5c3b4843c1dbd389bb43daa6b1f6f1f007bb082f5abcb76bb2b5dc9f421647743b71d3d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@polka/url@npm:^1.0.0-next.24":
|
||||
version: 1.0.0-next.29
|
||||
resolution: "@polka/url@npm:1.0.0-next.29"
|
||||
|
@ -3299,23 +3294,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.0":
|
||||
version: 5.1.4
|
||||
resolution: "@rollup/pluginutils@npm:5.1.4"
|
||||
dependencies:
|
||||
"@types/estree": "npm:^1.0.0"
|
||||
estree-walker: "npm:^2.0.2"
|
||||
picomatch: "npm:^4.0.2"
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
checksum: 10c0/6d58fbc6f1024eb4b087bc9bf59a1d655a8056a60c0b4021d3beaeec3f0743503f52467fd89d2cf0e7eccf2831feb40a05ad541a17637ea21ba10b21c2004deb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/pluginutils@npm:^5.1.3":
|
||||
"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.0, @rollup/pluginutils@npm:^5.1.3":
|
||||
version: 5.2.0
|
||||
resolution: "@rollup/pluginutils@npm:5.2.0"
|
||||
dependencies:
|
||||
|
@ -3986,10 +3965,10 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:*, @types/estree@npm:1.0.7, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6":
|
||||
version: 1.0.7
|
||||
resolution: "@types/estree@npm:1.0.7"
|
||||
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
|
||||
"@types/estree@npm:*, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "@types/estree@npm:1.0.8"
|
||||
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -4000,6 +3979,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:1.0.7":
|
||||
version: 1.0.7
|
||||
resolution: "@types/estree@npm:1.0.7"
|
||||
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/express-serve-static-core@npm:^4.17.33":
|
||||
version: 4.17.41
|
||||
resolution: "@types/express-serve-static-core@npm:4.17.41"
|
||||
|
@ -4502,13 +4488,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.33.0, @typescript-eslint/types@npm:^8.33.0":
|
||||
"@typescript-eslint/types@npm:8.33.0":
|
||||
version: 8.33.0
|
||||
resolution: "@typescript-eslint/types@npm:8.33.0"
|
||||
checksum: 10c0/348b64eb408719d7711a433fc9716e0c2aab8b3f3676f5a1cc2e00269044132282cf655deb6d0dd9817544116909513de3b709005352d186949d1014fad1a3cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:^8.33.0, @typescript-eslint/types@npm:^8.34.1":
|
||||
version: 8.36.0
|
||||
resolution: "@typescript-eslint/types@npm:8.36.0"
|
||||
checksum: 10c0/cacb941a0caad6ab556c416051b97ec33b364b7c8e0703e2729ae43f12daf02b42eef12011705329107752e3f1685ca82cfffe181d637f85907293cb634bee31
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.29.1":
|
||||
version: 8.29.1
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.29.1"
|
||||
|
@ -4997,12 +4990,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.14.0, acorn@npm:^8.8.2":
|
||||
version: 8.14.1
|
||||
resolution: "acorn@npm:8.14.1"
|
||||
"acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.8.2":
|
||||
version: 8.15.0
|
||||
resolution: "acorn@npm:8.15.0"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123
|
||||
checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -5380,13 +5373,13 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"axios@npm:^1.4.0":
|
||||
version: 1.9.0
|
||||
resolution: "axios@npm:1.9.0"
|
||||
version: 1.11.0
|
||||
resolution: "axios@npm:1.11.0"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
form-data: "npm:^4.0.4"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/9371a56886c2e43e4ff5647b5c2c3c046ed0a3d13482ef1d0135b994a628c41fbad459796f101c655e62f0c161d03883454474d2e435b2e021b1924d9f24994c
|
||||
checksum: 10c0/5de273d33d43058610e4d252f0963cc4f10714da0bfe872e8ef2cbc23c2c999acc300fd357b6bce0fc84a2ca9bd45740fa6bb28199ce2c1266c8b1a393f2b36e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -5816,9 +5809,9 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chromatic@npm:^12.1.0":
|
||||
version: 12.1.0
|
||||
resolution: "chromatic@npm:12.1.0"
|
||||
"chromatic@npm:^13.0.0":
|
||||
version: 13.0.0
|
||||
resolution: "chromatic@npm:13.0.0"
|
||||
peerDependencies:
|
||||
"@chromatic-com/cypress": ^0.*.* || ^1.0.0
|
||||
"@chromatic-com/playwright": ^0.*.* || ^1.0.0
|
||||
|
@ -5831,7 +5824,7 @@ __metadata:
|
|||
chroma: dist/bin.js
|
||||
chromatic: dist/bin.js
|
||||
chromatic-cli: dist/bin.js
|
||||
checksum: 10c0/4acb70a4a84605f1963a823beed4f3062ec91e373104500f4295af2298b8d0b49f864d06ca81bc9389e44cae3a284332aac07c6cbfc123aa6457f3b52a4c4b78
|
||||
checksum: 10c0/30c697eb84d5b3b8cdab989df0e4fed0bf51f4bfefb616873f68fc00337978b9b38b84e52af22861769176181bd98525d467baeb22daa712a0f7a58bd61bf336
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -6599,6 +6592,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emojibase-regex@npm:^16.0.0":
|
||||
version: 16.0.0
|
||||
resolution: "emojibase-regex@npm:16.0.0"
|
||||
checksum: 10c0/8ee5ff798e51caa581434b1cb2f9737e50195093c4efa1739df21a50a5496f80517924787d865e8cf7d6a0b4c90dbedc04bdc506dcbcc582e14cdf0bb47af0f0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emojibase@npm:^16.0.0":
|
||||
version: 16.0.0
|
||||
resolution: "emojibase@npm:16.0.0"
|
||||
|
@ -6781,7 +6781,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-module-lexer@npm:^1.5.3, es-module-lexer@npm:^1.7.0":
|
||||
"es-module-lexer@npm:^1.7.0":
|
||||
version: 1.7.0
|
||||
resolution: "es-module-lexer@npm:1.7.0"
|
||||
checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b
|
||||
|
@ -7041,24 +7041,23 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-plugin-jsdoc@npm:^50.6.9":
|
||||
version: 50.6.9
|
||||
resolution: "eslint-plugin-jsdoc@npm:50.6.9"
|
||||
"eslint-plugin-jsdoc@npm:^51.0.0":
|
||||
version: 51.3.4
|
||||
resolution: "eslint-plugin-jsdoc@npm:51.3.4"
|
||||
dependencies:
|
||||
"@es-joy/jsdoccomment": "npm:~0.49.0"
|
||||
"@es-joy/jsdoccomment": "npm:~0.52.0"
|
||||
are-docs-informative: "npm:^0.0.2"
|
||||
comment-parser: "npm:1.4.1"
|
||||
debug: "npm:^4.3.6"
|
||||
debug: "npm:^4.4.1"
|
||||
escape-string-regexp: "npm:^4.0.0"
|
||||
espree: "npm:^10.1.0"
|
||||
espree: "npm:^10.4.0"
|
||||
esquery: "npm:^1.6.0"
|
||||
parse-imports: "npm:^2.1.1"
|
||||
semver: "npm:^7.6.3"
|
||||
parse-imports-exports: "npm:^0.2.4"
|
||||
semver: "npm:^7.7.2"
|
||||
spdx-expression-parse: "npm:^4.0.0"
|
||||
synckit: "npm:^0.9.1"
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
|
||||
checksum: 10c0/cad199d262c2e889a3af4e402f6adc624e4273b3d5ca1940e7227b37d87af8090ca3444f7fff57f58dab9a827faed8722fc2f5d4daf31ec085eb00e9f5a338a7
|
||||
checksum: 10c0/59e5aa972bdd1bd4e2ca2796ed4455dff1069044abc028621e107aa4b0cbb62ce09554c8e7c2ff3a44a1cbd551e54b6970adc420ba3a89adc6236b94310a81ff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -7164,10 +7163,10 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-visitor-keys@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "eslint-visitor-keys@npm:4.2.0"
|
||||
checksum: 10c0/2ed81c663b147ca6f578312919483eb040295bbab759e5a371953456c636c5b49a559883e2677112453728d66293c0a4c90ab11cab3428cf02a0236d2e738269
|
||||
"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "eslint-visitor-keys@npm:4.2.1"
|
||||
checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -7221,14 +7220,14 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"espree@npm:^10.0.1, espree@npm:^10.1.0, espree@npm:^10.3.0":
|
||||
version: 10.3.0
|
||||
resolution: "espree@npm:10.3.0"
|
||||
"espree@npm:^10.0.1, espree@npm:^10.3.0, espree@npm:^10.4.0":
|
||||
version: 10.4.0
|
||||
resolution: "espree@npm:10.4.0"
|
||||
dependencies:
|
||||
acorn: "npm:^8.14.0"
|
||||
acorn: "npm:^8.15.0"
|
||||
acorn-jsx: "npm:^5.3.2"
|
||||
eslint-visitor-keys: "npm:^4.2.0"
|
||||
checksum: 10c0/272beeaca70d0a1a047d61baff64db04664a33d7cfb5d144f84bc8a5c6194c6c8ebe9cc594093ca53add88baa23e59b01e69e8a0160ab32eac570482e165c462
|
||||
eslint-visitor-keys: "npm:^4.2.1"
|
||||
checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -7586,14 +7585,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.0":
|
||||
version: 4.0.1
|
||||
resolution: "form-data@npm:4.0.1"
|
||||
"form-data@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "form-data@npm:4.0.4"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
es-set-tostringtag: "npm:^2.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/bb102d570be8592c23f4ea72d7df9daa50c7792eb0cf1c5d7e506c1706e7426a4e4ae48a35b109e91c85f1c0ec63774a21ae252b66f4eb981cb8efef7d0463c8
|
||||
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -9172,27 +9173,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isboolean@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "lodash.isboolean@npm:3.0.3"
|
||||
checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isequal@npm:^4.5.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.isequal@npm:4.5.0"
|
||||
checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isobject@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "lodash.isobject@npm:3.0.2"
|
||||
checksum: 10c0/da4c8480d98b16835b59380b2fbd43c54081acd9466febb788ba77c434384349e0bec162d1c4e89f613f21687b2b6d8384d8a112b80da00c78d28d9915a5cdde
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.merge@npm:^4.6.2":
|
||||
version: 4.6.2
|
||||
resolution: "lodash.merge@npm:4.6.2"
|
||||
|
@ -9603,13 +9583,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mousetrap@npm:^1.5.2":
|
||||
version: 1.6.5
|
||||
resolution: "mousetrap@npm:1.6.5"
|
||||
checksum: 10c0/5c361bdbbff3966fd58d70f39b9fe1f8e32c78f3ce65989d83af7aad32a3a95313ce835a8dd8a55cb5de9eeb7c1f0c2b9048631a3073b5606241589e8fc0ba53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mrmime@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "mrmime@npm:2.0.1"
|
||||
|
@ -10032,13 +10005,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-imports@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "parse-imports@npm:2.1.1"
|
||||
"parse-imports-exports@npm:^0.2.4":
|
||||
version: 0.2.4
|
||||
resolution: "parse-imports-exports@npm:0.2.4"
|
||||
dependencies:
|
||||
es-module-lexer: "npm:^1.5.3"
|
||||
slashes: "npm:^3.0.12"
|
||||
checksum: 10c0/c9bb0b4e1823f84f034d2d7bd2b37415b1715a5c963fda14968c706186b48b02c10e97d04bce042b9dcd679b42f29c391ea120799ddf581c7f54786edd99e3a9
|
||||
parse-statements: "npm:1.0.11"
|
||||
checksum: 10c0/51b729037208abdf65c4a1f8e9ed06f4e7ccd907c17c668a64db54b37d95bb9e92081f8b16e4133e14102af3cb4e89870975b6ad661b4d654e9ec8f4fb5c77d6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -10054,6 +10026,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-statements@npm:1.0.11":
|
||||
version: 1.0.11
|
||||
resolution: "parse-statements@npm:1.0.11"
|
||||
checksum: 10c0/48960e085019068a5f5242e875fd9d21ec87df2e291acf5ad4e4887b40eab6929a8c8d59542acb85a6497e870c5c6a24f5ab7f980ef5f907c14cc5f7984a93f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse5@npm:^7.2.1":
|
||||
version: 7.2.1
|
||||
resolution: "parse5@npm:7.2.1"
|
||||
|
@ -10342,27 +10321,27 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright-core@npm:1.52.0"
|
||||
"playwright-core@npm:1.54.1":
|
||||
version: 1.54.1
|
||||
resolution: "playwright-core@npm:1.54.1"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f
|
||||
checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:^1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright@npm:1.52.0"
|
||||
"playwright@npm:^1.54.1":
|
||||
version: 1.54.1
|
||||
resolution: "playwright@npm:1.54.1"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.52.0"
|
||||
playwright-core: "npm:1.54.1"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579
|
||||
checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -11115,22 +11094,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-hotkeys@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "react-hotkeys@npm:1.1.4"
|
||||
dependencies:
|
||||
lodash.isboolean: "npm:^3.0.3"
|
||||
lodash.isequal: "npm:^4.5.0"
|
||||
lodash.isobject: "npm:^3.0.2"
|
||||
mousetrap: "npm:^1.5.2"
|
||||
prop-types: "npm:^15.6.0"
|
||||
peerDependencies:
|
||||
react: ">= 0.14.0"
|
||||
react-dom: ">= 0.14.0"
|
||||
checksum: 10c0/6bd566ea97e00058749d43d768ee843e5132f988571536e090b564d5dbaa71093695255514fc5b9fcf9fbd03fcb0603f6e135dcab6dcaaffe43dedbfe742a163
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-immutable-proptypes@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "react-immutable-proptypes@npm:2.2.0"
|
||||
|
@ -12076,7 +12039,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1":
|
||||
"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.7.1, semver@npm:^7.7.2":
|
||||
version: 7.7.2
|
||||
resolution: "semver@npm:7.7.2"
|
||||
bin:
|
||||
|
@ -12281,13 +12244,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"slashes@npm:^3.0.12":
|
||||
version: 3.0.12
|
||||
resolution: "slashes@npm:3.0.12"
|
||||
checksum: 10c0/71ca2a1fcd1ab6814b0fdb8cf9c33a3d54321deec2aa8d173510f0086880201446021a9b9e6a18561f7c472b69a2145977c6a8fb9c53a8ff7be31778f203d175
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"slice-ansi@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "slice-ansi@npm:4.0.0"
|
||||
|
@ -12975,16 +12931,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"synckit@npm:^0.9.1":
|
||||
version: 0.9.1
|
||||
resolution: "synckit@npm:0.9.1"
|
||||
dependencies:
|
||||
"@pkgr/core": "npm:^0.1.0"
|
||||
tslib: "npm:^2.6.2"
|
||||
checksum: 10c0/d8b89e1bf30ba3ffb469d8418c836ad9c0c062bf47028406b4d06548bc66af97155ea2303b96c93bf5c7c0f0d66153a6fbd6924c76521b434e6a9898982abc2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"systemjs@npm:^6.15.1":
|
||||
version: 6.15.1
|
||||
resolution: "systemjs@npm:6.15.1"
|
||||
|
@ -13325,7 +13271,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0":
|
||||
"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.8.0":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
|
||||
|
|
Loading…
Reference in New Issue
Block a user