mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-24 21:28:16 +00:00
Compare commits
130 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a863e68d17 | ||
![]() |
847b37552a | ||
![]() |
dfaca794bf | ||
![]() |
6fc77a545b | ||
![]() |
c871c7398e | ||
![]() |
8baed8b90e | ||
![]() |
8a1c43bf3b | ||
![]() |
5c01ccc31f | ||
![]() |
67be8208db | ||
![]() |
7d136feccf | ||
![]() |
e54e96d61f | ||
![]() |
469304359a | ||
![]() |
290e36d7e8 | ||
![]() |
4241ce9888 | ||
![]() |
7f9ad7eabf | ||
![]() |
a6794c066d | ||
![]() |
7d3ef27a8d | ||
![]() |
14a781fa24 | ||
![]() |
cec26d58c8 | ||
![]() |
593cdae404 | ||
![]() |
d2ef9ac04a | ||
![]() |
d065ec9298 | ||
![]() |
b19131202f | ||
![]() |
70058ae49d | ||
![]() |
62a23b1985 | ||
![]() |
6917cd2f40 | ||
![]() |
d36236cbcd | ||
![]() |
760d00b7f7 | ||
![]() |
0af2c4829f | ||
![]() |
be3dc5b508 | ||
![]() |
ae13063460 | ||
![]() |
1ed58aaaf2 | ||
![]() |
bf17895d19 | ||
![]() |
20b3c43dde | ||
![]() |
ee21f72211 | ||
![]() |
4de5cbd6f5 | ||
![]() |
fab95b8dae | ||
![]() |
4d2655490c | ||
![]() |
6bb4113d0a | ||
![]() |
3e76f01db4 | ||
![]() |
cf580d8c90 | ||
![]() |
dbd0c3cbd9 | ||
![]() |
3771f9e04b | ||
![]() |
a842b14c84 | ||
![]() |
138746bdcc | ||
![]() |
9e6a9efe10 | ||
![]() |
19626ad89f | ||
![]() |
7e2d92284c | ||
![]() |
20fb6bd788 | ||
![]() |
faffb73cbd | ||
![]() |
02a4e30594 | ||
![]() |
f10b522f0c | ||
![]() |
331599fa2b | ||
![]() |
558b9c90a6 | ||
![]() |
7d2dda97b3 | ||
![]() |
74fc4dbacf | ||
![]() |
07912a1cb7 | ||
![]() |
d36bf3b6fb | ||
![]() |
594976a538 | ||
![]() |
0efb889a9c | ||
![]() |
c0eabe289b | ||
![]() |
5bbc3c5ebb | ||
![]() |
d5e2cf5d3c | ||
![]() |
82a6ff091f | ||
![]() |
4b8e60682d | ||
![]() |
6c2db9b1cf | ||
![]() |
30344d6abf | ||
![]() |
1637297085 | ||
![]() |
dec1fb71f4 | ||
![]() |
7273f6c03c | ||
![]() |
a3ffd2edf8 | ||
![]() |
a2c5eace88 | ||
![]() |
a643d9d498 | ||
![]() |
3b52dca405 | ||
![]() |
853a0c466e | ||
![]() |
94bceb8683 | ||
![]() |
88b0f3a172 | ||
![]() |
b69b5ba775 | ||
![]() |
c442589593 | ||
![]() |
28633a504a | ||
![]() |
ad78701b6f | ||
![]() |
1496488771 | ||
![]() |
dd3d958e75 | ||
![]() |
b363a3651d | ||
![]() |
86645fc14c | ||
![]() |
f9beecb343 | ||
![]() |
4ecfbd3920 | ||
![]() |
a315934314 | ||
![]() |
e9170e2de1 | ||
![]() |
5cfc1fabcf | ||
![]() |
786b12e379 | ||
![]() |
e7c5c25de8 | ||
![]() |
a1e8813522 | ||
![]() |
76c1446416 | ||
![]() |
8bd2c87399 | ||
![]() |
1e2d77f2c7 | ||
![]() |
fb6c22f5c2 | ||
![]() |
f7259f625f | ||
![]() |
b628a98d32 | ||
![]() |
d8fa807998 | ||
![]() |
ef66d8379c | ||
![]() |
8ee6cee36e | ||
![]() |
71b2120e5c | ||
![]() |
b10078633c | ||
![]() |
b5eebd4d2b | ||
![]() |
fdefc4d2b4 | ||
![]() |
f6b2609353 | ||
![]() |
bdffdcb12f | ||
![]() |
1ebb87a6a8 | ||
![]() |
83660ee381 | ||
![]() |
1fa72d6c44 | ||
![]() |
5a7c0d42f7 | ||
![]() |
e8d2432e6a | ||
![]() |
2af17adc34 | ||
![]() |
e97f43399b | ||
![]() |
c66c5fd73d | ||
![]() |
3c0767f543 | ||
![]() |
70cd1fdc63 | ||
![]() |
39028dde40 | ||
![]() |
6e39b5ef04 | ||
![]() |
49db8a9662 | ||
![]() |
2cfa6cb0e0 | ||
![]() |
1ae3510ede | ||
![]() |
6f1135d763 | ||
![]() |
52bc2f64f4 | ||
![]() |
b1375328e1 | ||
![]() |
9443e2cc4b | ||
![]() |
3a533c6c8d | ||
![]() |
c047014214 | ||
![]() |
68b05e994f |
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -1,6 +1,6 @@
|
||||||
name: Bug Report (Web Interface)
|
name: Bug Report (Web Interface)
|
||||||
description: There is a problem using Mastodon's web interface.
|
description: There is a problem using Mastodon's web interface.
|
||||||
labels: ['status/to triage', 'area/web interface']
|
labels: ['area/web interface']
|
||||||
type: Bug
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
name: Bug Report (server / API)
|
name: Bug Report (server / API)
|
||||||
description: |
|
description: |
|
||||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||||
labels: ['status/to triage']
|
|
||||||
type: 'Bug'
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
|
@ -23,7 +23,6 @@
|
||||||
matchManagers: ['npm'],
|
matchManagers: ['npm'],
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
'tesseract.js', // Requires code changes
|
'tesseract.js', // Requires code changes
|
||||||
'react-hotkeys', // Requires code changes
|
|
||||||
|
|
||||||
// react-router: Requires manual upgrade
|
// react-router: Requires manual upgrade
|
||||||
'history',
|
'history',
|
||||||
|
|
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
||||||
# Only tag with latest when ran against the latest stable branch
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
|
@ -39,7 +39,7 @@ jobs:
|
||||||
# Only tag with latest when ran against the latest stable branch
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.4.4
|
3.4.5
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
|
@ -26,6 +28,12 @@ const config: StorybookConfig = {
|
||||||
'oops.png',
|
'oops.png',
|
||||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||||
],
|
],
|
||||||
|
viteFinal(config) {
|
||||||
|
// For an unknown reason, Storybook does not use the root
|
||||||
|
// from the Vite config so we need to set it manually.
|
||||||
|
config.root = resolve(__dirname, '../app/javascript');
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
30
CHANGELOG.md
30
CHANGELOG.md
|
@ -2,6 +2,36 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.4.2] - 2025-07-23
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
|
||||||
|
- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
|
||||||
|
- Fix quote posts styling on notifications page (#35411 by @diondiondion)
|
||||||
|
- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
|
||||||
|
- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
|
||||||
|
- Update age limit wording (#35387 by @diondiondion)
|
||||||
|
- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
|
||||||
|
- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
|
||||||
|
- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
|
||||||
|
- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
|
||||||
|
- Fix styling of external log-in button (#35320 by @ClearlyClaire)
|
||||||
|
|
||||||
|
## [4.4.1] - 2025-07-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
|
||||||
|
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
|
||||||
|
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
|
||||||
|
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
|
||||||
|
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
|
||||||
|
|
||||||
## [4.4.0] - 2025-07-08
|
## [4.4.0] - 2025-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.4"
|
ARG RUBY_VERSION="3.4.5"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.0
|
ARG VIPS_VERSION=8.17.1
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# 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
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -62,7 +62,7 @@ gem 'inline_svg'
|
||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'linzer', '~> 0.7.2'
|
gem 'linzer', '~> 0.7.7'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
|
|
108
Gemfile.lock
108
Gemfile.lock
|
@ -90,13 +90,13 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.16.0)
|
annotaterb (4.17.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1103.0)
|
aws-partitions (1.1131.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
@ -109,9 +109,9 @@ GEM
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-blob (0.5.8)
|
azure-blob (0.5.9.1)
|
||||||
rexml
|
rexml
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
|
@ -224,16 +224,16 @@ GEM
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.0.1)
|
erb (5.0.2)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.2.5)
|
excon (1.2.8)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.1)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.1)
|
faraday (2.13.2)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
|
@ -241,7 +241,7 @@ GEM
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.2)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.1)
|
||||||
net-http (>= 0.5.0)
|
net-http (>= 0.5.0)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
|
@ -266,14 +266,14 @@ GEM
|
||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.0)
|
formatador (1.1.1)
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.31.0)
|
google-protobuf (4.31.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
|
@ -287,21 +287,21 @@ GEM
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
haml_lint (0.64.0)
|
haml_lint (0.65.1)
|
||||||
haml (>= 5.0)
|
haml (>= 5.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.2.0)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.24.0)
|
hiredis-client (0.25.1)
|
||||||
redis-client (= 0.24.0)
|
redis-client (= 0.25.1)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
@ -315,7 +315,7 @@ GEM
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
httplog (1.7.0)
|
httplog (1.7.2)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
|
@ -335,7 +335,7 @@ GEM
|
||||||
inline_svg (1.10.0)
|
inline_svg (1.10.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.8.0)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
|
@ -345,7 +345,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.12.2)
|
json (2.13.0)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
@ -362,14 +362,14 @@ GEM
|
||||||
rack (>= 2.2, < 4)
|
rack (>= 2.2, < 4)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rexml (~> 3.2)
|
rexml (~> 3.2)
|
||||||
json-ld-preloaded (3.3.1)
|
json-ld-preloaded (3.3.2)
|
||||||
json-ld (~> 3.3)
|
json-ld (~> 3.3)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
json-schema (5.1.1)
|
json-schema (5.2.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.10.1)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
|
@ -403,7 +403,7 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
linzer (0.7.3)
|
linzer (0.7.7)
|
||||||
cgi (~> 0.4.2)
|
cgi (~> 0.4.2)
|
||||||
forwardable (~> 1.3, >= 1.3.3)
|
forwardable (~> 1.3, >= 1.3.3)
|
||||||
logger (~> 1.7, >= 1.7.0)
|
logger (~> 1.7, >= 1.7.0)
|
||||||
|
@ -433,21 +433,21 @@ GEM
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
mario-redis-lock (1.2.1)
|
mario-redis-lock (1.2.1)
|
||||||
redis (>= 3.0.5)
|
redis (>= 3.0.5)
|
||||||
matrix (0.4.2)
|
matrix (0.4.3)
|
||||||
memory_profiler (1.1.0)
|
memory_profiler (1.1.0)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0514)
|
mime-types-data (3.2025.0715)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.17.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.8)
|
net-imap (0.5.9)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
|
@ -458,7 +458,7 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.8)
|
nokogiri (1.18.9)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.11)
|
oj (3.16.11)
|
||||||
|
@ -515,7 +515,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.12.1)
|
opentelemetry-instrumentation-action_pack (0.12.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
|
@ -553,7 +553,7 @@ GEM
|
||||||
opentelemetry-instrumentation-faraday (0.27.0)
|
opentelemetry-instrumentation-faraday (0.27.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http (0.25.0)
|
opentelemetry-instrumentation-http (0.25.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http_client (0.23.0)
|
opentelemetry-instrumentation-http_client (0.23.0)
|
||||||
|
@ -597,7 +597,7 @@ GEM
|
||||||
opentelemetry-semantic_conventions (1.11.0)
|
opentelemetry-semantic_conventions (1.11.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.3)
|
||||||
ox (2.14.23)
|
ox (2.14.23)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
|
@ -610,7 +610,7 @@ GEM
|
||||||
pg (1.5.9)
|
pg (1.5.9)
|
||||||
pghero (3.7.0)
|
pghero (3.7.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
playwright-ruby-client (1.52.0)
|
playwright-ruby-client (1.54.0)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.2)
|
pp (0.6.2)
|
||||||
|
@ -627,11 +627,10 @@ GEM
|
||||||
prism (1.4.0)
|
prism (1.4.0)
|
||||||
prometheus_exporter (2.2.0)
|
prometheus_exporter (2.2.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.1.0)
|
propshaft (1.2.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
railties (>= 7.0.0)
|
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
|
@ -682,7 +681,7 @@ GEM
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.2)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
|
@ -702,23 +701,28 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
rdf (3.3.2)
|
rdf (3.3.4)
|
||||||
bcp47_spec (~> 0.2)
|
bcp47_spec (~> 0.2)
|
||||||
bigdecimal (~> 3.1, >= 3.1.5)
|
bigdecimal (~> 3.1, >= 3.1.5)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
|
logger (~> 1.5)
|
||||||
|
ostruct (~> 0.6)
|
||||||
|
readline (~> 0.0)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.14.1)
|
rdoc (6.14.2)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
|
readline (0.0.4)
|
||||||
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.24.0)
|
redis-client (0.25.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.10.0)
|
regexp_parser (2.10.0)
|
||||||
reline (0.6.1)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
@ -733,11 +737,11 @@ GEM
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.0)
|
||||||
rspec (3.13.0)
|
rspec (3.13.1)
|
||||||
rspec-core (~> 3.13.0)
|
rspec-core (~> 3.13.0)
|
||||||
rspec-expectations (~> 3.13.0)
|
rspec-expectations (~> 3.13.0)
|
||||||
rspec-mocks (~> 3.13.0)
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.4)
|
rspec-core (3.13.5)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
@ -755,13 +759,13 @@ GEM
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-sidekiq (5.1.0)
|
rspec-sidekiq (5.2.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.77.0)
|
rubocop (1.78.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
@ -772,7 +776,7 @@ GEM
|
||||||
rubocop-ast (>= 1.45.1, < 2.0)
|
rubocop-ast (>= 1.45.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.45.1)
|
rubocop-ast (1.46.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
|
@ -815,7 +819,7 @@ GEM
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
scenic (1.8.0)
|
scenic (1.9.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
|
@ -846,7 +850,7 @@ GEM
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.1)
|
simplecov-html (0.13.2)
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.8.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
|
@ -866,11 +870,11 @@ GEM
|
||||||
temple (0.10.3)
|
temple (0.10.3)
|
||||||
terminal-table (4.0.0)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 4)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
terrapin (1.1.0)
|
terrapin (1.1.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.4.4)
|
test-prof (1.4.4)
|
||||||
thor (1.3.2)
|
thor (1.4.0)
|
||||||
tilt (2.6.0)
|
tilt (2.6.1)
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
|
@ -932,7 +936,7 @@ GEM
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.1)
|
webrick (1.9.1)
|
||||||
websocket-driver (0.7.7)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
@ -1008,7 +1012,7 @@ DEPENDENCIES
|
||||||
letter_opener (~> 1.8)
|
letter_opener (~> 1.8)
|
||||||
letter_opener_web (~> 3.0)
|
letter_opener_web (~> 3.0)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
linzer (~> 0.7.2)
|
linzer (~> 0.7.7)
|
||||||
lograge (~> 0.12)
|
lograge (~> 0.12)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
|
@ -1102,4 +1106,4 @@ RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.6.9
|
2.7.0
|
||||||
|
|
62
README.md
62
README.md
|
@ -17,71 +17,71 @@
|
||||||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
- [Support the development via Patreon][patreon]
|
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog](https://blog.joinmastodon.org)
|
- [Blog 📰](https://blog.joinmastodon.org)
|
||||||
- [Documentation](https://docs.joinmastodon.org)
|
- [Documentation 📚](https://docs.joinmastodon.org)
|
||||||
- [Roadmap](https://joinmastodon.org/roadmap)
|
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||||
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
|
||||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
|
||||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
|
||||||
|
|
||||||
[patreon]: https://www.patreon.com/mastodon
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||||
|
|
||||||
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
|
||||||
|
|
||||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
||||||
|
|
||||||
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
|
||||||
|
|
||||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
|
||||||
|
|
||||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- **Ruby on Rails** powers the REST API and other web pages
|
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
||||||
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
||||||
- **Node.js** powers the streaming API
|
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
||||||
|
- [Node.js](https://nodejs.org/) powers the streaming API.
|
||||||
|
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
|
||||||
|
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
|
||||||
|
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
|
- **Ruby** 3.2+
|
||||||
- **PostgreSQL** 13+
|
- **PostgreSQL** 13+
|
||||||
- **Redis** 6.2+
|
- **Redis** 6.2+
|
||||||
- **Ruby** 3.2+
|
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
||||||
|
|
||||||
You can open issues for bugs you've found or features you think are missing. You
|
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
||||||
can also submit pull requests to this repository or translations via Crowdin. To
|
|
||||||
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
|
||||||
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
|
||||||
|
|
||||||
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
|
||||||
|
|
||||||
## License
|
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
||||||
|
|
||||||
|
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
||||||
|
|
||||||
|
## LICENSE
|
||||||
|
|
||||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
```
|
```text
|
||||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
@ -97,7 +97,3 @@ details.
|
||||||
You should have received a copy of the GNU Affero General Public License along
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
with this program. If not, see https://www.gnu.org/licenses/
|
with this program. If not, see https://www.gnu.org/licenses/
|
||||||
```
|
```
|
||||||
|
|
||||||
[CONTRIBUTING]: CONTRIBUTING.md
|
|
||||||
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
|
||||||
[OpenCollective]: https://opencollective.com/mastodon
|
|
||||||
|
|
|
@ -16,11 +16,14 @@ module Admin
|
||||||
def batch
|
def batch
|
||||||
authorize :account, :index?
|
authorize :account, :index?
|
||||||
|
|
||||||
@form = Form::AccountBatch.new(form_account_batch_params)
|
@form = Form::AccountBatch.new(
|
||||||
@form.current_account = current_account
|
form_account_batch_params.merge(
|
||||||
@form.action = action_from_button
|
action: action_from_button,
|
||||||
@form.select_all_matching = params[:select_all_matching]
|
current_account:,
|
||||||
@form.query = filtered_accounts
|
query: filtered_accounts,
|
||||||
|
select_all_matching: params[:select_all_matching]
|
||||||
|
)
|
||||||
|
)
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
|
|
@ -19,15 +19,13 @@ module Admin
|
||||||
|
|
||||||
log_action :resend, @user
|
log_action :resend, @user
|
||||||
|
|
||||||
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
|
redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_confirmed_user
|
def redirect_confirmed_user
|
||||||
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_confirmed?
|
def user_confirmed?
|
||||||
|
|
|
@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive'
|
||||||
status_batch_action = Admin::StatusBatchAction.new(
|
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
status_ids: @report.status_ids,
|
|
||||||
current_account: current_account,
|
|
||||||
report_id: @report.id,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
status_batch_action.save!
|
|
||||||
when 'silence', 'suspend'
|
when 'silence', 'suspend'
|
||||||
account_action = Admin::AccountAction.new(
|
Admin::AccountAction.new(account_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
report_id: @report.id,
|
|
||||||
target_account: @report.target_account,
|
|
||||||
current_account: current_account,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
account_action.save!
|
|
||||||
else
|
else
|
||||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
|
@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def status_batch_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(status_ids: @report.status_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(target_account: @report.target_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def shared_params
|
||||||
|
{
|
||||||
|
current_account: current_account,
|
||||||
|
report_id: @report.id,
|
||||||
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text],
|
||||||
|
type: action_from_button,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def set_report
|
def set_report
|
||||||
@report = Report.find(params[:report_id])
|
@report = Report.find(params[:report_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,8 +14,7 @@ module Admin
|
||||||
@admin_settings = Form::AdminSettings.new(settings_params)
|
@admin_settings = Form::AdminSettings.new(settings_params)
|
||||||
|
|
||||||
if @admin_settings.save
|
if @admin_settings.save
|
||||||
flash[:notice] = I18n.t('generic.changes_saved_msg')
|
redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
|
||||||
redirect_to after_update_redirect_path
|
|
||||||
else
|
else
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,7 @@ module Admin
|
||||||
before_action :set_tag, except: [:index]
|
before_action :set_tag, except: [:index]
|
||||||
|
|
||||||
PER_PAGE = 20
|
PER_PAGE = 20
|
||||||
|
PERIOD_DAYS = 6.days
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
@ -15,7 +16,7 @@ module Admin
|
||||||
def show
|
def show
|
||||||
authorize @tag, :show?
|
authorize @tag, :show?
|
||||||
|
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
@ -24,7 +25,7 @@ module Admin
|
||||||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||||
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
||||||
else
|
else
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
|
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
@ -36,6 +37,10 @@ module Admin
|
||||||
@tag = Tag.find(params[:id])
|
@tag = Tag.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def report_range
|
||||||
|
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
||||||
|
end
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
params
|
params
|
||||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||||
|
|
|
@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
skip_around_action :set_locale
|
skip_around_action :set_locale
|
||||||
|
|
||||||
before_action :set_invite
|
before_action :set_invite
|
||||||
|
before_action :check_valid_usage!
|
||||||
before_action :check_enabled_registrations!
|
before_action :check_enabled_registrations!
|
||||||
|
|
||||||
# Override `current_user` to avoid reading session cookies
|
# Override `current_user` to avoid reading session cookies
|
||||||
|
@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
@invite = Invite.find_by!(code: params[:invite_code])
|
@invite = Invite.find_by!(code: params[:invite_code])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_enabled_registrations!
|
def check_valid_usage!
|
||||||
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_enabled_registrations!
|
||||||
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
def create
|
def create
|
||||||
with_redis_lock("push_subscription:#{current_user.id}") do
|
with_redis_lock("push_subscription:#{current_user.id}") do
|
||||||
destroy_web_push_subscriptions!
|
destroy_web_push_subscriptions!
|
||||||
|
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
|
||||||
@push_subscription = Web::PushSubscription.create!(
|
|
||||||
endpoint: subscription_params[:endpoint],
|
|
||||||
key_p256dh: subscription_params[:keys][:p256dh],
|
|
||||||
key_auth: subscription_params[:keys][:auth],
|
|
||||||
standard: subscription_params[:standard] || false,
|
|
||||||
data: data_params,
|
|
||||||
user_id: current_user.id,
|
|
||||||
access_token_id: doorkeeper_token.id
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||||
|
@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
not_found if @push_subscription.nil?
|
not_found if @push_subscription.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def web_push_subscription_params
|
||||||
|
{
|
||||||
|
access_token_id: doorkeeper_token.id,
|
||||||
|
data: data_params,
|
||||||
|
endpoint: subscription_params[:endpoint],
|
||||||
|
key_auth: subscription_params[:keys][:auth],
|
||||||
|
key_p256dh: subscription_params[:keys][:p256dh],
|
||||||
|
standard: subscription_params[:standard] || false,
|
||||||
|
user_id: current_user.id,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def subscription_params
|
def subscription_params
|
||||||
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::StatusesController < Api::BaseController
|
class Api::V1::StatusesController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
include AsyncRefreshesConcern
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||||
|
@ -57,9 +58,17 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants
|
||||||
|
|
||||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
refresh_key = "context:#{@status.id}:refresh"
|
||||||
|
async_refresh = AsyncRefresh.new(refresh_key)
|
||||||
|
|
||||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
if async_refresh.running?
|
||||||
|
add_async_refresh_header(async_refresh)
|
||||||
|
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||||
|
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||||
|
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||||
{
|
{
|
||||||
policy: 'all',
|
policy: 'all',
|
||||||
alerts: Notification::TYPES.index_with { alerts_enabled },
|
alerts: Notification::TYPES.index_with { alerts_enabled },
|
||||||
}
|
}.deep_stringify_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
def alerts_enabled
|
def alerts_enabled
|
||||||
|
|
|
@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
private
|
private
|
||||||
|
|
||||||
def record_login_activity
|
def record_login_activity
|
||||||
LoginActivity.create(
|
@user.login_activities.create(
|
||||||
user: @user,
|
|
||||||
success: true,
|
success: true,
|
||||||
authentication_method: :omniauth,
|
authentication_method: :omniauth,
|
||||||
provider: @provider,
|
provider: @provider,
|
||||||
|
|
|
@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_reset_token
|
def redirect_invalid_reset_token
|
||||||
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
|
||||||
redirect_to new_password_path(resource_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_password_token_is_valid?
|
def reset_password_token_is_valid?
|
||||||
|
|
|
@ -151,12 +151,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: true,
|
|
||||||
authentication_method: security_measure,
|
authentication_method: security_measure,
|
||||||
ip: request.remote_ip,
|
success: true
|
||||||
user_agent: request.user_agent
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||||
|
@ -167,13 +166,12 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_authentication_failure(user, security_measure, failure_reason)
|
def on_authentication_failure(user, security_measure, failure_reason)
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: false,
|
|
||||||
authentication_method: security_measure,
|
authentication_method: security_measure,
|
||||||
failure_reason: failure_reason,
|
failure_reason: failure_reason,
|
||||||
ip: request.remote_ip,
|
success: false
|
||||||
user_agent: request.user_agent
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
|
@ -182,6 +180,13 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_details
|
||||||
|
{
|
||||||
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,18 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
include Hcaptcha::Adapters::ViewMethods
|
include Hcaptcha::Adapters::ViewMethods
|
||||||
|
|
||||||
|
CAPTCHA_DIRECTIVES = %w(
|
||||||
|
connect_src
|
||||||
|
frame_src
|
||||||
|
script_src
|
||||||
|
style_src
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES = %w(
|
||||||
|
https://*.hcaptcha.com
|
||||||
|
https://hcaptcha.com
|
||||||
|
).freeze
|
||||||
|
|
||||||
included do
|
included do
|
||||||
helper_method :render_captcha
|
helper_method :render_captcha
|
||||||
end
|
end
|
||||||
|
@ -42,20 +54,9 @@ module Auth::CaptchaConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def extend_csp_for_captcha!
|
def extend_csp_for_captcha!
|
||||||
policy = request.content_security_policy&.clone
|
return unless captcha_required? && request.content_security_policy.present?
|
||||||
|
|
||||||
return unless captcha_required? && policy.present?
|
request.content_security_policy = captcha_adjusted_policy
|
||||||
|
|
||||||
%w(script_src frame_src style_src connect_src).each do |directive|
|
|
||||||
values = policy.send(directive)
|
|
||||||
|
|
||||||
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
|
||||||
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
|
||||||
|
|
||||||
policy.send(directive, *values)
|
|
||||||
end
|
|
||||||
|
|
||||||
request.content_security_policy = policy
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_captcha
|
def render_captcha
|
||||||
|
@ -63,4 +64,24 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
hcaptcha_tags
|
hcaptcha_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def captcha_adjusted_policy
|
||||||
|
request.content_security_policy.clone.tap do |policy|
|
||||||
|
populate_captcha_policy(policy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def populate_captcha_policy(policy)
|
||||||
|
CAPTCHA_DIRECTIVES.each do |directive|
|
||||||
|
values = policy.send(directive)
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES.each do |source|
|
||||||
|
values << source unless values.include?(source) || values.include?('https:')
|
||||||
|
end
|
||||||
|
|
||||||
|
policy.send(directive, *values)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@session.destroy!
|
@session.destroy!
|
||||||
flash[:notice] = I18n.t('sessions.revoke_success')
|
redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
|
||||||
redirect_to edit_user_registration_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -86,13 +86,11 @@ module Settings
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_otp
|
def redirect_invalid_otp
|
||||||
flash[:error] = t('webauthn_credentials.otp_required')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_invalid_webauthn
|
def redirect_invalid_webauthn
|
||||||
flash[:error] = t('webauthn_credentials.not_enabled')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -66,7 +66,7 @@ module ApplicationHelper
|
||||||
|
|
||||||
def provider_sign_in_link(provider)
|
def provider_sign_in_link(provider)
|
||||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_direction
|
def locale_direction
|
||||||
|
|
|
@ -26,6 +26,12 @@ module ContextHelper
|
||||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||||
|
quotes: {
|
||||||
|
'quote' => 'https://w3id.org/fep/044f#quote',
|
||||||
|
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
|
||||||
|
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
},
|
||||||
interaction_policies: {
|
interaction_policies: {
|
||||||
'gts' => 'https://gotosocial.org/ns#',
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { apiGetContext } from 'mastodon/api/statuses';
|
import { apiGetContext } from 'mastodon/api/statuses';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -6,13 +8,18 @@ import { importFetchedStatuses } from './importer';
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||||
(context, { dispatch }) => {
|
({ context, refresh }, { dispatch }) => {
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
|
refresh,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||||
|
'status/context/complete',
|
||||||
|
);
|
||||||
|
|
|
@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => {
|
||||||
return LinkHeader.parse(value);
|
return LinkHeader.parse(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AsyncRefreshHeader {
|
||||||
|
id: string;
|
||||||
|
retry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
|
||||||
|
'id' in obj && 'retry' in obj;
|
||||||
|
|
||||||
|
export const getAsyncRefreshHeader = (
|
||||||
|
response: AxiosResponse,
|
||||||
|
): AsyncRefreshHeader | null => {
|
||||||
|
const value = response.headers['mastodon-async-refresh'] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asyncRefreshHeader: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
value.split(/,\s*/).forEach((pair) => {
|
||||||
|
const [key, val] = pair.split('=', 2);
|
||||||
|
|
||||||
|
let typedValue: string | number;
|
||||||
|
|
||||||
|
if (key && ['id', 'retry'].includes(key) && val) {
|
||||||
|
if (val.startsWith('"')) {
|
||||||
|
typedValue = val.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
typedValue = parseInt(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncRefreshHeader[key] = typedValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
|
||||||
|
return asyncRefreshHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const csrfHeader: RawAxiosRequestHeaders = {};
|
const csrfHeader: RawAxiosRequestHeaders = {};
|
||||||
|
|
||||||
const setCSRFHeader = () => {
|
const setCSRFHeader = () => {
|
||||||
|
@ -83,7 +127,7 @@ export default function api(withAuthorization = true) {
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiUrl = `v${1 | 2}/${string}`;
|
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||||
type RequestParamsOrData = Record<string, unknown>;
|
type RequestParamsOrData = Record<string, unknown>;
|
||||||
|
|
||||||
export async function apiRequest<ApiResponse = unknown>(
|
export async function apiRequest<ApiResponse = unknown>(
|
||||||
|
|
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
|
||||||
|
|
||||||
|
export const apiGetAsyncRefresh = (id: string) =>
|
||||||
|
apiRequestGet<ApiAsyncRefreshJSON>(`v1_alpha/async_refreshes/${id}`);
|
|
@ -1,5 +1,14 @@
|
||||||
import { apiRequestGet } from 'mastodon/api';
|
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
||||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
export const apiGetContext = (statusId: string) =>
|
export const apiGetContext = async (statusId: string) => {
|
||||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
const response = await api().request<ApiContextJSON>({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/statuses/${statusId}/context`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: response.data,
|
||||||
|
refresh: getAsyncRefreshHeader(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export interface ApiAsyncRefreshJSON {
|
||||||
|
async_refresh: {
|
||||||
|
id: string;
|
||||||
|
status: 'running' | 'finished';
|
||||||
|
result_count: number;
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,20 +1,76 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
|
|
||||||
export const AccountBio: React.FC<{
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
note: string;
|
import { useAppSelector } from '../store';
|
||||||
className: string;
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
}> = ({ note, className }) => {
|
|
||||||
const handleClick = useLinks();
|
|
||||||
|
|
||||||
if (note.length === 0 || note === '<p></p>') {
|
interface AccountBioProps {
|
||||||
|
className: string;
|
||||||
|
accountId: string;
|
||||||
|
showDropdown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
|
className,
|
||||||
|
accountId,
|
||||||
|
showDropdown = false,
|
||||||
|
}) => {
|
||||||
|
const handleClick = useLinks(showDropdown);
|
||||||
|
const handleNodeChange = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addDropdownToHashtags(node, accountId);
|
||||||
|
},
|
||||||
|
[showDropdown, accountId],
|
||||||
|
);
|
||||||
|
const note = useAppSelector((state) => {
|
||||||
|
const account = state.accounts.get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
||||||
|
});
|
||||||
|
const extraEmojis = useAppSelector((state) => {
|
||||||
|
const account = state.accounts.get(accountId);
|
||||||
|
return account?.emojis;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (note.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className} translate`}
|
className={`${className} translate`}
|
||||||
dangerouslySetInnerHTML={{ __html: note }}
|
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
/>
|
ref={handleNodeChange}
|
||||||
|
>
|
||||||
|
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const childNode of node.childNodes) {
|
||||||
|
if (!(childNode instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
childNode instanceof HTMLAnchorElement &&
|
||||||
|
(childNode.classList.contains('hashtag') ||
|
||||||
|
childNode.innerText.startsWith('#')) &&
|
||||||
|
!childNode.dataset.menuHashtag
|
||||||
|
) {
|
||||||
|
childNode.dataset.menuHashtag = accountId;
|
||||||
|
} else if (childNode.childNodes.length > 0) {
|
||||||
|
addDropdownToHashtags(childNode, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
Children,
|
Children,
|
||||||
|
useId,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
|
||||||
import type {
|
import type {
|
||||||
OffsetValue,
|
OffsetValue,
|
||||||
UsePopperOptions,
|
UsePopperOptions,
|
||||||
|
Placement,
|
||||||
} from 'react-overlays/esm/usePopper';
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||||
|
@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
|
||||||
title?: string;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
|
placement?: Placement;
|
||||||
|
/**
|
||||||
|
* Prevent the `ScrollableList` with this scrollKey
|
||||||
|
* from being scrolled while the dropdown is open
|
||||||
|
*/
|
||||||
scrollKey?: string;
|
scrollKey?: string;
|
||||||
status?: ImmutableMap<string, unknown>;
|
status?: ImmutableMap<string, unknown>;
|
||||||
forceDropdown?: boolean;
|
forceDropdown?: boolean;
|
||||||
|
@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
title = 'Menu',
|
title = 'Menu',
|
||||||
disabled,
|
disabled,
|
||||||
scrollable,
|
scrollable,
|
||||||
|
placement = 'bottom',
|
||||||
status,
|
status,
|
||||||
forceDropdown = false,
|
forceDropdown = false,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
);
|
);
|
||||||
const [currentId] = useState(id++);
|
const [currentId] = useState(id++);
|
||||||
const open = currentId === openDropdownId;
|
const open = currentId === openDropdownId;
|
||||||
const activeElement = useRef<HTMLElement | null>(null);
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
const menuId = useId();
|
||||||
const prefetchAccountId = status
|
const prefetchAccountId = status
|
||||||
? status.getIn(['account', 'id'])
|
? status.getIn(['account', 'id'])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (activeElement.current) {
|
if (buttonRef.current) {
|
||||||
activeElement.current.focus({ preventScroll: true });
|
buttonRef.current.focus({ preventScroll: true });
|
||||||
activeElement.current = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
[handleClose, onItemClick, items],
|
[handleClose, onItemClick, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const toggleDropdown = useCallback(
|
||||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
const { type } = e;
|
const { type } = e;
|
||||||
|
|
||||||
|
@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(() => {
|
|
||||||
if (!open && document.activeElement instanceof HTMLElement) {
|
|
||||||
activeElement.current = document.activeElement;
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleButtonKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
handleMouseDown();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleMouseDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyPress = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
handleClick(e);
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleClick],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (currentId === openDropdownId) {
|
if (currentId === openDropdownId) {
|
||||||
|
@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
|
|
||||||
let button: React.ReactElement;
|
let button: React.ReactElement;
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
disabled,
|
||||||
|
onClick: toggleDropdown,
|
||||||
|
'aria-expanded': open,
|
||||||
|
'aria-controls': menuId,
|
||||||
|
ref: buttonRef,
|
||||||
|
};
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
button = cloneElement(Children.only(children), {
|
button = cloneElement(Children.only(children), buttonProps);
|
||||||
onClick: handleClick,
|
|
||||||
onMouseDown: handleMouseDown,
|
|
||||||
onKeyDown: handleButtonKeyDown,
|
|
||||||
onKeyPress: handleKeyPress,
|
|
||||||
ref: targetRef,
|
|
||||||
});
|
|
||||||
} else if (icon && iconComponent) {
|
} else if (icon && iconComponent) {
|
||||||
button = (
|
button = (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
iconComponent={iconComponent}
|
iconComponent={iconComponent}
|
||||||
title={title}
|
title={title}
|
||||||
active={open}
|
active={open}
|
||||||
disabled={disabled}
|
{...buttonProps}
|
||||||
onClick={handleClick}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onKeyDown={handleButtonKeyDown}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
ref={targetRef}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
placement='bottom'
|
placement={placement}
|
||||||
flip
|
flip
|
||||||
target={targetRef}
|
target={buttonRef}
|
||||||
popperConfig={popperConfig}
|
popperConfig={popperConfig}
|
||||||
>
|
>
|
||||||
{({ props, arrowProps, placement }) => (
|
{({ props, arrowProps, placement }) => (
|
||||||
<div {...props}>
|
<div {...props} id={menuId}>
|
||||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-menu__arrow ${placement}`}
|
className={`dropdown-menu__arrow ${placement}`}
|
||||||
|
|
|
@ -37,7 +37,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
@ -49,7 +48,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
lang={lang}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
|
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import type { HandlerMap } from '.';
|
||||||
|
import { Hotkeys } from '.';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Hotkeys',
|
||||||
|
component: Hotkeys,
|
||||||
|
args: {
|
||||||
|
global: undefined,
|
||||||
|
focusable: undefined,
|
||||||
|
handlers: {},
|
||||||
|
},
|
||||||
|
tags: ['test'],
|
||||||
|
} satisfies Meta<typeof Hotkeys>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
|
||||||
|
async function confirmHotkey(name: string, shouldFind = true) {
|
||||||
|
// 'status' is the role of the 'output' element
|
||||||
|
const output = await canvas.findByRole('status');
|
||||||
|
if (shouldFind) {
|
||||||
|
await expect(output).toHaveTextContent(name);
|
||||||
|
} else {
|
||||||
|
await expect(output).not.toHaveTextContent(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = await canvas.findByRole('button');
|
||||||
|
await userEvent.click(button);
|
||||||
|
|
||||||
|
await userEvent.keyboard('n');
|
||||||
|
await confirmHotkey('new');
|
||||||
|
|
||||||
|
await userEvent.keyboard('/');
|
||||||
|
await confirmHotkey('search');
|
||||||
|
|
||||||
|
await userEvent.keyboard('o');
|
||||||
|
await confirmHotkey('open');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Alt>}N{/Alt}');
|
||||||
|
await confirmHotkey('forceNew');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gh');
|
||||||
|
await confirmHotkey('goToHome');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gn');
|
||||||
|
await confirmHotkey('goToNotifications');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gf');
|
||||||
|
await confirmHotkey('goToFavourites');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that hotkeys are not triggered when certain
|
||||||
|
* interactive elements are focused:
|
||||||
|
*/
|
||||||
|
|
||||||
|
await userEvent.keyboard('{enter}');
|
||||||
|
await confirmHotkey('open', false);
|
||||||
|
|
||||||
|
const input = await canvas.findByRole('textbox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
await userEvent.keyboard('n');
|
||||||
|
await confirmHotkey('new', false);
|
||||||
|
|
||||||
|
await userEvent.keyboard('{backspace}');
|
||||||
|
await confirmHotkey('None', false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset playground:
|
||||||
|
*/
|
||||||
|
|
||||||
|
await userEvent.click(button);
|
||||||
|
await userEvent.keyboard('{backspace}');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
render: function Render() {
|
||||||
|
const [matchedHotkey, setMatchedHotkey] = useState<keyof HandlerMap | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
back: () => {
|
||||||
|
setMatchedHotkey(null);
|
||||||
|
},
|
||||||
|
new: () => {
|
||||||
|
setMatchedHotkey('new');
|
||||||
|
},
|
||||||
|
forceNew: () => {
|
||||||
|
setMatchedHotkey('forceNew');
|
||||||
|
},
|
||||||
|
search: () => {
|
||||||
|
setMatchedHotkey('search');
|
||||||
|
},
|
||||||
|
open: () => {
|
||||||
|
setMatchedHotkey('open');
|
||||||
|
},
|
||||||
|
goToHome: () => {
|
||||||
|
setMatchedHotkey('goToHome');
|
||||||
|
},
|
||||||
|
goToNotifications: () => {
|
||||||
|
setMatchedHotkey('goToNotifications');
|
||||||
|
},
|
||||||
|
goToFavourites: () => {
|
||||||
|
setMatchedHotkey('goToFavourites');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Hotkeys handlers={handlers}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: '1em',
|
||||||
|
border: '1px dashed #ccc',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#222',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
marginBottom: '0.3em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hotkey playground
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Last pressed hotkey: <output>{matchedHotkey ?? 'None'}</output>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Click within the dashed border and press the "<kbd>n</kbd>
|
||||||
|
" or "<kbd>/</kbd>" key. Press "
|
||||||
|
<kbd>Backspace</kbd>" to clear the displayed hotkey.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Try typing a sequence, like "<kbd>g</kbd>" shortly
|
||||||
|
followed by "<kbd>h</kbd>", "<kbd>n</kbd>", or
|
||||||
|
"<kbd>f</kbd>"
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Note that this playground doesn't support all hotkeys we use in
|
||||||
|
the app.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When a <button>Button</button> is focused, "
|
||||||
|
<kbd>Enter</kbd>
|
||||||
|
" should not trigger "open", but "<kbd>o</kbd>
|
||||||
|
" should.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When an input element is focused, hotkeys should not interfere with
|
||||||
|
regular typing:
|
||||||
|
</p>
|
||||||
|
<input type='text' />
|
||||||
|
</div>
|
||||||
|
</Hotkeys>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
play: hotkeyTest,
|
||||||
|
};
|
282
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
282
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { normalizeKey, isKeyboardEvent } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In case of multiple hotkeys matching the pressed key(s),
|
||||||
|
* the hotkey with a higher priority is selected. All others
|
||||||
|
* are ignored.
|
||||||
|
*/
|
||||||
|
const hotkeyPriority = {
|
||||||
|
singleKey: 0,
|
||||||
|
combo: 1,
|
||||||
|
sequence: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This type of function receives a keyboard event and an array of
|
||||||
|
* previously pressed keys (within the last second), and returns
|
||||||
|
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
|
||||||
|
* (a weighting used to resolve conflicts when two hotkeys match the
|
||||||
|
* pressed keys)
|
||||||
|
*/
|
||||||
|
type KeyMatcher = (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
bufferedKeys?: string[],
|
||||||
|
) => {
|
||||||
|
/**
|
||||||
|
* Whether the event.key matches the hotkey
|
||||||
|
*/
|
||||||
|
isMatch: boolean;
|
||||||
|
/**
|
||||||
|
* If there are multiple matching hotkeys, the
|
||||||
|
* first one with the highest priority will be handled
|
||||||
|
*/
|
||||||
|
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a single key
|
||||||
|
*/
|
||||||
|
function just(keyName: string): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
isMatch: normalizeKey(event.key) === keyName,
|
||||||
|
priority: hotkeyPriority.singleKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches any single key out of those provided
|
||||||
|
*/
|
||||||
|
function any(...keys: string[]): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
|
||||||
|
priority: hotkeyPriority.singleKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a single key combined with the option/alt modifier
|
||||||
|
*/
|
||||||
|
function optionPlus(key: string): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
// Matching against event.code here as alt combos are often
|
||||||
|
// mapped to other characters
|
||||||
|
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
|
||||||
|
priority: hotkeyPriority.combo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches when all provided keys are pressed in sequence.
|
||||||
|
*/
|
||||||
|
function sequence(...sequence: string[]): KeyMatcher {
|
||||||
|
return (event, bufferedKeys) => {
|
||||||
|
const lastKeyInSequence = sequence.at(-1);
|
||||||
|
const startOfSequence = sequence.slice(0, -1);
|
||||||
|
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
|
||||||
|
|
||||||
|
const bufferMatchesStartOfSequence =
|
||||||
|
!!relevantBufferedKeys &&
|
||||||
|
startOfSequence.join('') === relevantBufferedKeys.join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMatch:
|
||||||
|
bufferMatchesStartOfSequence &&
|
||||||
|
normalizeKey(event.key) === lastKeyInSequence,
|
||||||
|
priority: hotkeyPriority.sequence,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a map of all global hotkeys we support.
|
||||||
|
* To trigger a hotkey, a handler with a matching name must be
|
||||||
|
* provided to the `useHotkeys` hook or `Hotkeys` component.
|
||||||
|
*/
|
||||||
|
const hotkeyMatcherMap = {
|
||||||
|
help: just('?'),
|
||||||
|
search: any('s', '/'),
|
||||||
|
back: just('backspace'),
|
||||||
|
new: just('n'),
|
||||||
|
forceNew: optionPlus('n'),
|
||||||
|
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||||
|
reply: just('r'),
|
||||||
|
favourite: just('f'),
|
||||||
|
boost: just('b'),
|
||||||
|
mention: just('m'),
|
||||||
|
open: any('enter', 'o'),
|
||||||
|
openProfile: just('p'),
|
||||||
|
moveDown: any('down', 'j'),
|
||||||
|
moveUp: any('up', 'k'),
|
||||||
|
toggleHidden: just('x'),
|
||||||
|
toggleSensitive: just('h'),
|
||||||
|
toggleComposeSpoilers: optionPlus('x'),
|
||||||
|
openMedia: just('e'),
|
||||||
|
onTranslate: just('t'),
|
||||||
|
goToHome: sequence('g', 'h'),
|
||||||
|
goToNotifications: sequence('g', 'n'),
|
||||||
|
goToLocal: sequence('g', 'l'),
|
||||||
|
goToFederated: sequence('g', 't'),
|
||||||
|
goToDirect: sequence('g', 'd'),
|
||||||
|
goToStart: sequence('g', 's'),
|
||||||
|
goToFavourites: sequence('g', 'f'),
|
||||||
|
goToPinned: sequence('g', 'p'),
|
||||||
|
goToProfile: sequence('g', 'u'),
|
||||||
|
goToBlocked: sequence('g', 'b'),
|
||||||
|
goToMuted: sequence('g', 'm'),
|
||||||
|
goToRequests: sequence('g', 'r'),
|
||||||
|
cheat: sequence(
|
||||||
|
'up',
|
||||||
|
'up',
|
||||||
|
'down',
|
||||||
|
'down',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
'enter',
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type HotkeyName = keyof typeof hotkeyMatcherMap;
|
||||||
|
|
||||||
|
export type HandlerMap = Partial<
|
||||||
|
Record<HotkeyName, (event: KeyboardEvent) => void>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const bufferedKeys = useRef<string[]>([]);
|
||||||
|
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the latest handlers object in a ref so we don't need to
|
||||||
|
* add it as a dependency to the main event listener effect
|
||||||
|
*/
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
}, [handlers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current ?? document;
|
||||||
|
|
||||||
|
function listener(event: Event) {
|
||||||
|
// Ignore key presses from input, textarea, or select elements
|
||||||
|
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
|
||||||
|
const shouldHandleEvent =
|
||||||
|
isKeyboardEvent(event) &&
|
||||||
|
!event.defaultPrevented &&
|
||||||
|
!['input', 'textarea', 'select'].includes(tagName) &&
|
||||||
|
!(
|
||||||
|
['a', 'button'].includes(tagName) &&
|
||||||
|
normalizeKey(event.key) === 'enter'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldHandleEvent) {
|
||||||
|
const matchCandidates: {
|
||||||
|
handler: (event: KeyboardEvent) => void;
|
||||||
|
priority: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||||
|
(handlerName) => {
|
||||||
|
const handler = handlersRef.current[handlerName];
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||||
|
|
||||||
|
const { isMatch, priority } = hotkeyMatcher(
|
||||||
|
event,
|
||||||
|
bufferedKeys.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matchCandidates.push({ handler, priority });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort all matches by priority
|
||||||
|
matchCandidates.sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
const bestMatchingHandler = matchCandidates.at(0)?.handler;
|
||||||
|
if (bestMatchingHandler) {
|
||||||
|
bestMatchingHandler(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last keypress to buffer
|
||||||
|
bufferedKeys.current.push(normalizeKey(event.key));
|
||||||
|
|
||||||
|
// Reset the timeout
|
||||||
|
if (sequenceTimer.current) {
|
||||||
|
clearTimeout(sequenceTimer.current);
|
||||||
|
}
|
||||||
|
sequenceTimer.current = setTimeout(() => {
|
||||||
|
bufferedKeys.current = [];
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener('keydown', listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('keydown', listener);
|
||||||
|
if (sequenceTimer.current) {
|
||||||
|
clearTimeout(sequenceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hotkeys component allows us to globally register keyboard combinations
|
||||||
|
* under a name and assign actions to them, either globally or scoped to a portion
|
||||||
|
* of the app.
|
||||||
|
*
|
||||||
|
* ### How to use
|
||||||
|
*
|
||||||
|
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
|
||||||
|
* and give it a name.
|
||||||
|
*
|
||||||
|
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
|
||||||
|
* where you want to handle the action, and pass in a handlers object.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <Hotkeys handlers={{open: openStatus}} />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Now this function will be called when the 'open' hotkey is pressed by the user.
|
||||||
|
*/
|
||||||
|
export const Hotkeys: React.FC<{
|
||||||
|
/**
|
||||||
|
* An object containing functions to be run when a hotkey is pressed.
|
||||||
|
* The key must be the name of a registered hotkey, e.g. "help" or "search"
|
||||||
|
*/
|
||||||
|
handlers: HandlerMap;
|
||||||
|
/**
|
||||||
|
* When enabled, hotkeys will be matched against the document root
|
||||||
|
* rather than only inside of this component's DOM node.
|
||||||
|
*/
|
||||||
|
global?: boolean;
|
||||||
|
/**
|
||||||
|
* Allow the rendered `div` to be focused
|
||||||
|
*/
|
||||||
|
focusable?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ handlers, global, focusable = true, children }) => {
|
||||||
|
const ref = useHotkeys<HTMLDivElement>(handlers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export function isKeyboardEvent(event: Event): event is KeyboardEvent {
|
||||||
|
return 'key' in event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKey(key: string): string {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
|
switch (lowerKey) {
|
||||||
|
case ' ':
|
||||||
|
case 'spacebar': // for older browsers
|
||||||
|
return 'space';
|
||||||
|
|
||||||
|
case 'arrowup':
|
||||||
|
return 'up';
|
||||||
|
case 'arrowdown':
|
||||||
|
return 'down';
|
||||||
|
case 'arrowleft':
|
||||||
|
return 'left';
|
||||||
|
case 'arrowright':
|
||||||
|
return 'right';
|
||||||
|
|
||||||
|
case 'esc':
|
||||||
|
case 'escape':
|
||||||
|
return 'escape';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return lowerKey;
|
||||||
|
}
|
||||||
|
}
|
|
@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
|
||||||
<>
|
<>
|
||||||
<div className='hover-card__text-row'>
|
<div className='hover-card__text-row'>
|
||||||
<AccountBio
|
<AccountBio
|
||||||
note={account.note_emojified}
|
accountId={account.id}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
<AccountFields fields={account.fields} limit={2} />
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
|
|
@ -14,7 +14,6 @@ interface Props {
|
||||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
activeStyle,
|
activeStyle,
|
||||||
onClick,
|
onClick,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyPress,
|
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
[disabled, onClick],
|
[disabled, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
|
||||||
useCallback(
|
|
||||||
(e) => {
|
|
||||||
if (!disabled) {
|
|
||||||
onKeyPress?.(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, onKeyPress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||||
useCallback(
|
useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -8,10 +8,9 @@ import { Link } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { VisibilityIcon } from './visibility_icon';
|
import { VisibilityIcon } from './visibility_icon';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
|
@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveUp = e => {
|
handleHotkeyMoveUp = e => {
|
||||||
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveDown = e => {
|
handleHotkeyMoveDown = e => {
|
||||||
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyToggleHidden = () => {
|
handleHotkeyToggleHidden = () => {
|
||||||
|
@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||||
{expanded && <span>{status.get('content')}</span>}
|
{expanded && <span>{status.get('content')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent {
|
||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||||
{!skipPrepend && prepend}
|
{!skipPrepend && prepend}
|
||||||
|
|
||||||
|
@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ import { Icon } from 'mastodon/components/icon';
|
||||||
import { Poll } from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||||
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
|
@ -23,6 +25,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return status.getIn(['translation', 'content']) || status.get('content');
|
||||||
|
}
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,7 +233,7 @@ class StatusContent extends PureComponent {
|
||||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||||
|
|
||||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
const content = statusContent ?? getStatusContent(status);
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
'status__content--with-action': this.props.onClick && this.props.history,
|
'status__content--with-action': this.props.onClick && this.props.history,
|
||||||
|
@ -253,7 +258,12 @@ class StatusContent extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
lang={language}
|
||||||
|
htmlString={content}
|
||||||
|
extraEmojis={status.get('emojis')}
|
||||||
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
@ -265,7 +275,12 @@ class StatusContent extends PureComponent {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
lang={language}
|
||||||
|
htmlString={content}
|
||||||
|
extraEmojis={status.get('emojis')}
|
||||||
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
|
|
@ -40,6 +40,14 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.columnHeaderHeight = this.node?.node
|
||||||
|
? parseFloat(
|
||||||
|
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||||
|
) || 0
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
getFeaturedStatusCount = () => {
|
getFeaturedStatusCount = () => {
|
||||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||||
};
|
};
|
||||||
|
@ -53,34 +61,68 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = (id, featured) => {
|
handleMoveUp = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
const index = this.getCurrentStatusIndex(id, featured);
|
||||||
this._selectChild(elementIndex, true);
|
this._selectChild(id, index, -1);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveDown = (id, featured) => {
|
handleMoveDown = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
const index = this.getCurrentStatusIndex(id, featured);
|
||||||
this._selectChild(elementIndex, false);
|
this._selectChild(id, index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_selectChild = (id, index, direction) => {
|
||||||
|
const listContainer = this.node?.node;
|
||||||
|
let listItem = listContainer?.querySelector(
|
||||||
|
// :nth-child uses 1-based indexing
|
||||||
|
`.item-list > :nth-child(${index + 1 + direction})`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!listItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If selected container element is empty, we skip it
|
||||||
|
if (listItem.matches(':empty')) {
|
||||||
|
this._selectChild(id, index + direction, direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the list item is a post
|
||||||
|
let targetElement = listItem.querySelector('.focusable');
|
||||||
|
|
||||||
|
// Otherwise, check if the item contains follow suggestions or
|
||||||
|
// is a 'load more' button.
|
||||||
|
if (
|
||||||
|
!targetElement && (
|
||||||
|
listItem.querySelector('.inline-follow-suggestions') ||
|
||||||
|
listItem.matches('.load-more')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
targetElement = listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const elementRect = targetElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const isFullyVisible =
|
||||||
|
elementRect.top >= this.columnHeaderHeight &&
|
||||||
|
elementRect.bottom <= window.innerHeight;
|
||||||
|
|
||||||
|
if (!isFullyVisible) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
block: direction === 1 ? 'start' : 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
const { statusIds, lastId, onLoadMore } = this.props;
|
const { statusIds, lastId, onLoadMore } = this.props;
|
||||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
_selectChild (index, align_top) {
|
|
||||||
const container = this.node.node;
|
|
||||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (align_top && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = { __html: account.note_emojified };
|
|
||||||
const displayNameHtml = { __html: account.display_name_html };
|
const displayNameHtml = { __html: account.display_name_html };
|
||||||
const fields = account.fields;
|
const fields = account.fields;
|
||||||
const isLocal = !account.acct.includes('@');
|
const isLocal = !account.acct.includes('@');
|
||||||
|
@ -897,12 +897,10 @@ export const AccountHeader: React.FC<{
|
||||||
<AccountNote accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
<AccountBio
|
||||||
<div
|
accountId={accountId}
|
||||||
className='account__header__content translate'
|
className='account__header__content'
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='account__header__fields'>
|
<div className='account__header__fields'>
|
||||||
<dl>
|
<dl>
|
||||||
|
|
|
@ -261,7 +261,9 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||||
);
|
);
|
||||||
const lang = useAppSelector(
|
const lang = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
|
'language',
|
||||||
|
) as string,
|
||||||
);
|
);
|
||||||
const focusX =
|
const focusX =
|
||||||
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||||
|
|
|
@ -92,10 +92,29 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
this.props.onChange(e.target.value);
|
this.props.onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
blurOnEscape = (e) => {
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
if (['esc', 'escape'].includes(e.key.toLowerCase())) {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDownPost = (e) => {
|
||||||
|
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
this.handleSubmit();
|
this.handleSubmit();
|
||||||
}
|
}
|
||||||
|
this.blurOnEscape(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDownSpoiler = (e) => {
|
||||||
|
if (e.key.toLowerCase() === 'enter') {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
this.handleSubmit();
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
this.textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.blurOnEscape(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
getFulltextForCharacterCounting = () => {
|
getFulltextForCharacterCounting = () => {
|
||||||
|
@ -248,7 +267,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
value={this.props.spoilerText}
|
value={this.props.spoilerText}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onChange={this.handleChangeSpoilerText}
|
onChange={this.handleChangeSpoilerText}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDownSpoiler}
|
||||||
ref={this.setSpoilerText}
|
ref={this.setSpoilerText}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
|
@ -273,7 +292,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDownPost}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
|
|
|
@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
import { replyCompose } from 'mastodon/actions/compose';
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import AttachmentList from 'mastodon/components/attachment_list';
|
import AttachmentList from 'mastodon/components/attachment_list';
|
||||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
||||||
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||||
<AvatarComposite accounts={accounts} size={48} />
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
|
@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
120
app/javascript/mastodon/features/emoji/constants.ts
Normal file
120
app/javascript/mastodon/features/emoji/constants.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
// Utility codes
|
||||||
|
export const VARIATION_SELECTOR_CODE = 0xfe0f;
|
||||||
|
export const KEYCAP_CODE = 0x20e3;
|
||||||
|
|
||||||
|
// Gender codes
|
||||||
|
export const GENDER_FEMALE_CODE = 0x2640;
|
||||||
|
export const GENDER_MALE_CODE = 0x2642;
|
||||||
|
|
||||||
|
// Skin tone codes
|
||||||
|
export const SKIN_TONE_CODES = [
|
||||||
|
0x1f3fb, // Light skin tone
|
||||||
|
0x1f3fc, // Medium-light skin tone
|
||||||
|
0x1f3fd, // Medium skin tone
|
||||||
|
0x1f3fe, // Medium-dark skin tone
|
||||||
|
0x1f3ff, // Dark skin tone
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
||||||
|
export const EMOJI_MODE_NATIVE = 'native';
|
||||||
|
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
||||||
|
export const EMOJI_MODE_TWEMOJI = 'twemoji';
|
||||||
|
|
||||||
|
export const EMOJI_TYPE_UNICODE = 'unicode';
|
||||||
|
export const EMOJI_TYPE_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
export const EMOJI_STATE_MISSING = 'missing';
|
||||||
|
|
||||||
|
export const EMOJIS_WITH_DARK_BORDER = [
|
||||||
|
'🎱', // 1F3B1
|
||||||
|
'🐜', // 1F41C
|
||||||
|
'⚫', // 26AB
|
||||||
|
'🖤', // 1F5A4
|
||||||
|
'⬛', // 2B1B
|
||||||
|
'◼️', // 25FC-FE0F
|
||||||
|
'◾', // 25FE
|
||||||
|
'◼️', // 25FC-FE0F
|
||||||
|
'✒️', // 2712-FE0F
|
||||||
|
'▪️', // 25AA-FE0F
|
||||||
|
'💣', // 1F4A3
|
||||||
|
'🎳', // 1F3B3
|
||||||
|
'📷', // 1F4F7
|
||||||
|
'📸', // 1F4F8
|
||||||
|
'♣️', // 2663-FE0F
|
||||||
|
'🕶️', // 1F576-FE0F
|
||||||
|
'✴️', // 2734-FE0F
|
||||||
|
'🔌', // 1F50C
|
||||||
|
'💂♀️', // 1F482-200D-2640-FE0F
|
||||||
|
'📽️', // 1F4FD-FE0F
|
||||||
|
'🍳', // 1F373
|
||||||
|
'🦍', // 1F98D
|
||||||
|
'💂', // 1F482
|
||||||
|
'🔪', // 1F52A
|
||||||
|
'🕳️', // 1F573-FE0F
|
||||||
|
'🕹️', // 1F579-FE0F
|
||||||
|
'🕋', // 1F54B
|
||||||
|
'🖊️', // 1F58A-FE0F
|
||||||
|
'🖋️', // 1F58B-FE0F
|
||||||
|
'💂♂️', // 1F482-200D-2642-FE0F
|
||||||
|
'🎤', // 1F3A4
|
||||||
|
'🎓', // 1F393
|
||||||
|
'🎥', // 1F3A5
|
||||||
|
'🎼', // 1F3BC
|
||||||
|
'♠️', // 2660-FE0F
|
||||||
|
'🎩', // 1F3A9
|
||||||
|
'🦃', // 1F983
|
||||||
|
'📼', // 1F4FC
|
||||||
|
'📹', // 1F4F9
|
||||||
|
'🎮', // 1F3AE
|
||||||
|
'🐃', // 1F403
|
||||||
|
'🏴', // 1F3F4
|
||||||
|
'🐞', // 1F41E
|
||||||
|
'🕺', // 1F57A
|
||||||
|
'📱', // 1F4F1
|
||||||
|
'📲', // 1F4F2
|
||||||
|
'🚲', // 1F6B2
|
||||||
|
'🪮', // 1FAA6
|
||||||
|
'🐦⬛', // 1F426-200D-2B1B
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOJIS_WITH_LIGHT_BORDER = [
|
||||||
|
'👽', // 1F47D
|
||||||
|
'⚾', // 26BE
|
||||||
|
'🐔', // 1F414
|
||||||
|
'☁️', // 2601-FE0F
|
||||||
|
'💨', // 1F4A8
|
||||||
|
'🕊️', // 1F54A-FE0F
|
||||||
|
'👀', // 1F440
|
||||||
|
'🍥', // 1F365
|
||||||
|
'👻', // 1F47B
|
||||||
|
'🐐', // 1F410
|
||||||
|
'❕', // 2755
|
||||||
|
'❔', // 2754
|
||||||
|
'⛸️', // 26F8-FE0F
|
||||||
|
'🌩️', // 1F329-FE0F
|
||||||
|
'🔊', // 1F50A
|
||||||
|
'🔇', // 1F507
|
||||||
|
'📃', // 1F4C3
|
||||||
|
'🌧️', // 1F327-FE0F
|
||||||
|
'🐏', // 1F40F
|
||||||
|
'🍚', // 1F35A
|
||||||
|
'🍙', // 1F359
|
||||||
|
'🐓', // 1F413
|
||||||
|
'🐑', // 1F411
|
||||||
|
'💀', // 1F480
|
||||||
|
'☠️', // 2620-FE0F
|
||||||
|
'🌨️', // 1F328-FE0F
|
||||||
|
'🔉', // 1F509
|
||||||
|
'🔈', // 1F508
|
||||||
|
'💬', // 1F4AC
|
||||||
|
'💭', // 1F4AD
|
||||||
|
'🏐', // 1F3D0
|
||||||
|
'🏳️', // 1F3F3-FE0F
|
||||||
|
'⚪', // 26AA
|
||||||
|
'⬜', // 2B1C
|
||||||
|
'◽', // 25FD
|
||||||
|
'◻️', // 25FB-FE0F
|
||||||
|
'▫️', // 25AB-FE0F
|
||||||
|
'🪽', // 1FAE8
|
||||||
|
'🪿', // 1FABF
|
||||||
|
];
|
155
app/javascript/mastodon/features/emoji/database.ts
Normal file
155
app/javascript/mastodon/features/emoji/database.ts
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
import type { Locale } from 'emojibase';
|
||||||
|
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||||
|
import { openDB } from 'idb';
|
||||||
|
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
import type {
|
||||||
|
CustomEmojiData,
|
||||||
|
UnicodeEmojiData,
|
||||||
|
LocaleOrCustom,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface EmojiDB extends LocaleTables, DBSchema {
|
||||||
|
custom: {
|
||||||
|
key: string;
|
||||||
|
value: CustomEmojiData;
|
||||||
|
indexes: {
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
etags: {
|
||||||
|
key: LocaleOrCustom;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocaleTable {
|
||||||
|
key: string;
|
||||||
|
value: UnicodeEmojiData;
|
||||||
|
indexes: {
|
||||||
|
group: number;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
type LocaleTables = Record<Locale, LocaleTable>;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
let db: IDBPDatabase<EmojiDB> | null = null;
|
||||||
|
|
||||||
|
async function loadDB() {
|
||||||
|
if (db) {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
|
upgrade(database) {
|
||||||
|
const customTable = database.createObjectStore('custom', {
|
||||||
|
keyPath: 'shortcode',
|
||||||
|
autoIncrement: false,
|
||||||
|
});
|
||||||
|
customTable.createIndex('category', 'category');
|
||||||
|
|
||||||
|
database.createObjectStore('etags');
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const localeTable = database.createObjectStore(locale, {
|
||||||
|
keyPath: 'hexcode',
|
||||||
|
autoIncrement: false,
|
||||||
|
});
|
||||||
|
localeTable.createIndex('group', 'group');
|
||||||
|
localeTable.createIndex('label', 'label');
|
||||||
|
localeTable.createIndex('order', 'order');
|
||||||
|
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const trx = db.transaction(locale, 'readwrite');
|
||||||
|
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const trx = db.transaction('custom', 'readwrite');
|
||||||
|
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putLatestEtag(etag: string, localeString: string) {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.put('etags', etag, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmojiByHexcode(
|
||||||
|
hexcode: string,
|
||||||
|
localeString: string,
|
||||||
|
) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.get(locale, hexcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmojisByHexcodes(
|
||||||
|
hexcodes: string[],
|
||||||
|
localeString: string,
|
||||||
|
) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.getAll(
|
||||||
|
locale,
|
||||||
|
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmojiByTag(tag: string, localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
const range = IDBKeyRange.only(tag.toLowerCase());
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.getAllFromIndex(locale, 'tags', range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCustomEmojiByShortcode(shortcode: string) {
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.get('custom', shortcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.getAll(
|
||||||
|
'custom',
|
||||||
|
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMissingLocales(localeStrings: string[]) {
|
||||||
|
const locales = new Set(localeStrings.map(toSupportedLocale));
|
||||||
|
const missingLocales: Locale[] = [];
|
||||||
|
const db = await loadDB();
|
||||||
|
for (const locale of locales) {
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
if (!rowCount) {
|
||||||
|
missingLocales.push(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missingLocales;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLatestEtag(localeString: string) {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
if (!rowCount) {
|
||||||
|
return null; // No data for this locale, return null even if there is an etag.
|
||||||
|
}
|
||||||
|
const etag = await db.get('etags', locale);
|
||||||
|
return etag ?? null;
|
||||||
|
}
|
81
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
81
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
|
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import { useEmojiAppState } from './hooks';
|
||||||
|
import { emojifyElement } from './render';
|
||||||
|
import type { ExtraCustomEmojiMap } from './types';
|
||||||
|
|
||||||
|
type EmojiHTMLProps = Omit<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
'dangerouslySetInnerHTML'
|
||||||
|
> & {
|
||||||
|
htmlString: string;
|
||||||
|
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||||
|
htmlString,
|
||||||
|
extraEmojis,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return (
|
||||||
|
<ModernEmojiHTML
|
||||||
|
htmlString={htmlString}
|
||||||
|
extraEmojis={extraEmojis}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||||
|
extraEmojis: rawEmojis,
|
||||||
|
htmlString: text,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
const [innerHTML, setInnerHTML] = useState('');
|
||||||
|
|
||||||
|
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
|
||||||
|
if (!rawEmojis) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (isList(rawEmojis)) {
|
||||||
|
return (
|
||||||
|
rawEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||||
|
).reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rawEmojis;
|
||||||
|
}, [rawEmojis]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cb = async () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = text;
|
||||||
|
const ele = await emojifyElement(div, appState, extraEmojis);
|
||||||
|
setInnerHTML(ele.innerHTML);
|
||||||
|
};
|
||||||
|
void cb();
|
||||||
|
}, [text, appState, extraEmojis]);
|
||||||
|
|
||||||
|
if (!innerHTML) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
|
||||||
|
};
|
45
app/javascript/mastodon/features/emoji/emoji_text.tsx
Normal file
45
app/javascript/mastodon/features/emoji/emoji_text.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useEmojiAppState } from './hooks';
|
||||||
|
import { emojifyText } from './render';
|
||||||
|
|
||||||
|
interface EmojiTextProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cb = async () => {
|
||||||
|
const rendered = await emojifyText(text, appState);
|
||||||
|
setRendered(rendered ?? []);
|
||||||
|
};
|
||||||
|
void cb();
|
||||||
|
}, [text, appState]);
|
||||||
|
|
||||||
|
if (rendered.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rendered.map((fragment, index) => {
|
||||||
|
if (typeof fragment === 'string') {
|
||||||
|
return <span key={index}>{fragment}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
draggable='false'
|
||||||
|
src={fragment.src}
|
||||||
|
alt={fragment.alt}
|
||||||
|
title={fragment.title}
|
||||||
|
className={fragment.className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
16
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
16
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import { toSupportedLocale } from './locale';
|
||||||
|
import { determineEmojiMode } from './mode';
|
||||||
|
import type { EmojiAppState } from './types';
|
||||||
|
|
||||||
|
export function useEmojiAppState(): EmojiAppState {
|
||||||
|
const locale = useAppSelector((state) =>
|
||||||
|
toSupportedLocale(state.meta.get('locale') as string),
|
||||||
|
);
|
||||||
|
const mode = useAppSelector((state) =>
|
||||||
|
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { currentLocale: locale, locales: [locale], mode };
|
||||||
|
}
|
55
app/javascript/mastodon/features/emoji/index.ts
Normal file
55
app/javascript/mastodon/features/emoji/index.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import initialState from '@/mastodon/initial_state';
|
||||||
|
import { loadWorker } from '@/mastodon/utils/workers';
|
||||||
|
|
||||||
|
import { toSupportedLocale } from './locale';
|
||||||
|
|
||||||
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
|
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
|
export async function initializeEmoji() {
|
||||||
|
if (!worker && 'Worker' in window) {
|
||||||
|
try {
|
||||||
|
worker = loadWorker(new URL('./worker', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error creating web worker:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worker) {
|
||||||
|
// Assign worker to const to make TS happy inside the event listener.
|
||||||
|
const thisWorker = worker;
|
||||||
|
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
|
const { data: message } = event;
|
||||||
|
if (message === 'ready') {
|
||||||
|
thisWorker.postMessage('custom');
|
||||||
|
void loadEmojiLocale(userLocale);
|
||||||
|
// Load English locale as well, because people are still used to
|
||||||
|
// using it from before we supported other locales.
|
||||||
|
if (userLocale !== 'en') {
|
||||||
|
void loadEmojiLocale('en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const { importCustomEmojiData } = await import('./loader');
|
||||||
|
await importCustomEmojiData();
|
||||||
|
await loadEmojiLocale(userLocale);
|
||||||
|
if (userLocale !== 'en') {
|
||||||
|
await loadEmojiLocale('en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEmojiLocale(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
|
||||||
|
if (worker) {
|
||||||
|
worker.postMessage(locale);
|
||||||
|
} else {
|
||||||
|
const { importEmojiData } = await import('./loader');
|
||||||
|
await importEmojiData(locale);
|
||||||
|
}
|
||||||
|
}
|
78
app/javascript/mastodon/features/emoji/loader.ts
Normal file
78
app/javascript/mastodon/features/emoji/loader.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { flattenEmojiData } from 'emojibase';
|
||||||
|
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
|
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
putEmojiData,
|
||||||
|
putCustomEmojiData,
|
||||||
|
loadLatestEtag,
|
||||||
|
putLatestEtag,
|
||||||
|
} from './database';
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
import type { LocaleOrCustom } from './types';
|
||||||
|
|
||||||
|
export async function importEmojiData(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
|
||||||
|
if (!emojis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||||
|
await putEmojiData(flattenedEmojis, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCustomEmojiData() {
|
||||||
|
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
|
||||||
|
if (!emojis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await putCustomEmojiData(emojis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||||
|
localeOrCustom: LocaleOrCustom,
|
||||||
|
): Promise<ResultType | null> {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeOrCustom);
|
||||||
|
|
||||||
|
// Use location.origin as this script may be loaded from a CDN domain.
|
||||||
|
const url = new URL(location.origin);
|
||||||
|
if (locale === 'custom') {
|
||||||
|
url.pathname = '/api/v1/custom_emojis';
|
||||||
|
} else {
|
||||||
|
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldEtag = await loadLatestEtag(locale);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// If not modified, return null
|
||||||
|
if (response.status === 304) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ResultType;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected data format for ${localeOrCustom}: expected an array`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the ETag for future requests
|
||||||
|
const etag = response.headers.get('ETag');
|
||||||
|
if (etag) {
|
||||||
|
await putLatestEtag(etag, localeOrCustom);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
|
||||||
|
describe('toSupportedLocale', () => {
|
||||||
|
test('returns the same locale if it is supported', () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
expect(toSupportedLocale(locale)).toBe(locale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "en" for unsupported locales', () => {
|
||||||
|
const unsupportedLocales = ['xx', 'fr-CA'];
|
||||||
|
for (const locale of unsupportedLocales) {
|
||||||
|
expect(toSupportedLocale(locale)).toBe('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toSupportedLocaleOrCustom', () => {
|
||||||
|
test('returns custom for "custom" locale', () => {
|
||||||
|
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
|
||||||
|
});
|
||||||
|
test('returns supported locale for valid locales', () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Locale } from 'emojibase';
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
|
||||||
|
import type { LocaleOrCustom } from './types';
|
||||||
|
|
||||||
|
export function toSupportedLocale(localeBase: string): Locale {
|
||||||
|
const locale = localeBase.toLowerCase();
|
||||||
|
if (isSupportedLocale(locale)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
return 'en'; // Default to English if unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
|
||||||
|
if (locale.toLowerCase() === 'custom') {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
return toSupportedLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(locale: string): locale is Locale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
|
||||||
|
}
|
119
app/javascript/mastodon/features/emoji/mode.ts
Normal file
119
app/javascript/mastodon/features/emoji/mode.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
// Credit to Nolan Lawson for the original implementation.
|
||||||
|
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||||
|
|
||||||
|
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMOJI_MODE_NATIVE,
|
||||||
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
|
EMOJI_MODE_TWEMOJI,
|
||||||
|
} from './constants';
|
||||||
|
import type { EmojiMode } from './types';
|
||||||
|
|
||||||
|
type Feature = Uint8ClampedArray;
|
||||||
|
|
||||||
|
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
|
||||||
|
const FONT_FAMILY =
|
||||||
|
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
|
||||||
|
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
|
||||||
|
|
||||||
|
function getTextFeature(text: string, color: string) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = 1;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', {
|
||||||
|
// Improves the performance of `getImageData()`
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
|
||||||
|
willReadFrequently: true,
|
||||||
|
});
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Canvas context not available');
|
||||||
|
}
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = `100px ${FONT_FAMILY}`;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.scale(0.01, 0.01);
|
||||||
|
ctx.fillText(text, 0, 0);
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, 1, 1).data satisfies Feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFeatures(feature1: Feature, feature2: Feature) {
|
||||||
|
const feature1Str = [...feature1].join(',');
|
||||||
|
const feature2Str = [...feature2].join(',');
|
||||||
|
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
|
||||||
|
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
|
||||||
|
// 0,0,0,61 - there is a transparency here.
|
||||||
|
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEmojiSupport(text: string) {
|
||||||
|
// Render white and black and then compare them to each other and ensure they're the same
|
||||||
|
// color, and neither one is black. This shows that the emoji was rendered in color.
|
||||||
|
const feature1 = getTextFeature(text, '#000');
|
||||||
|
const feature2 = getTextFeature(text, '#fff');
|
||||||
|
return compareFeatures(feature1, feature2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15
|
||||||
|
const EMOJI_FLAG_TEST_EMOJI = '🇨🇭';
|
||||||
|
|
||||||
|
export function determineEmojiMode(style: string): EmojiMode {
|
||||||
|
if (style === EMOJI_MODE_NATIVE) {
|
||||||
|
// If flags are not supported, we replace them with Twemoji.
|
||||||
|
if (shouldReplaceFlags()) {
|
||||||
|
return EMOJI_MODE_NATIVE_WITH_FLAGS;
|
||||||
|
}
|
||||||
|
return EMOJI_MODE_NATIVE;
|
||||||
|
}
|
||||||
|
if (style === EMOJI_MODE_TWEMOJI) {
|
||||||
|
return EMOJI_MODE_TWEMOJI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto style so determine based on browser capabilities.
|
||||||
|
if (shouldUseTwemoji()) {
|
||||||
|
return EMOJI_MODE_TWEMOJI;
|
||||||
|
} else if (shouldReplaceFlags()) {
|
||||||
|
return EMOJI_MODE_NATIVE_WITH_FLAGS;
|
||||||
|
}
|
||||||
|
return EMOJI_MODE_NATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseTwemoji(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Test a known color emoji to see if 15.1 is supported.
|
||||||
|
return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// If an error occurs, fall back to Twemoji to be safe.
|
||||||
|
if (isDevelopment()) {
|
||||||
|
console.warn(
|
||||||
|
'Emoji rendering test failed, defaulting to Twemoji. Error:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19
|
||||||
|
export function shouldReplaceFlags(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Test a known flag emoji to see if it is rendered in color.
|
||||||
|
return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// If an error occurs, assume flags should be replaced.
|
||||||
|
if (isDevelopment()) {
|
||||||
|
console.warn(
|
||||||
|
'Flag emoji rendering test failed, defaulting to replacement. Error:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
101
app/javascript/mastodon/features/emoji/normalize.test.ts
Normal file
101
app/javascript/mastodon/features/emoji/normalize.test.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { readdir } from 'fs/promises';
|
||||||
|
import { basename, resolve } from 'path';
|
||||||
|
|
||||||
|
import { flattenEmojiData } from 'emojibase';
|
||||||
|
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||||
|
|
||||||
|
import {
|
||||||
|
twemojiHasBorder,
|
||||||
|
twemojiToUnicodeInfo,
|
||||||
|
unicodeToTwemojiHex,
|
||||||
|
CODES_WITH_DARK_BORDER,
|
||||||
|
CODES_WITH_LIGHT_BORDER,
|
||||||
|
emojiToUnicodeHex,
|
||||||
|
} from './normalize';
|
||||||
|
|
||||||
|
const emojiSVGFiles = await readdir(
|
||||||
|
// This assumes tests are run from project root
|
||||||
|
resolve(process.cwd(), 'public/emoji'),
|
||||||
|
{
|
||||||
|
withFileTypes: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const svgFileNames = emojiSVGFiles
|
||||||
|
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
|
||||||
|
.map((file) => basename(file.name, '.svg'));
|
||||||
|
const svgFileNamesWithoutBorder = svgFileNames.filter(
|
||||||
|
(fileName) => !fileName.endsWith('_border'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
|
||||||
|
|
||||||
|
describe('emojiToUnicodeHex', () => {
|
||||||
|
test.concurrent.for([
|
||||||
|
['🎱', '1F3B1'],
|
||||||
|
['🐜', '1F41C'],
|
||||||
|
['⚫', '26AB'],
|
||||||
|
['🖤', '1F5A4'],
|
||||||
|
['💀', '1F480'],
|
||||||
|
['💂♂️', '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
|
||||||
|
// Our version of Twemoji only supports up to version 15.1
|
||||||
|
.filter((emoji) => emoji.version < 16)
|
||||||
|
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
|
||||||
|
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
|
||||||
|
const result = unicodeToTwemojiHex(hexcode);
|
||||||
|
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('twemojiHasBorder', () => {
|
||||||
|
test.concurrent.for(
|
||||||
|
svgFileNames
|
||||||
|
.filter((file) => file.endsWith('_border'))
|
||||||
|
.map((file) => {
|
||||||
|
const hexCode = file.replace('_border', '');
|
||||||
|
return [
|
||||||
|
hexCode,
|
||||||
|
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
|
||||||
|
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
|
||||||
|
] as const;
|
||||||
|
}),
|
||||||
|
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
|
||||||
|
const result = twemojiHasBorder(hexCode);
|
||||||
|
expect(result).toHaveProperty('hexCode', hexCode);
|
||||||
|
expect(result).toHaveProperty('hasLightBorder', isLight);
|
||||||
|
expect(result).toHaveProperty('hasDarkBorder', isDark);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
168
app/javascript/mastodon/features/emoji/normalize.ts
Normal file
168
app/javascript/mastodon/features/emoji/normalize.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
VARIATION_SELECTOR_CODE,
|
||||||
|
KEYCAP_CODE,
|
||||||
|
GENDER_FEMALE_CODE,
|
||||||
|
GENDER_MALE_CODE,
|
||||||
|
SKIN_TONE_CODES,
|
||||||
|
EMOJIS_WITH_DARK_BORDER,
|
||||||
|
EMOJIS_WITH_LIGHT_BORDER,
|
||||||
|
} from './constants';
|
||||||
|
import type { TwemojiBorderInfo } from './types';
|
||||||
|
|
||||||
|
// Misc codes that have special handling
|
||||||
|
const SKIER_CODE = 0x26f7;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hexNumbersToString(codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||||
|
const codes = hexStringToNumbers(unicodeHex);
|
||||||
|
const normalizedCodes: number[] = [];
|
||||||
|
for (let i = 0; i < codes.length; i++) {
|
||||||
|
const code = codes[i];
|
||||||
|
if (!code) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Some emoji have their variation selector removed
|
||||||
|
if (code === VARIATION_SELECTOR_CODE) {
|
||||||
|
// Key emoji
|
||||||
|
if (i === 1 && codes.at(-1) === KEYCAP_CODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Eye in speech bubble
|
||||||
|
if (codes.at(0) === EYE_CODE && codes.at(-2) === SPEECH_BUBBLE_CODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This removes zero padding to correctly match the SVG filenames
|
||||||
|
normalizedCodes.push(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexNumbersToString(normalizedCodes, 0).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CODES_WITH_DARK_BORDER =
|
||||||
|
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||||
|
|
||||||
|
export const CODES_WITH_LIGHT_BORDER =
|
||||||
|
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||||
|
|
||||||
|
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
|
||||||
|
const normalizedHex = twemojiHex.toUpperCase();
|
||||||
|
let hasLightBorder = false;
|
||||||
|
let hasDarkBorder = false;
|
||||||
|
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||||
|
hasLightBorder = true;
|
||||||
|
}
|
||||||
|
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
||||||
|
hasDarkBorder = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hexCode: twemojiHex,
|
||||||
|
hasLightBorder,
|
||||||
|
hasDarkBorder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
|
return hexString
|
||||||
|
.split('-')
|
||||||
|
.map((code) => Number.parseInt(code, 16))
|
||||||
|
.filter((code) => !Number.isNaN(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
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('-');
|
||||||
|
}
|
163
app/javascript/mastodon/features/emoji/render.test.ts
Normal file
163
app/javascript/mastodon/features/emoji/render.test.ts
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import {
|
||||||
|
EMOJI_MODE_NATIVE,
|
||||||
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
|
EMOJI_MODE_TWEMOJI,
|
||||||
|
} from './constants';
|
||||||
|
import { emojifyElement, tokenizeText } from './render';
|
||||||
|
import type { CustomEmojiData, UnicodeEmojiData } from './types';
|
||||||
|
|
||||||
|
vitest.mock('./database', () => ({
|
||||||
|
searchCustomEmojisByShortcodes: vitest.fn(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shortcode: 'custom',
|
||||||
|
static_url: 'emoji/static',
|
||||||
|
url: 'emoji/custom',
|
||||||
|
category: 'test',
|
||||||
|
visible_in_picker: true,
|
||||||
|
},
|
||||||
|
] satisfies CustomEmojiData[],
|
||||||
|
),
|
||||||
|
searchEmojisByHexcodes: vitest.fn(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
hexcode: '1F60A',
|
||||||
|
group: 0,
|
||||||
|
label: 'smiling face with smiling eyes',
|
||||||
|
order: 0,
|
||||||
|
tags: ['smile', 'happy'],
|
||||||
|
unicode: '😊',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hexcode: '1F1EA-1F1FA',
|
||||||
|
group: 0,
|
||||||
|
label: 'flag-eu',
|
||||||
|
order: 0,
|
||||||
|
tags: ['flag', 'european union'],
|
||||||
|
unicode: '🇪🇺',
|
||||||
|
},
|
||||||
|
] satisfies UnicodeEmojiData[],
|
||||||
|
),
|
||||||
|
findMissingLocales: vitest.fn(() => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('emojifyElement', () => {
|
||||||
|
const testElement = document.createElement('div');
|
||||||
|
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
|
||||||
|
|
||||||
|
const expectedSmileImage =
|
||||||
|
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||||
|
const expectedFlagImage =
|
||||||
|
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||||
|
const expectedCustomEmojiImage =
|
||||||
|
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
|
||||||
|
|
||||||
|
function cloneTestElement() {
|
||||||
|
return testElement.cloneNode(true) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('emojifies custom emoji in native mode', async () => {
|
||||||
|
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||||
|
locales: ['en'],
|
||||||
|
mode: EMOJI_MODE_NATIVE,
|
||||||
|
currentLocale: 'en',
|
||||||
|
});
|
||||||
|
expect(emojifiedElement.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies flag emoji in native-with-flags mode', async () => {
|
||||||
|
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||||
|
locales: ['en'],
|
||||||
|
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
|
currentLocale: 'en',
|
||||||
|
});
|
||||||
|
expect(emojifiedElement.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies everything in twemoji mode', async () => {
|
||||||
|
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||||
|
locales: ['en'],
|
||||||
|
mode: EMOJI_MODE_TWEMOJI,
|
||||||
|
currentLocale: 'en',
|
||||||
|
});
|
||||||
|
expect(emojifiedElement.innerHTML).toBe(
|
||||||
|
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenizeText', () => {
|
||||||
|
test('returns empty array for string with only whitespace', () => {
|
||||||
|
expect(tokenizeText(' \n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns an array of text to be a single token', () => {
|
||||||
|
expect(tokenizeText('Hello')).toEqual(['Hello']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tokens for text with emoji', () => {
|
||||||
|
expect(tokenizeText('Hello 😊 🇿🇼!!')).toEqual([
|
||||||
|
'Hello ',
|
||||||
|
{
|
||||||
|
type: 'unicode',
|
||||||
|
code: '😊',
|
||||||
|
},
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
type: 'unicode',
|
||||||
|
code: '🇿🇼',
|
||||||
|
},
|
||||||
|
'!!',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tokens for text with custom emoji', () => {
|
||||||
|
expect(tokenizeText('Hello :smile:!!')).toEqual([
|
||||||
|
'Hello ',
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
code: 'smile',
|
||||||
|
},
|
||||||
|
'!!',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles custom emoji with underscores and numbers', () => {
|
||||||
|
expect(tokenizeText('Hello :smile_123:!!')).toEqual([
|
||||||
|
'Hello ',
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
code: 'smile_123',
|
||||||
|
},
|
||||||
|
'!!',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tokens for text with mixed emoji', () => {
|
||||||
|
expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([
|
||||||
|
'Hello ',
|
||||||
|
{
|
||||||
|
type: 'unicode',
|
||||||
|
code: '😊',
|
||||||
|
},
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
code: 'smile',
|
||||||
|
},
|
||||||
|
'!!',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not capture custom emoji with invalid characters', () => {
|
||||||
|
expect(tokenizeText('Hello :smile-123:!!')).toEqual([
|
||||||
|
'Hello :smile-123:!!',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
331
app/javascript/mastodon/features/emoji/render.ts
Normal file
331
app/javascript/mastodon/features/emoji/render.ts
Normal file
|
@ -0,0 +1,331 @@
|
||||||
|
import type { Locale } from 'emojibase';
|
||||||
|
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||||
|
|
||||||
|
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||||
|
import { assetHost } from '@/mastodon/utils/config';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMOJI_MODE_NATIVE,
|
||||||
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
|
EMOJI_TYPE_UNICODE,
|
||||||
|
EMOJI_TYPE_CUSTOM,
|
||||||
|
EMOJI_STATE_MISSING,
|
||||||
|
} from './constants';
|
||||||
|
import {
|
||||||
|
findMissingLocales,
|
||||||
|
searchCustomEmojisByShortcodes,
|
||||||
|
searchEmojisByHexcodes,
|
||||||
|
} from './database';
|
||||||
|
import { loadEmojiLocale } from './index';
|
||||||
|
import {
|
||||||
|
emojiToUnicodeHex,
|
||||||
|
twemojiHasBorder,
|
||||||
|
unicodeToTwemojiHex,
|
||||||
|
} from './normalize';
|
||||||
|
import type {
|
||||||
|
CustomEmojiToken,
|
||||||
|
EmojiAppState,
|
||||||
|
EmojiLoadedState,
|
||||||
|
EmojiMode,
|
||||||
|
EmojiState,
|
||||||
|
EmojiStateMap,
|
||||||
|
EmojiToken,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
LocaleOrCustom,
|
||||||
|
UnicodeEmojiToken,
|
||||||
|
} from './types';
|
||||||
|
import { stringHasUnicodeFlags } from './utils';
|
||||||
|
|
||||||
|
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||||
|
[EMOJI_TYPE_CUSTOM, new Map()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||||
|
export async function emojifyElement<Element extends HTMLElement>(
|
||||||
|
element: Element,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
): Promise<Element> {
|
||||||
|
const queue: (HTMLElement | Text)[] = [element];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current instanceof HTMLScriptElement ||
|
||||||
|
current instanceof HTMLStyleElement
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.textContent &&
|
||||||
|
(current instanceof Text || !current.hasChildNodes())
|
||||||
|
) {
|
||||||
|
const renderedContent = await emojifyText(
|
||||||
|
current.textContent,
|
||||||
|
appState,
|
||||||
|
extraEmojis,
|
||||||
|
);
|
||||||
|
if (renderedContent) {
|
||||||
|
if (!(current instanceof Text)) {
|
||||||
|
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||||
|
}
|
||||||
|
current.replaceWith(renderedToHTMLFragment(renderedContent));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of current.childNodes) {
|
||||||
|
if (child instanceof HTMLElement || child instanceof Text) {
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emojifyText(
|
||||||
|
text: string,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
) {
|
||||||
|
// Exit if no text to convert.
|
||||||
|
if (!text.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenizeText(text);
|
||||||
|
|
||||||
|
// If only one token and it's a string, exit early.
|
||||||
|
if (tokens.length === 1 && typeof tokens[0] === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all emoji from the state map, loading any missing ones.
|
||||||
|
await ensureLocalesAreLoaded(appState.locales);
|
||||||
|
await loadMissingEmojiIntoCache(tokens, appState.locales);
|
||||||
|
|
||||||
|
const renderedFragments: (string | HTMLImageElement)[] = [];
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||||
|
let state: EmojiState | undefined;
|
||||||
|
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||||
|
const extraEmojiData = extraEmojis[token.code];
|
||||||
|
if (extraEmojiData) {
|
||||||
|
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
|
||||||
|
} else {
|
||||||
|
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state = emojiForLocale(
|
||||||
|
emojiToUnicodeHex(token.code),
|
||||||
|
appState.currentLocale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||||
|
if (state && typeof state !== 'string') {
|
||||||
|
const image = stateToImage(state);
|
||||||
|
renderedFragments.push(image);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = typeof token === 'string' ? token : token.code;
|
||||||
|
renderedFragments.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedFragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
async function ensureLocalesAreLoaded(locales: Locale[]) {
|
||||||
|
const missingLocales = await findMissingLocales(locales);
|
||||||
|
for (const locale of missingLocales) {
|
||||||
|
await loadEmojiLocale(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||||
|
const TOKENIZE_REGEX = new RegExp(
|
||||||
|
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
type TokenizedText = (string | EmojiToken)[];
|
||||||
|
|
||||||
|
export function tokenizeText(text: string): TokenizedText {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
for (const match of text.matchAll(TOKENIZE_REGEX)) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
tokens.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = match[0];
|
||||||
|
|
||||||
|
if (code.startsWith(':') && code.endsWith(':')) {
|
||||||
|
// Custom emoji
|
||||||
|
tokens.push({
|
||||||
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
|
code: code.slice(1, -1), // Remove the colons
|
||||||
|
} satisfies CustomEmojiToken);
|
||||||
|
} else {
|
||||||
|
// Unicode emoji
|
||||||
|
tokens.push({
|
||||||
|
type: EMOJI_TYPE_UNICODE,
|
||||||
|
code: code,
|
||||||
|
} satisfies UnicodeEmojiToken);
|
||||||
|
}
|
||||||
|
lastIndex = match.index + code.length;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
tokens.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||||
|
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emojiForLocale(
|
||||||
|
code: string,
|
||||||
|
locale: LocaleOrCustom,
|
||||||
|
): EmojiState | undefined {
|
||||||
|
const cache = cacheForLocale(locale);
|
||||||
|
return cache.get(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMissingEmojiIntoCache(
|
||||||
|
tokens: TokenizedText,
|
||||||
|
locales: Locale[],
|
||||||
|
) {
|
||||||
|
const missingUnicodeEmoji = new Set<string>();
|
||||||
|
const missingCustomEmoji = new Set<string>();
|
||||||
|
|
||||||
|
// Iterate over tokens and check if they are in the cache already.
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
continue; // Skip plain strings.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a custom emoji, check it separately.
|
||||||
|
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||||
|
const code = token.code;
|
||||||
|
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||||
|
if (!emojiState) {
|
||||||
|
missingCustomEmoji.add(code);
|
||||||
|
}
|
||||||
|
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||||
|
} else {
|
||||||
|
const code = emojiToUnicodeHex(token.code);
|
||||||
|
if (missingUnicodeEmoji.has(code)) {
|
||||||
|
continue; // Already marked as missing.
|
||||||
|
}
|
||||||
|
for (const locale of locales) {
|
||||||
|
const emojiState = emojiForLocale(code, locale);
|
||||||
|
if (!emojiState) {
|
||||||
|
// If it's missing in one locale, we consider it missing for all.
|
||||||
|
missingUnicodeEmoji.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingUnicodeEmoji.size > 0) {
|
||||||
|
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||||
|
for (const locale of locales) {
|
||||||
|
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
|
||||||
|
const cache = cacheForLocale(locale);
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||||
|
}
|
||||||
|
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||||
|
emojis.every((emoji) => emoji.hexcode !== code),
|
||||||
|
);
|
||||||
|
for (const code of notFoundEmojis) {
|
||||||
|
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||||
|
}
|
||||||
|
localeCacheMap.set(locale, cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingCustomEmoji.size > 0) {
|
||||||
|
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
|
||||||
|
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||||
|
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
|
||||||
|
}
|
||||||
|
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||||
|
emojis.every((emoji) => emoji.shortcode !== code),
|
||||||
|
);
|
||||||
|
for (const code of notFoundEmojis) {
|
||||||
|
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||||
|
}
|
||||||
|
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
||||||
|
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||||
|
// If the mode is native or native with flags for non-flag emoji
|
||||||
|
// we can just append the text node directly.
|
||||||
|
if (
|
||||||
|
mode === EMOJI_MODE_NATIVE ||
|
||||||
|
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
|
||||||
|
!stringHasUnicodeFlags(token.code))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateToImage(state: EmojiLoadedState) {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.draggable = false;
|
||||||
|
image.classList.add('emojione');
|
||||||
|
|
||||||
|
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||||
|
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
||||||
|
if (emojiInfo.hasLightBorder) {
|
||||||
|
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
|
||||||
|
} else if (emojiInfo.hasDarkBorder) {
|
||||||
|
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
|
||||||
|
}
|
||||||
|
|
||||||
|
image.alt = state.data.unicode;
|
||||||
|
image.title = state.data.label;
|
||||||
|
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
|
||||||
|
} else {
|
||||||
|
// Custom emoji
|
||||||
|
const shortCode = `:${state.data.shortcode}:`;
|
||||||
|
image.classList.add('custom-emoji');
|
||||||
|
image.alt = shortCode;
|
||||||
|
image.title = shortCode;
|
||||||
|
image.src = autoPlayGif ? state.data.url : state.data.static_url;
|
||||||
|
image.dataset.original = state.data.url;
|
||||||
|
image.dataset.static = state.data.static_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const fragmentItem of renderedArray) {
|
||||||
|
if (typeof fragmentItem === 'string') {
|
||||||
|
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||||
|
} else if (fragmentItem instanceof HTMLImageElement) {
|
||||||
|
fragment.appendChild(fragmentItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fragment;
|
||||||
|
}
|
64
app/javascript/mastodon/features/emoji/types.ts
Normal file
64
app/javascript/mastodon/features/emoji/types.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EMOJI_MODE_NATIVE,
|
||||||
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
|
EMOJI_MODE_TWEMOJI,
|
||||||
|
EMOJI_STATE_MISSING,
|
||||||
|
EMOJI_TYPE_CUSTOM,
|
||||||
|
EMOJI_TYPE_UNICODE,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export type EmojiMode =
|
||||||
|
| typeof EMOJI_MODE_NATIVE
|
||||||
|
| typeof EMOJI_MODE_NATIVE_WITH_FLAGS
|
||||||
|
| typeof EMOJI_MODE_TWEMOJI;
|
||||||
|
|
||||||
|
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
|
||||||
|
|
||||||
|
export interface EmojiAppState {
|
||||||
|
locales: Locale[];
|
||||||
|
currentLocale: Locale;
|
||||||
|
mode: EmojiMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnicodeEmojiToken {
|
||||||
|
type: typeof EMOJI_TYPE_UNICODE;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
export interface CustomEmojiToken {
|
||||||
|
type: typeof EMOJI_TYPE_CUSTOM;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
|
||||||
|
|
||||||
|
export type CustomEmojiData = ApiCustomEmojiJSON;
|
||||||
|
export type UnicodeEmojiData = FlatCompactEmoji;
|
||||||
|
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
||||||
|
|
||||||
|
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
|
||||||
|
export interface EmojiStateUnicode {
|
||||||
|
type: typeof EMOJI_TYPE_UNICODE;
|
||||||
|
data: UnicodeEmojiData;
|
||||||
|
}
|
||||||
|
export interface EmojiStateCustom {
|
||||||
|
type: typeof EMOJI_TYPE_CUSTOM;
|
||||||
|
data: CustomEmojiData;
|
||||||
|
}
|
||||||
|
export type EmojiState =
|
||||||
|
| EmojiStateMissing
|
||||||
|
| EmojiStateUnicode
|
||||||
|
| EmojiStateCustom;
|
||||||
|
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
||||||
|
|
||||||
|
export type EmojiStateMap = Map<string, EmojiState>;
|
||||||
|
|
||||||
|
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
|
||||||
|
|
||||||
|
export interface TwemojiBorderInfo {
|
||||||
|
hexCode: string;
|
||||||
|
hasLightBorder: boolean;
|
||||||
|
hasDarkBorder: boolean;
|
||||||
|
}
|
47
app/javascript/mastodon/features/emoji/utils.test.ts
Normal file
47
app/javascript/mastodon/features/emoji/utils.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||||
|
|
||||||
|
describe('stringHasEmoji', () => {
|
||||||
|
test.concurrent.for([
|
||||||
|
['only text', false],
|
||||||
|
['text with emoji 😀', true],
|
||||||
|
['multiple emojis 😀😃😄', true],
|
||||||
|
['emoji with skin tone 👍🏽', true],
|
||||||
|
['emoji with ZWJ 👩❤️👨', true],
|
||||||
|
['emoji with variation selector ✊️', true],
|
||||||
|
['emoji with keycap 1️⃣', true],
|
||||||
|
['emoji with flags 🇺🇸', true],
|
||||||
|
['emoji with regional indicators 🇦🇺', true],
|
||||||
|
['emoji with gender 👩⚕️', true],
|
||||||
|
['emoji with family 👨👩👧👦', true],
|
||||||
|
['emoji with zero width joiner 👩🔬', true],
|
||||||
|
['emoji with non-BMP codepoint 🧑🚀', true],
|
||||||
|
['emoji with combining marks 👨👩👧👦', true],
|
||||||
|
['emoji with enclosing keycap #️⃣', true],
|
||||||
|
['emoji with no visible glyph \u200D', false],
|
||||||
|
] as const)(
|
||||||
|
'stringHasEmoji has emojis in "%s": %o',
|
||||||
|
([text, expected], { expect }) => {
|
||||||
|
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stringHasFlags', () => {
|
||||||
|
test.concurrent.for([
|
||||||
|
['EU 🇪🇺', true],
|
||||||
|
['Germany 🇩🇪', true],
|
||||||
|
['Canada 🇨🇦', true],
|
||||||
|
['São Tomé & Príncipe 🇸🇹', true],
|
||||||
|
['Scotland 🏴', true],
|
||||||
|
['black flag 🏴', false],
|
||||||
|
['arrr 🏴☠️', false],
|
||||||
|
['rainbow flag 🏳️🌈', false],
|
||||||
|
['non-flag 🔥', false],
|
||||||
|
['only text', false],
|
||||||
|
] as const)(
|
||||||
|
'stringHasFlags has flag in "%s": %o',
|
||||||
|
([text, expected], { expect }) => {
|
||||||
|
expect(stringHasUnicodeFlags(text)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
13
app/javascript/mastodon/features/emoji/utils.ts
Normal file
13
app/javascript/mastodon/features/emoji/utils.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||||
|
|
||||||
|
export function stringHasUnicodeEmoji(text: string): boolean {
|
||||||
|
return EMOJI_REGEX.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
|
||||||
|
const EMOJIS_FLAGS_REGEX =
|
||||||
|
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
|
||||||
|
|
||||||
|
export function stringHasUnicodeFlags(text: string): boolean {
|
||||||
|
return EMOJIS_FLAGS_REGEX.test(text);
|
||||||
|
}
|
13
app/javascript/mastodon/features/emoji/worker.ts
Normal file
13
app/javascript/mastodon/features/emoji/worker.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { importEmojiData, importCustomEmojiData } from './loader';
|
||||||
|
|
||||||
|
addEventListener('message', handleMessage);
|
||||||
|
self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||||
|
|
||||||
|
function handleMessage(event: MessageEvent<string>) {
|
||||||
|
const { data: locale } = event;
|
||||||
|
if (locale !== 'custom') {
|
||||||
|
void importEmojiData(locale);
|
||||||
|
} else {
|
||||||
|
void importCustomEmojiData();
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => {
|
||||||
|
|
||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const arr: MenuItem[] = [
|
const arr: MenuItem[] = [
|
||||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
|
||||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
|
||||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.domainBlocks),
|
href: '/filters',
|
||||||
to: '/domain_blocks',
|
text: intl.formatMessage(messages.filters),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/mutes',
|
||||||
|
text: intl.formatMessage(messages.mutes),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/blocks',
|
||||||
|
text: intl.formatMessage(messages.blocks),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/domain_blocks',
|
||||||
|
text: intl.formatMessage(messages.domainBlocks),
|
||||||
},
|
},
|
||||||
];
|
|
||||||
|
|
||||||
arr.push(
|
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
href: '/settings/privacy',
|
href: '/settings/privacy',
|
||||||
|
@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => {
|
||||||
href: '/settings/export',
|
href: '/settings/export',
|
||||||
text: intl.formatMessage(messages.importExport),
|
text: intl.formatMessage(messages.importExport),
|
||||||
},
|
},
|
||||||
);
|
];
|
||||||
|
|
||||||
if (canManageReports(permissions)) {
|
if (canManageReports(permissions)) {
|
||||||
arr.push(null, {
|
arr.push(null, {
|
||||||
|
@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => {
|
||||||
}, [intl, dispatch, permissions]);
|
}, [intl, dispatch, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown items={menu}>
|
<Dropdown items={menu} placement='bottom-start'>
|
||||||
<button className='column-link column-link--transparent'>
|
<button className='column-link column-link--transparent'>
|
||||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||||
|
|
||||||
|
|
|
@ -431,6 +431,7 @@ export const CollapsibleNavigationPanel: React.FC = () => {
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
||||||
rubberband: true,
|
rubberband: true,
|
||||||
|
enabled: openable,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||||
|
@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||||
|
@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='user' icon={PersonIcon} />
|
<Icon id='user' icon={PersonIcon} />
|
||||||
|
@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='star' icon={StarIcon} className='star-icon' />
|
<Icon id='star' icon={StarIcon} className='star-icon' />
|
||||||
|
@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='retweet' icon={RepeatIcon} />
|
<Icon id='retweet' icon={RepeatIcon} />
|
||||||
|
@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='home' icon={HomeIcon} />
|
<Icon id='home' icon={HomeIcon} />
|
||||||
|
@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='pencil' icon={EditIcon} />
|
<Icon id='pencil' icon={EditIcon} />
|
||||||
|
@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='tasks' icon={InsertChartIcon} />
|
<Icon id='tasks' icon={InsertChartIcon} />
|
||||||
|
@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
|
||||||
<RelationshipsSeveranceEvent
|
<RelationshipsSeveranceEvent
|
||||||
type={event.get('type')}
|
type={event.get('type')}
|
||||||
|
@ -381,7 +381,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
||||||
<ModerationWarning
|
<ModerationWarning
|
||||||
action={warning.get('action')}
|
action={warning.get('action')}
|
||||||
|
@ -402,7 +402,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||||
|
@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='flag' icon={FlagIcon} />
|
<Icon id='flag' icon={FlagIcon} />
|
||||||
|
@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,98 +122,93 @@ export const PolicyControls: React.FC = () => {
|
||||||
value={notificationPolicy.for_not_following}
|
value={notificationPolicy.for_not_following}
|
||||||
onChange={handleFilterNotFollowing}
|
onChange={handleFilterNotFollowing}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_title'
|
id='notifications.policy.filter_not_following_title'
|
||||||
defaultMessage="People you don't follow"
|
defaultMessage="People you don't follow"
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_hint'
|
id='notifications.policy.filter_not_following_hint'
|
||||||
defaultMessage='Until you manually approve them'
|
defaultMessage='Until you manually approve them'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_not_followers}
|
value={notificationPolicy.for_not_followers}
|
||||||
onChange={handleFilterNotFollowers}
|
onChange={handleFilterNotFollowers}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_title'
|
id='notifications.policy.filter_not_followers_title'
|
||||||
defaultMessage='People not following you'
|
defaultMessage='People not following you'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_hint'
|
id='notifications.policy.filter_not_followers_hint'
|
||||||
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 3 }}
|
values={{ days: 3 }}
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_new_accounts}
|
value={notificationPolicy.for_new_accounts}
|
||||||
onChange={handleFilterNewAccounts}
|
onChange={handleFilterNewAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts_title'
|
id='notifications.policy.filter_new_accounts_title'
|
||||||
defaultMessage='New accounts'
|
defaultMessage='New accounts'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts.hint'
|
id='notifications.policy.filter_new_accounts.hint'
|
||||||
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 30 }}
|
values={{ days: 30 }}
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_private_mentions}
|
value={notificationPolicy.for_private_mentions}
|
||||||
onChange={handleFilterPrivateMentions}
|
onChange={handleFilterPrivateMentions}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_title'
|
id='notifications.policy.filter_private_mentions_title'
|
||||||
defaultMessage='Unsolicited private mentions'
|
defaultMessage='Unsolicited private mentions'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_hint'
|
id='notifications.policy.filter_private_mentions_hint'
|
||||||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_limited_accounts}
|
value={notificationPolicy.for_limited_accounts}
|
||||||
onChange={handleFilterLimitedAccounts}
|
onChange={handleFilterLimitedAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_title'
|
id='notifications.policy.filter_limited_accounts_title'
|
||||||
defaultMessage='Moderated accounts'
|
defaultMessage='Moderated accounts'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_hint'
|
id='notifications.policy.filter_limited_accounts_hint'
|
||||||
defaultMessage='Limited by server moderators'
|
defaultMessage='Limited by server moderators'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useState, useRef, useId } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -16,6 +16,8 @@ interface DropdownProps {
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
'aria-labelledby': string;
|
||||||
|
'aria-describedby'?: string;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,51 +26,33 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
options,
|
options,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
|
'aria-labelledby': ariaLabelledBy,
|
||||||
|
'aria-describedby': ariaDescribedBy,
|
||||||
placement: initialPlacement = 'bottom-end',
|
placement: initialPlacement = 'bottom-end',
|
||||||
}) => {
|
}) => {
|
||||||
const activeElementRef = useRef<Element | null>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const [isOpen, setOpen] = useState<boolean>(false);
|
const [isOpen, setOpen] = useState<boolean>(false);
|
||||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||||
|
const uniqueId = useId();
|
||||||
const handleToggle = useCallback(() => {
|
const menuId = `${uniqueId}-menu`;
|
||||||
if (
|
const buttonLabelId = `${uniqueId}-button`;
|
||||||
isOpen &&
|
|
||||||
activeElementRef.current &&
|
|
||||||
activeElementRef.current instanceof HTMLElement
|
|
||||||
) {
|
|
||||||
activeElementRef.current.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(!isOpen);
|
|
||||||
}, [isOpen, setOpen]);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(() => {
|
|
||||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (
|
if (isOpen && buttonRef.current) {
|
||||||
isOpen &&
|
buttonRef.current.focus({ preventScroll: true });
|
||||||
activeElementRef.current &&
|
}
|
||||||
activeElementRef.current instanceof HTMLElement
|
|
||||||
)
|
|
||||||
activeElementRef.current.focus({ preventScroll: true });
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
const handleOverlayEnter = useCallback(
|
const handleOverlayEnter = useCallback(
|
||||||
(state: Partial<PopperState>) => {
|
(state: Partial<PopperState>) => {
|
||||||
if (state.placement) setPlacement(state.placement);
|
if (state.placement) setPlacement(state.placement);
|
||||||
|
@ -82,13 +66,18 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
|
ref={buttonRef}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={menuId}
|
||||||
|
aria-labelledby={`${ariaLabelledBy} ${buttonLabelId}`}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={classNames('dropdown-button', { active: isOpen })}
|
className={classNames('dropdown-button', { active: isOpen })}
|
||||||
>
|
>
|
||||||
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
<span id={buttonLabelId} className='dropdown-button__label'>
|
||||||
|
{valueOption?.text}
|
||||||
|
</span>
|
||||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@ -101,7 +90,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props}>
|
<div {...props} id={menuId}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||||
>
|
>
|
||||||
|
@ -123,6 +112,8 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
|
label: string | React.ReactElement;
|
||||||
|
hint: string | React.ReactElement;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
@ -130,13 +121,26 @@ interface Props {
|
||||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const labelId = `${uniqueId}-label`;
|
||||||
|
const descId = `${uniqueId}-desc`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// This label is only used for its click-forwarding behaviour,
|
||||||
|
// accessible names are assigned manually
|
||||||
|
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||||
<label className='app-form__toggle'>
|
<label className='app-form__toggle'>
|
||||||
<div className='app-form__toggle__label'>{children}</div>
|
<div className='app-form__toggle__label'>
|
||||||
|
<strong id={labelId}>{label}</strong>
|
||||||
|
<span className='hint' id={descId}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
<div>
|
||||||
|
@ -144,6 +148,8 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
aria-describedby={descId}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import { navigateToProfile } from 'mastodon/actions/accounts';
|
import { navigateToProfile } from 'mastodon/actions/accounts';
|
||||||
import { mentionComposeById } from 'mastodon/actions/compose';
|
import { mentionComposeById } from 'mastodon/actions/compose';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <HotKeys handlers={handlers}>{content}</HotKeys>;
|
return <Hotkeys handlers={handlers}>{content}</Hotkeys>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,12 +3,11 @@ import type { JSX } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import { replyComposeById } from 'mastodon/actions/compose';
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
|
@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div
|
<div
|
||||||
role='button'
|
role='button'
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -149,6 +148,6 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,14 +2,13 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import { replyComposeById } from 'mastodon/actions/compose';
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||||
import {
|
import {
|
||||||
navigateToStatus,
|
navigateToStatus,
|
||||||
toggleStatusSpoilers,
|
toggleStatusSpoilers,
|
||||||
} from 'mastodon/actions/statuses';
|
} from 'mastodon/actions/statuses';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
|
@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{
|
||||||
if (!statusId || isFiltered) return null;
|
if (!statusId || isFiltered) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div
|
<div
|
||||||
role='button'
|
role='button'
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -111,6 +110,6 @@ export const NotificationWithStatus: React.FC<{
|
||||||
unfocusable
|
unfocusable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
@ -21,6 +21,10 @@ import { openModal } from 'mastodon/actions/modal';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { useIdentity } from 'mastodon/identity_context';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
import type { Status } from 'mastodon/models/status';
|
||||||
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
|
import type { RootState } from 'mastodon/store';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -47,6 +51,11 @@ const messages = defineMessages({
|
||||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type GetStatusSelector = (
|
||||||
|
state: RootState,
|
||||||
|
props: { id?: string | null; contextType?: string },
|
||||||
|
) => Status | null;
|
||||||
|
|
||||||
export const Footer: React.FC<{
|
export const Footer: React.FC<{
|
||||||
statusId: string;
|
statusId: string;
|
||||||
withOpenButton?: boolean;
|
withOpenButton?: boolean;
|
||||||
|
@ -56,11 +65,9 @@ export const Footer: React.FC<{
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
||||||
const accountId = status?.get('account') as string | undefined;
|
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
|
||||||
const account = useAppSelector((state) =>
|
const account = status?.get('account') as Account | undefined;
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
|
||||||
);
|
|
||||||
const askReplyConfirmation = useAppSelector(
|
const askReplyConfirmation = useAppSelector(
|
||||||
(state) => (state.compose.get('text') as string).trim().length !== 0,
|
(state) => (state.compose.get('text') as string).trim().length !== 0,
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchContext,
|
||||||
|
completeContextRefresh,
|
||||||
|
} from 'mastodon/actions/statuses';
|
||||||
|
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||||
|
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
loading: {
|
||||||
|
id: 'status.context.loading',
|
||||||
|
defaultMessage: 'Checking for more replies',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefreshController: React.FC<{
|
||||||
|
statusId: string;
|
||||||
|
withBorder?: boolean;
|
||||||
|
}> = ({ statusId, withBorder }) => {
|
||||||
|
const refresh = useAppSelector(
|
||||||
|
(state) => state.contexts.refreshing[statusId],
|
||||||
|
);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||||
|
if (result.async_refresh.status === 'finished') {
|
||||||
|
dispatch(completeContextRefresh({ statusId }));
|
||||||
|
|
||||||
|
if (result.async_refresh.result_count > 0) {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scheduleRefresh(refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}, refresh.retry * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
scheduleRefresh(refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [dispatch, setReady, statusId, refresh]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setReady(false);
|
||||||
|
|
||||||
|
dispatch(fetchContext({ statusId }))
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [dispatch, setReady, statusId]);
|
||||||
|
|
||||||
|
if (ready && !loading) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classNames('load-more load-gap', {
|
||||||
|
'timeline-hint--with-descendants': withBorder,
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.context.load_new_replies'
|
||||||
|
defaultMessage='New replies available'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refresh && !loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('load-more load-gap', {
|
||||||
|
'timeline-hint--with-descendants': withBorder,
|
||||||
|
})}
|
||||||
|
aria-busy
|
||||||
|
aria-live='polite'
|
||||||
|
aria-label={intl.formatMessage(messages.loading)}
|
||||||
|
>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||||
|
@ -69,7 +68,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
|
||||||
|
|
||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
import { DetailedStatus } from './components/detailed_status';
|
import { DetailedStatus } from './components/detailed_status';
|
||||||
|
import { RefreshController } from './components/refresh_controller';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||||
|
@ -549,7 +548,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants, remoteHint;
|
let ancestors, descendants, remoteHint;
|
||||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
const { isLoading, status, ancestorsIds, descendantsIds, refresh, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
@ -579,11 +578,9 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (!isLocal) {
|
if (!isLocal) {
|
||||||
remoteHint = (
|
remoteHint = (
|
||||||
<TimelineHint
|
<RefreshController
|
||||||
className={classNames(!!descendants && 'timeline-hint--with-descendants')}
|
statusId={status.get('id')}
|
||||||
url={status.get('url')}
|
withBorder={!!descendants}
|
||||||
message={<FormattedMessage id='hints.threads.replies_may_be_missing' defaultMessage='Replies from other servers may be missing.' />}
|
|
||||||
label={<FormattedMessage id='hints.threads.see_more' defaultMessage='See more replies on {domain}' values={{ domain: <strong>{status.getIn(['account', 'acct']).split('@')[1]}</strong> }} />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -616,7 +613,7 @@ class Status extends ImmutablePureComponent {
|
||||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<HotKeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false)} ref={this.setStatusRef}>
|
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false)} ref={this.setStatusRef}>
|
||||||
<DetailedStatus
|
<DetailedStatus
|
||||||
key={`details-${status.get('id')}`}
|
key={`details-${status.get('id')}`}
|
||||||
|
@ -654,7 +651,7 @@ class Status extends ImmutablePureComponent {
|
||||||
onEmbed={this.handleEmbed}
|
onEmbed={this.handleEmbed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
{remoteHint}
|
{remoteHint}
|
||||||
|
|
|
@ -306,10 +306,8 @@ export const ZoomableImage: React.FC<ZoomableImageProps> = ({
|
||||||
|
|
||||||
<animated.img
|
<animated.img
|
||||||
style={{ transform }}
|
style={{ transform }}
|
||||||
role='presentation'
|
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
lang={lang}
|
||||||
src={src}
|
src={src}
|
||||||
width={width}
|
width={width}
|
||||||
|
|
|
@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||||
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
||||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||||
import { AlertsController } from 'mastodon/components/alerts_controller';
|
import { AlertsController } from 'mastodon/components/alerts_controller';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
||||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
|
@ -98,40 +98,6 @@ const mapStateToProps = state => ({
|
||||||
username: state.getIn(['accounts', me, 'username']),
|
username: state.getIn(['accounts', me, 'username']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const keyMap = {
|
|
||||||
help: '?',
|
|
||||||
new: 'n',
|
|
||||||
search: ['s', '/'],
|
|
||||||
forceNew: 'option+n',
|
|
||||||
toggleComposeSpoilers: 'option+x',
|
|
||||||
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
|
||||||
reply: 'r',
|
|
||||||
favourite: 'f',
|
|
||||||
boost: 'b',
|
|
||||||
mention: 'm',
|
|
||||||
open: ['enter', 'o'],
|
|
||||||
openProfile: 'p',
|
|
||||||
moveDown: ['down', 'j'],
|
|
||||||
moveUp: ['up', 'k'],
|
|
||||||
back: 'backspace',
|
|
||||||
goToHome: 'g h',
|
|
||||||
goToNotifications: 'g n',
|
|
||||||
goToLocal: 'g l',
|
|
||||||
goToFederated: 'g t',
|
|
||||||
goToDirect: 'g d',
|
|
||||||
goToStart: 'g s',
|
|
||||||
goToFavourites: 'g f',
|
|
||||||
goToPinned: 'g p',
|
|
||||||
goToProfile: 'g u',
|
|
||||||
goToBlocked: 'g b',
|
|
||||||
goToMuted: 'g m',
|
|
||||||
goToRequests: 'g r',
|
|
||||||
toggleHidden: 'x',
|
|
||||||
toggleSensitive: 'h',
|
|
||||||
openMedia: 'e',
|
|
||||||
onTranslate: 't',
|
|
||||||
};
|
|
||||||
|
|
||||||
class SwitchingColumnsArea extends PureComponent {
|
class SwitchingColumnsArea extends PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
|
@ -400,6 +366,10 @@ class UI extends PureComponent {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleDonate = () => {
|
||||||
|
location.href = 'https://joinmastodon.org/sponsors#donate'
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
const { signedIn } = this.props.identity;
|
const { signedIn } = this.props.identity;
|
||||||
|
|
||||||
|
@ -426,10 +396,6 @@ class UI extends PureComponent {
|
||||||
|
|
||||||
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
|
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
|
||||||
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
@ -509,10 +475,6 @@ class UI extends PureComponent {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setHotkeysRef = c => {
|
|
||||||
this.hotkeys = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHotkeyToggleHelp = () => {
|
handleHotkeyToggleHelp = () => {
|
||||||
if (this.props.location.pathname === '/keyboard-shortcuts') {
|
if (this.props.location.pathname === '/keyboard-shortcuts') {
|
||||||
this.props.history.goBack();
|
this.props.history.goBack();
|
||||||
|
@ -593,10 +555,11 @@ class UI extends PureComponent {
|
||||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||||
goToMuted: this.handleHotkeyGoToMuted,
|
goToMuted: this.handleHotkeyGoToMuted,
|
||||||
goToRequests: this.handleHotkeyGoToRequests,
|
goToRequests: this.handleHotkeyGoToRequests,
|
||||||
|
cheat: this.handleDonate,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
|
<Hotkeys global handlers={handlers}>
|
||||||
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
|
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
|
||||||
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
|
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -611,7 +574,7 @@ class UI extends PureComponent {
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
element.classList.contains('mention');
|
element.classList.contains('mention') &&
|
||||||
|
!element.classList.contains('hashtag');
|
||||||
|
|
||||||
const isHashtagClick = (element: HTMLAnchorElement) =>
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
element.textContent?.[0] === '#' ||
|
element.textContent?.[0] === '#' ||
|
||||||
element.previousSibling?.textContent?.endsWith('#');
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
export const useLinks = () => {
|
export const useLinks = (skipHashtags?: boolean) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
@ -61,12 +62,12 @@ export const useLinks = () => {
|
||||||
if (isMentionClick(target)) {
|
if (isMentionClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleMentionClick(target);
|
void handleMentionClick(target);
|
||||||
} else if (isHashtagClick(target)) {
|
} else if (isHashtagClick(target) && !skipHashtags) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleHashtagClick(target);
|
handleHashtagClick(target);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleMentionClick, handleHashtagClick],
|
[skipHashtags, handleMentionClick, handleHashtagClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleClick;
|
return handleClick;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
||||||
*/
|
*/
|
||||||
|
@ -46,6 +45,7 @@
|
||||||
* @property {string} sso_redirect
|
* @property {string} sso_redirect
|
||||||
* @property {string} status_page_url
|
* @property {string} status_page_url
|
||||||
* @property {boolean} terms_of_service_enabled
|
* @property {boolean} terms_of_service_enabled
|
||||||
|
* @property {string?} emoji_style
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,6 +64,7 @@
|
||||||
* @property {boolean=} critical_updates_pending
|
* @property {boolean=} critical_updates_pending
|
||||||
* @property {InitialStateMeta} meta
|
* @property {InitialStateMeta} meta
|
||||||
* @property {Role?} role
|
* @property {Role?} role
|
||||||
|
* @property {string[]} features
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const element = document.getElementById('initial-state');
|
const element = document.getElementById('initial-state');
|
||||||
|
@ -95,6 +96,7 @@ export const disableHoverCards = getMeta('disable_hover_cards');
|
||||||
export const disabledAccountId = getMeta('disabled_account_id');
|
export const disabledAccountId = getMeta('disabled_account_id');
|
||||||
export const displayMedia = getMeta('display_media');
|
export const displayMedia = getMeta('display_media');
|
||||||
export const domain = getMeta('domain');
|
export const domain = getMeta('domain');
|
||||||
|
export const emojiStyle = getMeta('emoji_style') || 'auto';
|
||||||
export const expandSpoilers = getMeta('expand_spoilers');
|
export const expandSpoilers = getMeta('expand_spoilers');
|
||||||
export const forceSingleColumn = !getMeta('advanced_layout');
|
export const forceSingleColumn = !getMeta('advanced_layout');
|
||||||
export const limitedFederationMode = getMeta('limited_federation_mode');
|
export const limitedFederationMode = getMeta('limited_federation_mode');
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "عرض المزيد من المتابعين على {domain}",
|
"hints.profiles.see_more_followers": "عرض المزيد من المتابعين على {domain}",
|
||||||
"hints.profiles.see_more_follows": "اطلع على المزيد من المتابعين على {domain}",
|
"hints.profiles.see_more_follows": "اطلع على المزيد من المتابعين على {domain}",
|
||||||
"hints.profiles.see_more_posts": "عرض المزيد من المنشورات من {domain}",
|
"hints.profiles.see_more_posts": "عرض المزيد من المنشورات من {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "قد تكون الردود الواردة من الخوادم الأخرى غائبة.",
|
|
||||||
"hints.threads.see_more": "اطلع على المزيد من الردود على {domain}",
|
|
||||||
"home.column_settings.show_quotes": "إظهار الاقتباسات",
|
"home.column_settings.show_quotes": "إظهار الاقتباسات",
|
||||||
"home.column_settings.show_reblogs": "اعرض المعاد نشرها",
|
"home.column_settings.show_reblogs": "اعرض المعاد نشرها",
|
||||||
"home.column_settings.show_replies": "اعرض الردود",
|
"home.column_settings.show_replies": "اعرض الردود",
|
||||||
|
|
|
@ -266,7 +266,6 @@
|
||||||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
|
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
|
||||||
"hashtag.follow": "Siguir a la etiqueta",
|
"hashtag.follow": "Siguir a la etiqueta",
|
||||||
"hashtag.unfollow": "Dexar de siguir a la etiqueta",
|
"hashtag.unfollow": "Dexar de siguir a la etiqueta",
|
||||||
"hints.threads.replies_may_be_missing": "Ye posible que falten les rempuestes d'otros sirvidores.",
|
|
||||||
"home.column_settings.show_reblogs": "Amosar los artículos compartíos",
|
"home.column_settings.show_reblogs": "Amosar los artículos compartíos",
|
||||||
"home.column_settings.show_replies": "Amosar les rempuestes",
|
"home.column_settings.show_replies": "Amosar les rempuestes",
|
||||||
"home.pending_critical_update.body": "¡Anueva'l sirvidor de Mastodon namás que puedas!",
|
"home.pending_critical_update.body": "¡Anueva'l sirvidor de Mastodon namás que puedas!",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"about.blocks": "Мадэраваныя серверы",
|
"about.blocks": "Мадэраваныя серверы",
|
||||||
"about.contact": "Кантакт:",
|
"about.contact": "Кантакт:",
|
||||||
|
"about.default_locale": "Прадвызначаная",
|
||||||
"about.disclaimer": "Mastodon - свабоднае праграмнае забеспячэнне, з адкрытым зыходным кодам, і гандлёвай маркай Mastodon gGmbH.",
|
"about.disclaimer": "Mastodon - свабоднае праграмнае забеспячэнне, з адкрытым зыходным кодам, і гандлёвай маркай Mastodon gGmbH.",
|
||||||
"about.domain_blocks.no_reason_available": "Прычына недаступная",
|
"about.domain_blocks.no_reason_available": "Прычына недаступная",
|
||||||
"about.domain_blocks.preamble": "Mastodon, у асноўным, дазваляе вам праглядаць кантэнт і ўзаемадзейнічаць з карыстальнікамі з іншых сервераў у федэсвету. Гэтыя выключэнні былі зроблены дакладна на гэтым серверы.",
|
"about.domain_blocks.preamble": "Mastodon, у асноўным, дазваляе вам праглядаць кантэнт і ўзаемадзейнічаць з карыстальнікамі з іншых сервераў у федэсвету. Гэтыя выключэнні былі зроблены дакладна на гэтым серверы.",
|
||||||
|
@ -8,6 +9,7 @@
|
||||||
"about.domain_blocks.silenced.title": "Абмежаваны",
|
"about.domain_blocks.silenced.title": "Абмежаваны",
|
||||||
"about.domain_blocks.suspended.explanation": "Ніякая інфармацыя з гэтага сервера не будзе апрацавана, захавана або абменена, узаемадзеянне або камунікацыя з карыстальнікамі гэтага сервера немагчымы.",
|
"about.domain_blocks.suspended.explanation": "Ніякая інфармацыя з гэтага сервера не будзе апрацавана, захавана або абменена, узаемадзеянне або камунікацыя з карыстальнікамі гэтага сервера немагчымы.",
|
||||||
"about.domain_blocks.suspended.title": "Прыпынены",
|
"about.domain_blocks.suspended.title": "Прыпынены",
|
||||||
|
"about.language_label": "Мова",
|
||||||
"about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.",
|
"about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.",
|
||||||
"about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}",
|
"about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}",
|
||||||
"about.rules": "Правілы сервера",
|
"about.rules": "Правілы сервера",
|
||||||
|
@ -19,13 +21,21 @@
|
||||||
"account.block_domain": "Заблакіраваць дамен {domain}",
|
"account.block_domain": "Заблакіраваць дамен {domain}",
|
||||||
"account.block_short": "Заблакіраваць",
|
"account.block_short": "Заблакіраваць",
|
||||||
"account.blocked": "Заблакіраваны",
|
"account.blocked": "Заблакіраваны",
|
||||||
|
"account.blocking": "Блакіраванне",
|
||||||
"account.cancel_follow_request": "Скасаваць запыт на падпіску",
|
"account.cancel_follow_request": "Скасаваць запыт на падпіску",
|
||||||
"account.copy": "Скапіраваць спасылку на профіль",
|
"account.copy": "Скапіраваць спасылку на профіль",
|
||||||
"account.direct": "Згадаць асабіста @{name}",
|
"account.direct": "Згадаць асабіста @{name}",
|
||||||
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
||||||
|
"account.domain_blocking": "Блакіраванне дамена",
|
||||||
"account.edit_profile": "Рэдагаваць профіль",
|
"account.edit_profile": "Рэдагаваць профіль",
|
||||||
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
||||||
"account.endorse": "Паказваць у профілі",
|
"account.endorse": "Паказваць у профілі",
|
||||||
|
"account.familiar_followers_many": "Мае сярод падпісчыкаў {name1}, {name2}, і {othersCount, plural, one {яшчэ # чалавека, знаёмага вам} few {яшчэ # чалавекі, знаёмыя вам} many {яшчэ # чалавек, знаёмых вам} other {яшчэ # чалавекі, знаёмыя вам}}",
|
||||||
|
"account.familiar_followers_one": "Мае сярод падпісчыкаў {name1}",
|
||||||
|
"account.familiar_followers_two": "Мае сярод падпісчыкаў {name1} і {name2}",
|
||||||
|
"account.featured": "Рэкамендаванае",
|
||||||
|
"account.featured.accounts": "Профілі",
|
||||||
|
"account.featured.hashtags": "Хэштэгі",
|
||||||
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
|
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
|
||||||
"account.featured_tags.last_status_never": "Няма допісаў",
|
"account.featured_tags.last_status_never": "Няма допісаў",
|
||||||
"account.follow": "Падпісацца",
|
"account.follow": "Падпісацца",
|
||||||
|
@ -33,9 +43,11 @@
|
||||||
"account.followers": "Падпісчыкі",
|
"account.followers": "Падпісчыкі",
|
||||||
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
||||||
|
"account.followers_you_know_counter": "{count, one {{counter}, знаёмы вам} other {{counter}, знаёмых вам}}",
|
||||||
"account.following": "Падпіскі",
|
"account.following": "Падпіскі",
|
||||||
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
|
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
|
||||||
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
|
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
|
||||||
|
"account.follows_you": "Падпісаны на вас",
|
||||||
"account.go_to_profile": "Перайсці да профілю",
|
"account.go_to_profile": "Перайсці да профілю",
|
||||||
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
||||||
"account.in_memoriam": "У памяць.",
|
"account.in_memoriam": "У памяць.",
|
||||||
|
@ -50,18 +62,22 @@
|
||||||
"account.mute_notifications_short": "Не апавяшчаць",
|
"account.mute_notifications_short": "Не апавяшчаць",
|
||||||
"account.mute_short": "Ігнараваць",
|
"account.mute_short": "Ігнараваць",
|
||||||
"account.muted": "Ігнаруецца",
|
"account.muted": "Ігнаруецца",
|
||||||
|
"account.mutual": "Вы падпісаны адно на аднаго",
|
||||||
"account.no_bio": "Апісанне адсутнічае.",
|
"account.no_bio": "Апісанне адсутнічае.",
|
||||||
"account.open_original_page": "Адкрыць арыгінальную старонку",
|
"account.open_original_page": "Адкрыць арыгінальную старонку",
|
||||||
"account.posts": "Допісы",
|
"account.posts": "Допісы",
|
||||||
"account.posts_with_replies": "Допісы і адказы",
|
"account.posts_with_replies": "Допісы і адказы",
|
||||||
|
"account.remove_from_followers": "Выдаліць карыстальніка {name} з падпісчыкаў",
|
||||||
"account.report": "Паскардзіцца на @{name}",
|
"account.report": "Паскардзіцца на @{name}",
|
||||||
"account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску",
|
"account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску",
|
||||||
"account.requested_follow": "{name} адправіў запыт на падпіску",
|
"account.requested_follow": "{name} адправіў запыт на падпіску",
|
||||||
|
"account.requests_to_follow_you": "Хоча падпісацца на вас",
|
||||||
"account.share": "Абагуліць профіль @{name}",
|
"account.share": "Абагуліць профіль @{name}",
|
||||||
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
||||||
"account.unblock": "Разблакіраваць @{name}",
|
"account.unblock": "Разблакіраваць @{name}",
|
||||||
"account.unblock_domain": "Разблакіраваць дамен {domain}",
|
"account.unblock_domain": "Разблакіраваць дамен {domain}",
|
||||||
|
"account.unblock_domain_short": "Разблакіраваць",
|
||||||
"account.unblock_short": "Разблакіраваць",
|
"account.unblock_short": "Разблакіраваць",
|
||||||
"account.unendorse": "Не паказваць у профілі",
|
"account.unendorse": "Не паказваць у профілі",
|
||||||
"account.unfollow": "Адпісацца",
|
"account.unfollow": "Адпісацца",
|
||||||
|
@ -83,26 +99,30 @@
|
||||||
"alert.unexpected.message": "Узнікла нечаканая памылка.",
|
"alert.unexpected.message": "Узнікла нечаканая памылка.",
|
||||||
"alert.unexpected.title": "Вой!",
|
"alert.unexpected.title": "Вой!",
|
||||||
"alt_text_badge.title": "Альтэрнатыўны тэкст",
|
"alt_text_badge.title": "Альтэрнатыўны тэкст",
|
||||||
|
"alt_text_modal.add_alt_text": "Дадаць альтэрнатыўны тэкст",
|
||||||
|
"alt_text_modal.add_text_from_image": "Дадаць тэкст з відарыса",
|
||||||
|
"alt_text_modal.cancel": "Скасаваць",
|
||||||
|
"alt_text_modal.change_thumbnail": "Змяніць мініяцюру",
|
||||||
"alt_text_modal.done": "Гатова",
|
"alt_text_modal.done": "Гатова",
|
||||||
"announcement.announcement": "Аб'ява",
|
"announcement.announcement": "Аб'ява",
|
||||||
"annual_report.summary.archetype.booster": "Трэнда-сьледнік",
|
"annual_report.summary.archetype.booster": "Паляўнічы на трэнды",
|
||||||
"annual_report.summary.archetype.lurker": "Назіральнік",
|
"annual_report.summary.archetype.lurker": "Назіральнік",
|
||||||
"annual_report.summary.archetype.oracle": "Аракул",
|
"annual_report.summary.archetype.oracle": "Аракул",
|
||||||
"annual_report.summary.archetype.pollster": "Апытвальнік",
|
"annual_report.summary.archetype.pollster": "Апытвальнік",
|
||||||
"annual_report.summary.archetype.replier": "Душа кампанійі",
|
"annual_report.summary.archetype.replier": "Душа кампаніі",
|
||||||
"annual_report.summary.followers.followers": "падпісанты",
|
"annual_report.summary.followers.followers": "падпісчыкі",
|
||||||
"annual_report.summary.followers.total": "Усяго {count}",
|
"annual_report.summary.followers.total": "Агулам {count}",
|
||||||
"annual_report.summary.here_it_is": "Вось вашыя вынікі {year} году:",
|
"annual_report.summary.here_it_is": "Вось вашы вынікі {year} за год:",
|
||||||
"annual_report.summary.highlighted_post.by_favourites": "самы ўпадабаны допіс",
|
"annual_report.summary.highlighted_post.by_favourites": "самы ўпадабаны допіс",
|
||||||
"annual_report.summary.highlighted_post.by_reblogs": "самы пашыраны допіс",
|
"annual_report.summary.highlighted_post.by_reblogs": "самы пашыраны допіс",
|
||||||
"annual_report.summary.highlighted_post.by_replies": "самы каментаваны допіс",
|
"annual_report.summary.highlighted_post.by_replies": "самы каментаваны допіс",
|
||||||
"annual_report.summary.highlighted_post.possessive": "{name}",
|
"annual_report.summary.highlighted_post.possessive": "{name}",
|
||||||
"annual_report.summary.most_used_app.most_used_app": "самая выкарыстоўваная аплікацыя",
|
"annual_report.summary.most_used_app.most_used_app": "праграма, якой карысталіся часцей",
|
||||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "самы выкарыстоўваны гэштаґ",
|
"annual_report.summary.most_used_hashtag.most_used_hashtag": "хэштэг, якім карысталіся часцей",
|
||||||
"annual_report.summary.most_used_hashtag.none": "Няма",
|
"annual_report.summary.most_used_hashtag.none": "Няма",
|
||||||
"annual_report.summary.new_posts.new_posts": "новыя допісы",
|
"annual_report.summary.new_posts.new_posts": "новыя допісы",
|
||||||
"annual_report.summary.percentile.text": "<topLabel>Мэта месьціць вас у топ</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>",
|
"annual_report.summary.percentile.text": "<topLabel>З-за гэтага, вы знаходзіцеся ў топе</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>",
|
||||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Мы ня скажам аб гэтым Сіняпальцаму.",
|
"annual_report.summary.percentile.we_wont_tell_bernie": "КДБ пра гэта не даведаецца.",
|
||||||
"annual_report.summary.thanks": "Дзякуй за ўдзел у Mastodon!",
|
"annual_report.summary.thanks": "Дзякуй за ўдзел у Mastodon!",
|
||||||
"attachments_list.unprocessed": "(неапрацаваны)",
|
"attachments_list.unprocessed": "(неапрацаваны)",
|
||||||
"audio.hide": "Схаваць аўдыя",
|
"audio.hide": "Схаваць аўдыя",
|
||||||
|
@ -127,7 +147,7 @@
|
||||||
"bundle_column_error.routing.body": "Запытаная старонка не знойдзена. Вы ўпэўнены, што URL у адрасным радку правільны?",
|
"bundle_column_error.routing.body": "Запытаная старонка не знойдзена. Вы ўпэўнены, што URL у адрасным радку правільны?",
|
||||||
"bundle_column_error.routing.title": "404",
|
"bundle_column_error.routing.title": "404",
|
||||||
"bundle_modal_error.close": "Закрыць",
|
"bundle_modal_error.close": "Закрыць",
|
||||||
"bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло ня так.",
|
"bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло не так.",
|
||||||
"bundle_modal_error.retry": "Паспрабуйце зноў",
|
"bundle_modal_error.retry": "Паспрабуйце зноў",
|
||||||
"closed_registrations.other_server_instructions": "Паколькі Mastodon дэцэнтралізаваны, вы можаце стварыць уліковы запіс на іншым серверы і працягваць узаемадзейнічаць з ім.",
|
"closed_registrations.other_server_instructions": "Паколькі Mastodon дэцэнтралізаваны, вы можаце стварыць уліковы запіс на іншым серверы і працягваць узаемадзейнічаць з ім.",
|
||||||
"closed_registrations_modal.description": "Стварэнне ўліковага запісу на {domain} цяпер немагчыма. Заўважце, што няма неабходнасці мець уліковы запіс менавіта на {domain}, каб выкарыстоўваць Mastodon.",
|
"closed_registrations_modal.description": "Стварэнне ўліковага запісу на {domain} цяпер немагчыма. Заўважце, што няма неабходнасці мець уліковы запіс менавіта на {domain}, каб выкарыстоўваць Mastodon.",
|
||||||
|
@ -147,7 +167,7 @@
|
||||||
"column.firehose": "Стужкі",
|
"column.firehose": "Стужкі",
|
||||||
"column.follow_requests": "Запыты на падпіску",
|
"column.follow_requests": "Запыты на падпіску",
|
||||||
"column.home": "Галоўная",
|
"column.home": "Галоўная",
|
||||||
"column.list_members": "Кіраванне ўдзельнікамі спісу",
|
"column.list_members": "Кіраванне ўдзельнікамі спіса",
|
||||||
"column.lists": "Спісы",
|
"column.lists": "Спісы",
|
||||||
"column.mutes": "Ігнараваныя карыстальнікі",
|
"column.mutes": "Ігнараваныя карыстальнікі",
|
||||||
"column.notifications": "Апавяшчэнні",
|
"column.notifications": "Апавяшчэнні",
|
||||||
|
@ -196,19 +216,24 @@
|
||||||
"confirmations.delete_list.confirm": "Выдаліць",
|
"confirmations.delete_list.confirm": "Выдаліць",
|
||||||
"confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?",
|
"confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?",
|
||||||
"confirmations.delete_list.title": "Выдаліць спіс?",
|
"confirmations.delete_list.title": "Выдаліць спіс?",
|
||||||
|
"confirmations.discard_draft.edit.cancel": "Працягнуць рэдагаванне",
|
||||||
"confirmations.discard_edit_media.confirm": "Адмяніць",
|
"confirmations.discard_edit_media.confirm": "Адмяніць",
|
||||||
"confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?",
|
"confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?",
|
||||||
"confirmations.follow_to_list.confirm": "Падпісацца й дадаць у сьпіс",
|
"confirmations.follow_to_list.confirm": "Падпісацца і дадаць у спіс",
|
||||||
"confirmations.follow_to_list.message": "Вы мусіце быць падпісаныя на {name} каб дадаць яго ў сьпіс.",
|
"confirmations.follow_to_list.message": "Вам трэба падпісацца на карыстальніка {name}, каб дадаць яго ў спіс.",
|
||||||
"confirmations.follow_to_list.title": "Падпісацца на карыстальніка?",
|
"confirmations.follow_to_list.title": "Падпісацца на карыстальніка?",
|
||||||
"confirmations.logout.confirm": "Выйсці",
|
"confirmations.logout.confirm": "Выйсці",
|
||||||
"confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?",
|
"confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?",
|
||||||
"confirmations.logout.title": "Выйсці?",
|
"confirmations.logout.title": "Выйсці?",
|
||||||
"confirmations.missing_alt_text.title": "Дадаць апісаньне?",
|
"confirmations.missing_alt_text.confirm": "Дадаць альтэрнатыўны тэкст",
|
||||||
|
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
|
||||||
|
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
|
||||||
"confirmations.mute.confirm": "Ігнараваць",
|
"confirmations.mute.confirm": "Ігнараваць",
|
||||||
"confirmations.redraft.confirm": "Выдаліць і перапісаць",
|
"confirmations.redraft.confirm": "Выдаліць і перапісаць",
|
||||||
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
||||||
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
||||||
|
"confirmations.remove_from_followers.confirm": "Выдаліць падпісчыка",
|
||||||
|
"confirmations.remove_from_followers.title": "Выдаліць падпісчыка?",
|
||||||
"confirmations.unfollow.confirm": "Адпісацца",
|
"confirmations.unfollow.confirm": "Адпісацца",
|
||||||
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
|
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
|
||||||
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
|
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
|
||||||
|
@ -221,7 +246,7 @@
|
||||||
"conversation.with": "З {names}",
|
"conversation.with": "З {names}",
|
||||||
"copy_icon_button.copied": "Скапіявана ў буфер абмену",
|
"copy_icon_button.copied": "Скапіявана ў буфер абмену",
|
||||||
"copypaste.copied": "Скапіравана",
|
"copypaste.copied": "Скапіравана",
|
||||||
"copypaste.copy_to_clipboard": "Капіраваць у буфер абмену",
|
"copypaste.copy_to_clipboard": "Скапіяваць у буфер абмену",
|
||||||
"directory.federated": "З вядомага федэсвету",
|
"directory.federated": "З вядомага федэсвету",
|
||||||
"directory.local": "Толькі з {domain}",
|
"directory.local": "Толькі з {domain}",
|
||||||
"directory.new_arrivals": "Новыя карыстальнікі",
|
"directory.new_arrivals": "Новыя карыстальнікі",
|
||||||
|
@ -230,7 +255,7 @@
|
||||||
"disabled_account_banner.text": "Ваш уліковы запіс {disabledAccount} часова адключаны.",
|
"disabled_account_banner.text": "Ваш уліковы запіс {disabledAccount} часова адключаны.",
|
||||||
"dismissable_banner.community_timeline": "Гэта самыя апошнія допісы ад людзей, уліковыя запісы якіх размяшчаюцца на {domain}.",
|
"dismissable_banner.community_timeline": "Гэта самыя апошнія допісы ад людзей, уліковыя запісы якіх размяшчаюцца на {domain}.",
|
||||||
"dismissable_banner.dismiss": "Адхіліць",
|
"dismissable_banner.dismiss": "Адхіліць",
|
||||||
"dismissable_banner.public_timeline": "Гэта самыя новыя публічныя допісы ад карыстальнікаў фэдывёрсу на якіх падпісаныя карыстальнікі {domain}.",
|
"dismissable_banner.public_timeline": "Вось апошнія публічныя допісы ад карыстальнікаў fediverse на якіх падпісаны карыстальнікі {domain}.",
|
||||||
"domain_block_modal.block": "Заблакіраваць сервер",
|
"domain_block_modal.block": "Заблакіраваць сервер",
|
||||||
"domain_block_modal.block_account_instead": "Заблакіраваць @{name} замест гэтага",
|
"domain_block_modal.block_account_instead": "Заблакіраваць @{name} замест гэтага",
|
||||||
"domain_block_modal.they_can_interact_with_old_posts": "Людзі з гэтага сервера змогуць узаемадзейнічаць з вашымі старымі допісамі.",
|
"domain_block_modal.they_can_interact_with_old_posts": "Людзі з гэтага сервера змогуць узаемадзейнічаць з вашымі старымі допісамі.",
|
||||||
|
@ -275,15 +300,15 @@
|
||||||
"empty_column.account_timeline": "Тут няма допісаў!",
|
"empty_column.account_timeline": "Тут няма допісаў!",
|
||||||
"empty_column.account_unavailable": "Профіль недаступны",
|
"empty_column.account_unavailable": "Профіль недаступны",
|
||||||
"empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.",
|
"empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.",
|
||||||
"empty_column.bookmarked_statuses": "У вас яшчэ няма паведамленняў з закладкамі. Калі вы дадасце закладку, яна з'явіцца тут.",
|
"empty_column.bookmarked_statuses": "У вашых закладках яшчэ няма допісаў. Калі вы дадасце закладку, яна з’явіцца тут.",
|
||||||
"empty_column.community": "Мясцовая стужка пустая. Напішыце што-небудзь публічна, каб зрушыць з месца!",
|
"empty_column.community": "Мясцовая стужка пустая. Напішыце нешта публічнае, каб разварушыць справу!",
|
||||||
"empty_column.direct": "Пакуль у вас няма асабістых згадак. Калі вы дашляце або атрымаеце штось, яно з'явіцца тут.",
|
"empty_column.direct": "Пакуль у вас няма асабістых згадванняў. Калі вы дашляце або атрымаеце штосьці, яно з’явіцца тут.",
|
||||||
"empty_column.domain_blocks": "Заблакіраваных даменаў пакуль няма.",
|
"empty_column.domain_blocks": "Заблакіраваных даменаў пакуль няма.",
|
||||||
"empty_column.explore_statuses": "Зараз не ў трэндзе. Праверце пазней",
|
"empty_column.explore_statuses": "Зараз не ў трэндзе. Праверце пазней",
|
||||||
"empty_column.favourited_statuses": "Вы яшчэ не ўпадабалі ніводны допіс. Калі гэта адбудзецца, вы ўбачыце яго тут.",
|
"empty_column.favourited_statuses": "Вы яшчэ не ўпадабалі ніводны допіс. Калі гэта адбудзецца, вы ўбачыце яго тут.",
|
||||||
"empty_column.favourites": "Ніхто яшчэ не ўпадабаў гэты допіс. Калі гэта адбудзецца, вы ўбачыце гэтых людзей тут.",
|
"empty_column.favourites": "Ніхто яшчэ не ўпадабаў гэты допіс. Калі гэта адбудзецца, вы ўбачыце гэтых людзей тут.",
|
||||||
"empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіскуі. Калі вы атрымаеце запыт, ён з'явяцца тут.",
|
"empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіску. Калі вы атрымаеце запыт, ён з’явіцца тут.",
|
||||||
"empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны з'явяцца тут.",
|
"empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны з’явяцца тут.",
|
||||||
"empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.",
|
"empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.",
|
||||||
"empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}",
|
"empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}",
|
||||||
"empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.",
|
"empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.",
|
||||||
|
@ -294,13 +319,18 @@
|
||||||
"error.unexpected_crash.explanation": "Гэта старонка не можа быць адлюстравана карэктна з-за памылкі ў нашым кодзе, або праблемы з сумяшчальнасцю браўзера.",
|
"error.unexpected_crash.explanation": "Гэта старонка не можа быць адлюстравана карэктна з-за памылкі ў нашым кодзе, або праблемы з сумяшчальнасцю браўзера.",
|
||||||
"error.unexpected_crash.explanation_addons": "Гэтая старонка не можа быць адлюстравана карэктна. Верагодна, гэтая памылка выклікана дадатковым кампанентам браўзера або інструментамі аўтаматычнага перакладу",
|
"error.unexpected_crash.explanation_addons": "Гэтая старонка не можа быць адлюстравана карэктна. Верагодна, гэтая памылка выклікана дадатковым кампанентам браўзера або інструментамі аўтаматычнага перакладу",
|
||||||
"error.unexpected_crash.next_steps": "Паспрабуйце абнавіць старонку. Калі гэта не дапаможа, вы можаце паспрабаваць іншы браўзер, альбо выкарыстаць усталяваную праграму.",
|
"error.unexpected_crash.next_steps": "Паспрабуйце абнавіць старонку. Калі гэта не дапаможа, вы можаце паспрабаваць іншы браўзер, альбо выкарыстаць усталяваную праграму.",
|
||||||
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і аднавіць старонку. Калі гэта не дапаможа, вы можаце карыстацца Мастадонт праз другі браўзер ці аплікацыю.",
|
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і абнавіць старонку. Калі гэта не дапамагае, вы ўсё яшчэ можаце карыстацца Mastodon праз іншы браўзер ці натыўную праграму.",
|
||||||
"errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену",
|
"errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену",
|
||||||
"errors.unexpected_crash.report_issue": "Паведаміць аб праблеме",
|
"errors.unexpected_crash.report_issue": "Паведаміць аб праблеме",
|
||||||
"explore.suggested_follows": "Людзі",
|
"explore.suggested_follows": "Людзі",
|
||||||
|
"explore.title": "Трэндавае",
|
||||||
"explore.trending_links": "Навіны",
|
"explore.trending_links": "Навіны",
|
||||||
"explore.trending_statuses": "Допісы",
|
"explore.trending_statuses": "Допісы",
|
||||||
"explore.trending_tags": "Хэштэгі",
|
"explore.trending_tags": "Хэштэгі",
|
||||||
|
"featured_carousel.next": "Далей",
|
||||||
|
"featured_carousel.post": "Допіс",
|
||||||
|
"featured_carousel.previous": "Назад",
|
||||||
|
"featured_carousel.slide": "{index} з {total}",
|
||||||
"filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім вы адкрылі гэты пост. Калі вы хочаце, каб паведамленне таксама было адфільтравана ў гэтым кантэксце, вам трэба будзе адрэдагаваць фільтр",
|
"filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім вы адкрылі гэты пост. Калі вы хочаце, каб паведамленне таксама было адфільтравана ў гэтым кантэксце, вам трэба будзе адрэдагаваць фільтр",
|
||||||
"filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!",
|
"filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!",
|
||||||
"filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася",
|
"filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася",
|
||||||
|
@ -349,10 +379,10 @@
|
||||||
"footer.privacy_policy": "Палітыка прыватнасці",
|
"footer.privacy_policy": "Палітыка прыватнасці",
|
||||||
"footer.source_code": "Прагледзець зыходны код",
|
"footer.source_code": "Прагледзець зыходны код",
|
||||||
"footer.status": "Статус",
|
"footer.status": "Статус",
|
||||||
"footer.terms_of_service": "Умовы абслугоўваньня",
|
"footer.terms_of_service": "Умовы выкарыстання",
|
||||||
"generic.saved": "Захавана",
|
"generic.saved": "Захавана",
|
||||||
"getting_started.heading": "Пачатак працы",
|
"getting_started.heading": "Пачатак працы",
|
||||||
"hashtag.admin_moderation": "Адкрыць інтэрфэйс мадаратара для #{name}",
|
"hashtag.admin_moderation": "Адкрыць інтэрфейс мадэратара для #{name}",
|
||||||
"hashtag.column_header.tag_mode.all": "і {additional}",
|
"hashtag.column_header.tag_mode.all": "і {additional}",
|
||||||
"hashtag.column_header.tag_mode.any": "або {additional}",
|
"hashtag.column_header.tag_mode.any": "або {additional}",
|
||||||
"hashtag.column_header.tag_mode.none": "без {additional}",
|
"hashtag.column_header.tag_mode.none": "без {additional}",
|
||||||
|
@ -374,35 +404,37 @@
|
||||||
"hints.profiles.see_more_followers": "Глядзець больш падпісаных на {domain}",
|
"hints.profiles.see_more_followers": "Глядзець больш падпісаных на {domain}",
|
||||||
"hints.profiles.see_more_follows": "Глядзець больш падпісак на {domain}",
|
"hints.profiles.see_more_follows": "Глядзець больш падпісак на {domain}",
|
||||||
"hints.profiles.see_more_posts": "Глядзець больш допісаў на {domain}",
|
"hints.profiles.see_more_posts": "Глядзець больш допісаў на {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Адказы зь іншых сэрвэраў могуць адсутнічаць.",
|
"home.column_settings.show_quotes": "Паказаць цытаты",
|
||||||
"hints.threads.see_more": "Глядзіце больш адказаў на {domain}",
|
|
||||||
"home.column_settings.show_reblogs": "Паказваць пашырэнні",
|
"home.column_settings.show_reblogs": "Паказваць пашырэнні",
|
||||||
"home.column_settings.show_replies": "Паказваць адказы",
|
"home.column_settings.show_replies": "Паказваць адказы",
|
||||||
"home.hide_announcements": "Схаваць аб'явы",
|
"home.hide_announcements": "Схаваць аб'явы",
|
||||||
"home.pending_critical_update.body": "Калі ласка, абнавіце свой сервер Mastodon як мага хутчэй!",
|
"home.pending_critical_update.body": "Абнавіце свой сервер Mastodon як мага хутчэй!",
|
||||||
"home.pending_critical_update.link": "Прагледзець абнаўленні",
|
"home.pending_critical_update.link": "Прагледзець абнаўленні",
|
||||||
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
|
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
|
||||||
"home.show_announcements": "Паказаць аб'явы",
|
"home.show_announcements": "Паказаць аб'явы",
|
||||||
"ignore_notifications_modal.disclaimer": "Mastodon ня можа йнфармаваць карыстальнікаў аб тым, што вы прайігнаравалі йх паведамленьні. Ігнараваньне паведамленьняў не спыніць іх адпраўку.",
|
"ignore_notifications_modal.disclaimer": "Mastodon не можа паведамляць карыстальнікам, што вы праігнаравалі апавяшчэнні ад іх. Ігнараванне апавяшчэнняў не спыніць адпраўку саміх паведамленняў.",
|
||||||
"ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць",
|
"ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць",
|
||||||
"ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў",
|
"ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў",
|
||||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Фільтраваньне дапамагае пазьбегнуць патэнцыйнай блытаніны",
|
"ignore_notifications_modal.filter_to_avoid_confusion": "Выкарыстанне фільтраў дапамагае пазбягаць патэнцыйнай блытаніны",
|
||||||
"ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя паведамленьні асобна",
|
"ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя апавяшчэнні асобна",
|
||||||
"ignore_notifications_modal.ignore": "Ігнараваць паведамленьні",
|
"ignore_notifications_modal.ignore": "Ігнараваць апавяшчэнні",
|
||||||
"ignore_notifications_modal.limited_accounts_title": "Ігнараваць паведамленьні ад абмежаваных уліковых запісаў?",
|
"ignore_notifications_modal.limited_accounts_title": "Ігнараваць апавяшчэнні ад уліковых запісаў пад мадэрацыяй?",
|
||||||
"ignore_notifications_modal.new_accounts_title": "Ігнараваць паведамленьні ад новых уліковых запісаў?",
|
"ignore_notifications_modal.new_accounts_title": "Ігнараваць апавяшчэнні ад новых уліковых запісаў?",
|
||||||
"ignore_notifications_modal.not_followers_title": "Ігнараваць паведамленьні ад людзей, якія ня падпісаныя на вас?",
|
"ignore_notifications_modal.not_followers_title": "Ігнараваць апавяшчэнні ад людзей, якія не падпісаныя на вас?",
|
||||||
"ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?",
|
"ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?",
|
||||||
"ignore_notifications_modal.private_mentions_title": "Ігнараваць паведамленьні аб непажаданых прыватных згадках?",
|
"ignore_notifications_modal.private_mentions_title": "Ігнараваць апавяшчэнні пра непажаданыя асабістыя згадванні?",
|
||||||
"info_button.label": "Даведка",
|
"info_button.label": "Даведка",
|
||||||
"interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць з вашага ўліковага запісу.",
|
"interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць нешта са свайго ўліковага запісу.",
|
||||||
"interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца з вашага ўліковага запісу.",
|
"interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца на некага са свайго ўліковага запісу.",
|
||||||
|
"interaction_modal.go": "Перайсці",
|
||||||
|
"interaction_modal.no_account_yet": "Не маеце ўліковага запісу?",
|
||||||
"interaction_modal.on_another_server": "На іншым серверы",
|
"interaction_modal.on_another_server": "На іншым серверы",
|
||||||
"interaction_modal.on_this_server": "На гэтым серверы",
|
"interaction_modal.on_this_server": "На гэтым серверы",
|
||||||
"interaction_modal.title.favourite": "Упадабаць допіс {name}",
|
"interaction_modal.title.favourite": "Упадабаць допіс {name}",
|
||||||
"interaction_modal.title.follow": "Падпісацца на {name}",
|
"interaction_modal.title.follow": "Падпісацца на {name}",
|
||||||
"interaction_modal.title.reblog": "Пашырыць допіс ад {name}",
|
"interaction_modal.title.reblog": "Пашырыць допіс ад {name}",
|
||||||
"interaction_modal.title.reply": "Адказаць на допіс {name}",
|
"interaction_modal.title.reply": "Адказаць на допіс {name}",
|
||||||
|
"interaction_modal.username_prompt": "Напр., {example}",
|
||||||
"intervals.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
"intervals.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
||||||
"intervals.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}}",
|
"intervals.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}}",
|
||||||
"intervals.full.minutes": "{number, plural, one {# хвіліна} few {# хвіліны} many {# хвілін} other {# хвіліны}}",
|
"intervals.full.minutes": "{number, plural, one {# хвіліна} few {# хвіліны} many {# хвілін} other {# хвіліны}}",
|
||||||
|
@ -438,21 +470,32 @@
|
||||||
"keyboard_shortcuts.toggle_hidden": "Паказаць/схаваць тэкст за папярэджаннем пра кантэнт",
|
"keyboard_shortcuts.toggle_hidden": "Паказаць/схаваць тэкст за папярэджаннем пра кантэнт",
|
||||||
"keyboard_shortcuts.toggle_sensitivity": "Паказаць/схаваць медыя",
|
"keyboard_shortcuts.toggle_sensitivity": "Паказаць/схаваць медыя",
|
||||||
"keyboard_shortcuts.toot": "Стварыць новы допіс",
|
"keyboard_shortcuts.toot": "Стварыць новы допіс",
|
||||||
|
"keyboard_shortcuts.translate": "каб перакласці допіс",
|
||||||
"keyboard_shortcuts.unfocus": "Расфакусаваць тэкставую вобласць/пошукавы радок",
|
"keyboard_shortcuts.unfocus": "Расфакусаваць тэкставую вобласць/пошукавы радок",
|
||||||
"keyboard_shortcuts.up": "Перамясціцца ўверх па спісе",
|
"keyboard_shortcuts.up": "Перамясціцца ўверх па спісе",
|
||||||
"lightbox.close": "Закрыць",
|
"lightbox.close": "Закрыць",
|
||||||
"lightbox.next": "Далей",
|
"lightbox.next": "Далей",
|
||||||
"lightbox.previous": "Назад",
|
"lightbox.previous": "Назад",
|
||||||
|
"lightbox.zoom_in": "Маштабаваць да фактычнага памеру",
|
||||||
|
"lightbox.zoom_out": "Дапасаваць усё змесціва пад памеры экрана",
|
||||||
"limited_account_hint.action": "Усе роўна паказваць профіль",
|
"limited_account_hint.action": "Усе роўна паказваць профіль",
|
||||||
"limited_account_hint.title": "Гэты профіль быў схаваны мадэратарамі",
|
"limited_account_hint.title": "Гэты профіль быў схаваны мадэратарамі",
|
||||||
"link_preview.author": "Ад {name}",
|
"link_preview.author": "Ад {name}",
|
||||||
"link_preview.more_from_author": "Больш ад {name}",
|
"link_preview.more_from_author": "Больш ад {name}",
|
||||||
"link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
"link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
||||||
"lists.add_member": "Дадаць",
|
"lists.add_member": "Дадаць",
|
||||||
|
"lists.add_to_list": "Дадаць у спіс",
|
||||||
|
"lists.add_to_lists": "Дадаць {name} у спісы",
|
||||||
"lists.create": "Стварыць",
|
"lists.create": "Стварыць",
|
||||||
"lists.create_list": "Стварыць спіс",
|
"lists.create_list": "Стварыць спіс",
|
||||||
"lists.delete": "Выдаліць спіс",
|
"lists.delete": "Выдаліць спіс",
|
||||||
|
"lists.done": "Гатова",
|
||||||
"lists.edit": "Рэдагаваць спіс",
|
"lists.edit": "Рэдагаваць спіс",
|
||||||
|
"lists.list_name": "Назва спіса",
|
||||||
|
"lists.new_list_name": "Назва новага спіса",
|
||||||
|
"lists.no_lists_yet": "Пакуль няма спісаў.",
|
||||||
|
"lists.no_members_yet": "Пакуль няма ўдзельнікаў.",
|
||||||
|
"lists.no_results_found": "Нічога не знойдзена.",
|
||||||
"lists.remove_member": "Выдаліць",
|
"lists.remove_member": "Выдаліць",
|
||||||
"lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся",
|
"lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся",
|
||||||
"lists.replies_policy.list": "Удзельнікі гэтага спісу",
|
"lists.replies_policy.list": "Удзельнікі гэтага спісу",
|
||||||
|
@ -460,7 +503,7 @@
|
||||||
"lists.save": "Захаваць",
|
"lists.save": "Захаваць",
|
||||||
"lists.search": "Пошук",
|
"lists.search": "Пошук",
|
||||||
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
|
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
|
||||||
"loading_indicator.label": "Загрузка…",
|
"loading_indicator.label": "Ідзе загрузка…",
|
||||||
"media_gallery.hide": "Схаваць",
|
"media_gallery.hide": "Схаваць",
|
||||||
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.",
|
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.",
|
||||||
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
|
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
|
||||||
|
@ -473,7 +516,10 @@
|
||||||
"mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.",
|
"mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.",
|
||||||
"mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.",
|
"mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.",
|
||||||
"navigation_bar.about": "Пра нас",
|
"navigation_bar.about": "Пра нас",
|
||||||
|
"navigation_bar.account_settings": "Пароль і бяспека",
|
||||||
|
"navigation_bar.administration": "Адміністрацыя",
|
||||||
"navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе",
|
"navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе",
|
||||||
|
"navigation_bar.automated_deletion": "Аўтаматычнае выдаленне допісаў",
|
||||||
"navigation_bar.blocks": "Заблакіраваныя карыстальнікі",
|
"navigation_bar.blocks": "Заблакіраваныя карыстальнікі",
|
||||||
"navigation_bar.bookmarks": "Закладкі",
|
"navigation_bar.bookmarks": "Закладкі",
|
||||||
"navigation_bar.direct": "Асабістыя згадванні",
|
"navigation_bar.direct": "Асабістыя згадванні",
|
||||||
|
@ -483,13 +529,21 @@
|
||||||
"navigation_bar.follow_requests": "Запыты на падпіску",
|
"navigation_bar.follow_requests": "Запыты на падпіску",
|
||||||
"navigation_bar.followed_tags": "Падпіскі",
|
"navigation_bar.followed_tags": "Падпіскі",
|
||||||
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
|
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
|
||||||
|
"navigation_bar.import_export": "Імпарт і экспарт",
|
||||||
"navigation_bar.lists": "Спісы",
|
"navigation_bar.lists": "Спісы",
|
||||||
|
"navigation_bar.live_feed_local": "Жывая стужка (лакальная)",
|
||||||
|
"navigation_bar.live_feed_public": "Жывая стужка (публічная)",
|
||||||
"navigation_bar.logout": "Выйсці",
|
"navigation_bar.logout": "Выйсці",
|
||||||
"navigation_bar.moderation": "Мадэрацыя",
|
"navigation_bar.moderation": "Мадэрацыя",
|
||||||
|
"navigation_bar.more": "Больш",
|
||||||
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
||||||
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
||||||
"navigation_bar.preferences": "Налады",
|
"navigation_bar.preferences": "Налады",
|
||||||
|
"navigation_bar.privacy_and_reach": "Прыватнасць і пошук",
|
||||||
"navigation_bar.search": "Пошук",
|
"navigation_bar.search": "Пошук",
|
||||||
|
"navigation_bar.search_trends": "Пошук / Трэндавае",
|
||||||
|
"navigation_panel.collapse_followed_tags": "Згарнуць меню падпісак на хэштэгі",
|
||||||
|
"navigation_panel.collapse_lists": "Згарнуць меню спісаў",
|
||||||
"not_signed_in_indicator.not_signed_in": "Вам трэба ўвайсці каб атрымаць доступ да гэтага рэсурсу.",
|
"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 {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}",
|
"notification.admin.report_account": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}",
|
||||||
|
@ -497,7 +551,10 @@
|
||||||
"notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}",
|
"notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}",
|
||||||
"notification.admin.report_statuses_other": "{name} паскардзіўся на {target}",
|
"notification.admin.report_statuses_other": "{name} паскардзіўся на {target}",
|
||||||
"notification.admin.sign_up": "{name} зарэгістраваўся",
|
"notification.admin.sign_up": "{name} зарэгістраваўся",
|
||||||
|
"notification.annual_report.view": "Перайсці да #Wrapstodon",
|
||||||
"notification.favourite": "Ваш допіс упадабаны {name}",
|
"notification.favourite": "Ваш допіс упадабаны {name}",
|
||||||
|
"notification.favourite_pm": "Ваша асабістае згадванне ўпадабана {name}",
|
||||||
|
"notification.favourite_pm.name_and_others_with_link": "{name} і <a>{count, plural, one {# іншы} few {# іншыя} many {# іншых} other {# іншых}}</a> ўпадабалі ваша асабістае згадванне",
|
||||||
"notification.follow": "{name} падпісаўся на вас",
|
"notification.follow": "{name} падпісаўся на вас",
|
||||||
"notification.follow_request": "{name} адправіў запыт на падпіску",
|
"notification.follow_request": "{name} адправіў запыт на падпіску",
|
||||||
"notification.follow_request.name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}} запыталіся падпісацца на вас",
|
"notification.follow_request.name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}} запыталіся падпісацца на вас",
|
||||||
|
@ -508,7 +565,7 @@
|
||||||
"notification.mention": "Згадванне",
|
"notification.mention": "Згадванне",
|
||||||
"notification.mentioned_you": "{name} згадаў вас",
|
"notification.mentioned_you": "{name} згадаў вас",
|
||||||
"notification.moderation-warning.learn_more": "Даведацца больш",
|
"notification.moderation-warning.learn_more": "Даведацца больш",
|
||||||
"notification.moderation_warning": "Вы атрымалі папярэджанне аб мадэрацыі",
|
"notification.moderation_warning": "Вы атрымалі папярэджанне ад мадэратараў",
|
||||||
"notification.moderation_warning.action_delete_statuses": "Некаторыя вашыя допісы былі выдаленыя.",
|
"notification.moderation_warning.action_delete_statuses": "Некаторыя вашыя допісы былі выдаленыя.",
|
||||||
"notification.moderation_warning.action_disable": "Ваш уліковы запіс быў адключаны.",
|
"notification.moderation_warning.action_disable": "Ваш уліковы запіс быў адключаны.",
|
||||||
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Некаторыя з вашых допісаў былі пазначаныя як далікатныя.",
|
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Некаторыя з вашых допісаў былі пазначаныя як далікатныя.",
|
||||||
|
@ -539,7 +596,7 @@
|
||||||
"notification_requests.title": "Адфільтраваныя апавяшчэнні",
|
"notification_requests.title": "Адфільтраваныя апавяшчэнні",
|
||||||
"notification_requests.view": "Прагляд апавяшчэнняў",
|
"notification_requests.view": "Прагляд апавяшчэнняў",
|
||||||
"notifications.clear": "Ачысціць апавяшчэнні",
|
"notifications.clear": "Ачысціць апавяшчэнні",
|
||||||
"notifications.clear_confirmation": "Вы ўпэўнены, што жадаеце назаўсёды сцерці ўсё паведамленні?",
|
"notifications.clear_confirmation": "Вы ўпэўнены, што хочаце назаўсёды сцерці ўсе свае паведамленні?",
|
||||||
"notifications.clear_title": "Ачысціць апавяшчэнні?",
|
"notifications.clear_title": "Ачысціць апавяшчэнні?",
|
||||||
"notifications.column_settings.admin.report": "Новыя скаргі:",
|
"notifications.column_settings.admin.report": "Новыя скаргі:",
|
||||||
"notifications.column_settings.admin.sign_up": "Новыя ўваходы:",
|
"notifications.column_settings.admin.sign_up": "Новыя ўваходы:",
|
||||||
|
@ -549,7 +606,7 @@
|
||||||
"notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі",
|
"notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі",
|
||||||
"notifications.column_settings.follow": "Новыя падпісчыкі:",
|
"notifications.column_settings.follow": "Новыя падпісчыкі:",
|
||||||
"notifications.column_settings.follow_request": "Новыя запыты на падпіску:",
|
"notifications.column_settings.follow_request": "Новыя запыты на падпіску:",
|
||||||
"notifications.column_settings.group": "Аб'яднаць апавяшчэнні ад падпісчыкаў",
|
"notifications.column_settings.group": "Аб’яднаць апавяшчэнні ад падпісчыкаў",
|
||||||
"notifications.column_settings.mention": "Згадванні:",
|
"notifications.column_settings.mention": "Згадванні:",
|
||||||
"notifications.column_settings.poll": "Вынікі апытання:",
|
"notifications.column_settings.poll": "Вынікі апытання:",
|
||||||
"notifications.column_settings.push": "Push-апавяшчэнні",
|
"notifications.column_settings.push": "Push-апавяшчэнні",
|
||||||
|
@ -571,13 +628,13 @@
|
||||||
"notifications.group": "{count} Апавяшчэнняў",
|
"notifications.group": "{count} Апавяшчэнняў",
|
||||||
"notifications.mark_as_read": "Пазначыць усе апавяшчэнні як прачытаныя",
|
"notifications.mark_as_read": "Пазначыць усе апавяшчэнні як прачытаныя",
|
||||||
"notifications.permission_denied": "Апавяшчэнні на працоўным стале недаступныя з-за папярэдне адхіленага запыта праў браўзера",
|
"notifications.permission_denied": "Апавяшчэнні на працоўным стале недаступныя з-за папярэдне адхіленага запыта праў браўзера",
|
||||||
"notifications.permission_denied_alert": "Апавяшчэнні на працоўным стале не могуць быць уключаныя, з-за таго што запыт браўзера быў адхілены",
|
"notifications.permission_denied_alert": "З-за таго, што запыт браўзера быў раней адхілены, немагчыма ўключыць апавяшчэнні на працоўным стале",
|
||||||
"notifications.permission_required": "Апавяшчэнні на працоўным стале недаступныя, з-за таго што неабходны дазвол не быў дадзены.",
|
"notifications.permission_required": "Апавяшчэнні на працоўным стале недаступныя, з-за таго што неабходны дазвол не быў дадзены.",
|
||||||
"notifications.policy.accept": "Прыняць",
|
"notifications.policy.accept": "Прыняць",
|
||||||
"notifications.policy.accept_hint": "Паказваць у апавяшчэннях",
|
"notifications.policy.accept_hint": "Паказваць у апавяшчэннях",
|
||||||
"notifications.policy.drop": "Iгнараваць",
|
"notifications.policy.drop": "Iгнараваць",
|
||||||
"notifications.policy.filter": "Фільтраваць",
|
"notifications.policy.filter": "Фільтраваць",
|
||||||
"notifications.policy.filter_limited_accounts_title": "Абмежаваныя ўліковыя запісы",
|
"notifications.policy.filter_limited_accounts_title": "Уліковыя запісы пад мадэрацыяй",
|
||||||
"notifications.policy.filter_new_accounts.hint": "Створаныя на працягу {days, plural, one {апошняга # дня} few {апошніх # дзён} many {апошніх # дзён} other {апошняй # дня}}",
|
"notifications.policy.filter_new_accounts.hint": "Створаныя на працягу {days, plural, one {апошняга # дня} few {апошніх # дзён} many {апошніх # дзён} other {апошняй # дня}}",
|
||||||
"notifications.policy.filter_new_accounts_title": "Новыя ўліковыя запісы",
|
"notifications.policy.filter_new_accounts_title": "Новыя ўліковыя запісы",
|
||||||
"notifications.policy.filter_not_followers_hint": "Уключаючы людзей, якія падпісаны на вас менш, чым {days, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
"notifications.policy.filter_not_followers_hint": "Уключаючы людзей, якія падпісаны на вас менш, чым {days, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
||||||
|
@ -594,13 +651,13 @@
|
||||||
"onboarding.follows.done": "Гатова",
|
"onboarding.follows.done": "Гатова",
|
||||||
"onboarding.follows.empty": "На жаль, зараз немагчыма паказаць вынікі. Вы можаце паспрабаваць выкарыстоўваць пошук і праглядзець старонку агляду, каб знайсці людзей, на якіх можна падпісацца, або паўтарыце спробу пазней.",
|
"onboarding.follows.empty": "На жаль, зараз немагчыма паказаць вынікі. Вы можаце паспрабаваць выкарыстоўваць пошук і праглядзець старонку агляду, каб знайсці людзей, на якіх можна падпісацца, або паўтарыце спробу пазней.",
|
||||||
"onboarding.follows.search": "Пошук",
|
"onboarding.follows.search": "Пошук",
|
||||||
"onboarding.follows.title": "Падпішыцеся каб пачаць",
|
"onboarding.follows.title": "Падпішыцеся на некага, каб пачаць",
|
||||||
"onboarding.profile.discoverable": "Зрабіць мой профіль бачным",
|
"onboarding.profile.discoverable": "Зрабіць мой профіль бачным",
|
||||||
"onboarding.profile.discoverable_hint": "Калі вы звяртаецеся да адкрытасці на Mastodon, вашы паведамленні могуць з'яўляцца ў выніках пошуку і тэндэнцый, а ваш профіль можа быць прапанаваны людзям з такімі ж інтарэсамі.",
|
"onboarding.profile.discoverable_hint": "Калі вы звяртаецеся да адкрытасці на Mastodon, вашы паведамленні могуць з'яўляцца ў выніках пошуку і тэндэнцый, а ваш профіль можа быць прапанаваны людзям з такімі ж інтарэсамі.",
|
||||||
"onboarding.profile.display_name": "Бачнае імя",
|
"onboarding.profile.display_name": "Бачнае імя",
|
||||||
"onboarding.profile.display_name_hint": "Ваша поўнае імя або ваш псеўданім…",
|
"onboarding.profile.display_name_hint": "Ваша поўнае імя або ваш псеўданім…",
|
||||||
"onboarding.profile.note": "Біяграфія",
|
"onboarding.profile.note": "Біяграфія",
|
||||||
"onboarding.profile.note_hint": "Вы можаце @згадаць іншых людзей або выкарыстоўваць #хэштэгі…",
|
"onboarding.profile.note_hint": "Вы можаце @згадваць іншых людзей або выкарыстоўваць #хэштэгі…",
|
||||||
"onboarding.profile.save_and_continue": "Захаваць і працягнуць",
|
"onboarding.profile.save_and_continue": "Захаваць і працягнуць",
|
||||||
"onboarding.profile.title": "Налады профілю",
|
"onboarding.profile.title": "Налады профілю",
|
||||||
"onboarding.profile.upload_avatar": "Загрузіць фота профілю",
|
"onboarding.profile.upload_avatar": "Загрузіць фота профілю",
|
||||||
|
@ -620,6 +677,7 @@
|
||||||
"poll_button.remove_poll": "Выдаліць апытанне",
|
"poll_button.remove_poll": "Выдаліць апытанне",
|
||||||
"privacy.change": "Змяніць прыватнасць допісу",
|
"privacy.change": "Змяніць прыватнасць допісу",
|
||||||
"privacy.direct.long": "Усе згаданыя ў допісе",
|
"privacy.direct.long": "Усе згаданыя ў допісе",
|
||||||
|
"privacy.direct.short": "Асабістае згадванне",
|
||||||
"privacy.private.long": "Толькі вашыя падпісчыкі",
|
"privacy.private.long": "Толькі вашыя падпісчыкі",
|
||||||
"privacy.private.short": "Падпісчыкі",
|
"privacy.private.short": "Падпісчыкі",
|
||||||
"privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon",
|
"privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon",
|
||||||
|
@ -629,10 +687,10 @@
|
||||||
"privacy.unlisted.short": "Ціхі публічны",
|
"privacy.unlisted.short": "Ціхі публічны",
|
||||||
"privacy_policy.last_updated": "Адноўлена {date}",
|
"privacy_policy.last_updated": "Адноўлена {date}",
|
||||||
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
||||||
"recommended": "Рэкамендуем",
|
"recommended": "Рэкамендаванае",
|
||||||
"refresh": "Абнавiць",
|
"refresh": "Абнавiць",
|
||||||
"regeneration_indicator.please_stand_by": "Калі ласка, пачакайце.",
|
"regeneration_indicator.please_stand_by": "Пачакайце.",
|
||||||
"regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашую стужку…",
|
"regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашу галоўную стужку…",
|
||||||
"relative_time.days": "{number} д",
|
"relative_time.days": "{number} д",
|
||||||
"relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму",
|
"relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму",
|
||||||
"relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму",
|
"relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму",
|
||||||
|
@ -649,7 +707,7 @@
|
||||||
"reply_indicator.poll": "Апытанне",
|
"reply_indicator.poll": "Апытанне",
|
||||||
"report.block": "Заблакіраваць",
|
"report.block": "Заблакіраваць",
|
||||||
"report.block_explanation": "Вы перастанеце бачыць допісы гэтага карыстальніка. Ён не зможа сачыць за вамі і бачыць вашы допісы. Ён зможа зразумець, што яго заблакіравалі.",
|
"report.block_explanation": "Вы перастанеце бачыць допісы гэтага карыстальніка. Ён не зможа сачыць за вамі і бачыць вашы допісы. Ён зможа зразумець, што яго заблакіравалі.",
|
||||||
"report.categories.legal": "Права",
|
"report.categories.legal": "Звязанае з правам",
|
||||||
"report.categories.other": "Іншае",
|
"report.categories.other": "Іншае",
|
||||||
"report.categories.spam": "Спам",
|
"report.categories.spam": "Спам",
|
||||||
"report.categories.violation": "Змест парушае адно ці некалькі правілаў сервера",
|
"report.categories.violation": "Змест парушае адно ці некалькі правілаў сервера",
|
||||||
|
@ -716,8 +774,8 @@
|
||||||
"search_results.accounts": "Профілі",
|
"search_results.accounts": "Профілі",
|
||||||
"search_results.all": "Усё",
|
"search_results.all": "Усё",
|
||||||
"search_results.hashtags": "Хэштэгі",
|
"search_results.hashtags": "Хэштэгі",
|
||||||
"search_results.no_results": "Анічога ня знойдзена.",
|
"search_results.no_results": "Няма вынікаў.",
|
||||||
"search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або гэштаґі.",
|
"search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або хэштэгі.",
|
||||||
"search_results.see_all": "Праглядзець усе",
|
"search_results.see_all": "Праглядзець усе",
|
||||||
"search_results.statuses": "Допісы",
|
"search_results.statuses": "Допісы",
|
||||||
"search_results.title": "Шукаць \"{q}\"",
|
"search_results.title": "Шукаць \"{q}\"",
|
||||||
|
@ -738,7 +796,7 @@
|
||||||
"status.bookmark": "Дадаць закладку",
|
"status.bookmark": "Дадаць закладку",
|
||||||
"status.cancel_reblog_private": "Прыбраць",
|
"status.cancel_reblog_private": "Прыбраць",
|
||||||
"status.cannot_reblog": "Гэты пост нельга пашырыць",
|
"status.cannot_reblog": "Гэты пост нельга пашырыць",
|
||||||
"status.continued_thread": "Працяг тэмы",
|
"status.continued_thread": "Працяг ланцужка",
|
||||||
"status.copy": "Скапіраваць спасылку на допіс",
|
"status.copy": "Скапіраваць спасылку на допіс",
|
||||||
"status.delete": "Выдаліць",
|
"status.delete": "Выдаліць",
|
||||||
"status.detailed_status": "Дэтальны агляд размовы",
|
"status.detailed_status": "Дэтальны агляд размовы",
|
||||||
|
@ -763,6 +821,7 @@
|
||||||
"status.mute_conversation": "Ігнараваць размову",
|
"status.mute_conversation": "Ігнараваць размову",
|
||||||
"status.open": "Разгарнуць гэты допіс",
|
"status.open": "Разгарнуць гэты допіс",
|
||||||
"status.pin": "Замацаваць у профілі",
|
"status.pin": "Замацаваць у профілі",
|
||||||
|
"status.quote_post_author": "Допіс карыстальніка @{name}",
|
||||||
"status.read_more": "Чытаць болей",
|
"status.read_more": "Чытаць болей",
|
||||||
"status.reblog": "Пашырыць",
|
"status.reblog": "Пашырыць",
|
||||||
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
||||||
|
@ -771,7 +830,7 @@
|
||||||
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
|
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
|
||||||
"status.redraft": "Выдаліць і паправіць",
|
"status.redraft": "Выдаліць і паправіць",
|
||||||
"status.remove_bookmark": "Выдаліць закладку",
|
"status.remove_bookmark": "Выдаліць закладку",
|
||||||
"status.replied_in_thread": "Адказаў у тэме",
|
"status.replied_in_thread": "Адказаў у ланцужку",
|
||||||
"status.replied_to": "Адказаў {name}",
|
"status.replied_to": "Адказаў {name}",
|
||||||
"status.reply": "Адказаць",
|
"status.reply": "Адказаць",
|
||||||
"status.replyAll": "Адказаць у ланцугу",
|
"status.replyAll": "Адказаць у ланцугу",
|
||||||
|
@ -791,8 +850,11 @@
|
||||||
"subscribed_languages.save": "Захаваць змены",
|
"subscribed_languages.save": "Захаваць змены",
|
||||||
"subscribed_languages.target": "Змяніць мовы падпіскі для {target}",
|
"subscribed_languages.target": "Змяніць мовы падпіскі для {target}",
|
||||||
"tabs_bar.home": "Галоўная",
|
"tabs_bar.home": "Галоўная",
|
||||||
|
"tabs_bar.menu": "Меню",
|
||||||
"tabs_bar.notifications": "Апавяшчэнні",
|
"tabs_bar.notifications": "Апавяшчэнні",
|
||||||
"terms_of_service.title": "Умовы абслугоўваньня",
|
"tabs_bar.publish": "Новы допіс",
|
||||||
|
"tabs_bar.search": "Пошук",
|
||||||
|
"terms_of_service.title": "Умовы выкарыстання",
|
||||||
"time_remaining.days": "{number, plural, one {застаўся # дзень} few {засталося # дні} many {засталося # дзён} other {засталося # дня}}",
|
"time_remaining.days": "{number, plural, one {застаўся # дзень} few {засталося # дні} many {засталося # дзён} other {засталося # дня}}",
|
||||||
"time_remaining.hours": "{number, plural, one {засталася # гадзіна} few {засталося # гадзіны} many {засталося # гадзін} other {засталося # гадзіны}}",
|
"time_remaining.hours": "{number, plural, one {засталася # гадзіна} few {засталося # гадзіны} many {засталося # гадзін} other {засталося # гадзіны}}",
|
||||||
"time_remaining.minutes": "{number, plural, one {засталася # хвіліна} few {засталося # хвіліны} many {засталося # хвілін} other {засталося # хвіліны}}",
|
"time_remaining.minutes": "{number, plural, one {засталася # хвіліна} few {засталося # хвіліны} many {засталося # хвілін} other {засталося # хвіліны}}",
|
||||||
|
@ -818,6 +880,12 @@
|
||||||
"video.expand": "Разгарнуць відэа",
|
"video.expand": "Разгарнуць відэа",
|
||||||
"video.fullscreen": "Увесь экран",
|
"video.fullscreen": "Увесь экран",
|
||||||
"video.hide": "Схаваць відэа",
|
"video.hide": "Схаваць відэа",
|
||||||
|
"video.mute": "Выключыць гук",
|
||||||
"video.pause": "Паўза",
|
"video.pause": "Паўза",
|
||||||
"video.play": "Прайграць"
|
"video.play": "Прайграць",
|
||||||
|
"video.skip_backward": "Праматаць назад",
|
||||||
|
"video.skip_forward": "Праматаць уперад",
|
||||||
|
"video.unmute": "Уключыць гук",
|
||||||
|
"video.volume_down": "Паменшыць гучнасць",
|
||||||
|
"video.volume_up": "Павялічыць гучнасць"
|
||||||
}
|
}
|
||||||
|
|
|
@ -188,7 +188,7 @@
|
||||||
"community.column_settings.remote_only": "Само отдалечено",
|
"community.column_settings.remote_only": "Само отдалечено",
|
||||||
"compose.language.change": "Смяна на езика",
|
"compose.language.change": "Смяна на езика",
|
||||||
"compose.language.search": "Търсене на езици...",
|
"compose.language.search": "Търсене на езици...",
|
||||||
"compose.published.body": "Публикувана публикация.",
|
"compose.published.body": "Публикувано.",
|
||||||
"compose.published.open": "Отваряне",
|
"compose.published.open": "Отваряне",
|
||||||
"compose.saved.body": "Запазена публикация.",
|
"compose.saved.body": "Запазена публикация.",
|
||||||
"compose_form.direct_message_warning_learn_more": "Още информация",
|
"compose_form.direct_message_warning_learn_more": "Още информация",
|
||||||
|
@ -419,8 +419,6 @@
|
||||||
"hints.profiles.see_more_followers": "Преглед на още последователи на {domain}",
|
"hints.profiles.see_more_followers": "Преглед на още последователи на {domain}",
|
||||||
"hints.profiles.see_more_follows": "Преглед на още последвания на {domain}",
|
"hints.profiles.see_more_follows": "Преглед на още последвания на {domain}",
|
||||||
"hints.profiles.see_more_posts": "Преглед на още публикации на {domain}",
|
"hints.profiles.see_more_posts": "Преглед на още публикации на {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Отговори от други сървъри може да липсват.",
|
|
||||||
"hints.threads.see_more": "Преглед на още отговори на {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Показване на цитираното",
|
"home.column_settings.show_quotes": "Показване на цитираното",
|
||||||
"home.column_settings.show_reblogs": "Показване на подсилванията",
|
"home.column_settings.show_reblogs": "Показване на подсилванията",
|
||||||
"home.column_settings.show_replies": "Показване на отговорите",
|
"home.column_settings.show_replies": "Показване на отговорите",
|
||||||
|
|
|
@ -219,6 +219,13 @@
|
||||||
"confirmations.delete_list.confirm": "Elimina",
|
"confirmations.delete_list.confirm": "Elimina",
|
||||||
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
|
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
|
||||||
"confirmations.delete_list.title": "Eliminar la llista?",
|
"confirmations.delete_list.title": "Eliminar la llista?",
|
||||||
|
"confirmations.discard_draft.confirm": "Descarta i continua",
|
||||||
|
"confirmations.discard_draft.edit.cancel": "Continua l'edició",
|
||||||
|
"confirmations.discard_draft.edit.message": "Continuar descartarà tots els canvis que hàgiu fet a la publicació que editeu.",
|
||||||
|
"confirmations.discard_draft.edit.title": "Descartar els canvis a la publicació?",
|
||||||
|
"confirmations.discard_draft.post.cancel": "Reprendre l'esborrany",
|
||||||
|
"confirmations.discard_draft.post.message": "Continuar descartarà la publicació que escriviu.",
|
||||||
|
"confirmations.discard_draft.post.title": "Descartar l'esborrany?",
|
||||||
"confirmations.discard_edit_media.confirm": "Descarta",
|
"confirmations.discard_edit_media.confirm": "Descarta",
|
||||||
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
|
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
|
||||||
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
|
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
|
||||||
|
@ -417,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Vegeu més seguidors a {domain}",
|
"hints.profiles.see_more_followers": "Vegeu més seguidors a {domain}",
|
||||||
"hints.profiles.see_more_follows": "Vegeu més seguiments a {domain}",
|
"hints.profiles.see_more_follows": "Vegeu més seguiments a {domain}",
|
||||||
"hints.profiles.see_more_posts": "Vegeu més publicacions a {domain}",
|
"hints.profiles.see_more_posts": "Vegeu més publicacions a {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Es poden haver perdut respostes d'altres servidors.",
|
|
||||||
"hints.threads.see_more": "Vegeu més respostes a {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Mostrar les cites",
|
"home.column_settings.show_quotes": "Mostrar les cites",
|
||||||
"home.column_settings.show_reblogs": "Mostra els impulsos",
|
"home.column_settings.show_reblogs": "Mostra els impulsos",
|
||||||
"home.column_settings.show_replies": "Mostra les respostes",
|
"home.column_settings.show_replies": "Mostra les respostes",
|
||||||
|
@ -556,6 +561,8 @@
|
||||||
"navigation_bar.follows_and_followers": "Seguint i seguidors",
|
"navigation_bar.follows_and_followers": "Seguint i seguidors",
|
||||||
"navigation_bar.import_export": "Importació i exportació",
|
"navigation_bar.import_export": "Importació i exportació",
|
||||||
"navigation_bar.lists": "Llistes",
|
"navigation_bar.lists": "Llistes",
|
||||||
|
"navigation_bar.live_feed_local": "Canal en directe (local)",
|
||||||
|
"navigation_bar.live_feed_public": "Canal en directe (públic)",
|
||||||
"navigation_bar.logout": "Tanca la sessió",
|
"navigation_bar.logout": "Tanca la sessió",
|
||||||
"navigation_bar.moderation": "Moderació",
|
"navigation_bar.moderation": "Moderació",
|
||||||
"navigation_bar.more": "Més",
|
"navigation_bar.more": "Més",
|
||||||
|
@ -564,7 +571,10 @@
|
||||||
"navigation_bar.preferences": "Preferències",
|
"navigation_bar.preferences": "Preferències",
|
||||||
"navigation_bar.privacy_and_reach": "Privacitat i abast",
|
"navigation_bar.privacy_and_reach": "Privacitat i abast",
|
||||||
"navigation_bar.search": "Cerca",
|
"navigation_bar.search": "Cerca",
|
||||||
|
"navigation_bar.search_trends": "Cerca / En tendència",
|
||||||
|
"navigation_panel.collapse_followed_tags": "Comprimeix el menú d'etiquetes seguides",
|
||||||
"navigation_panel.collapse_lists": "Tanca el menú",
|
"navigation_panel.collapse_lists": "Tanca el menú",
|
||||||
|
"navigation_panel.expand_followed_tags": "Expandeix el menú d'etiquetes seguides",
|
||||||
"navigation_panel.expand_lists": "Expandeix el menú",
|
"navigation_panel.expand_lists": "Expandeix el menú",
|
||||||
"not_signed_in_indicator.not_signed_in": "Cal que iniciïs la sessió per a accedir a aquest recurs.",
|
"not_signed_in_indicator.not_signed_in": "Cal que iniciïs la sessió per a accedir a aquest recurs.",
|
||||||
"notification.admin.report": "{name} ha reportat {target}",
|
"notification.admin.report": "{name} ha reportat {target}",
|
||||||
|
@ -792,6 +802,7 @@
|
||||||
"report_notification.categories.violation": "Violació de norma",
|
"report_notification.categories.violation": "Violació de norma",
|
||||||
"report_notification.categories.violation_sentence": "violació de normes",
|
"report_notification.categories.violation_sentence": "violació de normes",
|
||||||
"report_notification.open": "Obre l'informe",
|
"report_notification.open": "Obre l'informe",
|
||||||
|
"search.clear": "Esborra la cerca",
|
||||||
"search.no_recent_searches": "No hi ha cerques recents",
|
"search.no_recent_searches": "No hi ha cerques recents",
|
||||||
"search.placeholder": "Cerca",
|
"search.placeholder": "Cerca",
|
||||||
"search.quick_action.account_search": "Perfils coincidint amb {x}",
|
"search.quick_action.account_search": "Perfils coincidint amb {x}",
|
||||||
|
@ -833,6 +844,8 @@
|
||||||
"status.bookmark": "Marca",
|
"status.bookmark": "Marca",
|
||||||
"status.cancel_reblog_private": "Desfés l'impuls",
|
"status.cancel_reblog_private": "Desfés l'impuls",
|
||||||
"status.cannot_reblog": "No es pot impulsar aquest tut",
|
"status.cannot_reblog": "No es pot impulsar aquest tut",
|
||||||
|
"status.context.load_new_replies": "Hi ha respostes noves",
|
||||||
|
"status.context.loading": "Comprovació de més respostes",
|
||||||
"status.continued_thread": "Continuació del fil",
|
"status.continued_thread": "Continuació del fil",
|
||||||
"status.copy": "Copia l'enllaç al tut",
|
"status.copy": "Copia l'enllaç al tut",
|
||||||
"status.delete": "Elimina",
|
"status.delete": "Elimina",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Zobrazit více sledujících na {domain}",
|
"hints.profiles.see_more_followers": "Zobrazit více sledujících na {domain}",
|
||||||
"hints.profiles.see_more_follows": "Zobrazit další sledování na {domain}",
|
"hints.profiles.see_more_follows": "Zobrazit další sledování na {domain}",
|
||||||
"hints.profiles.see_more_posts": "Zobrazit další příspěvky na {domain}",
|
"hints.profiles.see_more_posts": "Zobrazit další příspěvky na {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Odpovědi z jiných serverů mohou chybět.",
|
|
||||||
"hints.threads.see_more": "Zobrazit další odpovědi na {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Zobrazit citace",
|
"home.column_settings.show_quotes": "Zobrazit citace",
|
||||||
"home.column_settings.show_reblogs": "Zobrazit boosty",
|
"home.column_settings.show_reblogs": "Zobrazit boosty",
|
||||||
"home.column_settings.show_replies": "Zobrazit odpovědi",
|
"home.column_settings.show_replies": "Zobrazit odpovědi",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Přidat do záložek",
|
"status.bookmark": "Přidat do záložek",
|
||||||
"status.cancel_reblog_private": "Zrušit boostnutí",
|
"status.cancel_reblog_private": "Zrušit boostnutí",
|
||||||
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
||||||
|
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
|
||||||
|
"status.context.loading": "Hledání dalších odpovědí",
|
||||||
"status.continued_thread": "Pokračuje ve vlákně",
|
"status.continued_thread": "Pokračuje ve vlákně",
|
||||||
"status.copy": "Zkopírovat odkaz na příspěvek",
|
"status.copy": "Zkopírovat odkaz na příspěvek",
|
||||||
"status.delete": "Smazat",
|
"status.delete": "Smazat",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Gweld mwy o ddilynwyr ar {domain}",
|
"hints.profiles.see_more_followers": "Gweld mwy o ddilynwyr ar {domain}",
|
||||||
"hints.profiles.see_more_follows": "Gweld mwy o 'yn dilyn' ar {domain}",
|
"hints.profiles.see_more_follows": "Gweld mwy o 'yn dilyn' ar {domain}",
|
||||||
"hints.profiles.see_more_posts": "Gweld mwy o bostiadau ar {domain}",
|
"hints.profiles.see_more_posts": "Gweld mwy o bostiadau ar {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Mae'n bosibl y bydd ymatebion gan weinyddion eraill ar goll.",
|
|
||||||
"hints.threads.see_more": "Gweld mwy o ymatebion ar {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Dangos dyfyniadau",
|
"home.column_settings.show_quotes": "Dangos dyfyniadau",
|
||||||
"home.column_settings.show_reblogs": "Dangos hybiau",
|
"home.column_settings.show_reblogs": "Dangos hybiau",
|
||||||
"home.column_settings.show_replies": "Dangos ymatebion",
|
"home.column_settings.show_replies": "Dangos ymatebion",
|
||||||
|
|
|
@ -62,7 +62,7 @@
|
||||||
"account.mute_notifications_short": "Sluk for notifikationer",
|
"account.mute_notifications_short": "Sluk for notifikationer",
|
||||||
"account.mute_short": "Skjul",
|
"account.mute_short": "Skjul",
|
||||||
"account.muted": "Skjult",
|
"account.muted": "Skjult",
|
||||||
"account.muting": "Tavsgørelse",
|
"account.muting": "Skjuler",
|
||||||
"account.mutual": "I følger hinanden",
|
"account.mutual": "I følger hinanden",
|
||||||
"account.no_bio": "Ingen beskrivelse til rådighed.",
|
"account.no_bio": "Ingen beskrivelse til rådighed.",
|
||||||
"account.open_original_page": "Åbn oprindelig side",
|
"account.open_original_page": "Åbn oprindelig side",
|
||||||
|
@ -350,7 +350,7 @@
|
||||||
"filter_modal.added.context_mismatch_title": "Kontekstmisforhold!",
|
"filter_modal.added.context_mismatch_title": "Kontekstmisforhold!",
|
||||||
"filter_modal.added.expired_explanation": "Denne filterkategori er udløbet. Ændr dens udløbsdato, for at anvende den.",
|
"filter_modal.added.expired_explanation": "Denne filterkategori er udløbet. Ændr dens udløbsdato, for at anvende den.",
|
||||||
"filter_modal.added.expired_title": "Udløbet filter!",
|
"filter_modal.added.expired_title": "Udløbet filter!",
|
||||||
"filter_modal.added.review_and_configure": "Gå til {settings_link} for at gennemse og yderligere opsætte denne filterkategori.",
|
"filter_modal.added.review_and_configure": "Gå til {settings_link} for at gennemgå og konfigurere denne filterkategori yderligere.",
|
||||||
"filter_modal.added.review_and_configure_title": "Filterindstillinger",
|
"filter_modal.added.review_and_configure_title": "Filterindstillinger",
|
||||||
"filter_modal.added.settings_link": "indstillingsside",
|
"filter_modal.added.settings_link": "indstillingsside",
|
||||||
"filter_modal.added.short_explanation": "Dette indlæg er nu føjet til følgende filterkategori: {title}.",
|
"filter_modal.added.short_explanation": "Dette indlæg er nu føjet til følgende filterkategori: {title}.",
|
||||||
|
@ -386,7 +386,7 @@
|
||||||
"follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig",
|
"follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig",
|
||||||
"follow_suggestions.view_all": "Vis alle",
|
"follow_suggestions.view_all": "Vis alle",
|
||||||
"follow_suggestions.who_to_follow": "Hvem, som skal følges",
|
"follow_suggestions.who_to_follow": "Hvem, som skal følges",
|
||||||
"followed_tags": "Hashtag, som følges",
|
"followed_tags": "Hashtags, som følges",
|
||||||
"footer.about": "Om",
|
"footer.about": "Om",
|
||||||
"footer.directory": "Profiloversigt",
|
"footer.directory": "Profiloversigt",
|
||||||
"footer.get_app": "Hent appen",
|
"footer.get_app": "Hent appen",
|
||||||
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Se flere følgere på {domain}",
|
"hints.profiles.see_more_followers": "Se flere følgere på {domain}",
|
||||||
"hints.profiles.see_more_follows": "Se flere fulgte på {domain}",
|
"hints.profiles.see_more_follows": "Se flere fulgte på {domain}",
|
||||||
"hints.profiles.see_more_posts": "Se flere indlæg på {domain}",
|
"hints.profiles.see_more_posts": "Se flere indlæg på {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Der kan mangle svar fra andre servere.",
|
|
||||||
"hints.threads.see_more": "Se flere svar på {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Vis citater",
|
"home.column_settings.show_quotes": "Vis citater",
|
||||||
"home.column_settings.show_reblogs": "Vis fremhævelser",
|
"home.column_settings.show_reblogs": "Vis fremhævelser",
|
||||||
"home.column_settings.show_replies": "Vis svar",
|
"home.column_settings.show_replies": "Vis svar",
|
||||||
|
@ -560,7 +558,7 @@
|
||||||
"navigation_bar.favourites": "Favoritter",
|
"navigation_bar.favourites": "Favoritter",
|
||||||
"navigation_bar.filters": "Skjulte ord",
|
"navigation_bar.filters": "Skjulte ord",
|
||||||
"navigation_bar.follow_requests": "Følgeanmodninger",
|
"navigation_bar.follow_requests": "Følgeanmodninger",
|
||||||
"navigation_bar.followed_tags": "Hashtag, som følges",
|
"navigation_bar.followed_tags": "Hashtags, som følges",
|
||||||
"navigation_bar.follows_and_followers": "Følges og følgere",
|
"navigation_bar.follows_and_followers": "Følges og følgere",
|
||||||
"navigation_bar.import_export": "Import og eksport",
|
"navigation_bar.import_export": "Import og eksport",
|
||||||
"navigation_bar.lists": "Lister",
|
"navigation_bar.lists": "Lister",
|
||||||
|
@ -572,7 +570,7 @@
|
||||||
"navigation_bar.mutes": "Skjulte brugere",
|
"navigation_bar.mutes": "Skjulte brugere",
|
||||||
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
|
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
|
||||||
"navigation_bar.preferences": "Præferencer",
|
"navigation_bar.preferences": "Præferencer",
|
||||||
"navigation_bar.privacy_and_reach": "Fortrolighed og udbredelse",
|
"navigation_bar.privacy_and_reach": "Fortrolighed og rækkevidde",
|
||||||
"navigation_bar.search": "Søg",
|
"navigation_bar.search": "Søg",
|
||||||
"navigation_bar.search_trends": "Søg/Trender",
|
"navigation_bar.search_trends": "Søg/Trender",
|
||||||
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
|
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Bogmærk",
|
"status.bookmark": "Bogmærk",
|
||||||
"status.cancel_reblog_private": "Fjern fremhævelse",
|
"status.cancel_reblog_private": "Fjern fremhævelse",
|
||||||
"status.cannot_reblog": "Dette indlæg kan ikke fremhæves",
|
"status.cannot_reblog": "Dette indlæg kan ikke fremhæves",
|
||||||
|
"status.context.load_new_replies": "Nye svar tilgængelige",
|
||||||
|
"status.context.loading": "Tjekker for flere svar",
|
||||||
"status.continued_thread": "Fortsat tråd",
|
"status.continued_thread": "Fortsat tråd",
|
||||||
"status.copy": "Kopiér link til indlæg",
|
"status.copy": "Kopiér link til indlæg",
|
||||||
"status.delete": "Slet",
|
"status.delete": "Slet",
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
"about.domain_blocks.no_reason_available": "Grund unbekannt",
|
"about.domain_blocks.no_reason_available": "Grund unbekannt",
|
||||||
"about.domain_blocks.preamble": "Mastodon erlaubt es dir grundsätzlich, alle Inhalte von allen Nutzer*innen auf allen Servern im Fediverse zu sehen und mit ihnen zu interagieren. Für diesen Server gibt es aber ein paar Ausnahmen.",
|
"about.domain_blocks.preamble": "Mastodon erlaubt es dir grundsätzlich, alle Inhalte von allen Nutzer*innen auf allen Servern im Fediverse zu sehen und mit ihnen zu interagieren. Für diesen Server gibt es aber ein paar Ausnahmen.",
|
||||||
"about.domain_blocks.silenced.explanation": "Standardmäßig werden von diesem Server keine Inhalte oder Profile angezeigt. Du kannst die Profile und Inhalte aber dennoch sehen, wenn du explizit nach diesen suchst oder diesen folgst.",
|
"about.domain_blocks.silenced.explanation": "Standardmäßig werden von diesem Server keine Inhalte oder Profile angezeigt. Du kannst die Profile und Inhalte aber dennoch sehen, wenn du explizit nach diesen suchst oder diesen folgst.",
|
||||||
"about.domain_blocks.silenced.title": "Stummgeschaltet",
|
"about.domain_blocks.silenced.title": "Ausgeblendet",
|
||||||
"about.domain_blocks.suspended.explanation": "Es werden keine Daten von diesem Server verarbeitet, gespeichert oder ausgetauscht, sodass eine Interaktion oder Kommunikation mit Nutzer*innen dieses Servers nicht möglich ist.",
|
"about.domain_blocks.suspended.explanation": "Es werden keine Daten von diesem Server verarbeitet, gespeichert oder ausgetauscht, sodass eine Interaktion oder Kommunikation mit Nutzer*innen dieses Servers nicht möglich ist.",
|
||||||
"about.domain_blocks.suspended.title": "Gesperrt",
|
"about.domain_blocks.suspended.title": "Gesperrt",
|
||||||
"about.language_label": "Sprache",
|
"about.language_label": "Sprache",
|
||||||
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Weitere Follower auf {domain} ansehen",
|
"hints.profiles.see_more_followers": "Weitere Follower auf {domain} ansehen",
|
||||||
"hints.profiles.see_more_follows": "Weitere gefolgte Profile auf {domain} ansehen",
|
"hints.profiles.see_more_follows": "Weitere gefolgte Profile auf {domain} ansehen",
|
||||||
"hints.profiles.see_more_posts": "Weitere Beiträge auf {domain} ansehen",
|
"hints.profiles.see_more_posts": "Weitere Beiträge auf {domain} ansehen",
|
||||||
"hints.threads.replies_may_be_missing": "Möglicherweise werden nicht alle Antworten von anderen Servern angezeigt.",
|
|
||||||
"hints.threads.see_more": "Weitere Antworten auf {domain} ansehen",
|
|
||||||
"home.column_settings.show_quotes": "Zitierte Beiträge anzeigen",
|
"home.column_settings.show_quotes": "Zitierte Beiträge anzeigen",
|
||||||
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
|
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
|
||||||
"home.column_settings.show_replies": "Antworten anzeigen",
|
"home.column_settings.show_replies": "Antworten anzeigen",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Lesezeichen setzen",
|
"status.bookmark": "Lesezeichen setzen",
|
||||||
"status.cancel_reblog_private": "Beitrag nicht mehr teilen",
|
"status.cancel_reblog_private": "Beitrag nicht mehr teilen",
|
||||||
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
|
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
|
||||||
|
"status.context.load_new_replies": "Neue Antworten verfügbar",
|
||||||
|
"status.context.loading": "Weitere Antworten werden abgerufen",
|
||||||
"status.continued_thread": "Fortgeführter Thread",
|
"status.continued_thread": "Fortgeführter Thread",
|
||||||
"status.copy": "Link zum Beitrag kopieren",
|
"status.copy": "Link zum Beitrag kopieren",
|
||||||
"status.delete": "Beitrag löschen",
|
"status.delete": "Beitrag löschen",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"about.default_locale": "Προεπιλογή",
|
"about.default_locale": "Προεπιλογή",
|
||||||
"about.disclaimer": "Το Mastodon είναι ελεύθερο λογισμικό ανοιχτού κώδικα και εμπορικό σήμα της Mastodon gGmbH.",
|
"about.disclaimer": "Το Mastodon είναι ελεύθερο λογισμικό ανοιχτού κώδικα και εμπορικό σήμα της Mastodon gGmbH.",
|
||||||
"about.domain_blocks.no_reason_available": "Αιτιολογία μη διαθέσιμη",
|
"about.domain_blocks.no_reason_available": "Αιτιολογία μη διαθέσιμη",
|
||||||
"about.domain_blocks.preamble": "Σε γενικές γραμμές το Mastodon σού επιτρέπει να βλέπεις περιεχόμενο και να αλληλεπιδράς με χρήστες από οποιονδήποτε άλλο διακομιστή σε ένα διασυνδεδεμένο σύμπαν διακομιστών (fediverse). Ακολουθούν οι εξαιρέσεις που ισχύουν για τον συγκεκριμένο διακομιστή.",
|
"about.domain_blocks.preamble": "Σε γενικές γραμμές το Mastodon σου επιτρέπει να βλέπεις περιεχόμενο και να αλληλεπιδράς με χρήστες από οποιονδήποτε άλλο διακομιστή σε ένα διασυνδεδεμένο σύμπαν διακομιστών (fediverse). Ακολουθούν οι εξαιρέσεις που ισχύουν για τον συγκεκριμένο διακομιστή.",
|
||||||
"about.domain_blocks.silenced.explanation": "Συνήθως δε θα βλέπεις προφίλ και περιεχόμενο απ' αυτόν τον διακομιστή, εκτός αν κάνεις συγκεκριμένη αναζήτηση ή επιλέξεις να τον ακολουθήσεις.",
|
"about.domain_blocks.silenced.explanation": "Συνήθως δε θα βλέπεις προφίλ και περιεχόμενο απ' αυτόν τον διακομιστή, εκτός αν κάνεις συγκεκριμένη αναζήτηση ή επιλέξεις να τον ακολουθήσεις.",
|
||||||
"about.domain_blocks.silenced.title": "Περιορισμένος",
|
"about.domain_blocks.silenced.title": "Περιορισμένος",
|
||||||
"about.domain_blocks.suspended.explanation": "Τα δεδομένα αυτού του διακομιστή, δε θα επεξεργάζονται, δε θα αποθηκεύονται και δε θα ανταλλάσσονται, καθιστώντας οποιαδήποτε αλληλεπίδραση ή επικοινωνία με χρήστες από αυτόν το διακομιστή αδύνατη.",
|
"about.domain_blocks.suspended.explanation": "Τα δεδομένα αυτού του διακομιστή, δε θα επεξεργάζονται, δε θα αποθηκεύονται και δε θα ανταλλάσσονται, καθιστώντας οποιαδήποτε αλληλεπίδραση ή επικοινωνία με χρήστες από αυτόν το διακομιστή αδύνατη.",
|
||||||
|
@ -65,7 +65,7 @@
|
||||||
"account.muting": "Σίγαση",
|
"account.muting": "Σίγαση",
|
||||||
"account.mutual": "Ακολουθείτε ο ένας τον άλλο",
|
"account.mutual": "Ακολουθείτε ο ένας τον άλλο",
|
||||||
"account.no_bio": "Δεν υπάρχει περιγραφή.",
|
"account.no_bio": "Δεν υπάρχει περιγραφή.",
|
||||||
"account.open_original_page": "Ανοικτό",
|
"account.open_original_page": "Άνοιγμα αυθεντικής σελίδας",
|
||||||
"account.posts": "Τουτ",
|
"account.posts": "Τουτ",
|
||||||
"account.posts_with_replies": "Τουτ και απαντήσεις",
|
"account.posts_with_replies": "Τουτ και απαντήσεις",
|
||||||
"account.remove_from_followers": "Κατάργηση {name} από τους ακόλουθους",
|
"account.remove_from_followers": "Κατάργηση {name} από τους ακόλουθους",
|
||||||
|
@ -194,9 +194,9 @@
|
||||||
"compose.saved.body": "Η ανάρτηση αποθηκεύτηκε.",
|
"compose.saved.body": "Η ανάρτηση αποθηκεύτηκε.",
|
||||||
"compose_form.direct_message_warning_learn_more": "Μάθε περισσότερα",
|
"compose_form.direct_message_warning_learn_more": "Μάθε περισσότερα",
|
||||||
"compose_form.encryption_warning": "Οι δημοσιεύσεις στο Mastodon δεν είναι κρυπτογραφημένες από άκρο σε άκρο. Μη μοιράζεσαι ευαίσθητες πληροφορίες μέσω του Mastodon.",
|
"compose_form.encryption_warning": "Οι δημοσιεύσεις στο Mastodon δεν είναι κρυπτογραφημένες από άκρο σε άκρο. Μη μοιράζεσαι ευαίσθητες πληροφορίες μέσω του Mastodon.",
|
||||||
"compose_form.hashtag_warning": "Αυτή η δημοσίευση δεν θα εμφανίζεται κάτω από οποιαδήποτε ετικέτα καθώς δεν είναι δημόσια. Μόνο οι δημόσιες δημοσιεύσεις μπορούν να αναζητηθούν με ετικέτα.",
|
"compose_form.hashtag_warning": "Αυτή η ανάρτηση δεν θα εμφανίζεται κάτω από οποιαδήποτε ετικέτα καθώς δεν είναι δημόσια. Μόνο οι δημόσιες αναρτήσεις μπορούν να αναζητηθούν με ετικέτα.",
|
||||||
"compose_form.lock_disclaimer": "Ο λογαριασμός σου δεν είναι {locked}. Οποιοσδήποτε μπορεί να σε ακολουθήσει για να δει τις δημοσιεύσεις σου προς τους ακολούθους σου.",
|
"compose_form.lock_disclaimer": "Ο λογαριασμός σου δεν είναι {locked}. Οποιοσδήποτε μπορεί να σε ακολουθήσει για να δει τις δημοσιεύσεις σου προς τους ακολούθους σου.",
|
||||||
"compose_form.lock_disclaimer.lock": "κλειδωμένο",
|
"compose_form.lock_disclaimer.lock": "κλειδωμένος",
|
||||||
"compose_form.placeholder": "Τι σκέφτεσαι;",
|
"compose_form.placeholder": "Τι σκέφτεσαι;",
|
||||||
"compose_form.poll.duration": "Διάρκεια δημοσκόπησης",
|
"compose_form.poll.duration": "Διάρκεια δημοσκόπησης",
|
||||||
"compose_form.poll.multiple": "Πολλαπλή επιλογή",
|
"compose_form.poll.multiple": "Πολλαπλή επιλογή",
|
||||||
|
@ -214,13 +214,18 @@
|
||||||
"confirmation_modal.cancel": "Άκυρο",
|
"confirmation_modal.cancel": "Άκυρο",
|
||||||
"confirmations.block.confirm": "Αποκλεισμός",
|
"confirmations.block.confirm": "Αποκλεισμός",
|
||||||
"confirmations.delete.confirm": "Διαγραφή",
|
"confirmations.delete.confirm": "Διαγραφή",
|
||||||
"confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή τη δημοσίευση;",
|
"confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή την ανάρτηση;",
|
||||||
"confirmations.delete.title": "Διαγραφή ανάρτησης;",
|
"confirmations.delete.title": "Διαγραφή ανάρτησης;",
|
||||||
"confirmations.delete_list.confirm": "Διαγραφή",
|
"confirmations.delete_list.confirm": "Διαγραφή",
|
||||||
"confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
|
"confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
|
||||||
"confirmations.delete_list.title": "Διαγραφή λίστας;",
|
"confirmations.delete_list.title": "Διαγραφή λίστας;",
|
||||||
"confirmations.discard_draft.confirm": "Απόρριψη και συνέχεια",
|
"confirmations.discard_draft.confirm": "Απόρριψη και συνέχεια",
|
||||||
"confirmations.discard_draft.edit.cancel": "Συνέχιση επεξεργασίας",
|
"confirmations.discard_draft.edit.cancel": "Συνέχιση επεξεργασίας",
|
||||||
|
"confirmations.discard_draft.edit.message": "Συνεχίζοντας θα απορρίψει τυχόν αλλαγές που έχετε κάνει στην ανάρτηση που επεξεργάζεστε.",
|
||||||
|
"confirmations.discard_draft.edit.title": "Απόρριψη αλλαγών στην ανάρτηση σας;",
|
||||||
|
"confirmations.discard_draft.post.cancel": "Συνέχιση προχείρου",
|
||||||
|
"confirmations.discard_draft.post.message": "Συνεχίζοντας θα απορρίψει την ανάρτηση που συνθέτετε.",
|
||||||
|
"confirmations.discard_draft.post.title": "Απόρριψη της πρόχειρης ανάρτησης σας;",
|
||||||
"confirmations.discard_edit_media.confirm": "Απόρριψη",
|
"confirmations.discard_edit_media.confirm": "Απόρριψη",
|
||||||
"confirmations.discard_edit_media.message": "Έχεις μη αποθηκευμένες αλλαγές στην περιγραφή πολυμέσων ή στην προεπισκόπηση, απόρριψη ούτως ή άλλως;",
|
"confirmations.discard_edit_media.message": "Έχεις μη αποθηκευμένες αλλαγές στην περιγραφή πολυμέσων ή στην προεπισκόπηση, απόρριψη ούτως ή άλλως;",
|
||||||
"confirmations.follow_to_list.confirm": "Ακολούθησε και πρόσθεσε στη λίστα",
|
"confirmations.follow_to_list.confirm": "Ακολούθησε και πρόσθεσε στη λίστα",
|
||||||
|
@ -232,7 +237,7 @@
|
||||||
"confirmations.missing_alt_text.confirm": "Προσθήκη εναλ κειμένου",
|
"confirmations.missing_alt_text.confirm": "Προσθήκη εναλ κειμένου",
|
||||||
"confirmations.missing_alt_text.message": "Η ανάρτησή σου περιέχει πολυμέσα χωρίς εναλλακτικό κείμενο. Η προσθήκη περιγραφών βοηθά να γίνει το περιεχόμενό σου προσβάσιμο σε περισσότερους ανθρώπους.",
|
"confirmations.missing_alt_text.message": "Η ανάρτησή σου περιέχει πολυμέσα χωρίς εναλλακτικό κείμενο. Η προσθήκη περιγραφών βοηθά να γίνει το περιεχόμενό σου προσβάσιμο σε περισσότερους ανθρώπους.",
|
||||||
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
|
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
|
||||||
"confirmations.missing_alt_text.title": "Προσθήκη alt κειμένου;",
|
"confirmations.missing_alt_text.title": "Προσθήκη εναλλακτικού κειμένου;",
|
||||||
"confirmations.mute.confirm": "Αποσιώπηση",
|
"confirmations.mute.confirm": "Αποσιώπηση",
|
||||||
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
|
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
|
||||||
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
|
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
|
||||||
|
@ -271,7 +276,7 @@
|
||||||
"domain_block_modal.you_will_lose_num_followers": "Θα χάσετε {followersCount, plural, one {{followersCountDisplay} ακόλουθο} other {{followersCountDisplay} ακόλουθους}} και {followingCount, plural, one {{followingCountDisplay} άτομο που ακολουθείτε} other {{followingCountDisplay} άτομα που ακολουθείτε}}.",
|
"domain_block_modal.you_will_lose_num_followers": "Θα χάσετε {followersCount, plural, one {{followersCountDisplay} ακόλουθο} other {{followersCountDisplay} ακόλουθους}} και {followingCount, plural, one {{followingCountDisplay} άτομο που ακολουθείτε} other {{followingCountDisplay} άτομα που ακολουθείτε}}.",
|
||||||
"domain_block_modal.you_will_lose_relationships": "Θα χάσετε όλους τους ακόλουθους και τα άτομα που ακολουθείτε από αυτόν τον διακομιστή.",
|
"domain_block_modal.you_will_lose_relationships": "Θα χάσετε όλους τους ακόλουθους και τα άτομα που ακολουθείτε από αυτόν τον διακομιστή.",
|
||||||
"domain_block_modal.you_wont_see_posts": "Δεν θα βλέπεις αναρτήσεις ή ειδοποιήσεις από χρήστες σε αυτόν το διακομιστή.",
|
"domain_block_modal.you_wont_see_posts": "Δεν θα βλέπεις αναρτήσεις ή ειδοποιήσεις από χρήστες σε αυτόν το διακομιστή.",
|
||||||
"domain_pill.activitypub_lets_connect": "Σού επιτρέπει να συνδεθείς και να αλληλεπιδράσεις με τους ανθρώπους όχι μόνο στο Mastodon, αλλά και σε διαφορετικές κοινωνικές εφαρμογές.",
|
"domain_pill.activitypub_lets_connect": "Σου επιτρέπει να συνδεθείς και να αλληλεπιδράσεις με τους ανθρώπους όχι μόνο στο Mastodon, αλλά και σε διαφορετικές κοινωνικές εφαρμογές.",
|
||||||
"domain_pill.activitypub_like_language": "Το ActivityPub είναι σαν τη γλώσσα Mastodon μιλάει με άλλα κοινωνικά δίκτυα.",
|
"domain_pill.activitypub_like_language": "Το ActivityPub είναι σαν τη γλώσσα Mastodon μιλάει με άλλα κοινωνικά δίκτυα.",
|
||||||
"domain_pill.server": "Διακομιστής",
|
"domain_pill.server": "Διακομιστής",
|
||||||
"domain_pill.their_handle": "Το πλήρες όνομα χρήστη:",
|
"domain_pill.their_handle": "Το πλήρες όνομα χρήστη:",
|
||||||
|
@ -317,7 +322,7 @@
|
||||||
"empty_column.favourited_statuses": "Δεν έχεις καμία αγαπημένη ανάρτηση ακόμα. Μόλις αγαπήσεις κάποια, θα εμφανιστεί εδώ.",
|
"empty_column.favourited_statuses": "Δεν έχεις καμία αγαπημένη ανάρτηση ακόμα. Μόλις αγαπήσεις κάποια, θα εμφανιστεί εδώ.",
|
||||||
"empty_column.favourites": "Κανείς δεν έχει αγαπήσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
"empty_column.favourites": "Κανείς δεν έχει αγαπήσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
||||||
"empty_column.follow_requests": "Δεν έχεις κανένα αίτημα παρακολούθησης ακόμα. Μόλις λάβεις κάποιο, θα εμφανιστεί εδώ.",
|
"empty_column.follow_requests": "Δεν έχεις κανένα αίτημα παρακολούθησης ακόμα. Μόλις λάβεις κάποιο, θα εμφανιστεί εδώ.",
|
||||||
"empty_column.followed_tags": "Δεν έχετε παρακολουθήσει ακόμα καμία ετικέτα. Όταν το κάνετε, θα εμφανιστούν εδώ.",
|
"empty_column.followed_tags": "Δεν έχετε ακολουθήσει ακόμα καμία ετικέτα. Όταν το κάνετε, θα εμφανιστούν εδώ.",
|
||||||
"empty_column.hashtag": "Δεν υπάρχει ακόμα κάτι για αυτή την ετικέτα.",
|
"empty_column.hashtag": "Δεν υπάρχει ακόμα κάτι για αυτή την ετικέτα.",
|
||||||
"empty_column.home": "Η τοπική σου ροή είναι κενή! Πήγαινε στο {public} ή κάνε αναζήτηση για να ξεκινήσεις και να γνωρίσεις άλλους χρήστες.",
|
"empty_column.home": "Η τοπική σου ροή είναι κενή! Πήγαινε στο {public} ή κάνε αναζήτηση για να ξεκινήσεις και να γνωρίσεις άλλους χρήστες.",
|
||||||
"empty_column.list": "Δεν υπάρχει τίποτα σε αυτή τη λίστα ακόμα. Όταν τα μέλη της δημοσιεύσουν νέες καταστάσεις, θα εμφανιστούν εδώ.",
|
"empty_column.list": "Δεν υπάρχει τίποτα σε αυτή τη λίστα ακόμα. Όταν τα μέλη της δημοσιεύσουν νέες καταστάσεις, θα εμφανιστούν εδώ.",
|
||||||
|
@ -381,7 +386,7 @@
|
||||||
"follow_suggestions.similar_to_recently_followed_longer": "Παρόμοια με προφίλ που ακολούθησες πρόσφατα",
|
"follow_suggestions.similar_to_recently_followed_longer": "Παρόμοια με προφίλ που ακολούθησες πρόσφατα",
|
||||||
"follow_suggestions.view_all": "Εμφάνιση όλων",
|
"follow_suggestions.view_all": "Εμφάνιση όλων",
|
||||||
"follow_suggestions.who_to_follow": "Ποιον να ακολουθήσεις",
|
"follow_suggestions.who_to_follow": "Ποιον να ακολουθήσεις",
|
||||||
"followed_tags": "Ετικέτες που ακολουθούνται",
|
"followed_tags": "Ακολουθούμενες ετικέτες",
|
||||||
"footer.about": "Σχετικά με",
|
"footer.about": "Σχετικά με",
|
||||||
"footer.directory": "Κατάλογος προφίλ",
|
"footer.directory": "Κατάλογος προφίλ",
|
||||||
"footer.get_app": "Αποκτήστε την εφαρμογή",
|
"footer.get_app": "Αποκτήστε την εφαρμογή",
|
||||||
|
@ -412,15 +417,13 @@
|
||||||
"hashtag.mute": "Σίγαση #{hashtag}",
|
"hashtag.mute": "Σίγαση #{hashtag}",
|
||||||
"hashtag.unfeature": "Να μην αναδεικνύεται στο προφίλ",
|
"hashtag.unfeature": "Να μην αναδεικνύεται στο προφίλ",
|
||||||
"hashtag.unfollow": "Διακοπή παρακολούθησης ετικέτας",
|
"hashtag.unfollow": "Διακοπή παρακολούθησης ετικέτας",
|
||||||
"hashtags.and_other": "…και {count, plural, one {}other {# ακόμη}}",
|
"hashtags.and_other": "…και {count, plural, other {# ακόμη}}",
|
||||||
"hints.profiles.followers_may_be_missing": "Μπορεί να λείπουν ακόλουθοι για αυτό το προφίλ.",
|
"hints.profiles.followers_may_be_missing": "Μπορεί να λείπουν ακόλουθοι για αυτό το προφίλ.",
|
||||||
"hints.profiles.follows_may_be_missing": "Άτομα που ακολουθούνται μπορεί να λείπουν απ' αυτό το προφίλ.",
|
"hints.profiles.follows_may_be_missing": "Άτομα που ακολουθούνται μπορεί να λείπουν απ' αυτό το προφίλ.",
|
||||||
"hints.profiles.posts_may_be_missing": "Κάποιες αναρτήσεις από αυτό το προφίλ μπορεί να λείπουν.",
|
"hints.profiles.posts_may_be_missing": "Κάποιες αναρτήσεις από αυτό το προφίλ μπορεί να λείπουν.",
|
||||||
"hints.profiles.see_more_followers": "Δες περισσότερους ακόλουθους στο {domain}",
|
"hints.profiles.see_more_followers": "Δες περισσότερους ακόλουθους στο {domain}",
|
||||||
"hints.profiles.see_more_follows": "Δες περισσότερα άτομα που ακολουθούνται στο {domain}",
|
"hints.profiles.see_more_follows": "Δες περισσότερα άτομα που ακολουθούνται στο {domain}",
|
||||||
"hints.profiles.see_more_posts": "Δες περισσότερες αναρτήσεις στο {domain}",
|
"hints.profiles.see_more_posts": "Δες περισσότερες αναρτήσεις στο {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Απαντήσεις από άλλους διακομιστές μπορεί να λείπουν.",
|
|
||||||
"hints.threads.see_more": "Δες περισσότερες αναρτήσεις στο {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Εμφάνιση παραθεμάτων",
|
"home.column_settings.show_quotes": "Εμφάνιση παραθεμάτων",
|
||||||
"home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων",
|
"home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων",
|
||||||
"home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
|
"home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
|
||||||
|
@ -451,7 +454,7 @@
|
||||||
"interaction_modal.no_account_yet": "Δεν έχεις ακόμη λογαριασμό;",
|
"interaction_modal.no_account_yet": "Δεν έχεις ακόμη λογαριασμό;",
|
||||||
"interaction_modal.on_another_server": "Σε διαφορετικό διακομιστή",
|
"interaction_modal.on_another_server": "Σε διαφορετικό διακομιστή",
|
||||||
"interaction_modal.on_this_server": "Σε αυτόν τον διακομιστή",
|
"interaction_modal.on_this_server": "Σε αυτόν τον διακομιστή",
|
||||||
"interaction_modal.title.favourite": "Favorite {name}'s post\nΠροτίμησε την ανάρτηση της/του {name}",
|
"interaction_modal.title.favourite": "Αγάπησε την ανάρτηση του χρήστη {name}",
|
||||||
"interaction_modal.title.follow": "Ακολούθησε {name}",
|
"interaction_modal.title.follow": "Ακολούθησε {name}",
|
||||||
"interaction_modal.title.reblog": "Ενίσχυσε την ανάρτηση του {name}",
|
"interaction_modal.title.reblog": "Ενίσχυσε την ανάρτηση του {name}",
|
||||||
"interaction_modal.title.reply": "Απάντηση στην ανάρτηση του {name}",
|
"interaction_modal.title.reply": "Απάντηση στην ανάρτηση του {name}",
|
||||||
|
@ -463,13 +466,13 @@
|
||||||
"keyboard_shortcuts.back": "Μετάβαση πίσω",
|
"keyboard_shortcuts.back": "Μετάβαση πίσω",
|
||||||
"keyboard_shortcuts.blocked": "Άνοιγμα λίστας αποκλεισμένων χρηστών",
|
"keyboard_shortcuts.blocked": "Άνοιγμα λίστας αποκλεισμένων χρηστών",
|
||||||
"keyboard_shortcuts.boost": "Ενίσχυση ανάρτησης",
|
"keyboard_shortcuts.boost": "Ενίσχυση ανάρτησης",
|
||||||
"keyboard_shortcuts.column": "Στήλη εστίασης",
|
"keyboard_shortcuts.column": "Εστίαση στη στήλη",
|
||||||
"keyboard_shortcuts.compose": "Περιοχή συγγραφής κειμένου εστίασης",
|
"keyboard_shortcuts.compose": "Εστίαση στην περιοχή συγγραφής κειμένου",
|
||||||
"keyboard_shortcuts.description": "Περιγραφή",
|
"keyboard_shortcuts.description": "Περιγραφή",
|
||||||
"keyboard_shortcuts.direct": "για το άνοιγμα της στήλης ιδιωτικών επισημάνσεων",
|
"keyboard_shortcuts.direct": "για το άνοιγμα της στήλης ιδιωτικών επισημάνσεων",
|
||||||
"keyboard_shortcuts.down": "κίνηση προς τα κάτω στη λίστα",
|
"keyboard_shortcuts.down": "Μετακίνηση προς τα κάτω στη λίστα",
|
||||||
"keyboard_shortcuts.enter": "Εμφάνιση ανάρτησης",
|
"keyboard_shortcuts.enter": "Άνοιγμα ανάρτησης",
|
||||||
"keyboard_shortcuts.favourite": "Αγαπημένη δημοσίευση",
|
"keyboard_shortcuts.favourite": "Αγάπησε την ανάρτηση",
|
||||||
"keyboard_shortcuts.favourites": "Άνοιγμα λίστας αγαπημένων",
|
"keyboard_shortcuts.favourites": "Άνοιγμα λίστας αγαπημένων",
|
||||||
"keyboard_shortcuts.federated": "Άνοιγμα ροής συναλλαγών",
|
"keyboard_shortcuts.federated": "Άνοιγμα ροής συναλλαγών",
|
||||||
"keyboard_shortcuts.heading": "Συντομεύσεις πληκτρολογίου",
|
"keyboard_shortcuts.heading": "Συντομεύσεις πληκτρολογίου",
|
||||||
|
@ -486,13 +489,13 @@
|
||||||
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
|
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
|
||||||
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
|
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
|
||||||
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
|
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
|
||||||
"keyboard_shortcuts.search": "Γραμμή αναζήτησης εστίασης",
|
"keyboard_shortcuts.search": "Εστίαση στη γραμμή αναζήτησης",
|
||||||
"keyboard_shortcuts.spoilers": "Εμφάνιση/απόκρυψη πεδίου CW",
|
"keyboard_shortcuts.spoilers": "Εμφάνιση/απόκρυψη πεδίου CW",
|
||||||
"keyboard_shortcuts.start": "Άνοιγμα της στήλης \"Ας ξεκινήσουμε\"",
|
"keyboard_shortcuts.start": "Άνοιγμα της στήλης \"Ας ξεκινήσουμε\"",
|
||||||
"keyboard_shortcuts.toggle_hidden": "Εμφάνιση/απόκρυψη κειμένου πίσω από το CW",
|
"keyboard_shortcuts.toggle_hidden": "Εμφάνιση/απόκρυψη κειμένου πίσω από το CW",
|
||||||
"keyboard_shortcuts.toggle_sensitivity": "Εμφάνιση/απόκρυψη πολυμέσων",
|
"keyboard_shortcuts.toggle_sensitivity": "Εμφάνιση/απόκρυψη πολυμέσων",
|
||||||
"keyboard_shortcuts.toot": "Δημιουργία νέας ανάρτησης",
|
"keyboard_shortcuts.toot": "Δημιουργία νέας ανάρτησης",
|
||||||
"keyboard_shortcuts.translate": "να μεταφράσει μια δημοσίευση",
|
"keyboard_shortcuts.translate": "για να μεταφραστεί μια ανάρτηση",
|
||||||
"keyboard_shortcuts.unfocus": "Αποεστίαση του πεδίου σύνθεσης/αναζήτησης",
|
"keyboard_shortcuts.unfocus": "Αποεστίαση του πεδίου σύνθεσης/αναζήτησης",
|
||||||
"keyboard_shortcuts.up": "Μετακίνηση προς τα πάνω στη λίστα",
|
"keyboard_shortcuts.up": "Μετακίνηση προς τα πάνω στη λίστα",
|
||||||
"lightbox.close": "Κλείσιμο",
|
"lightbox.close": "Κλείσιμο",
|
||||||
|
@ -555,10 +558,12 @@
|
||||||
"navigation_bar.favourites": "Αγαπημένα",
|
"navigation_bar.favourites": "Αγαπημένα",
|
||||||
"navigation_bar.filters": "Αποσιωπημένες λέξεις",
|
"navigation_bar.filters": "Αποσιωπημένες λέξεις",
|
||||||
"navigation_bar.follow_requests": "Αιτήματα ακολούθησης",
|
"navigation_bar.follow_requests": "Αιτήματα ακολούθησης",
|
||||||
"navigation_bar.followed_tags": "Ετικέτες που ακολουθούνται",
|
"navigation_bar.followed_tags": "Ακολουθούμενες ετικέτες",
|
||||||
"navigation_bar.follows_and_followers": "Ακολουθείς και σε ακολουθούν",
|
"navigation_bar.follows_and_followers": "Ακολουθείς και σε ακολουθούν",
|
||||||
"navigation_bar.import_export": "Εισαγωγή και εξαγωγή",
|
"navigation_bar.import_export": "Εισαγωγή και εξαγωγή",
|
||||||
"navigation_bar.lists": "Λίστες",
|
"navigation_bar.lists": "Λίστες",
|
||||||
|
"navigation_bar.live_feed_local": "Ζωντανή ροή (τοπική)",
|
||||||
|
"navigation_bar.live_feed_public": "Ζωντανή ροή (δημόσια)",
|
||||||
"navigation_bar.logout": "Αποσύνδεση",
|
"navigation_bar.logout": "Αποσύνδεση",
|
||||||
"navigation_bar.moderation": "Συντονισμός",
|
"navigation_bar.moderation": "Συντονισμός",
|
||||||
"navigation_bar.more": "Περισσότερα",
|
"navigation_bar.more": "Περισσότερα",
|
||||||
|
@ -568,9 +573,9 @@
|
||||||
"navigation_bar.privacy_and_reach": "Ιδιωτικότητα και προσιτότητα",
|
"navigation_bar.privacy_and_reach": "Ιδιωτικότητα και προσιτότητα",
|
||||||
"navigation_bar.search": "Αναζήτηση",
|
"navigation_bar.search": "Αναζήτηση",
|
||||||
"navigation_bar.search_trends": "Αναζήτηση / Τάσεις",
|
"navigation_bar.search_trends": "Αναζήτηση / Τάσεις",
|
||||||
"navigation_panel.collapse_followed_tags": "Σύμπτυξη μενού ετικετών που ακολουθούνται",
|
"navigation_panel.collapse_followed_tags": "Σύμπτυξη μενού ετικετών που ακολουθείτε",
|
||||||
"navigation_panel.collapse_lists": "Σύμπτυξη μενού λίστας",
|
"navigation_panel.collapse_lists": "Σύμπτυξη μενού λίστας",
|
||||||
"navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθούνται",
|
"navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθείτε",
|
||||||
"navigation_panel.expand_lists": "Επέκταση μενού λίστας",
|
"navigation_panel.expand_lists": "Επέκταση μενού λίστας",
|
||||||
"not_signed_in_indicator.not_signed_in": "Πρέπει να συνδεθείς για να αποκτήσεις πρόσβαση σε αυτόν τον πόρο.",
|
"not_signed_in_indicator.not_signed_in": "Πρέπει να συνδεθείς για να αποκτήσεις πρόσβαση σε αυτόν τον πόρο.",
|
||||||
"notification.admin.report": "Ο/Η {name} ανέφερε τον {target}",
|
"notification.admin.report": "Ο/Η {name} ανέφερε τον {target}",
|
||||||
|
@ -582,11 +587,11 @@
|
||||||
"notification.admin.sign_up.name_and_others": "{name} και {count, plural, one {# ακόμη} other {# ακόμη}} έχουν εγγραφεί",
|
"notification.admin.sign_up.name_and_others": "{name} και {count, plural, one {# ακόμη} other {# ακόμη}} έχουν εγγραφεί",
|
||||||
"notification.annual_report.message": "Το #Wrapstodon {year} σε περιμένει! Αποκάλυψε τα στιγμιότυπα της χρονιάς και αξέχαστες στιγμές σου στο Mastodon!",
|
"notification.annual_report.message": "Το #Wrapstodon {year} σε περιμένει! Αποκάλυψε τα στιγμιότυπα της χρονιάς και αξέχαστες στιγμές σου στο Mastodon!",
|
||||||
"notification.annual_report.view": "Προβολή #Wrapstodon",
|
"notification.annual_report.view": "Προβολή #Wrapstodon",
|
||||||
"notification.favourite": "{name} favorited your post\n{name} προτίμησε την ανάρτηση σου",
|
"notification.favourite": "{name} αγάπησε την ανάρτηση σου",
|
||||||
"notification.favourite.name_and_others_with_link": "{name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> αγάπησαν την ανάρτησή σου",
|
"notification.favourite.name_and_others_with_link": "{name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> αγάπησαν την ανάρτησή σου",
|
||||||
"notification.favourite_pm": "Ο χρήστης {name} αγάπησε την ιδιωτική σου επισήμανση",
|
"notification.favourite_pm": "Ο χρήστης {name} αγάπησε την ιδιωτική σου επισήμανση",
|
||||||
"notification.favourite_pm.name_and_others_with_link": "Ο χρήστης {name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> αγάπησαν την ιδωτική σου επισήμανση",
|
"notification.favourite_pm.name_and_others_with_link": "Ο χρήστης {name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> αγάπησαν την ιδωτική σου επισήμανση",
|
||||||
"notification.follow": "Ο/Η {name} σε ακολούθησε",
|
"notification.follow": "Ο χρήστης {name} σε ακολούθησε",
|
||||||
"notification.follow.name_and_others": "Ο χρήστης {name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> σε ακολούθησαν",
|
"notification.follow.name_and_others": "Ο χρήστης {name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> σε ακολούθησαν",
|
||||||
"notification.follow_request": "Ο/H {name} ζήτησε να σε ακολουθήσει",
|
"notification.follow_request": "Ο/H {name} ζήτησε να σε ακολουθήσει",
|
||||||
"notification.follow_request.name_and_others": "{name} και {count, plural, one {# άλλος} other {# άλλοι}} ζήτησαν να σε ακολουθήσουν",
|
"notification.follow_request.name_and_others": "{name} και {count, plural, one {# άλλος} other {# άλλοι}} ζήτησαν να σε ακολουθήσουν",
|
||||||
|
@ -719,7 +724,7 @@
|
||||||
"poll_button.add_poll": "Προσθήκη δημοσκόπησης",
|
"poll_button.add_poll": "Προσθήκη δημοσκόπησης",
|
||||||
"poll_button.remove_poll": "Αφαίρεση δημοσκόπησης",
|
"poll_button.remove_poll": "Αφαίρεση δημοσκόπησης",
|
||||||
"privacy.change": "Προσαρμογή ιδιωτικότητας ανάρτησης",
|
"privacy.change": "Προσαρμογή ιδιωτικότητας ανάρτησης",
|
||||||
"privacy.direct.long": "Όλοι όσοι αναφέρθηκαν στη δημοσίευση",
|
"privacy.direct.long": "Όλοι όσοι αναφέρθηκαν στην ανάρτηση",
|
||||||
"privacy.direct.short": "Ιδιωτική επισήμανση",
|
"privacy.direct.short": "Ιδιωτική επισήμανση",
|
||||||
"privacy.private.long": "Μόνο οι ακόλουθοί σας",
|
"privacy.private.long": "Μόνο οι ακόλουθοί σας",
|
||||||
"privacy.private.short": "Ακόλουθοι",
|
"privacy.private.short": "Ακόλουθοι",
|
||||||
|
@ -797,7 +802,7 @@
|
||||||
"report_notification.categories.spam_sentence": "ανεπιθύμητα",
|
"report_notification.categories.spam_sentence": "ανεπιθύμητα",
|
||||||
"report_notification.categories.violation": "Παραβίαση κανόνα",
|
"report_notification.categories.violation": "Παραβίαση κανόνα",
|
||||||
"report_notification.categories.violation_sentence": "παραβίαση κανόνα",
|
"report_notification.categories.violation_sentence": "παραβίαση κανόνα",
|
||||||
"report_notification.open": "Ανοιχτή αναφορά",
|
"report_notification.open": "Άνοιγμα αναφοράς",
|
||||||
"search.clear": "Εκκαθάριση αναζήτησης",
|
"search.clear": "Εκκαθάριση αναζήτησης",
|
||||||
"search.no_recent_searches": "Καμία πρόσφατη αναζήτηση",
|
"search.no_recent_searches": "Καμία πρόσφατη αναζήτηση",
|
||||||
"search.placeholder": "Αναζήτηση",
|
"search.placeholder": "Αναζήτηση",
|
||||||
|
@ -851,7 +856,7 @@
|
||||||
"status.edited_x_times": "Επεξεργάστηκε {count, plural, one {{count} φορά} other {{count} φορές}}",
|
"status.edited_x_times": "Επεξεργάστηκε {count, plural, one {{count} φορά} other {{count} φορές}}",
|
||||||
"status.embed": "Απόκτηση κώδικα ενσωμάτωσης",
|
"status.embed": "Απόκτηση κώδικα ενσωμάτωσης",
|
||||||
"status.favourite": "Αγαπημένα",
|
"status.favourite": "Αγαπημένα",
|
||||||
"status.favourites": "{count, plural, one {# αγαπημένο} other {# αγαπημένα}}",
|
"status.favourites": "{count, plural, one {αγαπημένο} other {αγαπημένα}}",
|
||||||
"status.filter": "Φιλτράρισμα αυτής της ανάρτησης",
|
"status.filter": "Φιλτράρισμα αυτής της ανάρτησης",
|
||||||
"status.history.created": "{name} δημιούργησε στις {date}",
|
"status.history.created": "{name} δημιούργησε στις {date}",
|
||||||
"status.history.edited": "{name} επεξεργάστηκε στις {date}",
|
"status.history.edited": "{name} επεξεργάστηκε στις {date}",
|
||||||
|
@ -876,7 +881,7 @@
|
||||||
"status.reblog": "Ενίσχυση",
|
"status.reblog": "Ενίσχυση",
|
||||||
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
||||||
"status.reblogged_by": "{name} προώθησε",
|
"status.reblogged_by": "{name} προώθησε",
|
||||||
"status.reblogs": "{count, plural, one {# ενίσχυση} other {# ενισχύσεις}}",
|
"status.reblogs": "{count, plural, one {ενίσχυση} other {ενισχύσεις}}",
|
||||||
"status.reblogs.empty": "Κανείς δεν ενίσχυσε αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
"status.reblogs.empty": "Κανείς δεν ενίσχυσε αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
||||||
"status.redraft": "Σβήσε & ξαναγράψε",
|
"status.redraft": "Σβήσε & ξαναγράψε",
|
||||||
"status.remove_bookmark": "Αφαίρεση σελιδοδείκτη",
|
"status.remove_bookmark": "Αφαίρεση σελιδοδείκτη",
|
||||||
|
@ -888,8 +893,8 @@
|
||||||
"status.report": "Αναφορά @{name}",
|
"status.report": "Αναφορά @{name}",
|
||||||
"status.sensitive_warning": "Ευαίσθητο περιεχόμενο",
|
"status.sensitive_warning": "Ευαίσθητο περιεχόμενο",
|
||||||
"status.share": "Κοινοποίηση",
|
"status.share": "Κοινοποίηση",
|
||||||
"status.show_less_all": "Δείξε λιγότερα για όλα",
|
"status.show_less_all": "Δείξε λιγότερο για όλες",
|
||||||
"status.show_more_all": "Δείξε περισσότερα για όλα",
|
"status.show_more_all": "Δείξε περισσότερο για όλες",
|
||||||
"status.show_original": "Εμφάνιση αρχικού",
|
"status.show_original": "Εμφάνιση αρχικού",
|
||||||
"status.title.with_attachments": "{user} δημοσίευσε {attachmentCount, plural, one {ένα συνημμένο} other {{attachmentCount} συνημμένα}}",
|
"status.title.with_attachments": "{user} δημοσίευσε {attachmentCount, plural, one {ένα συνημμένο} other {{attachmentCount} συνημμένα}}",
|
||||||
"status.translate": "Μετάφραση",
|
"status.translate": "Μετάφραση",
|
||||||
|
|
|
@ -219,6 +219,13 @@
|
||||||
"confirmations.delete_list.confirm": "Delete",
|
"confirmations.delete_list.confirm": "Delete",
|
||||||
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
||||||
"confirmations.delete_list.title": "Delete list?",
|
"confirmations.delete_list.title": "Delete list?",
|
||||||
|
"confirmations.discard_draft.confirm": "Discard and continue",
|
||||||
|
"confirmations.discard_draft.edit.cancel": "Resume editing",
|
||||||
|
"confirmations.discard_draft.edit.message": "Continuing will discard any changes you have made to the post you are currently editing.",
|
||||||
|
"confirmations.discard_draft.edit.title": "Discard changes to your post?",
|
||||||
|
"confirmations.discard_draft.post.cancel": "Resume draft",
|
||||||
|
"confirmations.discard_draft.post.message": "Continuing will discard the post you are currently composing.",
|
||||||
|
"confirmations.discard_draft.post.title": "Discard your draft post?",
|
||||||
"confirmations.discard_edit_media.confirm": "Discard",
|
"confirmations.discard_edit_media.confirm": "Discard",
|
||||||
"confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
|
"confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
|
||||||
"confirmations.follow_to_list.confirm": "Follow and add to list",
|
"confirmations.follow_to_list.confirm": "Follow and add to list",
|
||||||
|
@ -330,6 +337,7 @@
|
||||||
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
|
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
|
||||||
"errors.unexpected_crash.report_issue": "Report issue",
|
"errors.unexpected_crash.report_issue": "Report issue",
|
||||||
"explore.suggested_follows": "People",
|
"explore.suggested_follows": "People",
|
||||||
|
"explore.title": "Trending",
|
||||||
"explore.trending_links": "News",
|
"explore.trending_links": "News",
|
||||||
"explore.trending_statuses": "Posts",
|
"explore.trending_statuses": "Posts",
|
||||||
"explore.trending_tags": "Hashtags",
|
"explore.trending_tags": "Hashtags",
|
||||||
|
@ -416,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "See more followers on {domain}",
|
"hints.profiles.see_more_followers": "See more followers on {domain}",
|
||||||
"hints.profiles.see_more_follows": "See more follows on {domain}",
|
"hints.profiles.see_more_follows": "See more follows on {domain}",
|
||||||
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
|
|
||||||
"hints.threads.see_more": "See more replies on {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Show quotes",
|
"home.column_settings.show_quotes": "Show quotes",
|
||||||
"home.column_settings.show_reblogs": "Show boosts",
|
"home.column_settings.show_reblogs": "Show boosts",
|
||||||
"home.column_settings.show_replies": "Show replies",
|
"home.column_settings.show_replies": "Show replies",
|
||||||
|
@ -541,8 +547,10 @@
|
||||||
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
||||||
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
|
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
|
||||||
"navigation_bar.about": "About",
|
"navigation_bar.about": "About",
|
||||||
|
"navigation_bar.account_settings": "Password and security",
|
||||||
"navigation_bar.administration": "Administration",
|
"navigation_bar.administration": "Administration",
|
||||||
"navigation_bar.advanced_interface": "Open in advanced web interface",
|
"navigation_bar.advanced_interface": "Open in advanced web interface",
|
||||||
|
"navigation_bar.automated_deletion": "Automated post deletion",
|
||||||
"navigation_bar.blocks": "Blocked users",
|
"navigation_bar.blocks": "Blocked users",
|
||||||
"navigation_bar.bookmarks": "Bookmarks",
|
"navigation_bar.bookmarks": "Bookmarks",
|
||||||
"navigation_bar.direct": "Private mentions",
|
"navigation_bar.direct": "Private mentions",
|
||||||
|
@ -552,13 +560,23 @@
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
"navigation_bar.followed_tags": "Followed hashtags",
|
"navigation_bar.followed_tags": "Followed hashtags",
|
||||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||||
|
"navigation_bar.import_export": "Import and export",
|
||||||
"navigation_bar.lists": "Lists",
|
"navigation_bar.lists": "Lists",
|
||||||
|
"navigation_bar.live_feed_local": "Live feed (local)",
|
||||||
|
"navigation_bar.live_feed_public": "Live feed (public)",
|
||||||
"navigation_bar.logout": "Logout",
|
"navigation_bar.logout": "Logout",
|
||||||
"navigation_bar.moderation": "Moderation",
|
"navigation_bar.moderation": "Moderation",
|
||||||
|
"navigation_bar.more": "More",
|
||||||
"navigation_bar.mutes": "Muted users",
|
"navigation_bar.mutes": "Muted users",
|
||||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||||
"navigation_bar.preferences": "Preferences",
|
"navigation_bar.preferences": "Preferences",
|
||||||
|
"navigation_bar.privacy_and_reach": "Privacy and reach",
|
||||||
"navigation_bar.search": "Search",
|
"navigation_bar.search": "Search",
|
||||||
|
"navigation_bar.search_trends": "Search / Trending",
|
||||||
|
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
|
||||||
|
"navigation_panel.collapse_lists": "Collapse list menu",
|
||||||
|
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
|
||||||
|
"navigation_panel.expand_lists": "Expand list menu",
|
||||||
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
|
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
|
||||||
"notification.admin.report": "{name} reported {target}",
|
"notification.admin.report": "{name} reported {target}",
|
||||||
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
|
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
|
||||||
|
@ -785,6 +803,7 @@
|
||||||
"report_notification.categories.violation": "Rule violation",
|
"report_notification.categories.violation": "Rule violation",
|
||||||
"report_notification.categories.violation_sentence": "rule violation",
|
"report_notification.categories.violation_sentence": "rule violation",
|
||||||
"report_notification.open": "Open report",
|
"report_notification.open": "Open report",
|
||||||
|
"search.clear": "Clear search",
|
||||||
"search.no_recent_searches": "No recent searches",
|
"search.no_recent_searches": "No recent searches",
|
||||||
"search.placeholder": "Search",
|
"search.placeholder": "Search",
|
||||||
"search.quick_action.account_search": "Profiles matching {x}",
|
"search.quick_action.account_search": "Profiles matching {x}",
|
||||||
|
@ -887,7 +906,10 @@
|
||||||
"subscribed_languages.save": "Save changes",
|
"subscribed_languages.save": "Save changes",
|
||||||
"subscribed_languages.target": "Change subscribed languages for {target}",
|
"subscribed_languages.target": "Change subscribed languages for {target}",
|
||||||
"tabs_bar.home": "Home",
|
"tabs_bar.home": "Home",
|
||||||
|
"tabs_bar.menu": "Menu",
|
||||||
"tabs_bar.notifications": "Notifications",
|
"tabs_bar.notifications": "Notifications",
|
||||||
|
"tabs_bar.publish": "New Post",
|
||||||
|
"tabs_bar.search": "Search",
|
||||||
"terms_of_service.effective_as_of": "Effective as of {date}",
|
"terms_of_service.effective_as_of": "Effective as of {date}",
|
||||||
"terms_of_service.title": "Terms of Service",
|
"terms_of_service.title": "Terms of Service",
|
||||||
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "See more followers on {domain}",
|
"hints.profiles.see_more_followers": "See more followers on {domain}",
|
||||||
"hints.profiles.see_more_follows": "See more follows on {domain}",
|
"hints.profiles.see_more_follows": "See more follows on {domain}",
|
||||||
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
|
|
||||||
"hints.threads.see_more": "See more replies on {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Show quotes",
|
"home.column_settings.show_quotes": "Show quotes",
|
||||||
"home.column_settings.show_reblogs": "Show boosts",
|
"home.column_settings.show_reblogs": "Show boosts",
|
||||||
"home.column_settings.show_replies": "Show replies",
|
"home.column_settings.show_replies": "Show replies",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
"status.cancel_reblog_private": "Unboost",
|
"status.cancel_reblog_private": "Unboost",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
|
"status.context.load_new_replies": "New replies available",
|
||||||
|
"status.context.loading": "Checking for more replies",
|
||||||
"status.continued_thread": "Continued thread",
|
"status.continued_thread": "Continued thread",
|
||||||
"status.copy": "Copy link to post",
|
"status.copy": "Copy link to post",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"about.blocks": "Reguligitaj serviloj",
|
"about.blocks": "Reguligitaj serviloj",
|
||||||
"about.contact": "Kontakto:",
|
"about.contact": "Kontakto:",
|
||||||
"about.default_locale": "기본",
|
"about.default_locale": "Defaŭlta",
|
||||||
"about.disclaimer": "Mastodon estas libera, malfermitkoda programo kaj varmarko de la firmao Mastodon gGmbH.",
|
"about.disclaimer": "Mastodon estas libera, malfermitkoda programo kaj varmarko de la firmao Mastodon gGmbH.",
|
||||||
"about.domain_blocks.no_reason_available": "Kialo ne disponeblas",
|
"about.domain_blocks.no_reason_available": "Kialo ne disponeblas",
|
||||||
"about.domain_blocks.preamble": "Mastodon ĝenerale rajtigas vidi la enhavojn de uzantoj el aliaj serviloj en la fediverso, kaj komuniki kun ili. Jen la limigoj deciditaj de tiu ĉi servilo mem.",
|
"about.domain_blocks.preamble": "Mastodon ĝenerale rajtigas vidi la enhavojn de uzantoj el aliaj serviloj en la fediverso, kaj komuniki kun ili. Jen la limigoj deciditaj de tiu ĉi servilo mem.",
|
||||||
|
@ -331,6 +331,7 @@
|
||||||
"featured_carousel.next": "Antaŭen",
|
"featured_carousel.next": "Antaŭen",
|
||||||
"featured_carousel.post": "Afiŝi",
|
"featured_carousel.post": "Afiŝi",
|
||||||
"featured_carousel.previous": "Malantaŭen",
|
"featured_carousel.previous": "Malantaŭen",
|
||||||
|
"featured_carousel.slide": "{index} de {total}",
|
||||||
"filter_modal.added.context_mismatch_explanation": "Ĉi tiu filtrilkategorio ne kongruas kun la kunteksto en kiu vi akcesis ĉi tiun afiŝon. Se vi volas ke la afiŝo estas ankaŭ filtrita en ĉi tiu kunteksto, vi devus redakti la filtrilon.",
|
"filter_modal.added.context_mismatch_explanation": "Ĉi tiu filtrilkategorio ne kongruas kun la kunteksto en kiu vi akcesis ĉi tiun afiŝon. Se vi volas ke la afiŝo estas ankaŭ filtrita en ĉi tiu kunteksto, vi devus redakti la filtrilon.",
|
||||||
"filter_modal.added.context_mismatch_title": "Ne kongruas la kunteksto!",
|
"filter_modal.added.context_mismatch_title": "Ne kongruas la kunteksto!",
|
||||||
"filter_modal.added.expired_explanation": "Ĉi tiu filtrilkategorio eksvalidiĝis, vu bezonos ŝanĝi la eksvaliddaton por ĝi.",
|
"filter_modal.added.expired_explanation": "Ĉi tiu filtrilkategorio eksvalidiĝis, vu bezonos ŝanĝi la eksvaliddaton por ĝi.",
|
||||||
|
@ -409,8 +410,7 @@
|
||||||
"hints.profiles.see_more_followers": "Vidi pli da sekvantoj sur {domain}",
|
"hints.profiles.see_more_followers": "Vidi pli da sekvantoj sur {domain}",
|
||||||
"hints.profiles.see_more_follows": "Vidi pli da sekvatoj sur {domain}",
|
"hints.profiles.see_more_follows": "Vidi pli da sekvatoj sur {domain}",
|
||||||
"hints.profiles.see_more_posts": "Vidi pli da afiŝoj sur {domain}",
|
"hints.profiles.see_more_posts": "Vidi pli da afiŝoj sur {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Respondoj de aliaj serviloj eble mankas.",
|
"home.column_settings.show_quotes": "Montri citaĵojn",
|
||||||
"hints.threads.see_more": "Vidi pli da respondoj sur {domain}",
|
|
||||||
"home.column_settings.show_reblogs": "Montri diskonigojn",
|
"home.column_settings.show_reblogs": "Montri diskonigojn",
|
||||||
"home.column_settings.show_replies": "Montri respondojn",
|
"home.column_settings.show_replies": "Montri respondojn",
|
||||||
"home.hide_announcements": "Kaŝi la anoncojn",
|
"home.hide_announcements": "Kaŝi la anoncojn",
|
||||||
|
@ -533,8 +533,10 @@
|
||||||
"mute_modal.you_wont_see_mentions": "Vi ne vidos afiŝojn, kiuj mencias ilin.",
|
"mute_modal.you_wont_see_mentions": "Vi ne vidos afiŝojn, kiuj mencias ilin.",
|
||||||
"mute_modal.you_wont_see_posts": "Ili ankoraŭ povas vidi viajn afiŝojn, sed vi ne vidos iliajn.",
|
"mute_modal.you_wont_see_posts": "Ili ankoraŭ povas vidi viajn afiŝojn, sed vi ne vidos iliajn.",
|
||||||
"navigation_bar.about": "Pri",
|
"navigation_bar.about": "Pri",
|
||||||
|
"navigation_bar.account_settings": "Pasvorto kaj sekureco",
|
||||||
"navigation_bar.administration": "Administrado",
|
"navigation_bar.administration": "Administrado",
|
||||||
"navigation_bar.advanced_interface": "Malfermi altnivelan retpaĝan interfacon",
|
"navigation_bar.advanced_interface": "Malfermi altnivelan retpaĝan interfacon",
|
||||||
|
"navigation_bar.automated_deletion": "Aŭtomata forigo de afiŝoj",
|
||||||
"navigation_bar.blocks": "Blokitaj uzantoj",
|
"navigation_bar.blocks": "Blokitaj uzantoj",
|
||||||
"navigation_bar.bookmarks": "Legosignoj",
|
"navigation_bar.bookmarks": "Legosignoj",
|
||||||
"navigation_bar.direct": "Privataj mencioj",
|
"navigation_bar.direct": "Privataj mencioj",
|
||||||
|
@ -544,6 +546,7 @@
|
||||||
"navigation_bar.follow_requests": "Petoj de sekvado",
|
"navigation_bar.follow_requests": "Petoj de sekvado",
|
||||||
"navigation_bar.followed_tags": "Sekvataj kradvortoj",
|
"navigation_bar.followed_tags": "Sekvataj kradvortoj",
|
||||||
"navigation_bar.follows_and_followers": "Sekvatoj kaj sekvantoj",
|
"navigation_bar.follows_and_followers": "Sekvatoj kaj sekvantoj",
|
||||||
|
"navigation_bar.import_export": "Importo kaj eksporto",
|
||||||
"navigation_bar.lists": "Listoj",
|
"navigation_bar.lists": "Listoj",
|
||||||
"navigation_bar.logout": "Elsaluti",
|
"navigation_bar.logout": "Elsaluti",
|
||||||
"navigation_bar.moderation": "Modereco",
|
"navigation_bar.moderation": "Modereco",
|
||||||
|
@ -551,6 +554,7 @@
|
||||||
"navigation_bar.mutes": "Silentigitaj uzantoj",
|
"navigation_bar.mutes": "Silentigitaj uzantoj",
|
||||||
"navigation_bar.opened_in_classic_interface": "Afiŝoj, kontoj, kaj aliaj specifaj paĝoj kiuj estas malfermititaj defaulta en la klasika reta interfaco.",
|
"navigation_bar.opened_in_classic_interface": "Afiŝoj, kontoj, kaj aliaj specifaj paĝoj kiuj estas malfermititaj defaulta en la klasika reta interfaco.",
|
||||||
"navigation_bar.preferences": "Preferoj",
|
"navigation_bar.preferences": "Preferoj",
|
||||||
|
"navigation_bar.privacy_and_reach": "Privateco kaj atingo",
|
||||||
"navigation_bar.search": "Serĉi",
|
"navigation_bar.search": "Serĉi",
|
||||||
"not_signed_in_indicator.not_signed_in": "Necesas saluti por aliri tiun rimedon.",
|
"not_signed_in_indicator.not_signed_in": "Necesas saluti por aliri tiun rimedon.",
|
||||||
"notification.admin.report": "{name} raportis {target}",
|
"notification.admin.report": "{name} raportis {target}",
|
||||||
|
@ -787,7 +791,7 @@
|
||||||
"search.quick_action.open_url": "Malfermi URL en Mastodono",
|
"search.quick_action.open_url": "Malfermi URL en Mastodono",
|
||||||
"search.quick_action.status_search": "Afiŝoj kiuj konformas kun {x}",
|
"search.quick_action.status_search": "Afiŝoj kiuj konformas kun {x}",
|
||||||
"search.search_or_paste": "Serĉu aŭ algluu URL-on",
|
"search.search_or_paste": "Serĉu aŭ algluu URL-on",
|
||||||
"search_popout.full_text_search_disabled_message": "Ne havebla sur {domain}.",
|
"search_popout.full_text_search_disabled_message": "Ne disponebla sur {domain}.",
|
||||||
"search_popout.full_text_search_logged_out_message": "Disponebla nur kiam ensalutinte.",
|
"search_popout.full_text_search_logged_out_message": "Disponebla nur kiam ensalutinte.",
|
||||||
"search_popout.language_code": "ISO-lingva kodo",
|
"search_popout.language_code": "ISO-lingva kodo",
|
||||||
"search_popout.options": "Serĉaj opcioj",
|
"search_popout.options": "Serĉaj opcioj",
|
||||||
|
@ -820,6 +824,8 @@
|
||||||
"status.bookmark": "Aldoni al la legosignoj",
|
"status.bookmark": "Aldoni al la legosignoj",
|
||||||
"status.cancel_reblog_private": "Ne plu diskonigi",
|
"status.cancel_reblog_private": "Ne plu diskonigi",
|
||||||
"status.cannot_reblog": "Ĉi tiun afiŝon ne eblas diskonigi",
|
"status.cannot_reblog": "Ĉi tiun afiŝon ne eblas diskonigi",
|
||||||
|
"status.context.load_new_replies": "Disponeblaj novaj respondoj",
|
||||||
|
"status.context.loading": "Serĉante pliajn respondojn",
|
||||||
"status.continued_thread": "Daŭrigis fadenon",
|
"status.continued_thread": "Daŭrigis fadenon",
|
||||||
"status.copy": "Kopii la ligilon al la afiŝo",
|
"status.copy": "Kopii la ligilon al la afiŝo",
|
||||||
"status.delete": "Forigi",
|
"status.delete": "Forigi",
|
||||||
|
@ -845,6 +851,9 @@
|
||||||
"status.mute_conversation": "Silentigi konversacion",
|
"status.mute_conversation": "Silentigi konversacion",
|
||||||
"status.open": "Pligrandigu ĉi tiun afiŝon",
|
"status.open": "Pligrandigu ĉi tiun afiŝon",
|
||||||
"status.pin": "Alpingli al la profilo",
|
"status.pin": "Alpingli al la profilo",
|
||||||
|
"status.quote_error.not_found": "Ĉi tiu afiŝo ne povas esti montrata.",
|
||||||
|
"status.quote_error.rejected": "Ĉi tiu afiŝo ne povas esti montrata ĉar la originala aŭtoro ne permesas ĝian citadon.",
|
||||||
|
"status.quote_error.removed": "Ĉi tiu afiŝo estis forigita de ĝia aŭtoro.",
|
||||||
"status.read_more": "Legi pli",
|
"status.read_more": "Legi pli",
|
||||||
"status.reblog": "Diskonigi",
|
"status.reblog": "Diskonigi",
|
||||||
"status.reblog_private": "Diskonigi kun la sama videbleco",
|
"status.reblog_private": "Diskonigi kun la sama videbleco",
|
||||||
|
@ -876,6 +885,7 @@
|
||||||
"tabs_bar.home": "Hejmo",
|
"tabs_bar.home": "Hejmo",
|
||||||
"tabs_bar.menu": "Menuo",
|
"tabs_bar.menu": "Menuo",
|
||||||
"tabs_bar.notifications": "Sciigoj",
|
"tabs_bar.notifications": "Sciigoj",
|
||||||
|
"tabs_bar.publish": "Nova afiŝo",
|
||||||
"tabs_bar.search": "Serĉi",
|
"tabs_bar.search": "Serĉi",
|
||||||
"terms_of_service.effective_as_of": "Ĝi ekvalidas de {date}",
|
"terms_of_service.effective_as_of": "Ĝi ekvalidas de {date}",
|
||||||
"terms_of_service.title": "Kondiĉoj de uzado",
|
"terms_of_service.title": "Kondiĉoj de uzado",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
||||||
"hints.profiles.see_more_follows": "Ver más seguimientos en {domain}",
|
"hints.profiles.see_more_follows": "Ver más seguimientos en {domain}",
|
||||||
"hints.profiles.see_more_posts": "Ver más mensajes en {domain}",
|
"hints.profiles.see_more_posts": "Ver más mensajes en {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Es posible que falten respuestas de otros servidores.",
|
|
||||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Mostrar citas",
|
"home.column_settings.show_quotes": "Mostrar citas",
|
||||||
"home.column_settings.show_reblogs": "Mostrar adhesiones",
|
"home.column_settings.show_reblogs": "Mostrar adhesiones",
|
||||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||||
|
@ -564,8 +562,8 @@
|
||||||
"navigation_bar.follows_and_followers": "Cuentas seguidas y seguidores",
|
"navigation_bar.follows_and_followers": "Cuentas seguidas y seguidores",
|
||||||
"navigation_bar.import_export": "Importación y exportación",
|
"navigation_bar.import_export": "Importación y exportación",
|
||||||
"navigation_bar.lists": "Listas",
|
"navigation_bar.lists": "Listas",
|
||||||
"navigation_bar.live_feed_local": "Cronología local",
|
"navigation_bar.live_feed_local": "Línea temporal (local)",
|
||||||
"navigation_bar.live_feed_public": "Cronología pública",
|
"navigation_bar.live_feed_public": "Línea temporal (federada)",
|
||||||
"navigation_bar.logout": "Cerrar sesión",
|
"navigation_bar.logout": "Cerrar sesión",
|
||||||
"navigation_bar.moderation": "Moderación",
|
"navigation_bar.moderation": "Moderación",
|
||||||
"navigation_bar.more": "Más",
|
"navigation_bar.more": "Más",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Marcar",
|
"status.bookmark": "Marcar",
|
||||||
"status.cancel_reblog_private": "Quitar adhesión",
|
"status.cancel_reblog_private": "Quitar adhesión",
|
||||||
"status.cannot_reblog": "No se puede adherir a este mensaje",
|
"status.cannot_reblog": "No se puede adherir a este mensaje",
|
||||||
|
"status.context.load_new_replies": "Hay nuevas respuestas",
|
||||||
|
"status.context.loading": "Buscando más respuestas",
|
||||||
"status.continued_thread": "Continuación de hilo",
|
"status.continued_thread": "Continuación de hilo",
|
||||||
"status.copy": "Copiar enlace al mensaje",
|
"status.copy": "Copiar enlace al mensaje",
|
||||||
"status.delete": "Eliminar",
|
"status.delete": "Eliminar",
|
||||||
|
@ -858,7 +858,7 @@
|
||||||
"status.edited_x_times": "Editado {count, plural, one {{count} vez} other {{count} veces}}",
|
"status.edited_x_times": "Editado {count, plural, one {{count} vez} other {{count} veces}}",
|
||||||
"status.embed": "Obtener código para insertar",
|
"status.embed": "Obtener código para insertar",
|
||||||
"status.favourite": "Marcar como favorito",
|
"status.favourite": "Marcar como favorito",
|
||||||
"status.favourites": "{count, plural, one {# vez marcado como favorito} other {# veces marcado como favorito}}",
|
"status.favourites": "{count, plural, one {vez marcado como favorito} other {veces marcado como favorito}}",
|
||||||
"status.filter": "Filtrar este mensaje",
|
"status.filter": "Filtrar este mensaje",
|
||||||
"status.history.created": "Creado por {name}, {date}",
|
"status.history.created": "Creado por {name}, {date}",
|
||||||
"status.history.edited": "Editado por {name}, {date}",
|
"status.history.edited": "Editado por {name}, {date}",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
||||||
"hints.profiles.see_more_follows": "Ver más perfiles seguidos en {domain}",
|
"hints.profiles.see_more_follows": "Ver más perfiles seguidos en {domain}",
|
||||||
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Puede que no se muestren algunas respuestas de otros servidores.",
|
|
||||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Mostrar citas",
|
"home.column_settings.show_quotes": "Mostrar citas",
|
||||||
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
||||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Añadir marcador",
|
"status.bookmark": "Añadir marcador",
|
||||||
"status.cancel_reblog_private": "Deshacer impulso",
|
"status.cancel_reblog_private": "Deshacer impulso",
|
||||||
"status.cannot_reblog": "Esta publicación no puede ser impulsada",
|
"status.cannot_reblog": "Esta publicación no puede ser impulsada",
|
||||||
|
"status.context.load_new_replies": "Nuevas respuestas disponibles",
|
||||||
|
"status.context.loading": "Comprobando si hay más respuestas",
|
||||||
"status.continued_thread": "Hilo continuado",
|
"status.continued_thread": "Hilo continuado",
|
||||||
"status.copy": "Copiar enlace al estado",
|
"status.copy": "Copiar enlace al estado",
|
||||||
"status.delete": "Borrar",
|
"status.delete": "Borrar",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
"hints.profiles.see_more_followers": "Ver más seguidores en {domain}",
|
||||||
"hints.profiles.see_more_follows": "Ver más perfiles seguidos en {domain}",
|
"hints.profiles.see_more_follows": "Ver más perfiles seguidos en {domain}",
|
||||||
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Puede que no se muestren algunas respuestas de otros servidores.",
|
|
||||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Mostrar citas",
|
"home.column_settings.show_quotes": "Mostrar citas",
|
||||||
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
||||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||||
|
@ -795,7 +793,7 @@
|
||||||
"report.thanks.title_actionable": "Gracias por informar, estudiaremos esto.",
|
"report.thanks.title_actionable": "Gracias por informar, estudiaremos esto.",
|
||||||
"report.unfollow": "Dejar de seguir a @{name}",
|
"report.unfollow": "Dejar de seguir a @{name}",
|
||||||
"report.unfollow_explanation": "Estás siguiendo esta cuenta. Para dejar de ver sus publicaciones en tu página de inicio, deja de seguirla.",
|
"report.unfollow_explanation": "Estás siguiendo esta cuenta. Para dejar de ver sus publicaciones en tu página de inicio, deja de seguirla.",
|
||||||
"report_notification.attached_statuses": "{count, plural, one {{count} publicación} other {{count} publicaciones}} adjunta(s)",
|
"report_notification.attached_statuses": "{count, plural, one {{count} publicación adjunta} other {{count} publicaciones adjuntas}}",
|
||||||
"report_notification.categories.legal": "Legal",
|
"report_notification.categories.legal": "Legal",
|
||||||
"report_notification.categories.legal_sentence": "contenido ilegal",
|
"report_notification.categories.legal_sentence": "contenido ilegal",
|
||||||
"report_notification.categories.other": "Otros",
|
"report_notification.categories.other": "Otros",
|
||||||
|
@ -847,6 +845,8 @@
|
||||||
"status.bookmark": "Añadir marcador",
|
"status.bookmark": "Añadir marcador",
|
||||||
"status.cancel_reblog_private": "Deshacer impulso",
|
"status.cancel_reblog_private": "Deshacer impulso",
|
||||||
"status.cannot_reblog": "Esta publicación no se puede impulsar",
|
"status.cannot_reblog": "Esta publicación no se puede impulsar",
|
||||||
|
"status.context.load_new_replies": "Hay nuevas respuestas",
|
||||||
|
"status.context.loading": "Buscando más respuestas",
|
||||||
"status.continued_thread": "Continuó el hilo",
|
"status.continued_thread": "Continuó el hilo",
|
||||||
"status.copy": "Copiar enlace a la publicación",
|
"status.copy": "Copiar enlace a la publicación",
|
||||||
"status.delete": "Borrar",
|
"status.delete": "Borrar",
|
||||||
|
|
|
@ -424,8 +424,6 @@
|
||||||
"hints.profiles.see_more_followers": "Vaata rohkem jälgijaid kohas {domain}",
|
"hints.profiles.see_more_followers": "Vaata rohkem jälgijaid kohas {domain}",
|
||||||
"hints.profiles.see_more_follows": "Vaata rohkem jälgitavaid kohas {domain}",
|
"hints.profiles.see_more_follows": "Vaata rohkem jälgitavaid kohas {domain}",
|
||||||
"hints.profiles.see_more_posts": "Vaata rohkem postitusi kohas {domain}",
|
"hints.profiles.see_more_posts": "Vaata rohkem postitusi kohas {domain}",
|
||||||
"hints.threads.replies_may_be_missing": "Vastuseid teistest serveritest võib olla puudu.",
|
|
||||||
"hints.threads.see_more": "Vaata rohkem vastuseid kohas {domain}",
|
|
||||||
"home.column_settings.show_quotes": "Näita tsiteeritut",
|
"home.column_settings.show_quotes": "Näita tsiteeritut",
|
||||||
"home.column_settings.show_reblogs": "Näita jagamisi",
|
"home.column_settings.show_reblogs": "Näita jagamisi",
|
||||||
"home.column_settings.show_replies": "Näita vastuseid",
|
"home.column_settings.show_replies": "Näita vastuseid",
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
"account.edit_profile": "Editatu profila",
|
"account.edit_profile": "Editatu profila",
|
||||||
"account.enable_notifications": "Jakinarazi @{name} erabiltzaileak argitaratzean",
|
"account.enable_notifications": "Jakinarazi @{name} erabiltzaileak argitaratzean",
|
||||||
"account.endorse": "Nabarmendu profilean",
|
"account.endorse": "Nabarmendu profilean",
|
||||||
|
"account.familiar_followers_many": "Jarraitzaileak: {name1}, {name2} eta beste {othersCount, plural, one {ezagun bat} other {# ezagun}}",
|
||||||
"account.familiar_followers_one": "{name1}-k jarraitzen du",
|
"account.familiar_followers_one": "{name1}-k jarraitzen du",
|
||||||
"account.familiar_followers_two": "{name1}-k eta {name2}-k jarraitzen dute",
|
"account.familiar_followers_two": "{name1}-k eta {name2}-k jarraitzen dute",
|
||||||
"account.featured": "Gailenak",
|
"account.featured": "Gailenak",
|
||||||
|
@ -118,6 +119,8 @@
|
||||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "traola erabiliena",
|
"annual_report.summary.most_used_hashtag.most_used_hashtag": "traola erabiliena",
|
||||||
"annual_report.summary.most_used_hashtag.none": "Bat ere ez",
|
"annual_report.summary.most_used_hashtag.none": "Bat ere ez",
|
||||||
"annual_report.summary.new_posts.new_posts": "bidalketa berriak",
|
"annual_report.summary.new_posts.new_posts": "bidalketa berriak",
|
||||||
|
"annual_report.summary.percentile.text": "<topLabel>Horrek jartzen zaitu top </topLabel> <percentage> </percentage>(e)an <bottomLabel> {domain} erabiltzaileen artean </bottomLabel>",
|
||||||
|
"annual_report.summary.percentile.we_wont_tell_bernie": "Bernieri ez diogu ezer esango ;)..",
|
||||||
"annual_report.summary.thanks": "Eskerrik asko Mastodonen parte izateagatik!",
|
"annual_report.summary.thanks": "Eskerrik asko Mastodonen parte izateagatik!",
|
||||||
"attachments_list.unprocessed": "(prozesatu gabe)",
|
"attachments_list.unprocessed": "(prozesatu gabe)",
|
||||||
"audio.hide": "Ezkutatu audioa",
|
"audio.hide": "Ezkutatu audioa",
|
||||||
|
@ -216,6 +219,7 @@
|
||||||
"confirmations.discard_draft.edit.message": "Jarraitzeak editatzen ari zaren mezuan egindako aldaketak baztertuko ditu.",
|
"confirmations.discard_draft.edit.message": "Jarraitzeak editatzen ari zaren mezuan egindako aldaketak baztertuko ditu.",
|
||||||
"confirmations.discard_draft.edit.title": "Baztertu zure argitalpenari egindako aldaketak?",
|
"confirmations.discard_draft.edit.title": "Baztertu zure argitalpenari egindako aldaketak?",
|
||||||
"confirmations.discard_draft.post.cancel": "Zirriborroa berrekin",
|
"confirmations.discard_draft.post.cancel": "Zirriborroa berrekin",
|
||||||
|
"confirmations.discard_draft.post.message": "Jarraituz gero, idazten ari zaren sarrera bertan behera geratuko da.",
|
||||||
"confirmations.discard_draft.post.title": "Zure argitalpenaren zirriborroa baztertu nahi duzu?",
|
"confirmations.discard_draft.post.title": "Zure argitalpenaren zirriborroa baztertu nahi duzu?",
|
||||||
"confirmations.discard_edit_media.confirm": "Baztertu",
|
"confirmations.discard_edit_media.confirm": "Baztertu",
|
||||||
"confirmations.discard_edit_media.message": "Multimediaren deskribapen edo aurrebistan gorde gabeko aldaketak daude, baztertu nahi dituzu?",
|
"confirmations.discard_edit_media.message": "Multimediaren deskribapen edo aurrebistan gorde gabeko aldaketak daude, baztertu nahi dituzu?",
|
||||||
|
@ -413,8 +417,6 @@
|
||||||
"hints.profiles.see_more_followers": "Ikusi jarraitzaile gehiago {domain}-(e)n",
|
"hints.profiles.see_more_followers": "Ikusi jarraitzaile gehiago {domain}-(e)n",
|
||||||
"hints.profiles.see_more_follows": "Ikusi jarraitzaile gehiago {domain}-(e)n",
|
"hints.profiles.see_more_follows": "Ikusi jarraitzaile gehiago {domain}-(e)n",
|
||||||
"hints.profiles.see_more_posts": "Ikusi bidalketa gehiago {domain}-(e)n",
|
"hints.profiles.see_more_posts": "Ikusi bidalketa gehiago {domain}-(e)n",
|
||||||
"hints.threads.replies_may_be_missing": "Baliteke beste zerbitzari batzuen erantzun batzuk ez erakustea.",
|
|
||||||
"hints.threads.see_more": "Ikusi erantzun gehiago {domain}-(e)n",
|
|
||||||
"home.column_settings.show_quotes": "Erakutsi aipamenak",
|
"home.column_settings.show_quotes": "Erakutsi aipamenak",
|
||||||
"home.column_settings.show_reblogs": "Erakutsi bultzadak",
|
"home.column_settings.show_reblogs": "Erakutsi bultzadak",
|
||||||
"home.column_settings.show_replies": "Erakutsi erantzunak",
|
"home.column_settings.show_replies": "Erakutsi erantzunak",
|
||||||
|
@ -435,6 +437,7 @@
|
||||||
"ignore_notifications_modal.not_following_title": "Jarraitzen ez dituzun pertsonen jakinarazpenei ez ikusiarena egin?",
|
"ignore_notifications_modal.not_following_title": "Jarraitzen ez dituzun pertsonen jakinarazpenei ez ikusiarena egin?",
|
||||||
"ignore_notifications_modal.private_mentions_title": "Eskatu gabeko aipamen pribatuen jakinarazpenei ez ikusiarena egin?",
|
"ignore_notifications_modal.private_mentions_title": "Eskatu gabeko aipamen pribatuen jakinarazpenei ez ikusiarena egin?",
|
||||||
"info_button.label": "Laguntza",
|
"info_button.label": "Laguntza",
|
||||||
|
"info_button.what_is_alt_text": "<h1>Zer da Alt testua?</h1><p>Alt testuak irudiak deskribatzeko aukera ematen du, ikusmen-urritasunak, banda-zabalera txikiko konexioak edo testuinguru gehigarria nahi duten pertsonentzat.</p><p>Alt testu argi, zehatz eta objektiboen bidez, guztion irisgarritasuna eta ulermena hobetu ditzakezu.</p><ul><li>Hartu elementu garrantzitsuenak</li><li>Laburbildu irudietako testua</li><li>Erabili esaldien egitura erregularra</li><li>Baztertu informazio erredundantea.</li><li>Enfokatu joeretan eta funtsezko elementuetan irudi konplexuetan (diagrametan edo mapetan, adibidez)</li></ul>",
|
||||||
"interaction_modal.action.favourite": "Jarraitzeko, zure kontutik atsegindu behar duzu.",
|
"interaction_modal.action.favourite": "Jarraitzeko, zure kontutik atsegindu behar duzu.",
|
||||||
"interaction_modal.action.follow": "Jarraitzeko zure kontutik jarraitu behar duzu.",
|
"interaction_modal.action.follow": "Jarraitzeko zure kontutik jarraitu behar duzu.",
|
||||||
"interaction_modal.action.reply": "Jarraitzeko zure kontutik erantzun behar duzu.",
|
"interaction_modal.action.reply": "Jarraitzeko zure kontutik erantzun behar duzu.",
|
||||||
|
@ -569,6 +572,7 @@
|
||||||
"notification.admin.sign_up.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiago} other {# erabiltzaile gehiago}} erregistratu dira",
|
"notification.admin.sign_up.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiago} other {# erabiltzaile gehiago}} erregistratu dira",
|
||||||
"notification.favourite": "{name}(e)k zure bidalketa gogoko du",
|
"notification.favourite": "{name}(e)k zure bidalketa gogoko du",
|
||||||
"notification.favourite.name_and_others_with_link": "{name} eta <a>{count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}}</a> zure bidalketa gogoko dute",
|
"notification.favourite.name_and_others_with_link": "{name} eta <a>{count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}}</a> zure bidalketa gogoko dute",
|
||||||
|
"notification.favourite_pm": "{name}-ek zure aipamen pribatua gogokoetan jarri du",
|
||||||
"notification.follow": "{name}(e)k jarraitzen dizu",
|
"notification.follow": "{name}(e)k jarraitzen dizu",
|
||||||
"notification.follow_request": "{name}(e)k zu jarraitzeko eskaera egin du",
|
"notification.follow_request": "{name}(e)k zu jarraitzeko eskaera egin du",
|
||||||
"notification.follow_request.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}} zu jarraitzeko eskaera egin dute",
|
"notification.follow_request.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}} zu jarraitzeko eskaera egin dute",
|
||||||
|
@ -902,5 +906,8 @@
|
||||||
"video.hide": "Ezkutatu bideoa",
|
"video.hide": "Ezkutatu bideoa",
|
||||||
"video.pause": "Pausatu",
|
"video.pause": "Pausatu",
|
||||||
"video.play": "Jo",
|
"video.play": "Jo",
|
||||||
|
"video.skip_forward": "Jauzi aurrerantz",
|
||||||
|
"video.unmute": "Soinua ezarri",
|
||||||
|
"video.volume_down": "Bolumena jaitsi",
|
||||||
"video.volume_up": "Bolumena Igo"
|
"video.volume_up": "Bolumena Igo"
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user