mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-23 05:25:05 -05:00
Merge branch 'main' into compose-language-detection
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
<html class="no-reduce-motion theme-light">
|
||||
<html class="no-reduce-motion" data-color-scheme="light">
|
||||
</html>
|
||||
@@ -50,9 +50,19 @@ const preview: Preview = {
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
description: 'Theme for the story',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'circlehollow',
|
||||
items: [{ value: 'light' }, { value: 'dark' }],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
locale: 'en',
|
||||
theme: 'light',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters, globals, args, argTypes }) => {
|
||||
@@ -135,6 +145,13 @@ const preview: Preview = {
|
||||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
(Story, { globals }) => {
|
||||
const theme = (globals.theme as string) || 'light';
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-color-scheme', theme);
|
||||
}, [theme]);
|
||||
return <Story />;
|
||||
},
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.5] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
|
||||
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
|
||||
|
||||
## [4.5.4] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
@@ -183,7 +183,7 @@ FROM build AS libvips
|
||||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.3
|
||||
ARG VIPS_VERSION=8.18.0
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
||||
@@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
||||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table attempts to summary those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
|
||||
14
Gemfile
14
Gemfile
@@ -55,7 +55,7 @@ gem 'hiredis-client'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.3.0'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.7.0', require: false
|
||||
gem 'httplog', '~> 1.8.0', require: false
|
||||
gem 'i18n'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'inline_svg'
|
||||
@@ -109,12 +109,12 @@ group :opentelemetry do
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||
|
||||
74
Gemfile.lock
74
Gemfile.lock
@@ -96,8 +96,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1200.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-partitions (1.1201.0)
|
||||
aws-sdk-core (3.241.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -105,11 +105,11 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sdk-kms (1.120.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.209.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-s3 (1.211.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -282,7 +282,7 @@ GEM
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.22.0)
|
||||
google-protobuf (~> 4.26)
|
||||
haml (7.1.0)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
@@ -291,7 +291,7 @@ GEM
|
||||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.68.0)
|
||||
haml_lint (0.69.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
@@ -305,8 +305,8 @@ GEM
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.26.2)
|
||||
redis-client (= 0.26.2)
|
||||
hiredis-client (0.26.3)
|
||||
redis-client (= 0.26.3)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
@@ -320,7 +320,8 @@ GEM
|
||||
http_accept_language (2.1.1)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.3)
|
||||
httplog (1.8.0)
|
||||
benchmark
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.14.8)
|
||||
@@ -520,7 +521,8 @@ GEM
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.3.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-processor (0.3.1)
|
||||
opentelemetry-helpers-sql-processor (0.4.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
@@ -544,17 +546,17 @@ GEM
|
||||
opentelemetry-registry (~> 0.1)
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-excon (0.26.1)
|
||||
opentelemetry-instrumentation-excon (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-faraday (0.30.1)
|
||||
opentelemetry-instrumentation-faraday (0.31.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http (0.27.1)
|
||||
opentelemetry-instrumentation-http (0.28.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http_client (0.26.1)
|
||||
opentelemetry-instrumentation-http_client (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-net_http (0.26.1)
|
||||
opentelemetry-instrumentation-net_http (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-pg (0.34.1)
|
||||
opentelemetry-instrumentation-pg (0.35.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-processor
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
@@ -587,7 +589,7 @@ GEM
|
||||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
parser (3.3.10.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
@@ -611,7 +613,7 @@ GEM
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.7.0)
|
||||
prism (1.8.0)
|
||||
prometheus_exporter (2.3.1)
|
||||
webrick
|
||||
propshaft (1.3.1)
|
||||
@@ -706,7 +708,7 @@ GEM
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.26.2)
|
||||
redis-client (0.26.3)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
@@ -720,10 +722,10 @@ GEM
|
||||
rotp (6.3.0)
|
||||
rouge (4.7.0)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.1)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.1)
|
||||
rqrcode_core (2.1.0)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
@@ -752,7 +754,7 @@ GEM
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.81.7)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -760,7 +762,7 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
@@ -782,7 +784,7 @@ GEM
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.8.0)
|
||||
rubocop-rspec (3.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
rubocop-rspec_rails (2.32.0)
|
||||
@@ -823,7 +825,7 @@ GEM
|
||||
sidekiq-scheduler (6.0.1)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 7.3, < 9)
|
||||
sidekiq-unique-jobs (8.0.11)
|
||||
sidekiq-unique-jobs (8.0.13)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 7.0.0, < 9.0.0)
|
||||
thor (>= 1.0, < 3.0)
|
||||
@@ -860,8 +862,8 @@ GEM
|
||||
terrapin (1.1.1)
|
||||
climate_control
|
||||
test-prof (1.5.0)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.1)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
@@ -985,7 +987,7 @@ DEPENDENCIES
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.3.0)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.7.0)
|
||||
httplog (~> 1.8.0)
|
||||
i18n
|
||||
i18n-tasks (~> 1.0)
|
||||
idn-ruby
|
||||
@@ -1021,12 +1023,12 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.26.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.30.0)
|
||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.34.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.27.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.31.0)
|
||||
opentelemetry-instrumentation-http (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.27.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.35.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||
|
||||
@@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
SUPPORTED_COLLECTIONS = %w(featured tags).freeze
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FeaturedCollectionsController < ApplicationController
|
||||
include SignatureAuthentication
|
||||
include Authorization
|
||||
include AccountOwnedConcern
|
||||
|
||||
PER_PAGE = 5
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, if: -> { authorized_fetch_mode? }
|
||||
before_action :set_collections
|
||||
|
||||
skip_around_action :set_locale
|
||||
skip_before_action :require_functional!, unless: :limited_federation_mode?
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
|
||||
|
||||
render json: collection_presenter,
|
||||
serializer: ActivityPub::CollectionSerializer,
|
||||
adapter: ActivityPub::Adapter,
|
||||
content_type: 'application/activity+json'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collections
|
||||
authorize @account, :index_collections?
|
||||
@collections = @account.collections.page(params[:page]).per(PER_PAGE)
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def page_requested?
|
||||
params[:page].present?
|
||||
end
|
||||
|
||||
def next_page_url
|
||||
ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page)
|
||||
end
|
||||
|
||||
def prev_page_url
|
||||
ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page)
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :unordered,
|
||||
size: @account.collections.count,
|
||||
items: @collections,
|
||||
part_of: ap_account_featured_collections_url(@account),
|
||||
next: next_page_url,
|
||||
prev: prev_page_url
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: ap_account_featured_collections_url(@account),
|
||||
type: :unordered,
|
||||
size: @account.collections.count,
|
||||
first: ap_account_featured_collections_url(@account, page: 1)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@
|
||||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
||||
@@ -106,9 +106,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
@status = Status.where(account: current_account).find(params[:id])
|
||||
authorize @status, :update?
|
||||
|
||||
UpdateStatusService.new.call(
|
||||
@status,
|
||||
current_account.id,
|
||||
update_options = {
|
||||
text: status_params[:status],
|
||||
media_ids: status_params[:media_ids],
|
||||
media_attributes: status_params[:media_attributes],
|
||||
@@ -116,8 +114,11 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
poll: status_params[:poll],
|
||||
quote_approval_policy: quote_approval_policy
|
||||
)
|
||||
}
|
||||
|
||||
update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present?
|
||||
|
||||
UpdateStatusService.new.call(@status, current_account.id, update_options)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
@@ -74,6 +74,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
@collections = @collections.discoverable unless @account == current_account
|
||||
end
|
||||
|
||||
def set_collection
|
||||
@@ -81,11 +82,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def collection_creation_params
|
||||
params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: [])
|
||||
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: [])
|
||||
end
|
||||
|
||||
def collection_update_params
|
||||
params.permit(:name, :description, :sensitive, :discoverable, :tag_name)
|
||||
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name)
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
||||
46
app/controllers/collections_controller.rb
Normal file
46
app/controllers/collections_controller.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CollectionsController < ApplicationController
|
||||
include WebAppControllerConcern
|
||||
include SignatureAuthentication
|
||||
include Authorization
|
||||
include AccountOwnedConcern
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_collection
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
# TODO: format.html
|
||||
|
||||
format.json do
|
||||
expires_in expiration_duration, public: true if public_fetch_mode?
|
||||
render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection
|
||||
@collection = @account.collections.find(params[:id])
|
||||
authorize @collection, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def expiration_duration
|
||||
recently_updated = @collection.updated_at > 15.minutes.ago
|
||||
recently_updated ? 30.seconds : 5.minutes
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -89,6 +89,12 @@ module ApplicationHelper
|
||||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||
end
|
||||
|
||||
def page_color_scheme
|
||||
return content_for(:force_color_scheme) if content_for(:force_color_scheme)
|
||||
|
||||
color_scheme
|
||||
end
|
||||
|
||||
def label_for_scope(scope)
|
||||
safe_join [
|
||||
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||
@@ -153,6 +159,19 @@ module ApplicationHelper
|
||||
tag.meta(content: content, property: property)
|
||||
end
|
||||
|
||||
def html_attributes
|
||||
base = {
|
||||
lang: I18n.locale,
|
||||
class: html_classes,
|
||||
'data-contrast': contrast.parameterize,
|
||||
'data-color-scheme': page_color_scheme.parameterize,
|
||||
}
|
||||
|
||||
base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto'
|
||||
|
||||
base
|
||||
end
|
||||
|
||||
def html_classes
|
||||
output = []
|
||||
output << content_for(:html_classes)
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ThemeHelper
|
||||
def theme_style_tags(theme)
|
||||
if theme == 'system'
|
||||
''.html_safe.tap do |tags|
|
||||
tags << vite_stylesheet_tag('themes/mastodon-light', type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
tags << vite_stylesheet_tag('themes/default', type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
def javascript_inline_tag(path)
|
||||
entry = InlineScriptManager.instance.file(path)
|
||||
|
||||
# Only add hash if we don't allow arbitrary includes already, otherwise it's going
|
||||
# to break the React Tools browser extension or other inline scripts
|
||||
unless Rails.env.development? && request.content_security_policy.dup.script_src.include?("'unsafe-inline'")
|
||||
request.content_security_policy = request.content_security_policy.clone.tap do |policy|
|
||||
values = policy.script_src
|
||||
values << "'sha256-#{entry[:digest]}'"
|
||||
policy.script_src(*values)
|
||||
end
|
||||
else
|
||||
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
|
||||
end
|
||||
|
||||
content_tag(:script, entry[:contents], type: 'text/javascript')
|
||||
end
|
||||
|
||||
def theme_color_tags(theme)
|
||||
if theme == 'system'
|
||||
def theme_style_tags(theme)
|
||||
# TODO: get rid of that when we retire the themes and perform the settings migration
|
||||
theme = 'default' if %w(mastodon-light contrast system).include?(theme)
|
||||
|
||||
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
|
||||
end
|
||||
|
||||
def theme_color_tags(color_scheme)
|
||||
case color_scheme
|
||||
when 'auto'
|
||||
''.html_safe.tap do |tags|
|
||||
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)')
|
||||
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)')
|
||||
end
|
||||
else
|
||||
tag.meta name: 'theme-color', content: theme_color_for(theme)
|
||||
when 'light'
|
||||
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:light]
|
||||
when 'dark'
|
||||
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:dark]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,8 +64,4 @@ module ThemeHelper
|
||||
Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) }
|
||||
end
|
||||
end
|
||||
|
||||
def theme_color_for(theme)
|
||||
theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark]
|
||||
end
|
||||
end
|
||||
|
||||
1
app/javascript/entrypoints/theme-selection.ts
Normal file
1
app/javascript/entrypoints/theme-selection.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '../inline/theme-selection';
|
||||
10
app/javascript/images/icons/icon_admin.svg
Normal file
10
app/javascript/images/icons/icon_admin.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="url(#badgeGradient)" d="M8 .666c.301 0 .595.094.839.268l.101.079.006.005c1.092.956 2.603 1.649 3.721 1.649A1.333 1.333 0 0 1 14 4v4.667c0 1.842-.652 3.259-1.702 4.336-1.032 1.058-2.417 1.76-3.852 2.26l-.005.002a1.334 1.334 0 0 1-.879-.01c-1.437-.496-2.825-1.195-3.858-2.253C2.654 11.926 2 10.509 2 8.667V4a1.334 1.334 0 0 1 1.334-1.333c1.118 0 2.634-.7 3.72-1.65l.007-.004C7.322.789 7.656.666 8 .666Z"/>
|
||||
<path stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round" d="M5 10.998A3.053 3.053 0 0 1 6.173 9.57a3.333 3.333 0 0 1 1.828-.54m0 0c.653 0 1.29.189 1.826.54.537.353.946.852 1.173 1.43M8 9.03c1.179 0 2.133-.903 2.133-2.015C10.133 5.902 9.178 5 8 5c-1.179 0-2.134.902-2.134 2.015 0 1.112.956 2.015 2.135 2.015Z"/>
|
||||
<defs>
|
||||
<linearGradient id="badgeGradient" x1=".5" x2="14" y1="2.5" y2="15" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E17100"/>
|
||||
<stop offset="1" stop-color="#F0B100"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
10
app/javascript/images/icons/icon_verified.svg
Normal file
10
app/javascript/images/icons/icon_verified.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="url(#VerifiedGradient)" d="M8 .837a3.168 3.168 0 0 1 2.47 1.187 3.166 3.166 0 0 1 2.601.906 3.168 3.168 0 0 1 .905 2.6A3.167 3.167 0 0 1 15.164 8a3.172 3.172 0 0 1-1.188 2.47 3.167 3.167 0 0 1-.903 2.597 3.168 3.168 0 0 1-2.596.909 3.167 3.167 0 0 1-4.95.001 3.166 3.166 0 0 1-3.397-2.258 3.169 3.169 0 0 1-.107-1.24A3.168 3.168 0 0 1 .826 8a3.17 3.17 0 0 1 1.197-2.479 3.168 3.168 0 0 1 .91-2.593 3.166 3.166 0 0 1 2.596-.905A3.169 3.169 0 0 1 8 .837Z"/>
|
||||
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="m6 8 1.333 1.333L10 6.667"/>
|
||||
<defs>
|
||||
<linearGradient id="VerifiedGradient" x1="-.966" x2="12.162" y1="2.629" y2="17.493" gradientUnits="userSpaceOnUse">
|
||||
<stop offset=".13" stop-color="#5638CC"/>
|
||||
<stop offset=".995" stop-color="#DC03F0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
24
app/javascript/inline/theme-selection.js
Normal file
24
app/javascript/inline/theme-selection.js
Normal file
@@ -0,0 +1,24 @@
|
||||
(function (element) {
|
||||
const {colorScheme, contrast} = element.dataset;
|
||||
|
||||
const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const updateColorScheme = () => {
|
||||
const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark';
|
||||
|
||||
element.dataset.colorScheme = useDarkMode ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const updateContrast = () => {
|
||||
const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches;
|
||||
|
||||
element.dataset.contrast = useHighContrast ? 'high' : 'default';
|
||||
}
|
||||
|
||||
colorSchemeMediaWatcher.addEventListener('change', updateColorScheme);
|
||||
contrastMediaWatcher.addEventListener('change', updateContrast);
|
||||
|
||||
updateColorScheme();
|
||||
updateContrast();
|
||||
})(document.documentElement);
|
||||
@@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
const DIRECTORY_FETCH_LIMIT = 20;
|
||||
|
||||
export const fetchDirectory = createDataLoadingThunk(
|
||||
'directory/fetch',
|
||||
async (params: Parameters<typeof apiGetDirectory>[0]) =>
|
||||
apiGetDirectory(params),
|
||||
apiGetDirectory(params, DIRECTORY_FETCH_LIMIT),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||
|
||||
return { accounts: data };
|
||||
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,12 +28,15 @@ export const expandDirectory = createDataLoadingThunk(
|
||||
'items',
|
||||
]) as ImmutableList<unknown>;
|
||||
|
||||
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
|
||||
return apiGetDirectory(
|
||||
{ ...params, offset: loadedItems.size },
|
||||
DIRECTORY_FETCH_LIMIT,
|
||||
);
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||
|
||||
return { accounts: data };
|
||||
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,15 +128,18 @@ export default function api(withAuthorization = true) {
|
||||
}
|
||||
|
||||
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
type RequestParamsOrData<T = unknown> = T | Record<string, unknown>;
|
||||
|
||||
export async function apiRequest<ApiResponse = unknown>(
|
||||
export async function apiRequest<
|
||||
ApiResponse = unknown,
|
||||
ApiParamsOrData = unknown,
|
||||
>(
|
||||
method: Method,
|
||||
url: string,
|
||||
args: {
|
||||
signal?: AbortSignal;
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
params?: RequestParamsOrData<ApiParamsOrData>;
|
||||
data?: RequestParamsOrData<ApiParamsOrData>;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
@@ -149,30 +152,30 @@ export async function apiRequest<ApiResponse = unknown>(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiRequestGet<ApiResponse = unknown>(
|
||||
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
params?: RequestParamsOrData<ApiParams>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('GET', url, { params });
|
||||
}
|
||||
|
||||
export async function apiRequestPost<ApiResponse = unknown>(
|
||||
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
data?: RequestParamsOrData<ApiData>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('POST', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestPut<ApiResponse = unknown>(
|
||||
export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
data?: RequestParamsOrData<ApiData>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('PUT', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestDelete<ApiResponse = unknown>(
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
) {
|
||||
export async function apiRequestDelete<
|
||||
ApiResponse = unknown,
|
||||
ApiParams = unknown,
|
||||
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
|
||||
return apiRequest<ApiResponse>('DELETE', url, { params });
|
||||
}
|
||||
|
||||
@@ -41,11 +41,10 @@ export interface ApiPreviewCardJSON {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
type: string;
|
||||
language: string | null;
|
||||
type: 'video' | 'link';
|
||||
author_name: string;
|
||||
author_url: string;
|
||||
author_account?: ApiAccountJSON;
|
||||
provider_name: string;
|
||||
provider_url: string;
|
||||
html: string;
|
||||
@@ -55,7 +54,7 @@ export interface ApiPreviewCardJSON {
|
||||
image_description: string;
|
||||
embed_url: string;
|
||||
blurhash: string;
|
||||
published_at: string;
|
||||
published_at: string | null;
|
||||
authors: ApiPreviewCardAuthorJSON[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
|
||||
|
||||
|
||||
export const Badge = ({ icon = <PersonIcon />, label, domain, roleId }) => (
|
||||
<div className='account-role' data-account-role-id={roleId}>
|
||||
{icon}
|
||||
{label}
|
||||
{domain && <span className='account-role__domain'>{domain}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
Badge.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
label: PropTypes.node,
|
||||
domain: PropTypes.node,
|
||||
roleId: PropTypes.string
|
||||
};
|
||||
|
||||
export const GroupBadge = () => (
|
||||
<Badge icon={<GroupsIcon />} label={<FormattedMessage id='account.badges.group' defaultMessage='Group' />} />
|
||||
);
|
||||
|
||||
export const AutomatedBadge = () => (
|
||||
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
|
||||
);
|
||||
46
app/javascript/mastodon/components/badge.tsx
Normal file
46
app/javascript/mastodon/components/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
|
||||
|
||||
export const Badge: FC<{
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
domain?: ReactNode;
|
||||
roleId?: string;
|
||||
}> = ({ icon = <PersonIcon />, label, className, domain, roleId }) => (
|
||||
<div
|
||||
className={classNames('account-role', className)}
|
||||
data-account-role-id={roleId}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{domain && <span className='account-role__domain'>{domain}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GroupBadge: FC<{ className?: string }> = ({ className }) => (
|
||||
<Badge
|
||||
icon={<GroupsIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.badges.group' defaultMessage='Group' />
|
||||
}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => (
|
||||
<Badge
|
||||
icon={<SmartToyIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />
|
||||
}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
@@ -88,7 +88,10 @@ export const Emoji: FC<EmojiProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const src = unicodeHexToUrl(state.code, appState.darkTheme);
|
||||
const src = unicodeHexToUrl({
|
||||
unicodeHex: state.code,
|
||||
...appState,
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface MiniCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MiniCard: FC<MiniCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
}) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.card, className)}
|
||||
inert={hidden ? '' : undefined}
|
||||
>
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
221
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
221
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
interface MiniCardListProps {
|
||||
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & {
|
||||
key?: Key;
|
||||
})[];
|
||||
className?: string;
|
||||
onOverflowClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
cards = [],
|
||||
className,
|
||||
onOverflowClick,
|
||||
}) => {
|
||||
const {
|
||||
wrapperRef,
|
||||
listRef,
|
||||
hiddenCount,
|
||||
hasOverflow,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
} = useOverflow();
|
||||
|
||||
if (!cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(classes.wrapper, className)} ref={wrapperRef}>
|
||||
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard
|
||||
key={card.key ?? index}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
hidden={hasOverflow && index >= hiddenIndex}
|
||||
className={card.className}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
{cards.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(classes.more, !hasOverflow && classes.hidden)}
|
||||
onClick={onOverflowClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='minicard.more_items'
|
||||
defaultMessage='+{count}'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useOverflow() {
|
||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The main recalculation function.
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) return;
|
||||
|
||||
const reset = () => {
|
||||
setHiddenIndex(-1);
|
||||
setHiddenCount(0);
|
||||
setMaxWidth('none');
|
||||
};
|
||||
|
||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||
const maxWidth =
|
||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||
4;
|
||||
if (maxWidth <= 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through children until we exceed max width.
|
||||
let visible = 0;
|
||||
let index = 0;
|
||||
let totalWidth = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||
if (rightOffset <= maxWidth) {
|
||||
visible += 1;
|
||||
totalWidth = rightOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// All are visible, so remove max-width restriction.
|
||||
if (visible === listEle.children.length) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the width to avoid wrapping, and set hidden count.
|
||||
setHiddenIndex(index);
|
||||
setHiddenCount(listEle.children.length - visible);
|
||||
setMaxWidth(totalWidth);
|
||||
}, []);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
const observer = resizeObserver();
|
||||
|
||||
if (listEle) {
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
observer.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||
handleChildrenChange,
|
||||
));
|
||||
return observer;
|
||||
}, [handleChildrenChange]);
|
||||
|
||||
// Set up observers.
|
||||
const handleObserve = useCallback(() => {
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver().observe(wrapperRef.current);
|
||||
}
|
||||
if (listRef.current) {
|
||||
mutationObserver().observe(listRef.current, { childList: true });
|
||||
handleChildrenChange();
|
||||
}
|
||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||
|
||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
const wrapperRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
const listRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleObserve();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (mutationObserverRef.current) {
|
||||
mutationObserverRef.current.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { MiniCardList } from './list';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/MiniCard',
|
||||
component: MiniCardList,
|
||||
args: {
|
||||
onOverflowClick: action('Overflow clicked'),
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
resize: 'horizontal',
|
||||
padding: '1rem',
|
||||
border: '1px solid gray',
|
||||
overflow: 'auto',
|
||||
width: '400px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<MiniCardList {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof MiniCardList>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{ label: 'Pronouns', value: 'they/them' },
|
||||
{
|
||||
label: 'Website',
|
||||
value: <a href='https://example.com'>bowie-the-db.meow</a>,
|
||||
},
|
||||
{
|
||||
label: 'Free playlists',
|
||||
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
|
||||
},
|
||||
{ label: 'Location', value: 'Purris, France' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const LongValue: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{
|
||||
label: 'Username',
|
||||
value: 'bowie-the-dj',
|
||||
},
|
||||
{
|
||||
label: 'Bio',
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OneCard: Story = {
|
||||
args: {
|
||||
cards: [{ label: 'Pronouns', value: 'they/them' }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card,
|
||||
.more {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
background: none;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
transition: all 300ms linear;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label,
|
||||
.value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
@@ -15,7 +16,9 @@ export const DomainPill: React.FC<{
|
||||
domain: string;
|
||||
username: string;
|
||||
isSelf: boolean;
|
||||
}> = ({ domain, username, isSelf }) => {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}> = ({ domain, username, isSelf, children, className }) => {
|
||||
const accessibilityId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -32,7 +35,9 @@ export const DomainPill: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classNames('account__domain-pill', { active: open })}
|
||||
className={classNames('account__domain-pill', className, {
|
||||
active: open,
|
||||
})}
|
||||
ref={triggerRef}
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
@@ -40,6 +45,7 @@ export const DomainPill: React.FC<{
|
||||
type='button'
|
||||
>
|
||||
{domain}
|
||||
{children}
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function isRedesignEnabled() {
|
||||
return isClientFeatureEnabled('profile_redesign');
|
||||
}
|
||||
@@ -1,177 +1,32 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import {
|
||||
followAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
pinAccount,
|
||||
unpinAccount,
|
||||
removeAccountFromFollowers,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { initBlockModal } from 'mastodon/actions/blocks';
|
||||
import { mentionCompose, directCompose } from 'mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from 'mastodon/actions/domain_blocks';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
|
||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { AccountNote } from 'mastodon/features/account/components/account_note';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
} from 'mastodon/permissions';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import { AccountName } from './account_name';
|
||||
import { AccountBadges } from './badges';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { FamiliarFollowers } from './familiar_followers';
|
||||
import { AccountHeaderFields } from './fields';
|
||||
import { AccountInfo } from './info';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: {
|
||||
id: 'account.link_verified_on',
|
||||
defaultMessage: 'Ownership of this link was checked on {date}',
|
||||
},
|
||||
account_locked: {
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
},
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
import { AccountNumberFields } from './number_fields';
|
||||
import redesignClasses from './redesign.module.scss';
|
||||
import { AccountTabs } from './tabs';
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
const displayName = account.display_name;
|
||||
@@ -190,150 +45,12 @@ export const AccountHeader: React.FC<{
|
||||
hideTabs?: boolean;
|
||||
}> = ({ accountId, hideTabs }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(account.id));
|
||||
} else {
|
||||
dispatch(initBlockModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMention = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mentionCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(directCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReblogToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.showing_reblogs) {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.notifying) {
|
||||
dispatch(followAccount(account.id, { notify: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { notify: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initDomainBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleUnblockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = account.acct.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(unblockDomain(domain));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleEndorseToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleAddToList = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleChangeLanguages = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
@@ -359,410 +76,12 @@ export const AccountHeader: React.FC<{
|
||||
[dispatch, account],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.share({
|
||||
url: account.url,
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMention,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleDirect,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: handleChangeLanguages,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: handleAddToList,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(removeAccountFromFollowers({ accountId }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReport,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (signedIn && isRemote) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleUnblockDomain,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleBlockDomain,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
accountId,
|
||||
account,
|
||||
relationship,
|
||||
permissions,
|
||||
isRemote,
|
||||
remoteDomain,
|
||||
intl,
|
||||
signedIn,
|
||||
handleAddToList,
|
||||
handleBlock,
|
||||
handleBlockDomain,
|
||||
handleChangeLanguages,
|
||||
handleDirect,
|
||||
handleEndorseToggle,
|
||||
handleMention,
|
||||
handleMute,
|
||||
handleReblogToggle,
|
||||
handleReport,
|
||||
handleUnblockDomain,
|
||||
]);
|
||||
|
||||
const menu = accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship) {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
info.push(
|
||||
<span key='mutual' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
info.push(
|
||||
<span key='followed_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
info.push(
|
||||
<span key='requested_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.blocking) {
|
||||
info.push(
|
||||
<span key='blocking' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.muting) {
|
||||
info.push(
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.requested || relationship?.following) {
|
||||
bellBtn = (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ('share' in navigator) {
|
||||
shareBtn = (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
shareBtn = (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
|
||||
if (!isMovedAndUnfollowedAccount) {
|
||||
actionBtn = (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.locked) {
|
||||
lockedIcon = (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = account.fields;
|
||||
const suspendedOrHidden = hidden || account.suspended;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const username = account.acct.split('@')[0];
|
||||
const domain = isLocal ? localDomain : account.acct.split('@')[1];
|
||||
const isIndexable = !account.noindex;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
@@ -776,15 +95,16 @@ export const AccountHeader: React.FC<{
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className='account__header__image'>
|
||||
<div className='account__header__info'>{info}</div>
|
||||
{me !== account.id && relationship && (
|
||||
<AccountInfo relationship={relationship} />
|
||||
)}
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
src={autoPlayGif ? account.header : account.header_static}
|
||||
alt=''
|
||||
@@ -803,55 +123,50 @@ export const AccountHeader: React.FC<{
|
||||
onClick={handleOpenAvatar}
|
||||
>
|
||||
<Avatar
|
||||
account={suspended || hidden ? undefined : account}
|
||||
account={suspendedOrHidden ? undefined : account}
|
||||
size={92}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--desktop'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
{menu}
|
||||
</div>
|
||||
{!isRedesignEnabled() && (
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
className='account__header__buttons--desktop'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__header__tabs__name'>
|
||||
<h1>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
<small>
|
||||
<span>
|
||||
@{username}
|
||||
<span className='invisible'>@{domain}</span>
|
||||
</span>
|
||||
<DomainPill
|
||||
username={username ?? ''}
|
||||
domain={domain ?? ''}
|
||||
isSelf={me === account.id}
|
||||
/>
|
||||
{lockedIcon}
|
||||
</small>
|
||||
</h1>
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__tabs__name',
|
||||
isRedesignEnabled() && redesignClasses.nameWrapper,
|
||||
)}
|
||||
>
|
||||
<AccountName
|
||||
accountId={accountId}
|
||||
className={classNames(
|
||||
isRedesignEnabled() && redesignClasses.name,
|
||||
)}
|
||||
/>
|
||||
{isRedesignEnabled() && <AccountButtons accountId={accountId} />}
|
||||
</div>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='account__header__badges'>{badges}</div>
|
||||
)}
|
||||
<AccountBadges accountId={accountId} />
|
||||
|
||||
{account.id !== me && signedIn && !(suspended || hidden) && (
|
||||
{me && account.id !== me && !suspendedOrHidden && (
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--mobile'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{account.id !== me && signedIn && (
|
||||
{me && account.id !== me && (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
@@ -860,91 +175,22 @@ export const AccountHeader: React.FC<{
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
<dt>
|
||||
<FormattedMessage
|
||||
id='account.joined_short'
|
||||
defaultMessage='Joined'
|
||||
/>
|
||||
</dt>
|
||||
<dd>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
</div>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
<AccountNumberFields accountId={accountId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimateEmojiProvider>
|
||||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
<meta
|
||||
name='robots'
|
||||
content={isLocal && isIndexable ? 'all' : 'noindex'}
|
||||
content={isLocal && !account.noindex ? 'all' : 'noindex'}
|
||||
/>
|
||||
<link rel='canonical' href={account.url} />
|
||||
</Helmet>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
|
||||
import { DomainPill } from '../../account/components/domain_pill';
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountName: FC<{ accountId: string; className?: string }> = ({
|
||||
accountId,
|
||||
className,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [username = '', domain = localDomain] = account.acct.split('@');
|
||||
|
||||
return (
|
||||
<h1 className={className}>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
<small>
|
||||
<span>
|
||||
@{username}
|
||||
{isRedesignEnabled() && '@'}
|
||||
<span className='invisible'>
|
||||
{!isRedesignEnabled() && '@'}
|
||||
{domain}
|
||||
</span>
|
||||
</span>
|
||||
<DomainPill
|
||||
username={username}
|
||||
domain={domain}
|
||||
isSelf={me === account.id}
|
||||
className={(isRedesignEnabled() && classes.domainPill) || ''}
|
||||
>
|
||||
{isRedesignEnabled() && <Icon id='info' icon={InfoIcon} />}
|
||||
</DomainPill>
|
||||
{!isRedesignEnabled() && account.locked && (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</small>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import IconAdmin from '@/images/icons/icon_admin.svg?react';
|
||||
import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { AccountRole } from '@/mastodon/models/account';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAccount(accountId);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
const badges = [];
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = isRedesignEnabled() ? classes.badge : '';
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' className={className} />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' className={className} />);
|
||||
}
|
||||
|
||||
const domain = account.acct.includes('@')
|
||||
? account.acct.split('@')[1]
|
||||
: localDomain;
|
||||
account.roles.forEach((role) => {
|
||||
let icon: ReactNode = undefined;
|
||||
if (isAdminBadge(role)) {
|
||||
icon = (
|
||||
<Icon icon={IconAdmin} id='badge-admin' className={classes.badgeIcon} />
|
||||
);
|
||||
}
|
||||
badges.push(
|
||||
<Badge
|
||||
key={role.id}
|
||||
label={role.name}
|
||||
className={className}
|
||||
domain={isRedesignEnabled() ? `(${domain})` : domain}
|
||||
roleId={role.id}
|
||||
icon={icon}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (!badges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={'account__header__badges'}>{badges}</div>;
|
||||
};
|
||||
|
||||
function isAdminBadge(role: AccountRole) {
|
||||
const name = role.name.toLowerCase();
|
||||
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/mastodon/actions/accounts';
|
||||
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
|
||||
import { FollowButton } from '@/mastodon/components/follow_button';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
});
|
||||
|
||||
interface AccountButtonsProps {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
noShare?: boolean;
|
||||
}
|
||||
|
||||
export const AccountButtons: FC<AccountButtonsProps> = ({
|
||||
accountId,
|
||||
className,
|
||||
noShare,
|
||||
}) => {
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header__buttons', className)}>
|
||||
{!hidden && (
|
||||
<AccountButtonsOther accountId={accountId} noShare={noShare} />
|
||||
)}
|
||||
{accountId !== me && <AccountMenu accountId={accountId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountButtonsOther: FC<
|
||||
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
|
||||
> = ({ accountId, noShare }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (account) {
|
||||
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
const accountUrl = account?.url;
|
||||
const handleShare = useCallback(() => {
|
||||
if (accountUrl) {
|
||||
void navigator.share({
|
||||
url: accountUrl,
|
||||
});
|
||||
}
|
||||
}, [accountUrl]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
const isFollowing = relationship?.requested || relationship?.following;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMovedAndUnfollowedAccount && (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
)}
|
||||
{isFollowing && (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
)}
|
||||
{!noShare &&
|
||||
('share' in navigator ? (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
) : (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { MiniCardList } from '@/mastodon/components/mini_card/list';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountHeaderFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
return <RedesignAccountHeaderFields account={account} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
<dt>
|
||||
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />
|
||||
</dt>
|
||||
<dd>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={account.fields} emojis={account.emojis} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
account.fields
|
||||
.toArray()
|
||||
.map(({ value_emojified, name_emojified, verified_at }) => ({
|
||||
label: (
|
||||
<>
|
||||
<EmojiHTML
|
||||
htmlString={name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
as='span'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
value: (
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
),
|
||||
className: classNames(
|
||||
classes.fieldCard,
|
||||
!!verified_at && classes.fieldCardVerified,
|
||||
),
|
||||
})),
|
||||
[account.emojis, account.fields, htmlHandlers],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOverflowClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_FIELDS',
|
||||
modalProps: { accountId: account.id },
|
||||
}),
|
||||
);
|
||||
}, [account.id, dispatch]);
|
||||
|
||||
return (
|
||||
<MiniCardList
|
||||
cards={cards}
|
||||
className={classes.fieldList}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountFieldsModal: FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
icon='close'
|
||||
className={classes.modalCloseButton}
|
||||
onClick={onClose}
|
||||
iconComponent={CloseIcon}
|
||||
title={intl.formatMessage({
|
||||
id: 'account_fields_modal.close',
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
/>
|
||||
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
|
||||
<FormattedMessage
|
||||
id='account_fields_modal.title'
|
||||
defaultMessage="{name}'s info"
|
||||
values={{
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='dialog-modal__content'>
|
||||
<AnimateEmojiProvider>
|
||||
<dl className={classes.modalFieldsList}>
|
||||
{account.fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
|
||||
>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={field.name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<dd>
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={field.value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!field.verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</AnimateEmojiProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
|
||||
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (!relationship) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='account__header__info'>
|
||||
{(relationship.followed_by || relationship.requested_by) && (
|
||||
<span className='relationship-tag'>
|
||||
<AccountInfoFollower relationship={relationship} />
|
||||
</span>
|
||||
)}
|
||||
{relationship.blocking && (
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.muting && (
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.domain_blocking && (
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
return (
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
blockAccount,
|
||||
followAccount,
|
||||
pinAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
unpinAccount,
|
||||
} from '@/mastodon/actions/accounts';
|
||||
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
|
||||
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from '@/mastodon/actions/domain_blocks';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown_menu';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
PERMISSION_MANAGE_USERS,
|
||||
} from '@/mastodon/permissions';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(
|
||||
removeAccountFromFollowers({ accountId: account.id }),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from '@/mastodon/components/counters';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { ShortNumber } from '@/mastodon/components/short_number';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__extra__links',
|
||||
isRedesignEnabled() && classes.fieldNumbersWrapper,
|
||||
)}
|
||||
>
|
||||
{!isRedesignEnabled() && (
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
{isRedesignEnabled() && (
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage
|
||||
id='account.joined_long'
|
||||
defaultMessage='Joined on {date}'
|
||||
values={{
|
||||
date: (
|
||||
<strong>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
.nameWrapper {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-grow: 1;
|
||||
font-size: 22px;
|
||||
white-space: initial;
|
||||
text-overflow: initial;
|
||||
line-height: normal;
|
||||
|
||||
:global(.icon-info) {
|
||||
margin-left: 2px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides .account__header__tabs__name h1 small
|
||||
h1.name > small {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.domainPill {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: initial;
|
||||
|
||||
&:global(.active) {
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
|
||||
> span {
|
||||
font-weight: unset;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.badgeIcon {
|
||||
opacity: 1;
|
||||
fill: revert-layer;
|
||||
|
||||
path {
|
||||
fill: revert-layer;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fieldCard {
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
color: var(--color-text-brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldCardVerified {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
|
||||
dt {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
// Need to override .icon path.
|
||||
path {
|
||||
fill: revert-layer;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldNumbersWrapper {
|
||||
a {
|
||||
font-weight: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.modalCloseButton {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modalFieldsList {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modalFieldItem {
|
||||
&:not(:first-child) {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
return (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
|
||||
@@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
|
||||
@@ -81,7 +81,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
const topHashtag = report.data.top_hashtags[0];
|
||||
|
||||
return (
|
||||
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<div className={styles.header}>
|
||||
<h1>Wrapstodon {report.year}</h1>
|
||||
{account && <p>@{account.acct}</p>}
|
||||
|
||||
@@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{
|
||||
// default modal backdrop, preventing clicks to pass through.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
className={classNames('modal-root__modal', styles.modalWrapper)}
|
||||
data-color-scheme='dark'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{!showAnnouncement ? (
|
||||
|
||||
@@ -83,6 +83,9 @@ export const Directory: React.FC<{
|
||||
(state) =>
|
||||
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.user_lists.getIn(['directory', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchDirectory({ order, local }));
|
||||
@@ -182,7 +185,7 @@ export const Directory: React.FC<{
|
||||
|
||||
<LoadMore
|
||||
onClick={handleLoadMore}
|
||||
visible={!initialLoad}
|
||||
visible={!initialLoad && hasMore}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('emoji', () => {
|
||||
|
||||
it('does an emoji containing ZWJ properly', () => {
|
||||
expect(emojify('💂♀️💂♂️'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
|
||||
});
|
||||
|
||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const SKIN_TONE_CODES = [
|
||||
0x1f3ff, // Dark skin tone
|
||||
] as const;
|
||||
|
||||
export const EMOJI_MIN_TOKEN_LENGTH = 2;
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
|
||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
@@ -6,8 +7,6 @@ import { EMOJI_DB_SHORTCODE_TEST } from './constants';
|
||||
import {
|
||||
putEmojiData,
|
||||
loadEmojiByHexcode,
|
||||
searchEmojisByHexcodes,
|
||||
searchEmojisByTag,
|
||||
testClear,
|
||||
testGet,
|
||||
putCustomEmojiData,
|
||||
@@ -17,6 +16,14 @@ import {
|
||||
putLatestEtag,
|
||||
} from './database';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
return {
|
||||
...unicodeEmojiFactory(),
|
||||
tags: ['test', 'emoji'],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('emoji database', () => {
|
||||
afterEach(() => {
|
||||
testClear();
|
||||
@@ -32,7 +39,7 @@ describe('emoji database', () => {
|
||||
});
|
||||
|
||||
test('loads emoji into indexedDB', async () => {
|
||||
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
const { db } = await testGet();
|
||||
await expect(db.get('en', 'test')).resolves.toEqual(
|
||||
unicodeEmojiFactory(),
|
||||
@@ -60,7 +67,7 @@ describe('emoji database', () => {
|
||||
});
|
||||
await expect(db.get('custom', 'emoji1')).resolves.toBeUndefined();
|
||||
await expect(db.get('custom', 'emoji2')).resolves.toEqual(
|
||||
customEmojiFactory({ shortcode: 'emoji2' }),
|
||||
customEmojiFactory({ shortcode: 'emoji2', tokens: ['emoji2'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -79,12 +86,6 @@ describe('emoji database', () => {
|
||||
});
|
||||
|
||||
describe('loadEmojiByHexcode', () => {
|
||||
test('throws if the locale is not loaded', async () => {
|
||||
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
|
||||
'Locale en',
|
||||
);
|
||||
});
|
||||
|
||||
test('retrieves the emoji', async () => {
|
||||
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
|
||||
@@ -98,90 +99,6 @@ describe('emoji database', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEmojisByHexcodes', () => {
|
||||
const data = [
|
||||
unicodeEmojiFactory({ hexcode: 'not a number' }),
|
||||
unicodeEmojiFactory({ hexcode: '1' }),
|
||||
unicodeEmojiFactory({ hexcode: '2' }),
|
||||
unicodeEmojiFactory({ hexcode: '3' }),
|
||||
unicodeEmojiFactory({ hexcode: 'another not a number' }),
|
||||
];
|
||||
beforeEach(async () => {
|
||||
await putEmojiData(data, 'en');
|
||||
});
|
||||
test('finds emoji in consecutive range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['1', '2', '3'], 'en');
|
||||
expect(actual).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('finds emoji in split range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['1', '3'], 'en');
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).toContainEqual(data.at(1));
|
||||
expect(actual).toContainEqual(data.at(3));
|
||||
});
|
||||
|
||||
test('finds emoji with non-numeric range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(
|
||||
['3', 'not a number', '1'],
|
||||
'en',
|
||||
);
|
||||
expect(actual).toHaveLength(3);
|
||||
expect(actual).toContainEqual(data.at(0));
|
||||
expect(actual).toContainEqual(data.at(1));
|
||||
expect(actual).toContainEqual(data.at(3));
|
||||
});
|
||||
|
||||
test('not found emoji are not returned', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['not found'], 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('only found emojis are returned', async () => {
|
||||
const actual = await searchEmojisByHexcodes(
|
||||
['another not a number', 'not found'],
|
||||
'en',
|
||||
);
|
||||
expect(actual).toHaveLength(1);
|
||||
expect(actual).toContainEqual(data.at(4));
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEmojisByTag', () => {
|
||||
const data = [
|
||||
unicodeEmojiFactory({ hexcode: 'test1', tags: ['test 1'] }),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: 'test2',
|
||||
tags: ['test 2', 'something else'],
|
||||
}),
|
||||
unicodeEmojiFactory({ hexcode: 'test3', tags: ['completely different'] }),
|
||||
];
|
||||
beforeEach(async () => {
|
||||
await putEmojiData(data, 'en');
|
||||
});
|
||||
test('finds emojis with tag', async () => {
|
||||
const actual = await searchEmojisByTag('test 1', 'en');
|
||||
expect(actual).toHaveLength(1);
|
||||
expect(actual).toContainEqual(data.at(0));
|
||||
});
|
||||
|
||||
test('finds emojis starting with tag', async () => {
|
||||
const actual = await searchEmojisByTag('test', 'en');
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).not.toContainEqual(data.at(2));
|
||||
});
|
||||
|
||||
test('does not find emojis ending with tag', async () => {
|
||||
const actual = await searchEmojisByTag('else', 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('finds nothing with invalid tag', async () => {
|
||||
const actual = await searchEmojisByTag('not found', 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLegacyShortcodesByShortcode', () => {
|
||||
const data = {
|
||||
hexcode: 'test_hexcode',
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { Locale, ShortcodesDataset } from 'emojibase';
|
||||
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { CustomEmojiData, UnicodeEmojiData, EtagTypes } from './types';
|
||||
import { openEmojiDB } from './db-schema';
|
||||
import type { Database } from './db-schema';
|
||||
import {
|
||||
localeToSegmenter,
|
||||
toSupportedLocale,
|
||||
toSupportedLocaleOrCustom,
|
||||
} from './locale';
|
||||
import {
|
||||
extractTokens,
|
||||
skinHexcodeToEmoji,
|
||||
transformCustomEmojiData,
|
||||
transformEmojiData,
|
||||
} from './normalize';
|
||||
import type { AnyEmojiData, EtagTypes } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: CustomEmojiData;
|
||||
indexes: {
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
shortcodes: {
|
||||
key: string;
|
||||
value: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
indexes: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
};
|
||||
etags: {
|
||||
key: EtagTypes;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
group: number;
|
||||
label: string;
|
||||
order: number;
|
||||
tags: string[];
|
||||
shortcodes: string[];
|
||||
};
|
||||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
|
||||
const log = emojiLogger('database');
|
||||
@@ -60,75 +30,7 @@ const loadDB = (() => {
|
||||
|
||||
// Actually load the DB.
|
||||
async function initDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database, oldVersion, newVersion, trx) {
|
||||
if (!database.objectStoreNames.contains('custom')) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains('etags')) {
|
||||
database.createObjectStore('etags');
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (!database.objectStoreNames.contains(locale)) {
|
||||
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 });
|
||||
localeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
// Added in version 2.
|
||||
const localeTable = trx.objectStore(locale);
|
||||
if (!localeTable.indexNames.contains('shortcodes')) {
|
||||
localeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains('shortcodes')) {
|
||||
const shortcodeTable = database.createObjectStore('shortcodes', {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
shortcodeTable.createIndex('hexcode', 'hexcode');
|
||||
shortcodeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
'Upgraded emoji database from version %d to %d',
|
||||
oldVersion,
|
||||
newVersion,
|
||||
);
|
||||
},
|
||||
blocked(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d to %d is blocked',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
blocking(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d is blocking upgrade to %d',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
});
|
||||
const db = await openEmojiDB();
|
||||
await syncLocales(db);
|
||||
log('Loaded database version %d', db.version);
|
||||
return db;
|
||||
@@ -149,11 +51,128 @@ const loadDB = (() => {
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||
export async function search({
|
||||
query,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
const db = await loadDB();
|
||||
const resultArrays: Map<string, AnyEmojiData>[] = [];
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const range =
|
||||
i === queryTokens.length - 1
|
||||
? IDBKeyRange.bound(token, token + '\uffff')
|
||||
: IDBKeyRange.only(token);
|
||||
|
||||
const [unicodeResults, customResults] = await Promise.all([
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
]);
|
||||
const resultMap = new Map<string, AnyEmojiData>([
|
||||
...unicodeResults.map(
|
||||
(emoji) => [emoji.hexcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
...customResults.map(
|
||||
(emoji) => [emoji.shortcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
]);
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Utilize maps to find the intersection of all result sets.
|
||||
const results = Array.from(
|
||||
resultArrays
|
||||
.reduce((prev, curr) => {
|
||||
const intersection = new Map<string, AnyEmojiData>();
|
||||
for (const [code, emoji] of prev) {
|
||||
if (curr.has(code)) {
|
||||
intersection.set(code, emoji);
|
||||
}
|
||||
}
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
);
|
||||
|
||||
results.sort((a, b) => {
|
||||
// Checks if a or b has the last token exactly, or only a prefix.
|
||||
const aHasToken = a.tokens.includes(lastToken);
|
||||
const bHasToken = b.tokens.includes(lastToken);
|
||||
if (aHasToken && !bHasToken) {
|
||||
return -1;
|
||||
} else if (!aHasToken && bHasToken) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If one is a custom emoji, prioritize it over Unicode emojis.
|
||||
if ('category' in a) {
|
||||
return -1;
|
||||
} else if ('category' in b) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If both are Unicode emojis, prioritize by order.
|
||||
if ('order' in a && 'order' in b) {
|
||||
return (a.order ?? 0) - (b.order ?? 0); // If these are both Unicode emojis, sort by order.
|
||||
}
|
||||
|
||||
// ¯\_(ツ)_/¯
|
||||
return 0;
|
||||
});
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
results.length,
|
||||
time.duration,
|
||||
);
|
||||
if (limit > 0) {
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
loadedLocales.add(locale);
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction(locale, 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.store.clear();
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
await Promise.all(
|
||||
emojis
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((emoji) => trx.store.put(transformEmojiData(emoji, segmenter))),
|
||||
);
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
@@ -161,7 +180,7 @@ export async function putCustomEmojiData({
|
||||
emojis,
|
||||
clear = false,
|
||||
}: {
|
||||
emojis: CustomEmojiData[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
clear?: boolean;
|
||||
}) {
|
||||
const db = await loadDB();
|
||||
@@ -173,7 +192,9 @@ export async function putCustomEmojiData({
|
||||
log('Cleared existing custom emojis in database');
|
||||
}
|
||||
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await Promise.all(
|
||||
emojis.map((emoji) => trx.store.put(transformCustomEmojiData(emoji))),
|
||||
);
|
||||
await trx.done;
|
||||
|
||||
log('Imported %d custom emojis into database', emojis.length);
|
||||
@@ -210,32 +231,25 @@ export async function loadEmojiByHexcode(
|
||||
localeString: string,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
return db.get(locale, hexcode);
|
||||
}
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const result = await db.get(locale, hexcode);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function searchEmojisByHexcodes(
|
||||
hexcodes: string[],
|
||||
localeString: string,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const sortedCodes = hexcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
// If the emoji wasn't found, check if it's a skin tone variant.
|
||||
const skinResult = await db.getFromIndex(
|
||||
locale,
|
||||
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||
'skinHexcodes',
|
||||
IDBKeyRange.only(hexcode),
|
||||
);
|
||||
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||
}
|
||||
|
||||
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const range = IDBKeyRange.bound(
|
||||
tag.toLowerCase(),
|
||||
`${tag.toLowerCase()}\uffff`,
|
||||
);
|
||||
return db.getAllFromIndex(locale, 'tags', range);
|
||||
if (!skinResult) {
|
||||
return skinResult;
|
||||
}
|
||||
|
||||
// Reconstruct the full unicode string from the skin tone hexcode.
|
||||
return skinHexcodeToEmoji(hexcode, skinResult);
|
||||
}
|
||||
|
||||
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
@@ -301,13 +315,16 @@ async function syncLocales(db: Database) {
|
||||
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||
}
|
||||
|
||||
function toLoadedLocale(localeString: string) {
|
||||
async function toLoadedLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
if (localeString !== locale) {
|
||||
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||
}
|
||||
if (!loadedLocales.has(locale)) {
|
||||
throw new LocaleNotLoadedError(locale);
|
||||
log('Locale %s not loaded, importing...', locale);
|
||||
const { importEmojiData } = await import('./loader');
|
||||
await importEmojiData(locale);
|
||||
return locale;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
198
app/javascript/mastodon/features/emoji/db-schema.ts
Normal file
198
app/javascript/mastodon/features/emoji/db-schema.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { Locale } from 'emojibase';
|
||||
import { openDB } from 'idb';
|
||||
import type {
|
||||
DBSchema,
|
||||
IDBPDatabase,
|
||||
IDBPObjectStore,
|
||||
IDBPTransaction,
|
||||
IndexNames,
|
||||
StoreNames,
|
||||
} from 'idb';
|
||||
|
||||
import type { CustomEmojiData, EtagTypes, UnicodeEmojiData } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('database');
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: CustomEmojiData;
|
||||
indexes: {
|
||||
tokens: string[];
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
shortcodes: {
|
||||
key: string;
|
||||
value: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
indexes: {
|
||||
shortcodes: string[];
|
||||
};
|
||||
};
|
||||
etags: {
|
||||
key: EtagTypes;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
shortcodes: string[];
|
||||
groupOrder: [number, number];
|
||||
tokens: string[];
|
||||
skinHexcodes: string[];
|
||||
};
|
||||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
||||
IDBPTransaction<EmojiDB, StoreNames<EmojiDB>[], Mode>;
|
||||
|
||||
export type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
export async function openEmojiDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database, oldVersion, newVersion, trx) {
|
||||
if (!database.objectStoreNames.contains('custom')) {
|
||||
database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
}
|
||||
maybeAddIndex({ trx, storeName: 'custom', indexName: 'category' });
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: 'custom',
|
||||
indexName: 'tokens',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
|
||||
if (!database.objectStoreNames.contains('etags')) {
|
||||
database.createObjectStore('etags');
|
||||
}
|
||||
|
||||
SUPPORTED_LOCALES.forEach((locale) => {
|
||||
createLocaleTable(locale, database, trx);
|
||||
});
|
||||
|
||||
const shortcodeTable = database.objectStoreNames.contains('shortcodes')
|
||||
? trx.objectStore('shortcodes')
|
||||
: database.createObjectStore('shortcodes', {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: 'shortcodes',
|
||||
indexName: 'shortcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
||||
|
||||
log(
|
||||
'Upgraded emoji database from version %d to %d',
|
||||
oldVersion,
|
||||
newVersion,
|
||||
);
|
||||
},
|
||||
blocked(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d to %d is blocked',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
blocking(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d is blocking upgrade to %d',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function maybeAddIndex<StoreName extends StoreNames<EmojiDB>>({
|
||||
trx,
|
||||
storeName,
|
||||
indexName,
|
||||
keys,
|
||||
options,
|
||||
}: {
|
||||
trx: Transaction;
|
||||
storeName: StoreName;
|
||||
indexName: IndexNames<EmojiDB, StoreName>;
|
||||
keys?: string | string[];
|
||||
options?: IDBIndexParameters;
|
||||
}) {
|
||||
const store = trx.objectStore(storeName);
|
||||
if (!store.indexNames.contains(indexName)) {
|
||||
store.createIndex(indexName, keys ?? indexName, options);
|
||||
}
|
||||
}
|
||||
|
||||
function createLocaleTable(
|
||||
locale: Locale,
|
||||
database: Database,
|
||||
trx: Transaction,
|
||||
) {
|
||||
if (!database.objectStoreNames.contains(locale)) {
|
||||
database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
}
|
||||
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'shortcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'groupOrder',
|
||||
keys: ['group', 'order'],
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'tokens',
|
||||
keys: 'tokens',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'skinHexcodes',
|
||||
keys: 'skinHexcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
|
||||
const oldIndexes = ['group', 'order', 'tag', 'label'] as const;
|
||||
deleteOldIndexes(trx.objectStore(locale), oldIndexes);
|
||||
}
|
||||
|
||||
function deleteOldIndexes(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type is too complex, so only any works here.
|
||||
table: IDBPObjectStore<any, any, any, 'versionchange'>,
|
||||
indexes: readonly string[],
|
||||
) {
|
||||
for (const index of indexes) {
|
||||
if (table.indexNames.contains(index)) {
|
||||
table.deleteIndex(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Trie from 'substring-trie';
|
||||
|
||||
import { getUserTheme, isDarkMode } from '@/mastodon/utils/theme';
|
||||
import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
@@ -98,7 +98,7 @@ const emojifyTextNode = (node, customEmojis) => {
|
||||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
const isSystemTheme = getUserTheme() === 'system';
|
||||
const isSystemTheme = getIsSystemTheme();
|
||||
|
||||
const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark';
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import type {
|
||||
CompactEmoji,
|
||||
FlatCompactEmoji,
|
||||
Locale,
|
||||
ShortcodesDataset,
|
||||
} from 'emojibase';
|
||||
import { joinShortcodes } from 'emojibase';
|
||||
import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
@@ -28,7 +23,7 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
|
||||
shortcodes ? ' and shortcodes' : '',
|
||||
);
|
||||
|
||||
const emojis = await fetchAndCheckEtag<CompactEmoji[]>({
|
||||
let emojis = await fetchAndCheckEtag<CompactEmoji[]>({
|
||||
etagString: locale,
|
||||
path: localeToEmojiPath(locale),
|
||||
});
|
||||
@@ -49,12 +44,10 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
|
||||
}
|
||||
}
|
||||
|
||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(
|
||||
emojis,
|
||||
shortcodesData,
|
||||
);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
return flattenedEmojis;
|
||||
emojis = joinShortcodes(emojis, shortcodesData);
|
||||
|
||||
await putEmojiData(emojis, locale);
|
||||
return emojis;
|
||||
}
|
||||
|
||||
export async function importCustomEmojiData() {
|
||||
@@ -135,11 +128,11 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
checkEtag?: boolean;
|
||||
}): Promise<ResultType | null> {
|
||||
const etagName = toValidEtagName(etagString);
|
||||
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
|
||||
|
||||
// Use location.origin as this script may be loaded from a CDN domain.
|
||||
const url = new URL(path, location.origin);
|
||||
|
||||
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -148,6 +141,7 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
});
|
||||
// If not modified, return null
|
||||
if (response.status === 304) {
|
||||
log('etag not modified for %s', etagName);
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -163,6 +157,8 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
if (etag && checkEtag) {
|
||||
log(`storing new etag for ${etagName}: ${etag}`);
|
||||
await putLatestEtag(etag, etagName);
|
||||
} else if (!etag) {
|
||||
log(`no etag found in response for ${etagName}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -32,6 +32,13 @@ export function toValidEtagName(input: string): EtagTypes {
|
||||
return toSupportedLocale(lower);
|
||||
}
|
||||
|
||||
export function localeToSegmenter(locale: Locale): Intl.Segmenter | null {
|
||||
if (typeof Intl.Segmenter === 'function') {
|
||||
return new Intl.Segmenter(locale, { granularity: 'word' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupportedLocale(locale: string): locale is Locale {
|
||||
return SUPPORTED_LOCALES.includes(locale as Locale);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
import { isDarkMode } from '@/mastodon/utils/theme';
|
||||
|
||||
@@ -29,6 +30,7 @@ export function useEmojiAppState(): EmojiAppState {
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: isDarkMode(),
|
||||
assetHost,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import { basename, resolve } from 'path';
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||
|
||||
import {
|
||||
twemojiToUnicodeInfo,
|
||||
unicodeToTwemojiHex,
|
||||
emojiToUnicodeHex,
|
||||
} from './normalize';
|
||||
import { unicodeToTwemojiHex } from './normalize';
|
||||
|
||||
const emojiSVGFiles = await readdir(
|
||||
// This assumes tests are run from project root
|
||||
@@ -26,23 +22,6 @@ const svgFileNamesWithoutBorder = svgFileNames.filter(
|
||||
|
||||
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
|
||||
|
||||
describe('emojiToUnicodeHex', () => {
|
||||
test.concurrent.for([
|
||||
['🎱', '1F3B1'],
|
||||
['🐜', '1F41C'],
|
||||
['⚫', '26AB'],
|
||||
['🖤', '1F5A4'],
|
||||
['💀', '1F480'],
|
||||
['❤️', '2764'], // Checks for trailing variation selector removal.
|
||||
['💂♂️', '1F482-200D-2642-FE0F'],
|
||||
] as const)(
|
||||
'emojiToUnicodeHex converts %s to %s',
|
||||
([emoji, hexcode], { expect }) => {
|
||||
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('unicodeToTwemojiHex', () => {
|
||||
test.concurrent.for(
|
||||
unicodeEmojis
|
||||
@@ -54,26 +33,3 @@ describe('unicodeToTwemojiHex', () => {
|
||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twemojiToUnicodeInfo', () => {
|
||||
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
|
||||
|
||||
test.concurrent.for(svgFileNamesWithoutBorder)(
|
||||
'verifying SVG file %s maps to Unicode emoji',
|
||||
(svgFileName, { expect }) => {
|
||||
assert(!!svgFileName);
|
||||
const result = twemojiToUnicodeInfo(svgFileName);
|
||||
const hexcode = typeof result === 'string' ? result : result.unqualified;
|
||||
if (!hexcode) {
|
||||
// No hexcode means this is a special case like the Shibuya 109 emoji
|
||||
expect(result).toHaveProperty('label');
|
||||
return;
|
||||
}
|
||||
assert(!!hexcode);
|
||||
expect(
|
||||
unicodeCodeSet.has(hexcode),
|
||||
`${hexcode} (${svgFileName}) not found`,
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,48 +1,124 @@
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import type { CompactEmoji, SkinTone } from 'emojibase';
|
||||
import { fromHexcodeToCodepoint } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import {
|
||||
VARIATION_SELECTOR_CODE,
|
||||
KEYCAP_CODE,
|
||||
GENDER_FEMALE_CODE,
|
||||
GENDER_MALE_CODE,
|
||||
SKIN_TONE_CODES,
|
||||
EMOJIS_WITH_DARK_BORDER,
|
||||
EMOJIS_WITH_LIGHT_BORDER,
|
||||
EMOJIS_REQUIRING_INVERSION_IN_LIGHT_MODE,
|
||||
EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE,
|
||||
EMOJI_MIN_TOKEN_LENGTH,
|
||||
} from './constants';
|
||||
import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
|
||||
import type {
|
||||
CustomEmojiData,
|
||||
CustomEmojiMapArg,
|
||||
ExtraCustomEmojiMap,
|
||||
UnicodeEmojiData,
|
||||
} from './types';
|
||||
import { emojiToUnicodeHex } from './utils';
|
||||
|
||||
// Misc codes that have special handling
|
||||
const SKIER_CODE = 0x26f7;
|
||||
const CHRISTMAS_TREE_CODE = 0x1f384;
|
||||
const MR_CLAUS_CODE = 0x1f385;
|
||||
const EYE_CODE = 0x1f441;
|
||||
const LEVITATING_PERSON_CODE = 0x1f574;
|
||||
const SPEECH_BUBBLE_CODE = 0x1f5e8;
|
||||
const MS_CLAUS_CODE = 0x1f936;
|
||||
const SKIN_TONE_MAP: Record<number, SkinTone> = {
|
||||
0x1f3fb: 1, // Light skin tone
|
||||
0x1f3fc: 2, // Medium-light skin tone
|
||||
0x1f3fd: 3, // Medium skin tone
|
||||
0x1f3fe: 4, // Medium-dark skin tone
|
||||
0x1f3ff: 5, // Dark skin tone
|
||||
};
|
||||
|
||||
export function emojiToUnicodeHex(emoji: string): string {
|
||||
const codes: number[] = [];
|
||||
for (const char of emoji) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined) {
|
||||
codes.push(code);
|
||||
export function transformEmojiData(
|
||||
emoji: CompactEmoji,
|
||||
segmenter: Intl.Segmenter | null,
|
||||
): UnicodeEmojiData {
|
||||
const {
|
||||
shortcodes = [],
|
||||
tags = [],
|
||||
label,
|
||||
emoticon,
|
||||
hexcode,
|
||||
unicode,
|
||||
group,
|
||||
order,
|
||||
skins = [],
|
||||
} = emoji;
|
||||
const extract = (str: string) => extractTokens(str, segmenter);
|
||||
|
||||
let normalizedEmoticons: string[] | undefined = undefined;
|
||||
if (emoticon) {
|
||||
normalizedEmoticons = Array.isArray(emoticon) ? emoticon : [emoticon];
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
...new Set([
|
||||
...shortcodes.map(extract).flat(),
|
||||
...tags.map(extract).flat(),
|
||||
...extract(label),
|
||||
...(normalizedEmoticons ?? []),
|
||||
]),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const res: UnicodeEmojiData = {
|
||||
tokens,
|
||||
shortcodes,
|
||||
label,
|
||||
emoticons: normalizedEmoticons,
|
||||
hexcode,
|
||||
unicode,
|
||||
group,
|
||||
order,
|
||||
};
|
||||
|
||||
for (const skin of skins) {
|
||||
res.skinHexcodes ??= [];
|
||||
res.skinHexcodes.push(skin.hexcode);
|
||||
|
||||
res.skinTones ??= [];
|
||||
for (const codePoint of skin.unicode) {
|
||||
const tone = SKIN_TONE_MAP[codePoint.codePointAt(0) ?? 0];
|
||||
if (tone) {
|
||||
res.skinTones.push(tone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handles how Emojibase removes the variation selector for single code emojis.
|
||||
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
|
||||
if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) {
|
||||
codes.pop();
|
||||
}
|
||||
return hexNumbersToString(codes);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function transformCustomEmojiData(
|
||||
emoji: ApiCustomEmojiJSON,
|
||||
): CustomEmojiData {
|
||||
const tokens = emoji.shortcode
|
||||
.split('_')
|
||||
.filter((word) => word.length >= EMOJI_MIN_TOKEN_LENGTH)
|
||||
.map((word) => word.toLowerCase());
|
||||
return {
|
||||
...emoji,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function skinHexcodeToEmoji(
|
||||
skinHexcode: string,
|
||||
emoji: UnicodeEmojiData,
|
||||
): UnicodeEmojiData {
|
||||
return {
|
||||
...emoji,
|
||||
unicode: String.fromCodePoint(...fromHexcodeToCodepoint(skinHexcode)),
|
||||
hexcode: skinHexcode,
|
||||
};
|
||||
}
|
||||
|
||||
// Misc codes that have special handling
|
||||
const EYE_CODE = 0x1f441;
|
||||
const SPEECH_BUBBLE_CODE = 0x1f5e8;
|
||||
|
||||
export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||
const codes = hexStringToNumbers(unicodeHex);
|
||||
const codes = fromHexcodeToCodepoint(unicodeHex);
|
||||
const normalizedCodes: number[] = [];
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
@@ -64,19 +140,28 @@ export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||
normalizedCodes.push(code);
|
||||
}
|
||||
|
||||
return hexNumbersToString(normalizedCodes, 0).toLowerCase();
|
||||
return normalizedCodes
|
||||
.map((code) => code.toString(16))
|
||||
.join('-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export const CODES_WITH_DARK_BORDER =
|
||||
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||
const CODES_WITH_DARK_BORDER = EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export const CODES_WITH_LIGHT_BORDER =
|
||||
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||
const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
|
||||
export function unicodeHexToUrl({
|
||||
unicodeHex,
|
||||
darkTheme,
|
||||
assetHost,
|
||||
}: {
|
||||
unicodeHex: string;
|
||||
darkTheme: boolean;
|
||||
assetHost: string;
|
||||
}): string {
|
||||
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
|
||||
let url = `${assetHost}/emoji/${normalizedHex}`;
|
||||
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||
if (darkTheme && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||
url += '_border';
|
||||
}
|
||||
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
||||
@@ -86,78 +171,6 @@ export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
interface TwemojiSpecificEmoji {
|
||||
unqualified?: string;
|
||||
gender?: number;
|
||||
skin?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Normalize man/woman to male/female
|
||||
const GENDER_CODES_MAP: Record<number, number> = {
|
||||
[GENDER_FEMALE_CODE]: GENDER_FEMALE_CODE,
|
||||
[GENDER_MALE_CODE]: GENDER_MALE_CODE,
|
||||
// These are man/woman markers, but are used for gender sometimes.
|
||||
[0x1f468]: GENDER_MALE_CODE,
|
||||
[0x1f469]: GENDER_FEMALE_CODE,
|
||||
};
|
||||
|
||||
const TWEMOJI_SPECIAL_CASES: Record<string, string | TwemojiSpecificEmoji> = {
|
||||
'1F441-200D-1F5E8': '1F441-FE0F-200D-1F5E8-FE0F', // Eye in speech bubble
|
||||
// An emoji that was never ported to the Unicode standard.
|
||||
// See: https://emojipedia.org/shibuya
|
||||
E50A: { label: 'Shibuya 109' },
|
||||
};
|
||||
|
||||
export function twemojiToUnicodeInfo(
|
||||
twemojiHex: string,
|
||||
): TwemojiSpecificEmoji | string {
|
||||
const specialCase = TWEMOJI_SPECIAL_CASES[twemojiHex.toUpperCase()];
|
||||
if (specialCase) {
|
||||
return specialCase;
|
||||
}
|
||||
const codes = hexStringToNumbers(twemojiHex);
|
||||
let gender: undefined | number;
|
||||
let skin: undefined | number;
|
||||
for (const code of codes) {
|
||||
if (!gender && code in GENDER_CODES_MAP) {
|
||||
gender = GENDER_CODES_MAP[code];
|
||||
} else if (!skin && code in SKIN_TONE_CODES) {
|
||||
skin = code;
|
||||
}
|
||||
|
||||
// Exit if we have both skin and gender
|
||||
if (skin && gender) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mappedCodes: unknown[] = codes;
|
||||
|
||||
if (codes.at(-1) === CHRISTMAS_TREE_CODE && codes.length >= 3 && gender) {
|
||||
// Twemoji uses the christmas tree with a ZWJ for Mr. and Mrs. Claus,
|
||||
// but in Unicode that only works for Mx. Claus.
|
||||
const START_CODE =
|
||||
gender === GENDER_FEMALE_CODE ? MS_CLAUS_CODE : MR_CLAUS_CODE;
|
||||
mappedCodes = [START_CODE, skin];
|
||||
} else if (codes.at(-1) === KEYCAP_CODE && codes.length === 2) {
|
||||
// For key emoji, insert the variation selector
|
||||
mappedCodes = [codes[0], VARIATION_SELECTOR_CODE, KEYCAP_CODE];
|
||||
} else if (
|
||||
(codes.at(0) === SKIER_CODE || codes.at(0) === LEVITATING_PERSON_CODE) &&
|
||||
codes.length > 1
|
||||
) {
|
||||
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
|
||||
return {
|
||||
unqualified: hexNumbersToString([codes.at(0)]),
|
||||
skin,
|
||||
gender,
|
||||
};
|
||||
}
|
||||
|
||||
return hexNumbersToString(mappedCodes);
|
||||
}
|
||||
|
||||
export function emojiToInversionClassName(emoji: string): string | null {
|
||||
if (EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE.includes(emoji)) {
|
||||
return 'invert-on-dark';
|
||||
@@ -189,19 +202,37 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
||||
return extraEmojis;
|
||||
}
|
||||
|
||||
function hexStringToNumbers(hexString: string): number[] {
|
||||
return hexString
|
||||
.split('-')
|
||||
.map((code) => Number.parseInt(code, 16))
|
||||
.filter((code) => !Number.isNaN(code));
|
||||
}
|
||||
/**
|
||||
* Tokenizes an input string into words, using Intl.Segmenter if available.
|
||||
* @param input Any input string.
|
||||
* @param segmenter Segmenter, if available.
|
||||
* @returns Array of tokens in lowercase.
|
||||
*/
|
||||
export function extractTokens(
|
||||
input: string,
|
||||
segmenter: Intl.Segmenter | null,
|
||||
): string[] {
|
||||
if (!input.trim()) {
|
||||
return [];
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
|
||||
function hexNumbersToString(codes: unknown[], padding = 4): string {
|
||||
return codes
|
||||
.filter(
|
||||
(code): code is number =>
|
||||
typeof code === 'number' && code > 0 && !Number.isNaN(code),
|
||||
)
|
||||
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase())
|
||||
.join('-');
|
||||
// Prefer to use Intl.Segmenter if available for better locale support.
|
||||
if (segmenter) {
|
||||
for (const { isWordLike, segment } of segmenter.segment(
|
||||
input.replaceAll('_', ' '), // Handle underscores from shortcodes.
|
||||
)) {
|
||||
if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) {
|
||||
tokens.push(segment.toLowerCase());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple splitting.
|
||||
input.split(/[\s_-]+/).forEach((word) => {
|
||||
if (/\w/.test(word) && word.length >= EMOJI_MIN_TOKEN_LENGTH) {
|
||||
tokens.push(word.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
} from './constants';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
emojiToUnicodeHex,
|
||||
isCustomEmoji,
|
||||
isUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
@@ -140,12 +140,7 @@ export async function loadEmojiDataToState(
|
||||
}
|
||||
|
||||
// If not found, assume it's not an emoji and return null.
|
||||
log(
|
||||
'Could not find emoji %s of type %s for locale %s',
|
||||
state.code,
|
||||
state.type,
|
||||
locale,
|
||||
);
|
||||
log('Could not find emoji %s for locale %s', state.code, locale);
|
||||
return null;
|
||||
} catch (err: unknown) {
|
||||
// If the locale is not loaded, load it and retry once.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
import type { CompactEmoji, Locale, SkinTone } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
@@ -32,10 +32,20 @@ export interface EmojiAppState {
|
||||
currentLocale: Locale;
|
||||
mode: EmojiMode;
|
||||
darkTheme: boolean;
|
||||
assetHost: string;
|
||||
}
|
||||
|
||||
export type CustomEmojiData = ApiCustomEmojiJSON;
|
||||
export type UnicodeEmojiData = FlatCompactEmoji;
|
||||
export type CustomEmojiData = ApiCustomEmojiJSON & { tokens: string[] };
|
||||
export interface UnicodeEmojiData extends Omit<
|
||||
CompactEmoji,
|
||||
'emoticon' | 'skins' | 'tags'
|
||||
> {
|
||||
shortcodes: string[];
|
||||
tokens: string[];
|
||||
emoticons?: string[];
|
||||
skinHexcodes?: string[];
|
||||
skinTones?: (SkinTone | SkinTone[])[];
|
||||
}
|
||||
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
||||
|
||||
type CustomEmojiRenderFields = Pick<
|
||||
|
||||
@@ -2,6 +2,8 @@ import debug from 'debug';
|
||||
|
||||
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
|
||||
|
||||
import { VARIATION_SELECTOR_CODE } from './constants';
|
||||
|
||||
export function emojiLogger(segment: string) {
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
@@ -44,6 +46,27 @@ export function anyEmojiRegex() {
|
||||
);
|
||||
}
|
||||
|
||||
export function emojiToUnicodeHex(emoji: string): string {
|
||||
const codes: string[] = [];
|
||||
for (const char of emoji) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined) {
|
||||
codes.push(code.toString(16).toUpperCase().padStart(4, '0'));
|
||||
}
|
||||
}
|
||||
|
||||
// Handles how Emojibase removes the variation selector for single code emojis.
|
||||
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
|
||||
if (
|
||||
codes.at(1) === VARIATION_SELECTOR_CODE.toString(16).toUpperCase() &&
|
||||
codes.length === 2
|
||||
) {
|
||||
codes.pop();
|
||||
}
|
||||
|
||||
return codes.join('-');
|
||||
}
|
||||
|
||||
function supportsRegExpSets() {
|
||||
return 'unicodeSets' in RegExp.prototype;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
? decodeIDNA(getHostname(card.get('url')))
|
||||
: card.get('provider_name');
|
||||
const interactive = card.get('type') === 'video';
|
||||
const language = card.get('language') || '';
|
||||
const language = card.get('language') ?? '';
|
||||
const hasImage = (card.get('image')?.length ?? 0) > 0;
|
||||
const largeImage =
|
||||
(hasImage && card.get('width') > card.get('height')) || interactive;
|
||||
@@ -131,7 +131,11 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
{card.get('published_at') && (
|
||||
<>
|
||||
{' '}
|
||||
· <RelativeTimestamp timestamp={card.get('published_at')} />
|
||||
·{' '}
|
||||
<RelativeTimestamp
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
timestamp={card.get('published_at')!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const MediaModal: FC<MediaModalProps> = forwardRef<
|
||||
setIndex(newIndex);
|
||||
setZoomedIn(false);
|
||||
if (animate) {
|
||||
void api.start({ x: `-${newIndex * 100}%` });
|
||||
void api.start({ x: `calc(-${newIndex * 100}% + 0px)` });
|
||||
}
|
||||
},
|
||||
[api, media.size],
|
||||
|
||||
@@ -85,6 +85,7 @@ export const MODAL_COMPONENTS = {
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
'ANNUAL_REPORT': AnnualReportModal,
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_FIELDS': () => import('mastodon/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
||||
@@ -23,7 +23,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
confirm: confirmationMessage.confirm,
|
||||
onConfirm: () => dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: { ignoreFocus },
|
||||
ignoreFocus,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
@@ -31,7 +31,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
} else {
|
||||
dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: { ignoreFocus },
|
||||
ignoreFocus,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
26
app/javascript/mastodon/hooks/useAccount.ts
Normal file
26
app/javascript/mastodon/hooks/useAccount.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAccount } from '../actions/accounts';
|
||||
import { createAppSelector, useAppDispatch, useAppSelector } from '../store';
|
||||
|
||||
export const accountSelector = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string | null | undefined) => accountId,
|
||||
],
|
||||
(accounts, accountId) => (accountId ? accounts.get(accountId) : undefined),
|
||||
);
|
||||
|
||||
export function useAccount(accountId: string | null | undefined) {
|
||||
const account = useAppSelector((state) => accountSelector(state, accountId));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const accountInStore = !!account;
|
||||
useEffect(() => {
|
||||
if (accountId && !accountInStore) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [accountId, accountInStore, dispatch]);
|
||||
|
||||
return account;
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Перайсці да профілю",
|
||||
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
||||
"account.in_memoriam": "У памяць.",
|
||||
"account.joined_long": "Далучыў(-ла)ся {date}",
|
||||
"account.joined_short": "Далучыўся",
|
||||
"account.languages": "Змяніць выбраныя мовы",
|
||||
"account.link_verified_on": "Права ўласнасці на гэтую спасылку праверана {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Не ігнараваць @{name}",
|
||||
"account.unmute_notifications_short": "Апавяшчаць",
|
||||
"account.unmute_short": "Не ігнараваць",
|
||||
"account_fields_modal.close": "Закрыць",
|
||||
"account_fields_modal.title": "Інфармацыя пра {name}",
|
||||
"account_note.placeholder": "Націсніце, каб дадаць нататку",
|
||||
"admin.dashboard.daily_retention": "Штодзённы паказчык утрымання карыстальнікаў пасля рэгістрацыі",
|
||||
"admin.dashboard.monthly_retention": "Штомесячны паказчык утрымання карыстальнікаў пасля рэгістрацыі",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
|
||||
"loading_indicator.label": "Ідзе загрузка…",
|
||||
"media_gallery.hide": "Схаваць",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны, таму што Вы перайшлі на {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
|
||||
"mute_modal.hide_options": "Схаваць опцыі",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Přejít na profil",
|
||||
"account.hide_reblogs": "Skrýt boosty od @{name}",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
"account.joined_long": "Přidali se {date}",
|
||||
"account.joined_short": "Připojen/a",
|
||||
"account.languages": "Změnit odebírané jazyky",
|
||||
"account.link_verified_on": "Vlastnictví tohoto odkazu bylo zkontrolováno {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Zrušit skrytí @{name}",
|
||||
"account.unmute_notifications_short": "Zrušit ztlumení oznámení",
|
||||
"account.unmute_short": "Zrušit skrytí",
|
||||
"account_fields_modal.close": "Zavřít",
|
||||
"account_fields_modal.title": "info o {name}",
|
||||
"account_note.placeholder": "Klikněte pro přidání poznámky",
|
||||
"admin.dashboard.daily_retention": "Míra udržení uživatelů podle dne po registraci",
|
||||
"admin.dashboard.monthly_retention": "Míra udržení uživatelů podle měsíce po registraci",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nová položka} few {# nové položky} many {# nových položek} other {# nových položek}}",
|
||||
"loading_indicator.label": "Načítání…",
|
||||
"media_gallery.hide": "Skrýt",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Váš účet {disabledAccount} je momentálně deaktivován, protože jste se přesunul/a na {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Skrýt z oznámení",
|
||||
"mute_modal.hide_options": "Skrýt možnosti",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Gå til profil",
|
||||
"account.hide_reblogs": "Skjul fremhævelser fra @{name}",
|
||||
"account.in_memoriam": "Til minde om.",
|
||||
"account.joined_long": "Tilmeldt {date}",
|
||||
"account.joined_short": "Oprettet",
|
||||
"account.languages": "Skift abonnementssprog",
|
||||
"account.link_verified_on": "Ejerskab af dette link blev tjekket {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Vis @{name} igen",
|
||||
"account.unmute_notifications_short": "Vis notifikationer igen",
|
||||
"account.unmute_short": "Vis igen",
|
||||
"account_fields_modal.close": "Luk",
|
||||
"account_fields_modal.title": "Information om {name}",
|
||||
"account_note.placeholder": "Klik for at tilføje notat",
|
||||
"admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding",
|
||||
"admin.dashboard.monthly_retention": "Brugerfastholdelsesrate pr. måned efter tilmelding",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nyt element} other {# nye elementer}}",
|
||||
"loading_indicator.label": "Indlæser…",
|
||||
"media_gallery.hide": "Skjul",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Din konto {disabledAccount} er i øjeblikket deaktiveret, fordi du er flyttet til {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Skjul fra notifikationer",
|
||||
"mute_modal.hide_options": "Skjul valgmuligheder",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Profil aufrufen",
|
||||
"account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden",
|
||||
"account.in_memoriam": "Zum Andenken.",
|
||||
"account.joined_long": "Registriert am {date}",
|
||||
"account.joined_short": "Registriert am",
|
||||
"account.languages": "Sprachen verwalten",
|
||||
"account.link_verified_on": "Das Profil mit dieser E-Mail-Adresse wurde bereits am {date} bestätigt",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Stummschaltung von @{name} aufheben",
|
||||
"account.unmute_notifications_short": "Stummschaltung der Benachrichtigungen aufheben",
|
||||
"account.unmute_short": "Stummschaltung aufheben",
|
||||
"account_fields_modal.close": "Schließen",
|
||||
"account_fields_modal.title": "Informationen über {name}",
|
||||
"account_note.placeholder": "Klicken, um private Anmerkung hinzuzufügen",
|
||||
"admin.dashboard.daily_retention": "Verweildauer der Nutzer*innen pro Tag seit der Registrierung",
|
||||
"admin.dashboard.monthly_retention": "Verweildauer der Nutzer*innen pro Monat seit der Registrierung",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# neuer Beitrag} other {# neue Beiträge}}",
|
||||
"loading_indicator.label": "Lädt …",
|
||||
"media_gallery.hide": "Ausblenden",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Dein Konto {disabledAccount} ist derzeit deaktiviert, weil du zu {movedToAccount} umgezogen bist.",
|
||||
"mute_modal.hide_from_notifications": "Auch aus den Benachrichtigungen entfernen",
|
||||
"mute_modal.hide_options": "Optionen ausblenden",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"account.blocking": "Αποκλείεται",
|
||||
"account.cancel_follow_request": "Απόσυρση αιτήματος παρακολούθησης",
|
||||
"account.copy": "Αντιγραφή συνδέσμου προφίλ",
|
||||
"account.direct": "Ιδιωτική αναφορά @{name}",
|
||||
"account.direct": "Ιδιωτική επισήμανση @{name}",
|
||||
"account.disable_notifications": "Σταμάτα να με ειδοποιείς όταν δημοσιεύει ο @{name}",
|
||||
"account.domain_blocking": "Αποκλείεται ο τομέας",
|
||||
"account.edit_profile": "Επεξεργασία προφίλ",
|
||||
@@ -57,25 +57,26 @@
|
||||
"account.go_to_profile": "Μετάβαση στο προφίλ",
|
||||
"account.hide_reblogs": "Απόκρυψη ενισχύσεων από @{name}",
|
||||
"account.in_memoriam": "Εις μνήμην.",
|
||||
"account.joined_long": "Έγινε μέλος {date}",
|
||||
"account.joined_short": "Έγινε μέλος",
|
||||
"account.languages": "Αλλαγή εγγεγραμμένων γλωσσών",
|
||||
"account.link_verified_on": "Η ιδιοκτησία αυτού του συνδέσμου ελέχθηκε στις {date}",
|
||||
"account.locked_info": "Η κατάσταση απορρήτου αυτού του λογαριασμού έχει ρυθμιστεί σε κλειδωμένη. Ο ιδιοκτήτης αξιολογεί χειροκίνητα ποιος μπορεί να τον ακολουθήσει.",
|
||||
"account.media": "Πολυμέσα",
|
||||
"account.mention": "Ανάφερε @{name}",
|
||||
"account.mention": "Επισήμανε @{name}",
|
||||
"account.moved_to": "Ο/Η {name} έχει υποδείξει ότι ο νέος λογαριασμός του/της είναι τώρα:",
|
||||
"account.mute": "Σώπασε τον @{name}",
|
||||
"account.mute": "Σίγαση @{name}",
|
||||
"account.mute_notifications_short": "Σίγαση ειδοποιήσεων",
|
||||
"account.mute_short": "Σίγαση",
|
||||
"account.muted": "Αποσιωπημένος/η",
|
||||
"account.muted": "Σε σίγαση",
|
||||
"account.muting": "Σίγαση",
|
||||
"account.mutual": "Ακολουθείτε ο ένας τον άλλο",
|
||||
"account.no_bio": "Δεν υπάρχει περιγραφή.",
|
||||
"account.open_original_page": "Άνοιγμα αυθεντικής σελίδας",
|
||||
"account.posts": "Τουτ",
|
||||
"account.posts_with_replies": "Τουτ και απαντήσεις",
|
||||
"account.posts": "Αναρτήσεις",
|
||||
"account.posts_with_replies": "Αναρτήσεις και απαντήσεις",
|
||||
"account.remove_from_followers": "Κατάργηση {name} από τους ακόλουθους",
|
||||
"account.report": "Κατάγγειλε @{name}",
|
||||
"account.report": "Αναφορά @{name}",
|
||||
"account.requested_follow": "Ο/Η {name} αιτήθηκε να σε ακολουθήσει",
|
||||
"account.requests_to_follow_you": "Αιτήματα για να σε ακολουθήσουν",
|
||||
"account.share": "Κοινοποίηση του προφίλ @{name}",
|
||||
@@ -87,9 +88,11 @@
|
||||
"account.unblock_short": "Άρση αποκλεισμού",
|
||||
"account.unendorse": "Να μην παρέχεται στο προφίλ",
|
||||
"account.unfollow": "Άρση ακολούθησης",
|
||||
"account.unmute": "Διακοπή σίγασης @{name}",
|
||||
"account.unmute": "Άρση σίγασης @{name}",
|
||||
"account.unmute_notifications_short": "Σίγαση ειδοποιήσεων",
|
||||
"account.unmute_short": "Κατάργηση σίγασης",
|
||||
"account_fields_modal.close": "Κλείσιμο",
|
||||
"account_fields_modal.title": "Πληροφορίες {name}",
|
||||
"account_note.placeholder": "Κάνε κλικ για να προσθέσεις σημείωση",
|
||||
"admin.dashboard.daily_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά ημέρα",
|
||||
"admin.dashboard.monthly_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά μήνα",
|
||||
@@ -168,7 +171,7 @@
|
||||
"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": "Αναίρεση ενίσχυσης;",
|
||||
@@ -196,7 +199,7 @@
|
||||
"column.bookmarks": "Σελιδοδείκτες",
|
||||
"column.community": "Τοπική ροή",
|
||||
"column.create_list": "Δημιουργία λίστας",
|
||||
"column.direct": "Ιδιωτικές αναφορές",
|
||||
"column.direct": "Ιδιωτικές επισημάνσεις",
|
||||
"column.directory": "Περιήγηση στα προφίλ",
|
||||
"column.domain_blocks": "Αποκλεισμένοι τομείς",
|
||||
"column.edit_list": "Επεξεργασία λίστας",
|
||||
@@ -275,7 +278,7 @@
|
||||
"confirmations.missing_alt_text.message": "Η ανάρτησή σου περιέχει πολυμέσα χωρίς εναλλακτικό κείμενο. Η προσθήκη περιγραφών βοηθά να γίνει το περιεχόμενό σου προσβάσιμο σε περισσότερους ανθρώπους.",
|
||||
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
|
||||
"confirmations.missing_alt_text.title": "Προσθήκη εναλλακτικού κειμένου;",
|
||||
"confirmations.mute.confirm": "Αποσιώπηση",
|
||||
"confirmations.mute.confirm": "Σίγαση",
|
||||
"confirmations.private_quote_notify.cancel": "Πίσω στην επεξεργασία",
|
||||
"confirmations.private_quote_notify.confirm": "Δημοσίευση ανάρτησης",
|
||||
"confirmations.private_quote_notify.do_not_show_again": "Να μην εμφανιστεί ξανά αυτό το μήνυμα",
|
||||
@@ -297,14 +300,14 @@
|
||||
"confirmations.unblock.confirm": "Άρση αποκλεισμού",
|
||||
"confirmations.unblock.title": "Άρση αποκλεισμού {name};",
|
||||
"confirmations.unfollow.confirm": "Άρση ακολούθησης",
|
||||
"confirmations.unfollow.title": "Κατάργηση ακολούθησης του/της {name};",
|
||||
"confirmations.unfollow.title": "Άρση ακολούθησης του/της {name};",
|
||||
"confirmations.withdraw_request.confirm": "Απόσυρση αιτήματος",
|
||||
"confirmations.withdraw_request.title": "Απόσυρση αιτήματος για να ακολουθήσετε τον/την {name};",
|
||||
"content_warning.hide": "Απόκρυψη ανάρτησης",
|
||||
"content_warning.show": "Εμφάνιση ούτως ή άλλως",
|
||||
"content_warning.show_more": "Εμφάνιση περισσότερων",
|
||||
"conversation.delete": "Διαγραφή συζήτησης",
|
||||
"conversation.mark_as_read": "Σήμανση ως αναγνωσμένο",
|
||||
"conversation.delete": "Διαγραφή συνομιλίας",
|
||||
"conversation.mark_as_read": "Σήμανση ως αναγνωσμένη",
|
||||
"conversation.open": "Προβολή συνομιλίας",
|
||||
"conversation.with": "Με {names}",
|
||||
"copy_icon_button.copied": "Αντιγράφηκε στο πρόχειρο",
|
||||
@@ -403,7 +406,7 @@
|
||||
"filter_modal.added.context_mismatch_title": "Ασυμφωνία περιεχομένου!",
|
||||
"filter_modal.added.expired_explanation": "Αυτή η κατηγορία φίλτρων έχει λήξει, πρέπει να αλλάξετε την ημερομηνία λήξης για να ισχύσει.",
|
||||
"filter_modal.added.expired_title": "Ληγμένο φίλτρο!",
|
||||
"filter_modal.added.review_and_configure": "Για να επιθεωρήσετε και να εξειδικεύσετε περαιτέρω αυτή την κατηγορία φίλτρων, πηγαίνετε στο {settings_link}.",
|
||||
"filter_modal.added.review_and_configure": "Για να ελέγξετε και να ρυθμίσετε περαιτέρω αυτή την κατηγορία φίλτρων, πηγαίνετε στο {settings_link}.",
|
||||
"filter_modal.added.review_and_configure_title": "Ρυθμίσεις φίλτρου",
|
||||
"filter_modal.added.settings_link": "σελίδα ρυθμίσεων",
|
||||
"filter_modal.added.short_explanation": "Αυτή η ανάρτηση έχει προστεθεί στην ακόλουθη κατηγορία φίλτρου: {title}.",
|
||||
@@ -423,7 +426,7 @@
|
||||
"firehose.remote": "Άλλοι διακομιστές",
|
||||
"follow_request.authorize": "Εξουσιοδότησε",
|
||||
"follow_request.reject": "Απέρριψε",
|
||||
"follow_requests.unlocked_explanation": "Παρόλο που ο λογαριασμός σου δεν είναι κλειδωμένος, το προσωπικό του {domain} θεώρησαν πως ίσως να θέλεις να ελέγξεις χειροκίνητα αυτά τα αιτήματα ακολούθησης.",
|
||||
"follow_requests.unlocked_explanation": "Παρόλο που ο λογαριασμός σου δεν είναι κλειδωμένος, το προσωπικό του {domain} θεώρησε πως ίσως να θέλεις να ελέγχεις χειροκίνητα αυτά τα αιτήματα ακολούθησης.",
|
||||
"follow_suggestions.curated_suggestion": "Επιλογή προσωπικού",
|
||||
"follow_suggestions.dismiss": "Να μην εμφανιστεί ξανά",
|
||||
"follow_suggestions.featured_longer": "Προσεκτικά επιλεγμένα απ' την ομάδα του {domain}",
|
||||
@@ -498,7 +501,7 @@
|
||||
"ignore_notifications_modal.new_accounts_title": "Αγνόηση ειδοποιήσεων από νέους λογαριασμούς;",
|
||||
"ignore_notifications_modal.not_followers_title": "Αγνόηση ειδοποιήσεων από άτομα που δε σας ακολουθούν;",
|
||||
"ignore_notifications_modal.not_following_title": "Αγνόηση ειδοποιήσεων από άτομα που δεν ακολουθείς;",
|
||||
"ignore_notifications_modal.private_mentions_title": "Αγνόηση ειδοποιήσεων από μη ζητηθείσες ιδιωτικές αναφορές;",
|
||||
"ignore_notifications_modal.private_mentions_title": "Αγνόηση ειδοποιήσεων από μη ζητηθείσες ιδιωτικές επισημάνσεις;",
|
||||
"info_button.label": "Βοήθεια",
|
||||
"info_button.what_is_alt_text": "Το εναλλακτικό κείμενο παρέχει περιγραφές εικόνας για άτομα με προβλήματα όρασης, διαδικτυακές συνδέσεις χαμηλής ταχύτητας ή για άτομα που αναζητούν επιπλέον περιεχόμενο.\\n\\nΜπορείς να βελτιώσεις την προσβασιμότητα και την κατανόηση για όλους, γράφοντας σαφές, συνοπτικό και αντικειμενικό εναλλακτικό κείμενο.\\n\\n<ul><li>Κατέγραψε σημαντικά στοιχεία</li>\\n<li>Συνόψισε το κείμενο στις εικόνες</li>\\n<li>Χρησιμοποίησε δομή κανονικής πρότασης</li>\\n<li>Απέφυγε περιττές πληροφορίες</li>\\n<li>Εστίασε στις τάσεις και τα βασικά ευρήματα σε σύνθετα οπτικά στοιχεία (όπως διαγράμματα ή χάρτες)</li></ul>",
|
||||
"interaction_modal.action": "Για να αλληλεπιδράσετε με την ανάρτηση του/της {name}, πρέπει να συνδεθείτε στον λογαριασμό σας σε οποιονδήποτε διακομιστή Mastodon χρησιμοποιείτε.",
|
||||
@@ -589,16 +592,17 @@
|
||||
"load_pending": "{count, plural, one {# νέο στοιχείο} other {# νέα στοιχεία}}",
|
||||
"loading_indicator.label": "Φόρτωση…",
|
||||
"media_gallery.hide": "Απόκρυψη",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Ο λογαριασμός σου {disabledAccount} είναι προσωρινά απενεργοποιημένος επειδή μεταφέρθηκες στον {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Απόκρυψη από ειδοποιήσεις",
|
||||
"mute_modal.hide_options": "Απόκρυψη επιλογών",
|
||||
"mute_modal.indefinite": "Μέχρι να κάνω άρση σίγασης",
|
||||
"mute_modal.show_options": "Εμφάνιση επιλογών",
|
||||
"mute_modal.they_can_mention_and_follow": "Μπορεί να σε αναφέρει και να σε ακολουθήσει, αλλά δε θα τον βλέπεις.",
|
||||
"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_mentions": "Δε θα βλέπεις τις αναρτήσεις που τον επισημαίνουν.",
|
||||
"mute_modal.you_wont_see_posts": "Μπορεί ακόμη να βλέπει τις αναρτήσεις σου, αλλά δε θα βλέπεις τις δικές του.",
|
||||
"navigation_bar.about": "Σχετικά με",
|
||||
"navigation_bar.account_settings": "Κωδικός πρόσβασης και ασφάλεια",
|
||||
"navigation_bar.administration": "Διαχείριση",
|
||||
@@ -631,7 +635,7 @@
|
||||
"navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθείτε",
|
||||
"navigation_panel.expand_lists": "Επέκταση μενού λίστας",
|
||||
"not_signed_in_indicator.not_signed_in": "Πρέπει να συνδεθείς για να αποκτήσεις πρόσβαση σε αυτόν τον πόρο.",
|
||||
"notification.admin.report": "Ο/Η {name} ανέφερε τον {target}",
|
||||
"notification.admin.report": "Ο/Η {name} ανέφερε τον/την {target}",
|
||||
"notification.admin.report_account": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target} για {category}",
|
||||
"notification.admin.report_account_other": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target}",
|
||||
"notification.admin.report_statuses": "Ο χρήστης {name} ανέφερε τον χρήστη {target} για {category}",
|
||||
@@ -745,8 +749,8 @@
|
||||
"notifications.policy.filter_not_followers_title": "Άτομα που δε σε ακολουθούν",
|
||||
"notifications.policy.filter_not_following_hint": "Μέχρι να τους εγκρίνεις χειροκίνητα",
|
||||
"notifications.policy.filter_not_following_title": "Άτομα που δεν ακολουθείς",
|
||||
"notifications.policy.filter_private_mentions_hint": "Φιλτραρισμένο εκτός αν είναι απάντηση σε δική σου αναφορά ή αν ακολουθείς τον αποστολέα",
|
||||
"notifications.policy.filter_private_mentions_title": "Μη συναινετικές ιδιωτικές αναφορές",
|
||||
"notifications.policy.filter_private_mentions_hint": "Φιλτράρισμα εκτός αν είναι απάντηση σε δική σου επισήμανση ή αν ακολουθείς τον αποστολέα",
|
||||
"notifications.policy.filter_private_mentions_title": "Μη συναινετικές ιδιωτικές επισημάνσεις",
|
||||
"notifications.policy.title": "Διαχείριση ειδοποιήσεων από…",
|
||||
"notifications_permission_banner.enable": "Ενεργοποίηση ειδοποιήσεων επιφάνειας εργασίας",
|
||||
"notifications_permission_banner.how_to_control": "Για να λαμβάνεις ειδοποιήσεις όταν το Mastodon δεν είναι ανοιχτό, ενεργοποίησε τις ειδοποιήσεις επιφάνειας εργασίας. Μπορείς να ελέγξεις με ακρίβεια ποιοι τύποι αλληλεπιδράσεων δημιουργούν ειδοποιήσεις επιφάνειας εργασίας μέσω του κουμπιού {icon} μόλις ενεργοποιηθούν.",
|
||||
@@ -854,12 +858,12 @@
|
||||
"report.statuses.subtitle": "Επίλεξε όλα όσα ισχύουν",
|
||||
"report.statuses.title": "Υπάρχουν αναρτήσεις που τεκμηριώνουν αυτή την αναφορά;",
|
||||
"report.submit": "Υποβολή",
|
||||
"report.target": "Καταγγελία {target}",
|
||||
"report.target": "Αναφορά {target}",
|
||||
"report.thanks.take_action": "Αυτές είναι οι επιλογές σας για να ελέγχετε τι βλέπετε στο Mastodon:",
|
||||
"report.thanks.take_action_actionable": "Ενώ το εξετάζουμε, μπορείς να δράσεις εναντίον του @{name}:",
|
||||
"report.thanks.title": "Δε θες να το βλέπεις;",
|
||||
"report.thanks.title_actionable": "Σε ευχαριστούμε για την αναφορά, θα το διερευνήσουμε.",
|
||||
"report.unfollow": "Κατάργηση ακολούθησης του @{name}",
|
||||
"report.unfollow": "Άρση ακολούθησης του @{name}",
|
||||
"report.unfollow_explanation": "Ακολουθείς αυτό τον λογαριασμό. Για να μη βλέπεις τις αναρτήσεις τους στη δική σου ροή, πάψε να τον ακολουθείς.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} ανάρτηση} other {{count} αναρτήσεις}} επισυνάπτονται",
|
||||
"report_notification.categories.legal": "Νομικά",
|
||||
@@ -926,7 +930,7 @@
|
||||
"status.copy": "Αντιγραφή συνδέσμου ανάρτησης",
|
||||
"status.delete": "Διαγραφή",
|
||||
"status.delete.success": "Η ανάρτηση διαγράφηκε",
|
||||
"status.detailed_status": "Προβολή λεπτομερούς συζήτησης",
|
||||
"status.detailed_status": "Προβολή λεπτομερούς συνομιλίας",
|
||||
"status.direct": "Ιδιωτική επισήμανση @{name}",
|
||||
"status.direct_indicator": "Ιδιωτική επισήμανση",
|
||||
"status.edit": "Επεξεργασία",
|
||||
@@ -944,7 +948,7 @@
|
||||
"status.media_hidden": "Κρυμμένο πολυμέσο",
|
||||
"status.mention": "Επισήμανε @{name}",
|
||||
"status.more": "Περισσότερα",
|
||||
"status.mute": "Σίγαση σε @{name}",
|
||||
"status.mute": "Σίγαση @{name}",
|
||||
"status.mute_conversation": "Σίγαση συνομιλίας",
|
||||
"status.open": "Επέκταση ανάρτησης",
|
||||
"status.pin": "Καρφίτσωσε στο προφίλ",
|
||||
@@ -997,7 +1001,7 @@
|
||||
"status.translate": "Μετάφραση",
|
||||
"status.translated_from_with": "Μεταφράστηκε από {lang} χρησιμοποιώντας {provider}",
|
||||
"status.uncached_media_warning": "Μη διαθέσιμη προεπισκόπηση",
|
||||
"status.unmute_conversation": "Αναίρεση σίγασης συνομιλίας",
|
||||
"status.unmute_conversation": "Άρση σίγασης συνομιλίας",
|
||||
"status.unpin": "Ξεκαρφίτσωσε από το προφίλ",
|
||||
"subscribed_languages.lead": "Μόνο αναρτήσεις σε επιλεγμένες γλώσσες θα εμφανίζονται στην αρχική σου και θα παραθέτονται ροές μετά την αλλαγή. Επέλεξε καμία για να λαμβάνεις αναρτήσεις σε όλες τις γλώσσες.",
|
||||
"subscribed_languages.save": "Αποθήκευση αλλαγών",
|
||||
@@ -1053,7 +1057,7 @@
|
||||
"visibility_modal.direct_quote_warning.text": "Εάν αποθηκεύσετε τις τρέχουσες ρυθμίσεις, η ενσωματωμένη παράθεση θα μετατραπεί σε σύνδεσμο.",
|
||||
"visibility_modal.direct_quote_warning.title": "Οι παραθέσεις δεν μπορούν να ενσωματωθούν σε ιδιωτικές επισημάνσεις",
|
||||
"visibility_modal.header": "Ορατότητα και αλληλεπίδραση",
|
||||
"visibility_modal.helper.direct_quoting": "Ιδιωτικές αναφορές που έχουν συνταχθεί στο Mastodon δεν μπορούν να γίνουν παράθεση από άλλους.",
|
||||
"visibility_modal.helper.direct_quoting": "Ιδιωτικές επισημάνσεις που έχουν συνταχθεί στο Mastodon δεν μπορούν να γίνουν παράθεση από άλλους.",
|
||||
"visibility_modal.helper.privacy_editing": "Η ορατότητα δεν μπορεί να αλλάξει μετά τη δημοσίευση μιας ανάρτησης.",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "Αυτο-παραθέσεις ιδιωτικών αναρτήσεων δεν μπορούν να γίνουν δημόσιες.",
|
||||
"visibility_modal.helper.private_quoting": "Αναρτήσεις για ακολούθους μόνο που έχουν συνταχθεί στο Mastodon, δεν μπορούν να γίνουν παράθεση από άλλους.",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Go to profile",
|
||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
"account.joined_long": "Joined on {date}",
|
||||
"account.joined_short": "Joined",
|
||||
"account.languages": "Change subscribed languages",
|
||||
"account.link_verified_on": "Ownership of this link was checked on {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications_short": "Unmute notifications",
|
||||
"account.unmute_short": "Unmute",
|
||||
"account_fields_modal.close": "Close",
|
||||
"account_fields_modal.title": "{name}'s info",
|
||||
"account_note.placeholder": "Click to add note",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||
"loading_indicator.label": "Loading…",
|
||||
"media_gallery.hide": "Hide",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Hide from notifications",
|
||||
"mute_modal.hide_options": "Hide options",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Go to profile",
|
||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
"account.joined_long": "Joined on {date}",
|
||||
"account.joined_short": "Joined",
|
||||
"account.languages": "Change subscribed languages",
|
||||
"account.link_verified_on": "Ownership of this link was checked on {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications_short": "Unmute notifications",
|
||||
"account.unmute_short": "Unmute",
|
||||
"account_fields_modal.close": "Close",
|
||||
"account_fields_modal.title": "{name}'s info",
|
||||
"account_note.placeholder": "Click to add note",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||
"loading_indicator.label": "Loading…",
|
||||
"media_gallery.hide": "Hide",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Hide from notifications",
|
||||
"mute_modal.hide_options": "Hide options",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar adhesiones de @{name}",
|
||||
"account.in_memoriam": "Cuenta conmemorativa.",
|
||||
"account.joined_long": "En este servidor desde el {date}",
|
||||
"account.joined_short": "En este servidor desde el",
|
||||
"account.languages": "Cambiar idiomas suscritos",
|
||||
"account.link_verified_on": "La propiedad de este enlace fue verificada el {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Dejar de silenciar a @{name}",
|
||||
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
|
||||
"account.unmute_short": "Dejar de silenciar",
|
||||
"account_fields_modal.close": "Cerrar",
|
||||
"account_fields_modal.title": "Información de {name}",
|
||||
"account_note.placeholder": "Hacé clic par agregar una nota",
|
||||
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día, después del registro",
|
||||
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes, después del registro",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# elemento nuevo} other {# elementos nuevos}}",
|
||||
"loading_indicator.label": "Cargando…",
|
||||
"media_gallery.hide": "Ocultar",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Tu cuenta {disabledAccount} está actualmente deshabilitada porque te mudaste a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Ocultar en las notificaciones",
|
||||
"mute_modal.hide_options": "Ocultar opciones",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
||||
"account.in_memoriam": "En memoria.",
|
||||
"account.joined_long": "Se unión el {date}",
|
||||
"account.joined_short": "Se unió",
|
||||
"account.languages": "Cambiar idiomas suscritos",
|
||||
"account.link_verified_on": "El proprietario de este enlace fue comprobado el {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Dejar de silenciar a @{name}",
|
||||
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
|
||||
"account.unmute_short": "Dejar de silenciar",
|
||||
"account_fields_modal.close": "Cerrar",
|
||||
"account_fields_modal.title": "Información de {name}",
|
||||
"account_note.placeholder": "Haz clic para agregar una nota",
|
||||
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después de unirse",
|
||||
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después de unirse",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
|
||||
"loading_indicator.label": "Cargando…",
|
||||
"media_gallery.hide": "Ocultar",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Tu cuenta {disabledAccount} está actualmente deshabilitada porque te has mudado a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Ocultar de las notificaciones",
|
||||
"mute_modal.hide_options": "Ocultar opciones",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
||||
"account.in_memoriam": "Cuenta conmemorativa.",
|
||||
"account.joined_long": "Se unió el {date}",
|
||||
"account.joined_short": "Se unió",
|
||||
"account.languages": "Cambiar idiomas suscritos",
|
||||
"account.link_verified_on": "La propiedad de este enlace fue verificada el {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Dejar de silenciar a @{name}",
|
||||
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
|
||||
"account.unmute_short": "Dejar de silenciar",
|
||||
"account_fields_modal.close": "Cerrar",
|
||||
"account_fields_modal.title": "Información de {name}",
|
||||
"account_note.placeholder": "Haz clic para añadir nota",
|
||||
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después del registro",
|
||||
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después del registro",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
|
||||
"loading_indicator.label": "Cargando…",
|
||||
"media_gallery.hide": "Ocultar",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Tu cuenta {disabledAccount} está actualmente deshabilitada porque te has mudado a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Ocultar de las notificaciones",
|
||||
"mute_modal.hide_options": "Ocultar opciones",
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
"about.blocks": "Modereeritavad serverid",
|
||||
"about.contact": "Kontakt:",
|
||||
"about.default_locale": "Vaikimisi",
|
||||
"about.disclaimer": "Mastodon on tasuta ja vaba tarkvara ning Mastodon gGmbH kaubamärk.",
|
||||
"about.disclaimer": "Mastodon on vaba, tasuta ja avatud lähtekoodiga tarkvara ning Mastodon gGmbH kaubamärk.",
|
||||
"about.domain_blocks.no_reason_available": "Põhjus on teadmata",
|
||||
"about.domain_blocks.preamble": "Mastodon lubab tavaliselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest fediversumi serverist. Need on erandid, mis on paika pandud sellel kindlal serveril.",
|
||||
"about.domain_blocks.silenced.explanation": "Sa ei näe üldiselt profiile ja sisu sellelt serverilt, kui sa just tahtlikult seda ei otsi või jälgimise moel nõusolekut ei anna.",
|
||||
"about.domain_blocks.preamble": "Mastodon lubab üldiselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest födiversumi serverist. Need on erandid, mis kehtivad selles kindlas serveris.",
|
||||
"about.domain_blocks.silenced.explanation": "Sa üldjuhul ei näe profiile ja sisu sellest serverist, kui sa just tahtlikult neid ei otsi või jälgimise moel nõusolekut ei anna.",
|
||||
"about.domain_blocks.silenced.title": "Piiratud",
|
||||
"about.domain_blocks.suspended.explanation": "Mitte mingeid andmeid sellelt serverilt ei töödelda, salvestata ega vahetata, tehes igasuguse interaktsiooni või kirjavahetuse selle serveri kasutajatega võimatuks.",
|
||||
"about.domain_blocks.suspended.explanation": "Mitte mingeid andmeid sellelt serverilt ei töödelda, salvestata ega vahetata, tehes igasuguse suhestumise või infovahetuse selle serveri kasutajatega võimatuks.",
|
||||
"about.domain_blocks.suspended.title": "Peatatud",
|
||||
"about.language_label": "Keel",
|
||||
"about.not_available": "See info ei ole sellel serveril saadavaks tehtud.",
|
||||
"about.not_available": "See info ei ole selles serveris saadavaks tehtud.",
|
||||
"about.powered_by": "Hajutatud sotsiaalmeedia, mille taga on {mastodon}",
|
||||
"about.rules": "Serveri reeglid",
|
||||
"account.account_note_header": "Isiklik märge",
|
||||
@@ -18,19 +18,19 @@
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.group": "Grupp",
|
||||
"account.block": "Blokeeri @{name}",
|
||||
"account.block_domain": "Peida kõik domeenist {domain}",
|
||||
"account.block_domain": "Blokeeri kõik domeenist {domain}",
|
||||
"account.block_short": "Blokeerimine",
|
||||
"account.blocked": "Blokeeritud",
|
||||
"account.blocking": "Blokeeritud kasutaja",
|
||||
"account.cancel_follow_request": "Võta jälgimistaotlus tagasi",
|
||||
"account.copy": "Kopeeri profiili link",
|
||||
"account.direct": "Maini privaatselt @{name}",
|
||||
"account.disable_notifications": "Peata teavitused @{name} postitustest",
|
||||
"account.disable_notifications": "Ära teavita, kui @{name} postitab",
|
||||
"account.domain_blocking": "Blokeeritud domeen",
|
||||
"account.edit_profile": "Muuda profiili",
|
||||
"account.edit_profile_short": "Muuda",
|
||||
"account.enable_notifications": "Teavita mind @{name} postitustest",
|
||||
"account.endorse": "Too profiilil esile",
|
||||
"account.enable_notifications": "Teavita mind, kui {name} postitab",
|
||||
"account.endorse": "Too profiilis esile",
|
||||
"account.familiar_followers_many": "Jälgijateks {name1}, {name2} ja veel {othersCount, plural, one {üks kasutaja, keda tead} other {# kasutajat, keda tead}}",
|
||||
"account.familiar_followers_one": "Jälgijaks {name1}",
|
||||
"account.familiar_followers_two": "Jälgijateks {name1} ja {name2}",
|
||||
@@ -57,11 +57,12 @@
|
||||
"account.go_to_profile": "Mine profiilile",
|
||||
"account.hide_reblogs": "Peida @{name} jagamised",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
"account.joined_long": "Liitus {date}",
|
||||
"account.joined_short": "Liitus",
|
||||
"account.languages": "Muuda tellitud keeli",
|
||||
"account.link_verified_on": "Selle lingi autorsust kontrolliti {date}",
|
||||
"account.locked_info": "Selle konto privaatsussätteks on lukustatud. Omanik vaatab käsitsi üle, kes teda jälgida saab.",
|
||||
"account.media": "Meedia",
|
||||
"account.media": "Meedium",
|
||||
"account.mention": "Maini @{name}",
|
||||
"account.moved_to": "{name} on teada andnud, et ta uus konto on nüüd:",
|
||||
"account.mute": "Summuta @{name}",
|
||||
@@ -81,20 +82,22 @@
|
||||
"account.share": "Jaga @{name} profiili",
|
||||
"account.show_reblogs": "Näita @{name} jagamisi",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
|
||||
"account.unblock": "Eemalda blokeering @{name}",
|
||||
"account.unblock_domain": "Tee {domain} nähtavaks",
|
||||
"account.unblock": "Lõpeta {name} kasutaja blokeerimine",
|
||||
"account.unblock_domain": "Lõpeta {domain} domeeni blokeerimine",
|
||||
"account.unblock_domain_short": "Lõpeta blokeerimine",
|
||||
"account.unblock_short": "Eemalda blokeering",
|
||||
"account.unblock_short": "Lõpeta blokeerimine",
|
||||
"account.unendorse": "Ära kuva profiilil",
|
||||
"account.unfollow": "Jälgid",
|
||||
"account.unfollow": "Ära jälgi",
|
||||
"account.unmute": "Lõpeta {name} kasutaja summutamine",
|
||||
"account.unmute_notifications_short": "Lõpeta teavituste summutamine",
|
||||
"account.unmute_short": "Lõpeta summutamine",
|
||||
"account_fields_modal.close": "Sulge",
|
||||
"account_fields_modal.title": "Kasutaja teave: {name}",
|
||||
"account_note.placeholder": "Klõpsa märke lisamiseks",
|
||||
"admin.dashboard.daily_retention": "Kasutajate päevane allesjäämine peale registreerumist",
|
||||
"admin.dashboard.monthly_retention": "Kasutajate kuine allesjäämine peale registreerumist",
|
||||
"admin.dashboard.retention.average": "Keskmine",
|
||||
"admin.dashboard.retention.cohort": "Registreerumiskuu",
|
||||
"admin.dashboard.retention.cohort": "Liitumiskuu",
|
||||
"admin.dashboard.retention.cohort_size": "Uued kasutajad",
|
||||
"admin.impact_report.instance_accounts": "Kontode profiilid, mille see kustutaks",
|
||||
"admin.impact_report.instance_followers": "Jälgijad, kelle meie kasutajad kaotaks",
|
||||
@@ -103,11 +106,11 @@
|
||||
"alert.rate_limited.message": "Palun proovi uuesti pärast {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Kiiruspiirang",
|
||||
"alert.unexpected.message": "Tekkis ootamatu viga.",
|
||||
"alert.unexpected.title": "Oih!",
|
||||
"alt_text_badge.title": "Alternatiivtekst",
|
||||
"alt_text_modal.add_alt_text": "Lisa alt-tekst",
|
||||
"alert.unexpected.title": "Vaat kus lops!",
|
||||
"alt_text_badge.title": "Selgitustekst",
|
||||
"alt_text_modal.add_alt_text": "Lisa selgitustekst",
|
||||
"alt_text_modal.add_text_from_image": "Lisa tekst pildilt",
|
||||
"alt_text_modal.cancel": "Tühista",
|
||||
"alt_text_modal.cancel": "Katkesta",
|
||||
"alt_text_modal.change_thumbnail": "Muuda pisipilti",
|
||||
"alt_text_modal.describe_for_people_with_hearing_impairments": "Kirjelda seda kuulmispuudega inimeste jaoks…",
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Kirjelda seda nägemispuudega inimeste jaoks…",
|
||||
@@ -119,7 +122,7 @@
|
||||
"annual_report.announcement.description": "Vaata teavet oma suhestumise kohta Mastodonis eelmisel aastal.",
|
||||
"annual_report.announcement.title": "{year}. aasta Mastodoni kokkuvõte on valmis",
|
||||
"annual_report.nav_item.badge": "Uus",
|
||||
"annual_report.shared_page.donate": "Anneta",
|
||||
"annual_report.shared_page.donate": "Toeta rahaliselt",
|
||||
"annual_report.shared_page.footer": "Loodud {heart} Mastodoni meeskonna poolt",
|
||||
"annual_report.shared_page.footer_server_info": "{username} kasutab {domain}-i, üht paljudest kogukondadest, mis toimivad Mastodonil.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "{name} jätkas postituste otsimist, et neid edendada, tugevdades teisi loojaid täiusliku täpsusega.",
|
||||
@@ -517,6 +520,7 @@
|
||||
"keyboard_shortcuts.column": "Fookus veerule",
|
||||
"keyboard_shortcuts.compose": "Fookus teksti koostamise alale",
|
||||
"keyboard_shortcuts.description": "Kirjeldus",
|
||||
"keyboard_shortcuts.direct": "Ava privaatsete mainimiste veerg",
|
||||
"keyboard_shortcuts.down": "Liigu loetelus alla",
|
||||
"keyboard_shortcuts.enter": "Ava postitus",
|
||||
"keyboard_shortcuts.favourite": "Lemmikpostitus",
|
||||
@@ -588,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# uus kirje} other {# uut kirjet}}",
|
||||
"loading_indicator.label": "Laadimine…",
|
||||
"media_gallery.hide": "Peida",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Kontot {disabledAccount} ei ole praegu võimalik kasutada, sest kolisid kontole {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Peida teavituste hulgast",
|
||||
"mute_modal.hide_options": "Peida valikud",
|
||||
@@ -766,7 +771,7 @@
|
||||
"onboarding.profile.upload_avatar": "Laadi üles profiilipilt",
|
||||
"onboarding.profile.upload_header": "Laadi üles profiili päis",
|
||||
"password_confirmation.exceeds_maxlength": "Salasõnakinnitus on pikem kui salasõna maksimumpikkus",
|
||||
"password_confirmation.mismatching": "Salasõnakinnitus ei sobi kokku",
|
||||
"password_confirmation.mismatching": "Salasõnad ei klapi",
|
||||
"picture_in_picture.restore": "Pane tagasi",
|
||||
"poll.closed": "Suletud",
|
||||
"poll.refresh": "Värskenda",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Siirry profiiliin",
|
||||
"account.hide_reblogs": "Piilota käyttäjän @{name} tehostukset",
|
||||
"account.in_memoriam": "Muistoissamme.",
|
||||
"account.joined_long": "Liittynyt {date}",
|
||||
"account.joined_short": "Liittynyt",
|
||||
"account.languages": "Vaihda tilattuja kieliä",
|
||||
"account.link_verified_on": "Linkin omistus tarkistettiin {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Poista käyttäjän @{name} mykistys",
|
||||
"account.unmute_notifications_short": "Poista ilmoitusten mykistys",
|
||||
"account.unmute_short": "Poista mykistys",
|
||||
"account_fields_modal.close": "Sulje",
|
||||
"account_fields_modal.title": "Käyttäjän {name} tiedot",
|
||||
"account_note.placeholder": "Lisää muistiinpano napsauttamalla",
|
||||
"admin.dashboard.daily_retention": "Käyttäjien pysyvyys päivittäin rekisteröitymisen jälkeen",
|
||||
"admin.dashboard.monthly_retention": "Käyttäjien pysyvyys kuukausittain rekisteröitymisen jälkeen",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# uusi kohde} other {# uutta kohdetta}}",
|
||||
"loading_indicator.label": "Ladataan…",
|
||||
"media_gallery.hide": "Piilota",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Tilisi {disabledAccount} on tällä hetkellä poissa käytöstä, koska teit siirron tiliin {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Piilota ilmoituksista",
|
||||
"mute_modal.hide_options": "Piilota vaihtoehdot",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Far til vanga",
|
||||
"account.hide_reblogs": "Fjal stimbran frá @{name}",
|
||||
"account.in_memoriam": "In memoriam.",
|
||||
"account.joined_long": "Meldaði til {date}",
|
||||
"account.joined_short": "Gjørdist limur",
|
||||
"account.languages": "Broyt fylgd mál",
|
||||
"account.link_verified_on": "Ognarskapur av hesum leinki var eftirkannaður {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Doyv ikki @{name}",
|
||||
"account.unmute_notifications_short": "Tendra fráboðanir",
|
||||
"account.unmute_short": "Doyv ikki",
|
||||
"account_fields_modal.close": "Lat aftur",
|
||||
"account_fields_modal.title": "Upplýsingarnar hjá {name}",
|
||||
"account_note.placeholder": "Klikka fyri at leggja viðmerking afturat",
|
||||
"admin.dashboard.daily_retention": "Hvussu nógvir brúkarar eru eftir, síðani tey skrásettu seg, roknað í døgum",
|
||||
"admin.dashboard.monthly_retention": "Hvussu nógvir brúkarar eru eftir síðani tey skrásettu seg, roknað í mánaðum",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nýtt evni} other {# nýggj evni}}",
|
||||
"loading_indicator.label": "Innlesur…",
|
||||
"media_gallery.hide": "Fjal",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Konta tín {disabledAccount} er í løtuni óvirkin, tí tú flutti til {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Fjal boð",
|
||||
"mute_modal.hide_options": "Fjal valmøguleikar",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"account.block_domain": "Bloquer le domaine {domain}",
|
||||
"account.block_short": "Bloquer",
|
||||
"account.blocked": "Bloqué·e",
|
||||
"account.blocking": "Bloqué",
|
||||
"account.blocking": "Bloqué·e",
|
||||
"account.cancel_follow_request": "Retirer cette demande d'abonnement",
|
||||
"account.copy": "Copier le lien du profil",
|
||||
"account.direct": "Mention privée @{name}",
|
||||
@@ -32,8 +32,8 @@
|
||||
"account.enable_notifications": "Me notifier quand @{name} publie",
|
||||
"account.endorse": "Inclure sur profil",
|
||||
"account.familiar_followers_many": "Suivi par {name1}, {name2}, et {othersCount, plural, one {une autre personne que vous suivez} other {# autres personnes que vous suivez}}",
|
||||
"account.familiar_followers_one": "Suivi par {name1}",
|
||||
"account.familiar_followers_two": "Suivi par {name1} et {name2}",
|
||||
"account.familiar_followers_one": "Suivi·e par {name1}",
|
||||
"account.familiar_followers_two": "Suivi·e par {name1} et {name2}",
|
||||
"account.featured": "En vedette",
|
||||
"account.featured.accounts": "Profils",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
@@ -49,7 +49,7 @@
|
||||
"account.followers": "abonné·e·s",
|
||||
"account.followers.empty": "Personne ne suit ce compte pour l'instant.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} abonné·e} other {{counter} abonné·e·s}}",
|
||||
"account.followers_you_know_counter": "{count, plural, one {{counter} suivi}, other {{counter} suivis}}",
|
||||
"account.followers_you_know_counter": "{counter} que vous suivez",
|
||||
"account.following": "Abonné·e",
|
||||
"account.following_counter": "{count, plural, one {{counter} abonnement} other {{counter} abonnements}}",
|
||||
"account.follows.empty": "Ce compte ne suit personne présentement.",
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Voir ce profil",
|
||||
"account.hide_reblogs": "Masquer les boosts de @{name}",
|
||||
"account.in_memoriam": "En souvenir de",
|
||||
"account.joined_long": "Ici depuis le {date}",
|
||||
"account.joined_short": "Inscrit·e",
|
||||
"account.languages": "Changer les langues abonnées",
|
||||
"account.link_verified_on": "La propriété de ce lien a été vérifiée le {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Ne plus masquer @{name}",
|
||||
"account.unmute_notifications_short": "Ne plus masquer les notifications",
|
||||
"account.unmute_short": "Ne plus masquer",
|
||||
"account_fields_modal.close": "Fermer",
|
||||
"account_fields_modal.title": "Infos de {name}",
|
||||
"account_note.placeholder": "Cliquez pour ajouter une note",
|
||||
"admin.dashboard.daily_retention": "Taux de rétention des comptes par jour après inscription",
|
||||
"admin.dashboard.monthly_retention": "Taux de rétention des comptes par mois après inscription",
|
||||
@@ -164,10 +167,10 @@
|
||||
"block_modal.remote_users_caveat": "Nous allons demander au serveur {domain} de respecter votre décision. Cependant, ce respect n'est pas garanti, car certains serveurs peuvent gérer différemment les blocages. Les messages publics peuvent rester visibles par les utilisateur·rice·s non connecté·e·s.",
|
||||
"block_modal.show_less": "Afficher moins",
|
||||
"block_modal.show_more": "Afficher plus",
|
||||
"block_modal.they_cant_mention": "Il ne peut pas vous mentionner ou vous suivre.",
|
||||
"block_modal.they_cant_mention": "Elle ne pourra pas vous mentionner ou vous suivre.",
|
||||
"block_modal.they_cant_see_posts": "Il ne peut plus voir vos messages et vous ne verrez plus les siens.",
|
||||
"block_modal.they_will_know": "Il peut voir qu'il est bloqué.",
|
||||
"block_modal.title": "Bloquer le compte ?",
|
||||
"block_modal.they_will_know": "Elle pourra voir qu'elle est bloquée.",
|
||||
"block_modal.title": "Bloquer cette personne ?",
|
||||
"block_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
|
||||
"boost_modal.combo": "Vous pouvez appuyer sur {combo} pour sauter ceci la prochaine fois",
|
||||
"boost_modal.reblog": "Booster le message ?",
|
||||
@@ -589,16 +592,17 @@
|
||||
"load_pending": "{count, plural, one {# nouvel élément} other {# nouveaux éléments}}",
|
||||
"loading_indicator.label": "Chargement…",
|
||||
"media_gallery.hide": "Masquer",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Votre compte {disabledAccount} est actuellement désactivé parce que vous avez déménagé sur {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Cacher des notifications",
|
||||
"mute_modal.hide_options": "Masquer les options",
|
||||
"mute_modal.indefinite": "Jusqu'à ce que je les réactive",
|
||||
"mute_modal.indefinite": "Jusqu'à ce que je l'affiche à nouveau",
|
||||
"mute_modal.show_options": "Afficher les options",
|
||||
"mute_modal.they_can_mention_and_follow": "Ils peuvent vous mentionner et vous suivre, mais vous ne les verrez pas.",
|
||||
"mute_modal.they_wont_know": "Ils ne sauront pas qu'ils ont été rendus silencieux.",
|
||||
"mute_modal.title": "Rendre cet utilisateur silencieux ?",
|
||||
"mute_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
|
||||
"mute_modal.you_wont_see_posts": "Il peut toujours voir vos publications, mais vous ne verrez pas les siennes.",
|
||||
"mute_modal.they_can_mention_and_follow": "Elle pourra vous mentionner et vous suivre, mais vous ne la verrez pas.",
|
||||
"mute_modal.they_wont_know": "Elle ne saura pas qu'elle est masquée.",
|
||||
"mute_modal.title": "Masquer cette personne ?",
|
||||
"mute_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui la mentionnent.",
|
||||
"mute_modal.you_wont_see_posts": "Elle pourra toujours voir vos publications, mais vous ne verrez pas les siennes.",
|
||||
"navigation_bar.about": "À propos",
|
||||
"navigation_bar.account_settings": "Mot de passe et sécurité",
|
||||
"navigation_bar.administration": "Administration",
|
||||
@@ -786,7 +790,7 @@
|
||||
"privacy.private.short": "Abonnés",
|
||||
"privacy.public.long": "Tout le monde sur et en dehors de Mastodon",
|
||||
"privacy.public.short": "Public",
|
||||
"privacy.quote.anyone": "{visibility}, n'importe qui peut citer",
|
||||
"privacy.quote.anyone": "{visibility}, citations autorisées",
|
||||
"privacy.quote.disabled": "{visibility}, citations désactivées",
|
||||
"privacy.quote.limited": "{visibility}, citations limitées",
|
||||
"privacy.unlisted.additional": "Se comporte exactement comme « public », sauf que le message n'apparaîtra pas dans les flux en direct, les hashtags, explorer ou la recherche Mastodon, même si vous les avez activé au niveau de votre compte.",
|
||||
|
||||
@@ -21,19 +21,19 @@
|
||||
"account.block_domain": "Bloquer le domaine {domain}",
|
||||
"account.block_short": "Bloquer",
|
||||
"account.blocked": "Bloqué·e",
|
||||
"account.blocking": "Bloqué",
|
||||
"account.blocking": "Bloqué·e",
|
||||
"account.cancel_follow_request": "Annuler l'abonnement",
|
||||
"account.copy": "Copier le lien du profil",
|
||||
"account.direct": "Mention privée @{name}",
|
||||
"account.disable_notifications": "Ne plus me notifier quand @{name} publie quelque chose",
|
||||
"account.direct": "Mentionner @{name} en privé",
|
||||
"account.disable_notifications": "Ne plus me notifier les publications de @{name}",
|
||||
"account.domain_blocking": "Domaine bloqué",
|
||||
"account.edit_profile": "Modifier le profil",
|
||||
"account.edit_profile_short": "Modifier",
|
||||
"account.enable_notifications": "Me notifier quand @{name} publie quelque chose",
|
||||
"account.enable_notifications": "Me notifier les publications de @{name}",
|
||||
"account.endorse": "Recommander sur votre profil",
|
||||
"account.familiar_followers_many": "Suivi par {name1}, {name2}, et {othersCount, plural, one {une autre personne que vous suivez} other {# autres personnes que vous suivez}}",
|
||||
"account.familiar_followers_one": "Suivi par {name1}",
|
||||
"account.familiar_followers_two": "Suivi par {name1} et {name2}",
|
||||
"account.familiar_followers_one": "Suivi·e par {name1}",
|
||||
"account.familiar_followers_two": "Suivi·e par {name1} et {name2}",
|
||||
"account.featured": "En vedette",
|
||||
"account.featured.accounts": "Profils",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
@@ -49,7 +49,7 @@
|
||||
"account.followers": "Abonné·e·s",
|
||||
"account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} abonné·e} other {{counter} abonné·e·s}}",
|
||||
"account.followers_you_know_counter": "{count, plural, one {{counter} suivi}, other {{counter} suivis}}",
|
||||
"account.followers_you_know_counter": "{counter} que vous suivez",
|
||||
"account.following": "Abonnements",
|
||||
"account.following_counter": "{count, plural, one {{counter} abonnement} other {{counter} abonnements}}",
|
||||
"account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Voir le profil",
|
||||
"account.hide_reblogs": "Masquer les partages de @{name}",
|
||||
"account.in_memoriam": "En mémoire de.",
|
||||
"account.joined_long": "Ici depuis le {date}",
|
||||
"account.joined_short": "Ici depuis",
|
||||
"account.languages": "Modifier les langues d'abonnements",
|
||||
"account.link_verified_on": "La propriété de ce lien a été vérifiée le {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Ne plus masquer @{name}",
|
||||
"account.unmute_notifications_short": "Réactiver les notifications",
|
||||
"account.unmute_short": "Ne plus masquer",
|
||||
"account_fields_modal.close": "Fermer",
|
||||
"account_fields_modal.title": "Infos de {name}",
|
||||
"account_note.placeholder": "Cliquez pour ajouter une note",
|
||||
"admin.dashboard.daily_retention": "Taux de rétention des utilisateur·rice·s par jour après inscription",
|
||||
"admin.dashboard.monthly_retention": "Taux de rétention des utilisateur·rice·s par mois après inscription",
|
||||
@@ -164,10 +167,10 @@
|
||||
"block_modal.remote_users_caveat": "Nous allons demander au serveur {domain} de respecter votre décision. Cependant, ce respect n'est pas garanti, car certains serveurs peuvent gérer différemment les blocages. Les messages publics peuvent rester visibles par les utilisateur·rice·s non connecté·e·s.",
|
||||
"block_modal.show_less": "Afficher moins",
|
||||
"block_modal.show_more": "Afficher plus",
|
||||
"block_modal.they_cant_mention": "Il ne peut pas vous mentionner ou vous suivre.",
|
||||
"block_modal.they_cant_mention": "Elle ne pourra pas vous mentionner ou vous suivre.",
|
||||
"block_modal.they_cant_see_posts": "Il ne peut plus voir vos messages et vous ne verrez plus les siens.",
|
||||
"block_modal.they_will_know": "Il peut voir qu'il est bloqué.",
|
||||
"block_modal.title": "Bloquer le compte ?",
|
||||
"block_modal.they_will_know": "Elle pourra voir qu'elle est bloquée.",
|
||||
"block_modal.title": "Bloquer cette personne ?",
|
||||
"block_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
|
||||
"boost_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
|
||||
"boost_modal.reblog": "Booster le message ?",
|
||||
@@ -589,16 +592,17 @@
|
||||
"load_pending": "{count, plural, one {# nouvel élément} other {# nouveaux éléments}}",
|
||||
"loading_indicator.label": "Chargement…",
|
||||
"media_gallery.hide": "Masquer",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Votre compte {disabledAccount} est actuellement désactivé parce que vous l'avez déplacé à {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Cacher des notifications",
|
||||
"mute_modal.hide_options": "Masquer les options",
|
||||
"mute_modal.indefinite": "Jusqu'à ce que je les réactive",
|
||||
"mute_modal.indefinite": "Jusqu'à ce que je l'affiche à nouveau",
|
||||
"mute_modal.show_options": "Afficher les options",
|
||||
"mute_modal.they_can_mention_and_follow": "Ils peuvent vous mentionner et vous suivre, mais vous ne les verrez pas.",
|
||||
"mute_modal.they_wont_know": "Ils ne sauront pas qu'ils ont été rendus silencieux.",
|
||||
"mute_modal.title": "Rendre cet utilisateur silencieux ?",
|
||||
"mute_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
|
||||
"mute_modal.you_wont_see_posts": "Il peut toujours voir vos publications, mais vous ne verrez pas les siennes.",
|
||||
"mute_modal.they_can_mention_and_follow": "Elle pourra vous mentionner et vous suivre, mais vous ne la verrez pas.",
|
||||
"mute_modal.they_wont_know": "Elle ne saura pas qu'elle est masquée.",
|
||||
"mute_modal.title": "Masquer cette personne ?",
|
||||
"mute_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui la mentionnent.",
|
||||
"mute_modal.you_wont_see_posts": "Elle pourra toujours voir vos publications, mais vous ne verrez pas les siennes.",
|
||||
"navigation_bar.about": "À propos",
|
||||
"navigation_bar.account_settings": "Mot de passe et sécurité",
|
||||
"navigation_bar.administration": "Administration",
|
||||
@@ -786,7 +790,7 @@
|
||||
"privacy.private.short": "Abonnés",
|
||||
"privacy.public.long": "Tout le monde sur et en dehors de Mastodon",
|
||||
"privacy.public.short": "Public",
|
||||
"privacy.quote.anyone": "{visibility}, n'importe qui peut citer",
|
||||
"privacy.quote.anyone": "{visibility}, citations autorisées",
|
||||
"privacy.quote.disabled": "{visibility}, citations désactivées",
|
||||
"privacy.quote.limited": "{visibility}, citations limitées",
|
||||
"privacy.unlisted.additional": "Se comporte exactement comme « public », sauf que le message n'apparaîtra pas dans les flux en direct, les hashtags, explorer ou la recherche Mastodon, même si vous les avez activé au niveau de votre compte.",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Téigh go dtí próifíl",
|
||||
"account.hide_reblogs": "Folaigh moltaí ó @{name}",
|
||||
"account.in_memoriam": "Ón tseanaimsir.",
|
||||
"account.joined_long": "Chuaigh isteach ar {date}",
|
||||
"account.joined_short": "Cláraithe",
|
||||
"account.languages": "Athraigh teangacha foscríofa",
|
||||
"account.link_verified_on": "Seiceáladh úinéireacht an naisc seo ar {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Díbhalbhaigh @{name}",
|
||||
"account.unmute_notifications_short": "Díbhalbhaigh fógraí",
|
||||
"account.unmute_short": "Díbhalbhaigh",
|
||||
"account_fields_modal.close": "Dún",
|
||||
"account_fields_modal.title": "Eolas {name}",
|
||||
"account_note.placeholder": "Cliceáil chun nóta a chuir leis",
|
||||
"admin.dashboard.daily_retention": "Ráta coinneála an úsáideora de réir an lae tar éis clárú",
|
||||
"admin.dashboard.monthly_retention": "Ráta coinneála na n-úsáideoirí de réir na míosa tar éis dóibh clárú",
|
||||
@@ -101,9 +104,9 @@
|
||||
"admin.impact_report.instance_follows": "Leanúna a bheadh a n-úsáideoirí chailleadh",
|
||||
"admin.impact_report.title": "Achoimre ar an tionchar",
|
||||
"alert.rate_limited.message": "Atriail aris tar éis {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Rátatheoranta",
|
||||
"alert.rate_limited.title": "Ráta teoranta",
|
||||
"alert.unexpected.message": "Tharla earráid gan choinne.",
|
||||
"alert.unexpected.title": "Hiúps!",
|
||||
"alert.unexpected.title": "Úps!",
|
||||
"alt_text_badge.title": "Téacs alt",
|
||||
"alt_text_modal.add_alt_text": "Cuir téacs alt leis",
|
||||
"alt_text_modal.add_text_from_image": "Cuir téacs ón íomhá leis",
|
||||
@@ -150,7 +153,7 @@
|
||||
"annual_report.summary.highlighted_post.reply_count": "Fuair an post seo {count, plural, one {freagra amháin} two {# freagraí} few {# freagraí} many {# freagraí} other {# freagraí}}.",
|
||||
"annual_report.summary.highlighted_post.title": "An post is mó tóir",
|
||||
"annual_report.summary.most_used_app.most_used_app": "aip is mó a úsáidtear",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "hashtag is mó a úsáidtear",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "haischlib is mó a úsáidtear",
|
||||
"annual_report.summary.most_used_hashtag.used_count": "Chuir tú an haischlib seo i {count, plural, one {post amháin} two {# poist} few {# poist} many {# poist} other {# poist}}.",
|
||||
"annual_report.summary.most_used_hashtag.used_count_public": "Chuir {name} an haischlib seo i {count, plural, one {post amháin} two {# poist} few {# poist} many {# poist} other {# poist}}.",
|
||||
"annual_report.summary.new_posts.new_posts": "postanna nua",
|
||||
@@ -167,14 +170,14 @@
|
||||
"block_modal.they_cant_mention": "Ní féidir leo tú a lua ná a leanúint.",
|
||||
"block_modal.they_cant_see_posts": "Ní féidir leo do chuid postálacha a fheiceáil agus ní fheicfidh tú a gcuid postanna.",
|
||||
"block_modal.they_will_know": "Is féidir leo a fheiceáil go bhfuil bac orthu.",
|
||||
"block_modal.title": "An bhfuil fonn ort an t-úsáideoir a bhlocáil?",
|
||||
"block_modal.title": "Úsáideoir a bhlocáil?",
|
||||
"block_modal.you_wont_see_mentions": "Ní fheicfidh tú postálacha a luann iad.",
|
||||
"boost_modal.combo": "Is féidir leat {combo} a bhrú chun é seo a scipeáil an chéad uair eile",
|
||||
"boost_modal.reblog": "An post a threisiú?",
|
||||
"boost_modal.undo_reblog": "An deireadh a chur le postáil?",
|
||||
"bundle_column_error.copy_stacktrace": "Cóipeáil tuairisc earráide",
|
||||
"bundle_column_error.error.body": "Ní féidir an leathanach a iarradh a sholáthar. Seans gurb amhlaidh mar gheall ar fhabht sa chód, nó mar gheall ar mhíréireacht leis an mbrabhsálaí.",
|
||||
"bundle_column_error.error.title": "Ó, níl sé sin go maith!",
|
||||
"bundle_column_error.error.title": "Ó, ní hea!",
|
||||
"bundle_column_error.network.body": "Tharla earráid agus an leathanach á lódáil. Seans gur mar gheall ar fhadhb shealadach le do nasc idirlín nó i ndáil leis an bhfreastalaí seo atá sé.",
|
||||
"bundle_column_error.network.title": "Earráid líonra",
|
||||
"bundle_column_error.retry": "Bain triail as arís",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# mír nua} two {# mír nua} few {# mír nua} many {# mír nua} other {# mír nua}}",
|
||||
"loading_indicator.label": "Á lódáil…",
|
||||
"media_gallery.hide": "Folaigh",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Tá do chuntas {disabledAccount} díchumasaithe faoi láthair toisc gur bhog tú go {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Folaigh ó fhógraí",
|
||||
"mute_modal.hide_options": "Folaigh roghanna",
|
||||
|
||||
@@ -113,11 +113,52 @@
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Mìnich seo dhan fheadhainn air a bheil cion-lèirsinne…",
|
||||
"alt_text_modal.done": "Deiseil",
|
||||
"announcement.announcement": "Brath-fios",
|
||||
"annual_report.announcement.action_build": "Cruthaich Wrapstodon dhomh",
|
||||
"annual_report.announcement.action_dismiss": "Na cruthaich",
|
||||
"annual_report.announcement.action_view": "Seall an Wrapstodon agam",
|
||||
"annual_report.announcement.description": "Fidir mar a chaidh leat air Mastodon am bliadhna.",
|
||||
"annual_report.announcement.title": "Tha Wrapstodon {year} air a thighinn",
|
||||
"annual_report.nav_item.badge": "Ùr",
|
||||
"annual_report.shared_page.donate": "Thoir tabhartas",
|
||||
"annual_report.shared_page.footer": "Chaidh a ghintinn le {heart} le sgioba Mhastodon",
|
||||
"annual_report.shared_page.footer_server_info": "Tha {username} a’ cleachdadh {domain}, sin fear de dh’iomadh coimhearsnachd le cumhachd Mastodon.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "Bhiodh {name} an toir air postaichean rim brosnachadh, a’ toirt taic do chruthadairean gu sgiobalta.",
|
||||
"annual_report.summary.archetype.booster.desc_self": "Bhiodh tu an toir air postaichean rim brosnachadh, a’ toirt taic do chruthadairean gu sgiobalta.",
|
||||
"annual_report.summary.archetype.booster.name": "Am Boghadair",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.archetype.lurker.desc_public": "Tha fios againn gun robh {name} ann am badeigin, a’ gabhail tlachd à Mastodon air an dòigh fhèin.",
|
||||
"annual_report.summary.archetype.lurker.desc_self": "Tha fios againn gun robh thu ann am badeigin, a’ gabhail tlachd à Mastodon air do dhòigh fhèin.",
|
||||
"annual_report.summary.archetype.lurker.name": "An Socaireach",
|
||||
"annual_report.summary.archetype.oracle.desc_public": "Chruthaich {name} barrachd phostaichean ùra na freagairtean, a’ cumail Mastodon ùr ’s beòthail.",
|
||||
"annual_report.summary.archetype.oracle.desc_self": "Chruthaich thu barrachd phostaichean ùra na freagairtean, a’ cumail Mastodon ùr ’s beòthail.",
|
||||
"annual_report.summary.archetype.oracle.name": "Coinneach Odhar",
|
||||
"annual_report.summary.archetype.pollster.desc_public": "Chruthaich {name} barrachd chunntasan-beachd na seòrsaichean eile de phost, a’ cur ris an iongantas air Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.desc_self": "Chruthaich thu barrachd chunntasan-beachd na seòrsaichean eile de phost, a’ cur ris an iongantas air Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.name": "An Culaidh-iongantais",
|
||||
"annual_report.summary.archetype.replier.desc_public": "Fhreagradh {name} gu tric air postaichean càich, a’ cur deasbadan ùra gu dol air Mastodon.",
|
||||
"annual_report.summary.archetype.replier.desc_self": "Fhreagradh tu gu tric air postaichean càich, a’ cur deasbadan ùra gu dol air Mastodon.",
|
||||
"annual_report.summary.archetype.replier.name": "An Dealan-dè",
|
||||
"annual_report.summary.archetype.reveal": "Nochd am prìomh-choltas a th’ orm",
|
||||
"annual_report.summary.archetype.reveal_description": "Mòran taing airson conaltradh air Mastodon! Thàinig an t-àm a gheibheadh tu a-mach dè am prìomh-choltas a bh’ ort {year}.",
|
||||
"annual_report.summary.archetype.title_public": "Am prìomh-choltas air {name}",
|
||||
"annual_report.summary.archetype.title_self": "Am prìomh-choltas a th’ ort",
|
||||
"annual_report.summary.close": "Dùin",
|
||||
"annual_report.summary.copy_link": "Dèan lethbhreac dhen cheangal",
|
||||
"annual_report.summary.followers.new_followers": "{count, plural, one {neach-leantainn ùr} two {neach-leantainn ùra} few {luchd-leantainn ùra} other {luchd-leantainn ùra}}",
|
||||
"annual_report.summary.highlighted_post.boost_count": "Chaidh am post seo a bhrosnachadh {count, plural, one {# turas} two {# thuras} few {# tursan} other {# turas}}.",
|
||||
"annual_report.summary.highlighted_post.favourite_count": "Chaidh am post seo a chur ris na h-annsachdan {count, plural, one {# turas} two {# thuras} few {# tursan} other {# turas}}.",
|
||||
"annual_report.summary.highlighted_post.reply_count": "Fhuair am post seo {count, plural, one {# fhreagairt} two {# fhreagairt} few {# freagairtean} other {# freagairt}}.",
|
||||
"annual_report.summary.highlighted_post.title": "Am post air an robh fèill as motha",
|
||||
"annual_report.summary.most_used_app.most_used_app": "an aplacaid a chaidh a cleachdadh as trice",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "an taga hais a chaidh a cleachdadh as trice",
|
||||
"annual_report.summary.most_used_hashtag.used_count": "Ghabh thu a-staigh an taga hais seo ann an {count, plural, one {# phost} two {# phost} few {# postaichean} other {# post}}.",
|
||||
"annual_report.summary.most_used_hashtag.used_count_public": "Ghabh {name} a-staigh an taga hais seo ann an {count, plural, one {# phost} two {# phost} few {# postaichean} other {# post}}.",
|
||||
"annual_report.summary.new_posts.new_posts": "postaichean ùra",
|
||||
"annual_report.summary.percentile.text": "<topLabel>Tha thu am measg</topLabel><percentage></percentage><bottomLabel>dhen luchd-cleachdaidh as cliùitiche air {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Ainmeil ’nad latha ’s ’nad linn.",
|
||||
"annual_report.summary.share_elsewhere": "Co-roinn am badeigin eile",
|
||||
"annual_report.summary.share_message": "’S e {archetype} am prìomh-choltas a th’ orm!",
|
||||
"annual_report.summary.share_on_mastodon": "Co-roinn air Mastodon",
|
||||
"attachments_list.unprocessed": "(gun phròiseasadh)",
|
||||
"audio.hide": "Falaich an fhuaim",
|
||||
"block_modal.remote_users_caveat": "Iarraidh sinn air an fhrithealaiche {domain} gun gèill iad ri do cho-dhùnadh. Gidheadh, chan eil barantas gun gèill iad on a làimhsicheas cuid a fhrithealaichean bacaidhean air dòigh eadar-dhealaichte. Dh’fhaoidte gum faic daoine gun chlàradh a-steach na postaichean poblach agad fhathast.",
|
||||
@@ -162,7 +203,7 @@
|
||||
"column.favourites": "Annsachdan",
|
||||
"column.firehose": "An saoghal beò",
|
||||
"column.firehose_local": "Loidhne-ama bheò an fhrithealaiche seo",
|
||||
"column.firehose_singular": "Loidhne-ama bheò beò",
|
||||
"column.firehose_singular": "Loidhne-ama bheò",
|
||||
"column.follow_requests": "Iarrtasan leantainn",
|
||||
"column.home": "Dachaigh",
|
||||
"column.list_members": "Stiùir buill na liosta",
|
||||
@@ -242,7 +283,7 @@
|
||||
"confirmations.private_quote_notify.title": "A bheil thu airson a cho-roinneadh leis an luchd-leantainn ’s na cleachdaichean le iomradh orra?",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Na cuiribh seo ’nam chuimhne a-rithist",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Tha mi agaibh",
|
||||
"confirmations.quiet_post_quote_info.message": "Nuair a luaidheas tu post a tha poblach ach sàmhach, thèid am post agad fhalach o loidhnichean-ama nan treandaichean.",
|
||||
"confirmations.quiet_post_quote_info.message": "Nuair a luaidheas tu post sàmhach, thèid am post agad fhalach o loidhnichean-ama nan treandaichean.",
|
||||
"confirmations.quiet_post_quote_info.title": "Luaidh air postaichean sàmhach",
|
||||
"confirmations.redraft.confirm": "Sguab às ⁊ dèan dreachd ùr",
|
||||
"confirmations.redraft.message": "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às agus dreachd ùr a thòiseachadh? Caillidh tu gach annsachd is brosnachadh air agus thèid freagairtean dhan phost thùsail ’nan dìlleachdanan.",
|
||||
@@ -343,6 +384,7 @@
|
||||
"empty_column.notification_requests": "Glan! Chan eil dad an-seo. Nuair a gheibh thu brathan ùra, nochdaidh iad an-seo a-rèir nan roghainnean agad.",
|
||||
"empty_column.notifications": "Cha d’ fhuair thu brath sam bith fhathast. Nuair a nì càch conaltradh leat, chì thu an-seo e.",
|
||||
"empty_column.public": "Chan eil dad an-seo! Sgrìobh rudeigin gu poblach no lean càch o fhrithealaichean eile a làimh airson seo a lìonadh",
|
||||
"error.no_hashtag_feed_access": "Cruthaich cunntas no clàraich a-steach airson an taga hais seo a shealltainn is leantainn.",
|
||||
"error.unexpected_crash.explanation": "Air sàilleibh buga sa chòd againn no duilgheadas co-chòrdalachd leis a’ bhrabhsair, chan urrainn dhuinn an duilleag seo a shealltainn mar bu chòir.",
|
||||
"error.unexpected_crash.explanation_addons": "Cha b’ urrainn dhuinn an duilleag seo a shealltainn mar bu chòir. Tha sinn an dùil gu do dh’adhbharaich tuilleadan a’ bhrabhsair no inneal eadar-theangachaidh fèin-obrachail a’ mhearachd.",
|
||||
"error.unexpected_crash.next_steps": "Feuch an ath-nuadhaich thu an duilleag seo. Mura cuidich sin, dh’fhaoidte gur urrainn dhut Mastodon a chleachdadh fhathast le brabhsair eile no le aplacaid thùsail.",
|
||||
@@ -399,6 +441,8 @@
|
||||
"follow_suggestions.who_to_follow": "Molaidhean leantainn",
|
||||
"followed_tags": "Tagaichean hais ’gan leantainn",
|
||||
"footer.about": "Mu dhèidhinn",
|
||||
"footer.about_mastodon": "Mu Mhastodon",
|
||||
"footer.about_server": "Mu {domain}",
|
||||
"footer.about_this_server": "Mu dhèidhinn",
|
||||
"footer.directory": "Eòlaire nam pròifil",
|
||||
"footer.get_app": "Faigh an aplacaid",
|
||||
@@ -473,6 +517,7 @@
|
||||
"keyboard_shortcuts.column": "Cuir am fòcas air colbh",
|
||||
"keyboard_shortcuts.compose": "Cuir am fòcas air raon teacsa an sgrìobhaidh",
|
||||
"keyboard_shortcuts.description": "Tuairisgeul",
|
||||
"keyboard_shortcuts.direct": "Fosgail colbh nan iomraidhean prìobhaideach",
|
||||
"keyboard_shortcuts.down": "Gluais sìos air an liosta",
|
||||
"keyboard_shortcuts.enter": "Fosgail post",
|
||||
"keyboard_shortcuts.favourite": "Cuir am post ris na h-annsachdan",
|
||||
@@ -500,6 +545,7 @@
|
||||
"keyboard_shortcuts.toggle_hidden": "Seall/Falaich an teacsa fo rabhadh susbainte",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "Seall/Falaich na meadhanan",
|
||||
"keyboard_shortcuts.toot": "Tòisich air post ùr",
|
||||
"keyboard_shortcuts.top": "Gluais gu bàrr na liosta",
|
||||
"keyboard_shortcuts.translate": "airson post eadar-theangachadh",
|
||||
"keyboard_shortcuts.unfocus": "Thoir am fòcas far raon teacsa an sgrìobhaidh/an luirg",
|
||||
"keyboard_shortcuts.up": "Gluais suas air an liosta",
|
||||
@@ -744,7 +790,7 @@
|
||||
"privacy.quote.disabled": "{visibility}, luaidh à comas",
|
||||
"privacy.quote.limited": "{visibility}, luaidh cuingichte",
|
||||
"privacy.unlisted.additional": "Tha seo coltach ris an fhaicsinneachd phoblach ach cha nochd am post air loidhnichean-ama an t-saoghail phoblaich, nan tagaichean hais no an rùrachaidh no ann an toraidhean luirg Mhastodon fiù ’s ma thug thu ro-aonta airson sin seachad.",
|
||||
"privacy.unlisted.long": "Poblach ach falaichte o na toraidhean-luirg, na treandaichean ’s na loichnichean-ama poblach",
|
||||
"privacy.unlisted.long": "Falaichte o na toraidhean-luirg, na treandaichean ’s na loidhnichean-ama poblach",
|
||||
"privacy.unlisted.short": "Sàmhach",
|
||||
"privacy_policy.last_updated": "An t-ùrachadh mu dheireadh {date}",
|
||||
"privacy_policy.title": "Poileasaidh prìobhaideachd",
|
||||
@@ -888,6 +934,7 @@
|
||||
"status.edited_x_times": "Chaidh a dheasachadh {count, plural, one {{count} turas} two {{count} thuras} few {{count} tursan} other {{count} turas}}",
|
||||
"status.embed": "Faigh còd leabachaidh",
|
||||
"status.favourite": "Cuir ris na h-annsachdan",
|
||||
"status.favourites_count": "{count, plural, one {{counter} annsachd} two {{counter} annsachd} few {{counter} annsachdan} other {{counter} annsachd}}",
|
||||
"status.filter": "Criathraich am post seo",
|
||||
"status.history.created": "Chruthaich {name} {date} e",
|
||||
"status.history.edited": "Dheasaich {name} {date} e",
|
||||
@@ -922,12 +969,14 @@
|
||||
"status.quotes.empty": "Chan deach am post seo a luaidh le duine sam bith fhathast. Nuair a luaidheas cuideigin e, nochdaidh iad an-seo.",
|
||||
"status.quotes.local_other_disclaimer": "Cha tèid luaidhean a dhiùilt an ùghdar a shealltainn.",
|
||||
"status.quotes.remote_other_disclaimer": "Cha dèid ach luaidhean o {domain} a shealltainn an-seo le cinnt. Cha dèid luaidhean a dhiùilt an ùghdar a shealltainn.",
|
||||
"status.quotes_count": "{count, plural, one {{counter} luaidh} two {{counter} luaidh} few {{counter} luaidhean} other {{counter} luaidh}}",
|
||||
"status.read_more": "Leugh an còrr",
|
||||
"status.reblog": "Brosnaich",
|
||||
"status.reblog_or_quote": "Brosnaich no luaidh",
|
||||
"status.reblog_private": "Co-roinn leis an luchd-leantainn agad a-rithist",
|
||||
"status.reblogged_by": "’Ga bhrosnachadh le {name}",
|
||||
"status.reblogs.empty": "Chan deach am post seo a bhrosnachadh le duine sam bith fhathast. Nuair a bhrosnaicheas cuideigin e, nochdaidh iad an-seo.",
|
||||
"status.reblogs_count": "{count, plural, one {{counter} bhrosnachadh} two {{counter} bhrosnachadh} few {{counter} brosnachaidhean} other {{counter} brosnachadh}}",
|
||||
"status.redraft": "Sguab às ⁊ dèan dreachd ùr",
|
||||
"status.remove_bookmark": "Thoir an comharra-lìn air falbh",
|
||||
"status.remove_favourite": "Thoir air falbh o na h-annsachdan",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.group": "Grupo",
|
||||
"account.block": "Bloquear @{name}",
|
||||
"account.block_domain": "Agochar todo de {domain}",
|
||||
"account.block_domain": "Bloquear o dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
"account.blocked": "Bloqueada",
|
||||
"account.blocking": "Bloqueos",
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ir ao perfil",
|
||||
"account.hide_reblogs": "Agochar promocións de @{name}",
|
||||
"account.in_memoriam": "Lembranzas.",
|
||||
"account.joined_long": "Uníuse o {date}",
|
||||
"account.joined_short": "Uniuse",
|
||||
"account.languages": "Modificar os idiomas subscritos",
|
||||
"account.link_verified_on": "A propiedade desta ligazón foi verificada o {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Deixar de silenciar a @{name}",
|
||||
"account.unmute_notifications_short": "Reactivar notificacións",
|
||||
"account.unmute_short": "Non silenciar",
|
||||
"account_fields_modal.close": "Fechar",
|
||||
"account_fields_modal.title": "Info sobre {name}",
|
||||
"account_note.placeholder": "Preme para engadir nota",
|
||||
"admin.dashboard.daily_retention": "Ratio de retención de usuarias diaria após rexistrarse",
|
||||
"admin.dashboard.monthly_retention": "Ratio de retención de usuarias mensual após o rexistro",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# novo elemento} other {# novos elementos}}",
|
||||
"loading_indicator.label": "Estase a cargar…",
|
||||
"media_gallery.hide": "Agochar",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "A túa conta {disabledAccount} está actualmente desactivada porque movéchela a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Agochar nas notificacións",
|
||||
"mute_modal.hide_options": "Opcións ao ocultar",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "מעבר לפרופיל",
|
||||
"account.hide_reblogs": "להסתיר הידהודים מאת @{name}",
|
||||
"account.in_memoriam": "פרופיל זכרון.",
|
||||
"account.joined_long": "הצטרפו ב־{date}",
|
||||
"account.joined_short": "תאריך הצטרפות",
|
||||
"account.languages": "שנה רישום לשפות",
|
||||
"account.link_verified_on": "בעלות על הקישור הזה נבדקה לאחרונה ב{date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "הפסקת השתקת @{name}",
|
||||
"account.unmute_notifications_short": "הפעלת הודעות",
|
||||
"account.unmute_short": "ביטול השתקה",
|
||||
"account_fields_modal.close": "סגירה",
|
||||
"account_fields_modal.title": "הפרטים של {name}",
|
||||
"account_note.placeholder": "יש ללחוץ כדי להוסיף הערות",
|
||||
"admin.dashboard.daily_retention": "קצב שימור משתמשים יומי אחרי ההרשמה",
|
||||
"admin.dashboard.monthly_retention": "קצב שימור משתמשים (פר חודש) אחרי ההרשמה",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# פריט חדש} other {# פריטים חדשים}}",
|
||||
"loading_indicator.label": "בטעינה…",
|
||||
"media_gallery.hide": "להסתיר",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "חשבונך {disabledAccount} אינו פעיל כרגע עקב מעבר ל{movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "להסתיר מהתראות",
|
||||
"mute_modal.hide_options": "הסתרת אפשרויות",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ugrás a profilhoz",
|
||||
"account.hide_reblogs": "@{name} megtolásainak elrejtése",
|
||||
"account.in_memoriam": "Emlékünkben.",
|
||||
"account.joined_long": "Csatlakozás ideje: {date}",
|
||||
"account.joined_short": "Csatlakozott",
|
||||
"account.languages": "Feliratkozott nyelvek módosítása",
|
||||
"account.link_verified_on": "A linket eredetiségét ebben az időpontban ellenőriztük: {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "@{name} némításának feloldása",
|
||||
"account.unmute_notifications_short": "Értesítések némításának feloldása",
|
||||
"account.unmute_short": "Némitás feloldása",
|
||||
"account_fields_modal.close": "Bezárás",
|
||||
"account_fields_modal.title": "{name} információi",
|
||||
"account_note.placeholder": "Kattintás jegyzet hozzáadásához",
|
||||
"admin.dashboard.daily_retention": "Napi regisztráció utáni felhasználómegtartási arány",
|
||||
"admin.dashboard.monthly_retention": "Havi regisztráció utáni felhasználómegtartási arány",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# új elem} other {# új elem}}",
|
||||
"loading_indicator.label": "Betöltés…",
|
||||
"media_gallery.hide": "Elrejtés",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "A(z) {disabledAccount} fiókod jelenleg le van tiltva, mert átköltöztél ide: {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Elrejtés az értesítések közül",
|
||||
"mute_modal.hide_options": "Beállítások elrejtése",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Fara í notandasnið",
|
||||
"account.hide_reblogs": "Fela endurbirtingar fyrir @{name}",
|
||||
"account.in_memoriam": "Minning.",
|
||||
"account.joined_long": "Skáði sig {date}",
|
||||
"account.joined_short": "Gerðist þátttakandi",
|
||||
"account.languages": "Breyta tungumálum í áskrift",
|
||||
"account.link_verified_on": "Eignarhald á þessum tengli var athugað þann {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Hætta að þagga niður í @{name}",
|
||||
"account.unmute_notifications_short": "Hætta að þagga í tilkynningum",
|
||||
"account.unmute_short": "Hætta að þagga niður",
|
||||
"account_fields_modal.close": "Loka",
|
||||
"account_fields_modal.title": "Upplýsingar um {name}",
|
||||
"account_note.placeholder": "Smelltu til að bæta við minnispunkti",
|
||||
"admin.dashboard.daily_retention": "Hlutfall virkra notenda eftir nýskráningu eftir dögum",
|
||||
"admin.dashboard.monthly_retention": "Hlutfall virkra notenda eftir nýskráningu eftir mánuðum",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nýtt atriði} other {# ný atriði}}",
|
||||
"loading_indicator.label": "Hleð inn…",
|
||||
"media_gallery.hide": "Fela",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Aðgangurinn þinn {disabledAccount} er óvirkur í augnablikinu vegna þess að þú fluttir þig yfir á {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Fela úr tilkynningum",
|
||||
"mute_modal.hide_options": "Fela valkosti",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Vai al profilo",
|
||||
"account.hide_reblogs": "Nascondi condivisioni da @{name}",
|
||||
"account.in_memoriam": "In memoria.",
|
||||
"account.joined_long": "Su questa istanza dal {date}",
|
||||
"account.joined_short": "Iscritto",
|
||||
"account.languages": "Modifica le lingue d'iscrizione",
|
||||
"account.link_verified_on": "La proprietà di questo link è stata controllata il {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Riattiva @{name}",
|
||||
"account.unmute_notifications_short": "Riattiva notifiche",
|
||||
"account.unmute_short": "Attiva audio",
|
||||
"account_fields_modal.close": "Chiudi",
|
||||
"account_fields_modal.title": "Informazioni su {name}",
|
||||
"account_note.placeholder": "Clicca per aggiungere una nota",
|
||||
"admin.dashboard.daily_retention": "Tasso di ritenzione dell'utente per giorno, dopo la registrazione",
|
||||
"admin.dashboard.monthly_retention": "Tasso di ritenzione dell'utente per mese, dopo la registrazione",
|
||||
@@ -536,7 +539,7 @@
|
||||
"keyboard_shortcuts.open_media": "Apre i multimedia",
|
||||
"keyboard_shortcuts.pinned": "Apre l'elenco dei post fissati",
|
||||
"keyboard_shortcuts.profile": "Apre il profilo dell'autore",
|
||||
"keyboard_shortcuts.quote": "Cita il post",
|
||||
"keyboard_shortcuts.quote": "Cita post",
|
||||
"keyboard_shortcuts.reply": "Risponde al post",
|
||||
"keyboard_shortcuts.requests": "Apre l'elenco delle richieste di seguirti",
|
||||
"keyboard_shortcuts.search": "Focalizza sulla barra di ricerca",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nuovo oggetto} other {# nuovi oggetti}}",
|
||||
"loading_indicator.label": "Caricamento…",
|
||||
"media_gallery.hide": "Nascondi",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Il tuo profilo {disabledAccount} è correntemente disabilitato perché ti sei spostato a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Nascondi dalle notifiche",
|
||||
"mute_modal.hide_options": "Nascondi le opzioni",
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"account.mute_short": "Nutildyti",
|
||||
"account.muted": "Nutildytas",
|
||||
"account.muting": "Užtildymas",
|
||||
"account.mutual": "Jūs sekate vienas kitą",
|
||||
"account.mutual": "Sekate vienas kitą",
|
||||
"account.no_bio": "Nėra pateikto aprašymo.",
|
||||
"account.open_original_page": "Atidaryti originalų puslapį",
|
||||
"account.posts": "Įrašai",
|
||||
@@ -685,7 +685,7 @@
|
||||
"notifications.filter.follows": "Sekimai",
|
||||
"notifications.filter.mentions": "Paminėjimai",
|
||||
"notifications.filter.polls": "Balsavimo rezultatai",
|
||||
"notifications.filter.statuses": "Naujinimai iš žmonių, kuriuos seki",
|
||||
"notifications.filter.statuses": "Naujienos iš žmonių, kuriuos sekate",
|
||||
"notifications.grant_permission": "Suteikti leidimą.",
|
||||
"notifications.group": "{count} pranešimai",
|
||||
"notifications.mark_as_read": "Pažymėti kiekvieną pranešimą kaip perskaitytą",
|
||||
@@ -821,7 +821,7 @@
|
||||
"report.thanks.title": "Nenori to matyti?",
|
||||
"report.thanks.title_actionable": "Ačiū, kad pranešei, mes tai išnagrinėsime.",
|
||||
"report.unfollow": "Nebesekti @{name}",
|
||||
"report.unfollow_explanation": "Tu seki šią paskyrą. Jei nori nebematyti jų įrašų savo pagrindiniame sraute, nebesek jų.",
|
||||
"report.unfollow_explanation": "Jūs sekate šią paskyrą. Kad nebematytumėte jų įrašų savo pagrindiniame sraute, nebesekite.",
|
||||
"report_notification.attached_statuses": "Pridėti {count, plural, one {{count} įrašas} few {{count} įrašai} many {{count} įrašo} other {{count} įrašų}}",
|
||||
"report_notification.categories.legal": "Teisinės",
|
||||
"report_notification.categories.legal_sentence": "nelegalus turinys",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"about.blocks": "Pelayan yang diselaraskan",
|
||||
"about.contact": "Hubungi:",
|
||||
"about.default_locale": "Lalai",
|
||||
"about.disclaimer": "Mastodon ialah perisian sumber terbuka percuma, dan merupakan tanda dagangan Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Sebab tidak tersedia",
|
||||
"about.domain_blocks.preamble": "Secara amnya, Mastodon membenarkan anda melihat kandungan pengguna daripada mana-mana pelayan dalam alam bersekutu dan berinteraksi dengan mereka. Berikut ialah pengecualian yang khusus pada pelayan ini.",
|
||||
@@ -8,6 +9,7 @@
|
||||
"about.domain_blocks.silenced.title": "Terhad",
|
||||
"about.domain_blocks.suspended.explanation": "Tiada data daripada pelayan ini yang akan diproses, disimpan atau ditukar, menjadikan sebarang interaksi atau perhubungan dengan pengguna daripada pelayan ini adalah mustahil.",
|
||||
"about.domain_blocks.suspended.title": "Digantung",
|
||||
"about.language_label": "Bahasa",
|
||||
"about.not_available": "Maklumat ini belum tersedia pada pelayan ini.",
|
||||
"about.powered_by": "Media sosial terpencar yang dikuasakan oleh {mastodon}",
|
||||
"about.rules": "Peraturan pelayan",
|
||||
@@ -26,6 +28,7 @@
|
||||
"account.disable_notifications": "Berhenti maklumkan saya apabila @{name} mengirim hantaran",
|
||||
"account.domain_blocking": "Blocking domain",
|
||||
"account.edit_profile": "Sunting profil",
|
||||
"account.edit_profile_short": "Sunting",
|
||||
"account.enable_notifications": "Maklumi saya apabila @{name} mengirim hantaran",
|
||||
"account.endorse": "Tampilkan di profil",
|
||||
"account.familiar_followers_one": "melayuikutikut{name1}",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Gå til profil",
|
||||
"account.hide_reblogs": "Gøym framhevingar frå @{name}",
|
||||
"account.in_memoriam": "Til minne om.",
|
||||
"account.joined_long": "Vart med {date}",
|
||||
"account.joined_short": "Vart med",
|
||||
"account.languages": "Endre språktingingar",
|
||||
"account.link_verified_on": "Eigarskap for denne lenkja vart sist sjekka {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Opphev demping av @{name}",
|
||||
"account.unmute_notifications_short": "Opphev demping av varslingar",
|
||||
"account.unmute_short": "Opphev demping",
|
||||
"account_fields_modal.close": "Lukk",
|
||||
"account_fields_modal.title": "{name} sine opplysingar",
|
||||
"account_note.placeholder": "Klikk for å leggja til merknad",
|
||||
"admin.dashboard.daily_retention": "Mengda brukarar aktive ved dagar etter registrering",
|
||||
"admin.dashboard.monthly_retention": "Mengda brukarar aktive ved månader etter registrering",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# nytt element} other {# nye element}}",
|
||||
"loading_indicator.label": "Lastar…",
|
||||
"media_gallery.hide": "Gøym",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Kontoen din, {disabledAccount} er for tida deaktivert fordi du har flytta til {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Ikkje vis varslingar",
|
||||
"mute_modal.hide_options": "Gøym val",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"account.follows_you": "ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹਨ",
|
||||
"account.go_to_profile": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਜਾਓ",
|
||||
"account.hide_reblogs": "{name} ਵਲੋਂ ਬੂਸਟ ਨੂੰ ਲੁਕਾਓ",
|
||||
"account.joined_long": "{date} ਨੂੰ ਜੁਆਇਨ ਕੀਤਾ",
|
||||
"account.joined_short": "ਜੁਆਇਨ ਕੀਤਾ",
|
||||
"account.media": "ਮੀਡੀਆ",
|
||||
"account.mention": "@{name} ਦਾ ਜ਼ਿਕਰ",
|
||||
@@ -76,6 +77,8 @@
|
||||
"account.unmute": "@{name} ਲਈ ਮੌਨ ਹਟਾਓ",
|
||||
"account.unmute_notifications_short": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਅਣ-ਮੌਨ ਕਰੋ",
|
||||
"account.unmute_short": "ਮੌਨ-ਰਹਿਤ ਕਰੋ",
|
||||
"account_fields_modal.close": "ਬੰਦ ਕਰੋ",
|
||||
"account_fields_modal.title": "{name} ਦੀ ਜਾਣਕਾਰੀ",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"admin.dashboard.retention.average": "ਔਸਤ",
|
||||
"admin.dashboard.retention.cohort_size": "ਨਵੇਂ ਵਰਤੋਂਕਾਰ",
|
||||
@@ -85,6 +88,13 @@
|
||||
"alt_text_modal.cancel": "ਰੱਦ ਕਰੋ",
|
||||
"alt_text_modal.done": "ਮੁਕੰਮਲ",
|
||||
"announcement.announcement": "ਹੋਕਾ",
|
||||
"annual_report.announcement.action_dismiss": "ਨਹੀਂ, ਧੰਨਵਾਦ",
|
||||
"annual_report.nav_item.badge": "ਨਵਾਂ",
|
||||
"annual_report.shared_page.donate": "ਦਾਨ ਕਰੋ",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.close": "ਬੰਦ ਕਰੋ",
|
||||
"annual_report.summary.copy_link": "ਲਿੰਕ ਨੂੰ ਕਾਪੀ ਕਰੋ",
|
||||
"annual_report.summary.highlighted_post.title": "ਸਭ ਤੋਂ ਵੱਧ ਹਰਮਨਪਿਆਰੀ ਪੋਸਟ",
|
||||
"annual_report.summary.most_used_app.most_used_app": "ਸਭ ਤੋਂ ਵੱਧ ਵਰਤੀ ਐਪ",
|
||||
"annual_report.summary.new_posts.new_posts": "ਨਵੀਆਂ ਪੋਸਟਾਂ",
|
||||
"audio.hide": "ਆਡੀਓ ਨੂੰ ਲੁਕਾਓ",
|
||||
@@ -273,6 +283,8 @@
|
||||
"follow_suggestions.who_to_follow": "ਕਿਸ ਨੂੰ ਫ਼ਾਲੋ ਕਰੀਏ",
|
||||
"followed_tags": "ਫ਼ਾਲੋ ਕੀਤੇ ਹੈਸ਼ਟੈਗ",
|
||||
"footer.about": "ਸਾਡੇ ਬਾਰੇ",
|
||||
"footer.about_mastodon": "Mastodon ਬਾਰੇ",
|
||||
"footer.about_server": "{domain} ਬਾਰੇ",
|
||||
"footer.about_this_server": "ਇਸ ਬਾਰੇ",
|
||||
"footer.directory": "ਪਰੋਫਾਇਲ ਡਾਇਰੈਕਟਰੀ",
|
||||
"footer.get_app": "ਐਪ ਲਵੋ",
|
||||
@@ -293,6 +305,7 @@
|
||||
"hashtag.column_settings.tag_mode.any": "ਇਹਨਾਂ ਵਿੱਚੋਂ ਕੋਈ",
|
||||
"hashtag.column_settings.tag_mode.none": "ਇਹਨਾਂ ਵਿੱਚੋਂ ਕੋਈ ਨਹੀਂ",
|
||||
"hashtag.column_settings.tag_toggle": "Include additional tags in this column",
|
||||
"hashtag.feature": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ",
|
||||
"hashtag.follow": "ਹੈਸ਼ਟੈਗ ਨੂੰ ਫ਼ਾਲੋ ਕਰੋ",
|
||||
"hashtag.mute": "#{hashtag} ਨੂੰ ਮੌਨ ਕਰੋ",
|
||||
"hashtag.unfollow": "ਹੈਸ਼ਟੈਗ ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰੋ",
|
||||
@@ -438,6 +451,7 @@
|
||||
"notification_requests.exit_selection": "ਮੁਕੰਮਲ",
|
||||
"notification_requests.notifications_from": "{name} ਵਲੋਂ ਨੋਟੀਫਿਕੇਸ਼ਨ",
|
||||
"notification_requests.title": "ਫਿਲਟਰ ਕੀਤੇ ਨੋਟੀਫਿਕੇਸ਼ਨ",
|
||||
"notification_requests.view": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਵੇਖੋ",
|
||||
"notifications.clear": "ਸੂਚਨਾਵਾਂ ਨੂੰ ਮਿਟਾਓ",
|
||||
"notifications.clear_confirmation": "ਕੀ ਤੁਸੀਂ ਆਪਣੇ ਸਾਰੇ ਨੋਟੀਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਪੱਕੇ ਤੌਰ ਉੱਤੇ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
|
||||
"notifications.clear_title": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਮਿਟਾਉਣਾ ਹੈ?",
|
||||
@@ -481,6 +495,7 @@
|
||||
"onboarding.follows.back": "ਪਿੱਛੇ",
|
||||
"onboarding.follows.done": "ਮੁਕੰਮਲ",
|
||||
"onboarding.follows.search": "ਖੋਜੋ",
|
||||
"onboarding.profile.display_name": "ਦਿਖਾਇਆ ਜਾਣ ਵਾਲਾ ਨਾਂ",
|
||||
"onboarding.profile.note": "ਜਾਣਕਾਰੀ",
|
||||
"onboarding.profile.save_and_continue": "ਸੰਭਾਲੋ ਅਤੇ ਜਾਰੀ ਰੱਖੋ",
|
||||
"onboarding.profile.title": "ਪਰੋਫਾਈਲ ਸੈਟਅੱਪ",
|
||||
@@ -584,6 +599,7 @@
|
||||
"status.edited": "ਆਖਰੀ ਸੋਧ ਦੀ ਤਾਰੀਖ {date}",
|
||||
"status.edited_x_times": "Edited {count, plural, one {# time} other {# times}}",
|
||||
"status.favourite": "ਪਸੰਦ",
|
||||
"status.filter": "ਇਸ ਪੋਸਟ ਨੂੰ ਫਿਲਟਰ ਕਰੋ",
|
||||
"status.history.created": "{name} ਨੇ {date} ਨੂੰ ਬਣਾਇਆ",
|
||||
"status.history.edited": "{name} ਨੇ {date} ਨੂੰ ਸੋਧਿਆ",
|
||||
"status.load_more": "ਹੋਰ ਦਿਖਾਓ",
|
||||
@@ -598,6 +614,7 @@
|
||||
"status.pin": "ਪਰੋਫਾਈਲ ਉੱਤੇ ਟੰਗੋ",
|
||||
"status.quote": "ਹਵਾਲਾ",
|
||||
"status.quote.cancel": "ਹਵਾਲੇ ਨੂੰ ਰੱਦ ਕਰੋ",
|
||||
"status.quote_noun": "ਹਵਾਲਾ",
|
||||
"status.quotes_count": "{count, plural, one {{counter} ਹਵਾਲਾ} other {{counter} ਹਵਾਲੇ}}",
|
||||
"status.read_more": "ਹੋਰ ਪੜ੍ਹੋ",
|
||||
"status.reblog": "ਬੂਸਟ",
|
||||
|
||||
@@ -113,11 +113,37 @@
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Opisz to dla osób niedowidzących…",
|
||||
"alt_text_modal.done": "Gotowe",
|
||||
"announcement.announcement": "Ogłoszenie",
|
||||
"annual_report.announcement.action_build": "Zbuduj mój Wrapstodon",
|
||||
"annual_report.announcement.action_dismiss": "Nie, dziękuję",
|
||||
"annual_report.announcement.action_view": "Zobacz mój Wrapstodon",
|
||||
"annual_report.announcement.description": "Odkryj więcej na temat swojego zaangażowania w Mastodon w ciągu ostatniego roku.",
|
||||
"annual_report.announcement.title": "Wrapstodon roku {year} przybył",
|
||||
"annual_report.nav_item.badge": "Nowe",
|
||||
"annual_report.shared_page.donate": "Wesprzyj nas",
|
||||
"annual_report.shared_page.footer": "Wygenerowane przez {heart} przez zespół Mastodon",
|
||||
"annual_report.shared_page.footer_server_info": "{username} używa {domain}, jednej z wielu społeczności wspieranych przez Mastodon.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "{name} został na polowaniu na posty do wzmocnienia, wzmacniając innych twórców doskonałym celem.",
|
||||
"annual_report.summary.archetype.booster.desc_self": "Pozostałeś na polowaniu na posty do wzmocnienia i wzmocnienia innych twórców doskonałym celem.",
|
||||
"annual_report.summary.archetype.booster.name": "Łucznik",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.archetype.lurker.name": "Stoik",
|
||||
"annual_report.summary.archetype.oracle.name": "Wyrocznia",
|
||||
"annual_report.summary.archetype.pollster.name": "Zastanawiacz",
|
||||
"annual_report.summary.archetype.replier.name": "Motylek",
|
||||
"annual_report.summary.archetype.reveal": "Pokaż mój archetyp",
|
||||
"annual_report.summary.archetype.title_public": "Archetyp {name}",
|
||||
"annual_report.summary.archetype.title_self": "Twój archetyp",
|
||||
"annual_report.summary.close": "Zamknij",
|
||||
"annual_report.summary.copy_link": "Skopiuj adres",
|
||||
"annual_report.summary.followers.new_followers": "{count, plural, one {{counter} obserwujący} few {{counter} obserwujących} many {{counter} obserwujących} other {{counter} obserwujących}}",
|
||||
"annual_report.summary.most_used_app.most_used_app": "najczęściej używana aplikacja",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "najczęściej używany hashtag",
|
||||
"annual_report.summary.new_posts.new_posts": "nowe wpisy",
|
||||
"annual_report.summary.percentile.text": "<topLabel>To plasuje cię w czołówce</topLabel><percentage></percentage><bottomLabel> użytkowników {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Nie powiemy Berniemu.",
|
||||
"annual_report.summary.share_elsewhere": "Udostępnij gdziekolwiek",
|
||||
"annual_report.summary.share_message": "Mam archetyp {archetype}!",
|
||||
"annual_report.summary.share_on_mastodon": "Udostępnij na Mastodonie",
|
||||
"attachments_list.unprocessed": "(nieprzetworzone)",
|
||||
"audio.hide": "Ukryj dźwięk",
|
||||
"block_modal.remote_users_caveat": "Poprosimy serwer {domain} o uszanowanie twojej decyzji. Nie jest to jednak gwarantowane, bo niektóre serwery mogą obsługiwać blokady w inny sposób. Publiczne wpisy mogą być nadal widoczne dla niezalogowanych użytkowników.",
|
||||
@@ -159,6 +185,7 @@
|
||||
"column.edit_list": "Edytuj listę",
|
||||
"column.favourites": "Ulubione",
|
||||
"column.firehose": "Aktualności",
|
||||
"column.firehose_local": "Kanał na żywo dla tego serwera",
|
||||
"column.firehose_singular": "Na żywo",
|
||||
"column.follow_requests": "Prośby o obserwację",
|
||||
"column.home": "Strona główna",
|
||||
@@ -179,6 +206,7 @@
|
||||
"community.column_settings.local_only": "Tylko lokalne",
|
||||
"community.column_settings.media_only": "Tylko multimedia",
|
||||
"community.column_settings.remote_only": "Tylko zdalne",
|
||||
"compose.error.blank_post": "Post nie może być pusty.",
|
||||
"compose.language.change": "Zmień język",
|
||||
"compose.language.search": "Wyszukaj języki...",
|
||||
"compose.published.body": "Wpis został opublikowany.",
|
||||
@@ -231,6 +259,11 @@
|
||||
"confirmations.missing_alt_text.secondary": "Opublikuj mimo wszystko",
|
||||
"confirmations.missing_alt_text.title": "Dodać tekst pomocniczy?",
|
||||
"confirmations.mute.confirm": "Wycisz",
|
||||
"confirmations.private_quote_notify.cancel": "Wróć do edycji",
|
||||
"confirmations.private_quote_notify.confirm": "Opublikuj wpis",
|
||||
"confirmations.private_quote_notify.do_not_show_again": "Nie pokazuj tej wiadomości ponownie",
|
||||
"confirmations.private_quote_notify.message": "Osoba, którą cytujesz, a inne wzmianki zostaną powiadomione i będą mogły zobaczyć Twój post, nawet jeśli nie obserwują Ciebie.",
|
||||
"confirmations.private_quote_notify.title": "Udostępnić obserwującym i wspomnianym użytkownikom?",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Nie przypominaj mi ponownie",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Rozumiem",
|
||||
"confirmations.quiet_post_quote_info.message": "Kiedy cytujesz niewidoczny wpis publiczny, twój wpis zostanie ukryty z popularnych osi czasu.",
|
||||
@@ -388,6 +421,9 @@
|
||||
"follow_suggestions.who_to_follow": "Kogo warto obserwować",
|
||||
"followed_tags": "Obserwowane hasztagi",
|
||||
"footer.about": "O serwerze",
|
||||
"footer.about_mastodon": "O Mastodonie",
|
||||
"footer.about_server": "O {domain}",
|
||||
"footer.about_this_server": "O nas",
|
||||
"footer.directory": "Katalog profili",
|
||||
"footer.get_app": "Pobierz aplikację",
|
||||
"footer.keyboard_shortcuts": "Skróty klawiszowe",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Ir para o perfil",
|
||||
"account.hide_reblogs": "Esconder partilhas de @{name}",
|
||||
"account.in_memoriam": "Em Memória.",
|
||||
"account.joined_long": "Juntou-se em {date}",
|
||||
"account.joined_short": "Juntou-se a",
|
||||
"account.languages": "Alterar idiomas subscritos",
|
||||
"account.link_verified_on": "O proprietário desta hiperligação foi verificado em {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Desocultar @{name}",
|
||||
"account.unmute_notifications_short": "Desocultar notificações",
|
||||
"account.unmute_short": "Desocultar",
|
||||
"account_fields_modal.close": "Fechar",
|
||||
"account_fields_modal.title": "Info de {name}",
|
||||
"account_note.placeholder": "Clicar para adicionar nota",
|
||||
"admin.dashboard.daily_retention": "Taxa de retenção de utilizadores por dia após a inscrição",
|
||||
"admin.dashboard.monthly_retention": "Taxa de retenção de utilizadores por mês após a inscrição",
|
||||
@@ -589,6 +592,7 @@
|
||||
"load_pending": "{count, plural, one {# novo item} other {# novos itens}}",
|
||||
"loading_indicator.label": "A carregar…",
|
||||
"media_gallery.hide": "Esconder",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "A tua conta {disabledAccount} está neste momento desativada porque migraste para {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Ocultar das notificações",
|
||||
"mute_modal.hide_options": "Ocultar opções",
|
||||
|
||||
@@ -38,8 +38,10 @@
|
||||
"account.follow": "Sledi",
|
||||
"account.follow_back": "Sledi nazaj",
|
||||
"account.follow_back_short": "Sledi nazaj",
|
||||
"account.follow_request": "Zaprosi za sledenje",
|
||||
"account.follow_request_cancel": "Prekliči zahtevo",
|
||||
"account.follow_request_cancel_short": "Prekliči",
|
||||
"account.follow_request_short": "Zaprosi",
|
||||
"account.followers": "Sledilci",
|
||||
"account.followers.empty": "Nihče še ne sledi temu uporabniku.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} sledilec} two {{counter} sledilca} few {{counter} sledilci} other {{counter} sledilcev}}",
|
||||
@@ -50,6 +52,7 @@
|
||||
"account.go_to_profile": "Pojdi na profil",
|
||||
"account.hide_reblogs": "Skrij izpostavitve od @{name}",
|
||||
"account.in_memoriam": "V spomin.",
|
||||
"account.joined_long": "Pridružen/a {date}",
|
||||
"account.joined_short": "Pridružil/a",
|
||||
"account.languages": "Spremeni naročene jezike",
|
||||
"account.link_verified_on": "Lastništvo te povezave je bilo preverjeno {date}",
|
||||
@@ -61,13 +64,16 @@
|
||||
"account.mute_notifications_short": "Utišaj obvestila",
|
||||
"account.mute_short": "Utišaj",
|
||||
"account.muted": "Utišan",
|
||||
"account.muting": "Izklop zvoka",
|
||||
"account.mutual": "Drug drugemu sledita",
|
||||
"account.no_bio": "Ni opisa.",
|
||||
"account.open_original_page": "Odpri izvirno stran",
|
||||
"account.posts": "Objave",
|
||||
"account.posts_with_replies": "Objave in odgovori",
|
||||
"account.remove_from_followers": "Odstrani {name} iz sledilcev",
|
||||
"account.report": "Prijavi @{name}",
|
||||
"account.requested_follow": "{name} vam želi slediti",
|
||||
"account.requests_to_follow_you": "Vas prosi za sledenje",
|
||||
"account.share": "Deli profil osebe @{name}",
|
||||
"account.show_reblogs": "Pokaži izpostavitve osebe @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} objava} two {{counter} objavi} few {{counter} objave} other {{counter} objav}}",
|
||||
@@ -80,6 +86,7 @@
|
||||
"account.unmute": "Povrni glas @{name}",
|
||||
"account.unmute_notifications_short": "Izklopi utišanje obvestil",
|
||||
"account.unmute_short": "Povrni glas",
|
||||
"account_fields_modal.close": "Zapri",
|
||||
"account_note.placeholder": "Kliknite, da dodate opombo",
|
||||
"admin.dashboard.daily_retention": "Mera ohranjanja uporabnikov po dnevih od registracije",
|
||||
"admin.dashboard.monthly_retention": "Mera ohranjanja uporabnikov po mesecih od registracije",
|
||||
@@ -103,11 +110,24 @@
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Podaj opis za slabovidne ...",
|
||||
"alt_text_modal.done": "Opravljeno",
|
||||
"announcement.announcement": "Oznanilo",
|
||||
"annual_report.announcement.action_dismiss": "Ne, hvala",
|
||||
"annual_report.nav_item.badge": "Nov",
|
||||
"annual_report.shared_page.donate": "Prispevaj",
|
||||
"annual_report.summary.archetype.replier.name": "Metulj",
|
||||
"annual_report.summary.archetype.reveal": "Razkrij moj arhetip",
|
||||
"annual_report.summary.archetype.title_public": "Arhetip {name}",
|
||||
"annual_report.summary.archetype.title_self": "Vaš arhetip",
|
||||
"annual_report.summary.close": "Zapri",
|
||||
"annual_report.summary.copy_link": "Kopiraj povezavo",
|
||||
"annual_report.summary.followers.new_followers": "{count, plural, one {{counter} nov sledilec} two {{counter} nova sledilca} few {{counter} novi sledilci} other {{counter} novih sledilcev}}",
|
||||
"annual_report.summary.highlighted_post.title": "Najbolj priljubljena objava",
|
||||
"annual_report.summary.most_used_app.most_used_app": "najpogosteje uporabljena aplikacija",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "največkrat uporabljen ključnik",
|
||||
"annual_report.summary.new_posts.new_posts": "nove objave",
|
||||
"annual_report.summary.percentile.text": "<topLabel>S tem ste se uvrstili med zgornjih </topLabel><percentage></percentage><bottomLabel> uporabnikov domene {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Živi duši ne bomo povedali.",
|
||||
"annual_report.summary.share_elsewhere": "Deli drugje",
|
||||
"annual_report.summary.share_on_mastodon": "Deli na Mastodonu",
|
||||
"attachments_list.unprocessed": "(neobdelano)",
|
||||
"audio.hide": "Skrij zvok",
|
||||
"block_modal.remote_users_caveat": "Strežnik {domain} bomo pozvali, naj spoštuje vašo odločitev. Kljub temu pa ni gotovo, da bo strežnik prošnjo upošteval, saj nekateri strežniki blokiranja obravnavajo drugače. Javne objave bodo morda še vedno vidne neprijavljenim uporabnikom.",
|
||||
@@ -368,6 +388,8 @@
|
||||
"follow_suggestions.who_to_follow": "Komu slediti",
|
||||
"followed_tags": "Sledeni ključniki",
|
||||
"footer.about": "O Mastodonu",
|
||||
"footer.about_mastodon": "O Mastodonu",
|
||||
"footer.about_server": "O {domain}",
|
||||
"footer.directory": "Imenik profilov",
|
||||
"footer.get_app": "Prenesite aplikacijo",
|
||||
"footer.keyboard_shortcuts": "Tipkovne bližnjice",
|
||||
@@ -812,6 +834,10 @@
|
||||
"status.cancel_reblog_private": "Prekliči izpostavitev",
|
||||
"status.cannot_reblog": "Te objave ni mogoče izpostaviti",
|
||||
"status.contains_quote": "Vsebuje citat",
|
||||
"status.context.loading": "Nalaganje več odgovorov",
|
||||
"status.context.loading_error": "Novih odgovorov ni bilo možno naložiti",
|
||||
"status.context.loading_success": "Novi odgovori naloženi",
|
||||
"status.context.more_replies_found": "Najdenih več odgovorov",
|
||||
"status.context.retry": "Poskusi znova",
|
||||
"status.context.show": "Pokaži",
|
||||
"status.continued_thread": "Nadaljevanje niti",
|
||||
@@ -847,6 +873,9 @@
|
||||
"status.quote_followers_only": "Samo sledilci lahko citirajo to objavo",
|
||||
"status.quote_policy_change": "Spremenite, kdo lahko citira",
|
||||
"status.quote_private": "Zasebnih objav ni možno citirati",
|
||||
"status.quotes.empty": "Nihče še ni citiral te objave. Ko se bo to zgodilo, se bodo pojavile tukaj.",
|
||||
"status.quotes.local_other_disclaimer": "Citati, ki jih je avtor zavrnil, ne bodo prikazani.",
|
||||
"status.quotes_count": "{count, plural, one {{counter} citat} two {{counter} citata} few {{counter} citati} other {{counter} citatov}}",
|
||||
"status.read_more": "Preberi več",
|
||||
"status.reblog": "Izpostavi",
|
||||
"status.reblogged_by": "{name} je izpostavil/a",
|
||||
@@ -920,7 +949,9 @@
|
||||
"video.unmute": "Odtišaj",
|
||||
"video.volume_down": "Zmanjšaj glasnost",
|
||||
"video.volume_up": "Povečaj glasnost",
|
||||
"visibility_modal.button_title": "Določi vidnost",
|
||||
"visibility_modal.header": "Vidnost in interakcija",
|
||||
"visibility_modal.helper.privacy_editing": "Vidnosti ni moč spremeniti, ko je objava objavljena.",
|
||||
"visibility_modal.privacy_label": "Vidnost",
|
||||
"visibility_modal.quote_followers": "Samo sledilci",
|
||||
"visibility_modal.quote_label": "Kdo lahko citira",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"account.go_to_profile": "Kalo te profili",
|
||||
"account.hide_reblogs": "Fshih përforcime nga @{name}",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
"account.joined_long": "U bë pjesë më {date}",
|
||||
"account.joined_short": "U bë pjesë",
|
||||
"account.languages": "Ndryshoni gjuhë pajtimesh",
|
||||
"account.link_verified_on": "Pronësia e kësaj lidhjeje qe kontrolluar më {date}",
|
||||
@@ -90,6 +91,8 @@
|
||||
"account.unmute": "Ktheji zërin @{name}",
|
||||
"account.unmute_notifications_short": "Shfaqi njoftimet",
|
||||
"account.unmute_short": "Çheshtoje",
|
||||
"account_fields_modal.close": "Mbylle",
|
||||
"account_fields_modal.title": "Hollësi për {name}",
|
||||
"account_note.placeholder": "Klikoni për të shtuar shënim",
|
||||
"admin.dashboard.daily_retention": "Shkallë mbajtjeje përdoruesi, në ditë, pas regjistrimit",
|
||||
"admin.dashboard.monthly_retention": "Shkallë mbajtjeje përdoruesi, në muaj, pas regjistrimit",
|
||||
@@ -587,6 +590,7 @@
|
||||
"load_pending": "{count, plural,one {# objekt i ri }other {# objekte të rinj }}",
|
||||
"loading_indicator.label": "Po ngarkohet…",
|
||||
"media_gallery.hide": "Fshihe",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Llogaria juaj {disabledAccount} aktualisht është e çaktivizuar, ngaqë kaluat te {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Fshihe prej njoftimeve",
|
||||
"mute_modal.hide_options": "Fshihi mundësitë",
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
"annual_report.shared_page.donate": "Donera",
|
||||
"annual_report.shared_page.footer": "Genererad med {heart} av Mastodon-teamet",
|
||||
"annual_report.shared_page.footer_server_info": "{username} använder {domain}, en av många forum som drivs av Mastodon.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "{name} jagade efter inlägg att boosta och förstärka andra skapare med perfekt sikte.",
|
||||
"annual_report.summary.archetype.booster.desc_self": "Du jagade efter inlägg att boosta och förstärka andra skapare med perfekt sikte.",
|
||||
"annual_report.summary.archetype.booster.name": "Bågskytten",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.archetype.lurker.desc_public": "Vi vet att {name} var där ute någonstans och njuter av Mastodon på sitt egna tysta sätt.",
|
||||
@@ -131,13 +133,31 @@
|
||||
"annual_report.summary.archetype.oracle.desc_self": "Du skapade nya inlägg mer än svar och höll Mastodon fräscht och framtidsinriktat.",
|
||||
"annual_report.summary.archetype.oracle.name": "Oraklet",
|
||||
"annual_report.summary.archetype.pollster.desc_public": "{name} skapade fler undersökningar än andra inläggstyper och skapade nyfikenhet på Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.desc_self": "Du skapade fler undersökningar än andra inläggstyper och skapade nyfikenhet på Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.name": "Undraren",
|
||||
"annual_report.summary.archetype.replier.desc_public": "{name} svarade ofta på andras inlägg och pollinerade Mastodon med nya diskussioner.",
|
||||
"annual_report.summary.archetype.replier.desc_self": "Du svarade ofta på andras inlägg och pollinerade Mastodon med nya diskussioner.",
|
||||
"annual_report.summary.archetype.replier.name": "Fjärilen",
|
||||
"annual_report.summary.archetype.reveal": "Avslöja min arketyp",
|
||||
"annual_report.summary.archetype.reveal_description": "Tack för att du är en del av Mastodon! Dags att ta reda på vilken arketyp du förkroppsligade under {year}.",
|
||||
"annual_report.summary.archetype.title_public": "{name}s arketyp",
|
||||
"annual_report.summary.archetype.title_self": "Din arketyp",
|
||||
"annual_report.summary.close": "Stäng",
|
||||
"annual_report.summary.copy_link": "Kopiera länk",
|
||||
"annual_report.summary.followers.new_followers": "{count, plural, one {ny följare} other {nya följare}}",
|
||||
"annual_report.summary.highlighted_post.boost_count": "Det här inlägget förstärktes {count, plural, one {en gång} other {# gånger}}.",
|
||||
"annual_report.summary.highlighted_post.favourite_count": "Det här inlägget favoriserades {count, plural, one {en gång} other {# gånger}}.",
|
||||
"annual_report.summary.highlighted_post.reply_count": "Det här inlägget fick {count, plural, one {ett svar} other {# svar}}.",
|
||||
"annual_report.summary.highlighted_post.title": "Mest populära inlägg",
|
||||
"annual_report.summary.most_used_app.most_used_app": "mest använda app",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "mest använda hashtag",
|
||||
"annual_report.summary.most_used_hashtag.used_count": "Du inkluderade denna hashtag i {count, plural, one {ett inlägg} other {# inlägg}}.",
|
||||
"annual_report.summary.most_used_hashtag.used_count_public": "{name} inkluderade denna hashtag i {count, plural, one {ett inlägg} other {# inlägg}}.",
|
||||
"annual_report.summary.new_posts.new_posts": "nya inlägg",
|
||||
"annual_report.summary.percentile.text": "<topLabel>Det placerar dig i topp</topLabel><percentage></percentage><bottomLabel>bland {domain} användare.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Vi berättar inte för Bernie.",
|
||||
"annual_report.summary.share_elsewhere": "Dela någon annanstans",
|
||||
"annual_report.summary.share_message": "Jag fick {archetype}-arketypen!",
|
||||
"annual_report.summary.share_on_mastodon": "Dela på Mastodon",
|
||||
"attachments_list.unprocessed": "(obehandlad)",
|
||||
"audio.hide": "Dölj audio",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user