Compare commits

..

24 commits

Author SHA1 Message Date
b768870895 revert 750f5f4885
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
revert EN translation edit
2025-06-15 06:25:53 +02:00
3e7972c5b1 Update app/validators/status_length_validator.rb
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:23:37 +02:00
7a74c056ee JP to EN
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:18:55 +02:00
750f5f4885 EN translation edit
Some checks are pending
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:18:29 +02:00
3daed614d5 En edit
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:17:29 +02:00
f600d9f95b En edit
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:17:06 +02:00
5267d19474 Set max trending tags
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:16:01 +02:00
Mario
59657684e1 fix
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 00:05:01 -04:00
Mario
44efbc7141 Fix
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 23:54:06 -04:00
Mario
25d176c280 Followed tags + dir
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 23:40:51 -04:00
Mario
f6447c0f4d New icons set
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 23:11:27 -04:00
KMY(雪あすか)
da60c95317
Merge pull request #1005 from kmycode/kb-draft-18.1
Release: 18.1
2025-05-07 16:35:45 +09:00
KMY
49e05d9f2b Fix test 2025-05-07 11:27:14 +09:00
KMY
9524487909 Bump version to v4.3.8 (#34626) 2025-05-07 08:00:56 +09:00
Claire
d21a4f8c39 Fix code style issue (#34624) 2025-05-07 07:59:03 +09:00
Claire
d510b5d8b9 Merge commit from fork
* Check scheme in account and post links

* Harden media attachments

* Client-side mitigation

* Client-side mitigation for media attachments
2025-05-07 07:57:43 +09:00
Claire
ff90575e2c Add warning for REDIS_NAMESPACE deprecation at startup (#34581) 2025-05-07 07:57:29 +09:00
Claire
f41c09f291 Remove double-query for signed query strings (#34610) 2025-05-07 07:57:08 +09:00
Claire
a7bc288569 Add built-in context for interaction policies (#34574) 2025-05-07 07:56:54 +09:00
Claire
843a5446e8 Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549) 2025-05-07 07:55:10 +09:00
Claire
038d3b1513 Fix sign-up e-mail confirmation page reloading on error or redirect (#34548) 2025-05-07 07:54:41 +09:00
KMY(雪あすか)
b091cbdbea
Merge pull request #1001 from kmycode/kb-draft-18.0
Release: kb18.0
2025-04-14 17:43:53 +09:00
KMY
8992602acf Bump version to 18.0 2025-04-14 13:34:50 +09:00
KMY
c68762e2bf Fix: フルダークで文字が薄く表示される問題 2025-04-14 10:52:33 +09:00
261 changed files with 3262 additions and 3753 deletions

View file

@ -110,6 +110,3 @@ FETCH_REPLIES_MAX_SINGLE=500
# Max number of replies Collection pages to fetch - total
FETCH_REPLIES_MAX_PAGES=500
# Maximum allowed character count
MAX_CHARS=5555

View file

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.75.2.
# using RuboCop version 1.75.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -62,6 +62,12 @@ Style/FormatStringToken:
Style/GuardClause:
Enabled: false
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/HashTransformValues:
Exclude:
- 'app/serializers/rest/web_push_subscription_serializer.rb'
- 'app/services/import_service.rb'
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter:

View file

@ -1 +1 @@
3.4.3
3.4.2

View file

@ -2,9 +2,34 @@
All notable changes to this project will be documented in this file.
## [4.3.8] - 2025-05-06
### Security
- Update dependencies
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
### Added
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
### Changed
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
### Removed
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
### Fixed
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
## [4.3.7] - 2025-04-02
### Add
### Added
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)

View file

@ -94,7 +94,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.3.2)
aws-partitions (1.1087.0)
aws-partitions (1.1066.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -120,13 +120,13 @@ GEM
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (3.1.9)
bindata (2.5.1)
bindata (2.5.0)
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
blurhash (0.1.8)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.2)
brakeman (7.0.0)
racc
browser (6.2.0)
brpoplpush-redis_script (0.1.3)
@ -170,7 +170,7 @@ GEM
crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.4)
csv (3.3.3)
database_cleaner-active_record (2.2.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
@ -194,14 +194,14 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.6.1)
diff-lcs (1.6.0)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
domain_name (0.6.20240107)
doorkeeper (5.8.2)
doorkeeper (5.8.1)
railties (>= 5)
dotenv (3.1.8)
dotenv (3.1.7)
drb (2.2.1)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
@ -227,7 +227,7 @@ GEM
fabrication (2.31.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.13.0)
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@ -239,7 +239,7 @@ GEM
net-http (>= 0.5.0)
fast_blank (1.0.1)
fastimage (2.4.0)
ffi (1.17.2)
ffi (1.17.1)
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
@ -266,10 +266,10 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (4.30.2)
google-protobuf (4.30.1)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.19.0)
googleapis-common-protos-types (1.18.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
@ -280,7 +280,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.62.0)
haml_lint (0.61.1)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -328,7 +328,7 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.0)
irb (1.15.2)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@ -395,7 +395,7 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
linzer (0.6.5)
linzer (0.6.3)
openssl (~> 3.0, >= 3.0.0)
rack (>= 2.2, < 4.0)
starry (~> 0.2)
@ -426,7 +426,7 @@ GEM
mime-types (3.6.2)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2025.0408)
mime-types-data (3.2025.0318)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.5)
@ -435,7 +435,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.6)
net-imap (0.5.7)
date
net-protocol
net-ldap (0.19.0)
@ -446,7 +446,7 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.7)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.10)
@ -585,8 +585,8 @@ GEM
ostruct (0.6.1)
ox (2.14.22)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.8.0)
parallel (1.26.3)
parser (3.3.7.4)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@ -688,7 +688,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.13.1)
rdoc (6.12.0)
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (4.8.1)
@ -697,7 +697,7 @@ GEM
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.10.0)
reline (0.6.1)
reline (0.6.0)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -740,7 +740,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.2)
rubocop (1.75.2)
rubocop (1.75.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -748,10 +748,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.43.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
rubocop-ast (1.43.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
@ -797,7 +797,7 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
selenium-webdriver (4.31.0)
selenium-webdriver (4.30.1)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@ -840,7 +840,7 @@ GEM
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.6)
strong_migrations (2.3.0)
strong_migrations (2.2.1)
activerecord (>= 7)
swd (2.0.3)
activesupport (>= 3)
@ -851,7 +851,7 @@ GEM
temple (0.10.3)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.0)
terrapin (1.0.1)
climate_control
test-prof (1.4.4)
thor (1.3.2)
@ -1085,4 +1085,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.8
2.6.6

124
README.md
View file

@ -1,27 +1,123 @@
NAS is an KMY & Mastodon Fork
# ![kmyblue icon](https://raw.githubusercontent.com/kmycode/mastodon/kb_development/app/javascript/icons/favicon-32x32.png) kmyblue
The following are just a few of the most common features. There are many other minor changes to the specifications.
[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml)
Emoji reactions
! FOR ENGLISH USER ! We do not provide English documentation for kmyblue; we assume that you will use automatic translation software, such as Google, to translate the site.
Local Public (Does not appear on the federated timeline of remote servers, but does appear on followers' home timelines. This is different from local only)
kmyblueは、ActivityPubに接続するSNSの1つである[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。
Bookmark classification
kmyblueはフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。
Set who can search your posts for each post (Searchability)
kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。確かにサーバーkmyblueは創作作家向けの利用規約が設定されていますが、フォークとしてのkmyblueのルールは全くの別物です。いかなるコミュニティにも平等にお使いいただけます。
kmyblueは、閉鎖的なコミュニティ、あまり目立ちたくないコミュニティには特に強力な機能を提供します。kmyblueはプライバシーを考慮したうえで強力な独自機能を提供するため、汎用サーバーとして利用するにもある程度十分な機能が揃っています。
Quote posts, modest quotes (references)
テストコード、Lint どちらも動いています。
Record posts that meet certain conditions such as domains, accounts, and keywords (Subscriptions/Antennas)
### アジェンダ
Send posts to a designated set of followers (Circles) (different from direct messages)
- 利用方法
- kmyblueの開発方針
- kmyblueは何でないか
- kmyblueの独自機能
- 英語のサポートについて
Notification of new posts on lists
## 利用方法
Exclude posts from people you follow when filtering posts
### インストール方法
Hide number of followers and followings
[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。
Automatically delete posts after a specified time has passed
### 開発への参加方法
Expanding moderation functions
CONTRIBUTING.mdを参照してください。
### テスト
```
# デバッグ実行(以下のいずれか)
foreman start
DB_USER=postgres DB_PASS=password foreman start
# 一部を除く全てのテストを行う
RAILS_ENV=test bundle exec rspec spec
# ElasticSearch連携テストを行う
RAILS_ENV=test ES_ENABLED=true bundle exec rspec --tag search
RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/search
```
## kmyblueの開発方針
### 本家Mastodonへの積極的追従
kmyblueは、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。
### ゆるやかな内輪での運用
kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪リを外部にできるだけもらさない」という部分に特化しています。
「ローカル公開」は、投稿を見せたくない人に見つかりにくくする効果があります。「サークル」は、フォロワーの中でも特に見せたい人だけに見せる効果があります。
「検索許可」という独自の検索オプションを利用することで、公開投稿の一部だけを検索されにくくするだけでなく、非収載投稿が誰でも自由に検索できるようになります。
内輪とは自分のサーバーに限ったものではありません。内輪同士で複数のサーバーを運営するとき、お互いが深く繋がれる「フレンドサーバー」というシステムも用意しています。
### 少人数サーバーでの運用
kmyblueは、人の少ないサーバーでの運用を考慮して設計しています。そのため、Fedibirdにあるような、人の多いサーバー向けの機能はあまり作っていません。
サーバーの負荷については一部度外視している部分があります。たとえば絵文字リアクション機能はサーバーへ著しい負荷をかける場合があります。ただしkmyblueでは、絵文字リアクション機能そのものを無効にしたり、負荷の高いストリーミング処理を無効にする管理者オプションも存在します。
もちろん人の多いサーバーでの運用が不便になるような修正は行っていません。人数にかかわらず、そのままお使いいただけます。
### 比較的高い防御力
kmyblueでは、「Fediverseは将来的に荒むのではないか」「Fediverseは将来的にスパムに溢れるのではないか」を念頭に設計している部分があります。投稿だけでなく絵文字リアクションも対象にした防衛策があります。
管理者は「NGワード」「NGルール」機能の利用が可能です。設定を変更することで、一部のモデレーターもこの機能を利用できます。
利用者は、独自拡張されたフィルター機能、絵文字リアクションのブロックなどを利用できます。
ただし防御力の高さは自由を犠牲にします。例えばNGワードが多すぎると、他のサーバーからの投稿が制限され、かつそれに気づきにくくなります。
## kmyblueは何でないか
kmyblueは、企業・政府機関向けに開発されたものではありません。開発者はセキュリティに関する専門知識を有しておらず、高度なセキュリティを求められる機関向けのソフトウェアを制作する能力はありません。また、kmyblueのメンテナは現在1人のみであり、そのメンテナが飽きたら開発がストップするリスクも高いです。Mastodonのような高い信頼性・安全性を保証することはできないので、導入の際はご自身で安全を十分に確認してからお使いになることを強くおすすめします。
個人サーバーであっても、安定性を強く求める方にはおすすめできません。glitch-socがよりよい選択肢になるでしょう。
kmyblueは、Misskeyではありません。Misskeyは「楽しむ」をコンセプトにしていますが、kmyblueはMastodonの思想を受け継ぎ、炎上や喧騒を避けることのできる落ち着いた場所を目指しています。そのため、思想に合わない機能は実装しないか、大幅に弱体化しています。
kmyblueは、Fedibirdではありません。Fedibirdは大規模サーバー向けに設定していると思われる機能があり、例えば購読機能がその代表例です。Fedibirdの購読は擬似的なフォロー体験を与えるものですが、本物のフォローではないため、購読対象の投稿が配送されることを確約したものではありません。小規模サーバーだとかえって不便になる機能を、kmyblueは避けています。
## kmyblueの独自機能
以下に列挙したものはあくまで代表的なものです。これ以外にも、細かい仕様変更などが多数含まれます。
- 絵文字リアクション
- ローカル公開Local Publicリモートサーバーの連合タイムラインには流れませんが、フォロワーのホームタイムラインには流れます。**ローカル限定とは異なります**
- ブックマークの分類
- 自分の投稿を検索できる人を投稿ごとに設定検索許可・Searchability
- 投稿の引用、ひかえめな引用(参照)
- ドメイン・アカウント・キーワードなど特定条件を満たした投稿を記録する機能(購読・アンテナ)
- フォロワーの一部を指名して投稿を送る機能(サークル)(ダイレクトメッセージとは異なります)
- リスト新着投稿の通知
- 投稿のフィルタリングにおいて、自分がフォローしている相手の投稿を除外
- フォロー・フォロワー数を隠す機能
- 指定した時間が経過したあとに投稿を自動削除する機能
- モデレーション機能の拡張
## 英語のサポートについて
kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、英語の読み書きがほとんどできません。そのため、ドキュメントの英語化、海外向け公式アカウントの新設などを行う予定はありません。
要望やバグ報告はIssueに書いて構いませんが、Issue画面内の説明やテンプレートはすべて日本語になっています。投稿が難しければ、Discussionに投稿してください。こちらで必要と判断したものは、改めてIssueとして起票します。
そのほか開発者へ質問があれば、[@askyq@kmy.blue](https://kmy.blue/@askyq)へ英語のまま送ってください。
ただしkmyblueのドキュメント、[@askyq@kmy.blue](https://kmy.blue/@askyq)内のkmyblueフォークに関係する投稿を、許可なく翻訳して公開することは問題ありません。
## 開発者のアカウントについて
kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、用途別にアカウントを分けるようなことはせず、すべての発言をつのアカウントで行っています。そのため、kmyblueの開発だけでなく、成人向け同人作品の話も混ざっています。
このうち、公開範囲「公開」「ローカル公開」「非収載」であるkmyblueフォークの開発に関係する投稿に限り抽出し、翻訳の有無に関係なく公開することを許可します。これはkmyblueフォークの利用者にとって公共性の高いコンテンツであると思われます。これは、日本と欧米では一般的に考えられている児童ポルの基準が異なり、欧米のサーバーの中にはこのアカウントをフォローしづらいものもあるという懸念を考慮したものです。

View file

@ -72,6 +72,13 @@ class Api::BaseController < ApplicationController
end
end
# Redefine `require_functional!` to properly output JSON instead of HTML redirects
def require_functional!
return if current_user.functional?
require_user!
end
def render_empty
render json: {}, status: 200
end

View file

@ -1,66 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::EndorsementsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!, except: :index
before_action :set_account
before_action :set_endorsed_accounts, only: :index
after_action :insert_pagination_headers, only: :index
def index
cache_if_unauthenticated!
render json: @endorsed_accounts, each_serializer: REST::AccountSerializer
end
def create
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
def destroy
pin = AccountPin.find_by(account: current_account, target_account: @account)
pin&.destroy!
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
private
def set_account
@account = Account.find(params[:account_id])
end
def set_endorsed_accounts
@endorsed_accounts = @account.unavailable? ? [] : paginated_endorsed_accounts
end
def paginated_endorsed_accounts
@account.endorsed_accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
def next_path
api_v1_account_endorsements_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_account_endorsements_url pagination_params(since_id: pagination_since_id) unless @endorsed_accounts.empty?
end
def pagination_collection
@endorsed_accounts
end
def records_continue?
@endorsed_accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
end

View file

@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
end
def set_featured_tags
@featured_tags = @account.unavailable? ? [] : @account.featured_tags
@featured_tags = @account.suspended? ? [] : @account.featured_tags
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::Accounts::PinsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
before_action :require_user!
before_action :set_account
def create
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
def destroy
pin = AccountPin.find_by(account: current_account, target_account: @account)
pin&.destroy!
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
private
def set_account
@account = Account.find(params[:account_id])
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
end

View file

@ -7,6 +7,10 @@ class Api::V1::ListsController < Api::BaseController
before_action :require_user!
before_action :set_list, except: [:index, :create]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def index
@lists = List.where(account: current_account).all
render json: @lists, each_serializer: REST::ListSerializer

View file

@ -4,7 +4,7 @@ class Api::V1::SuggestionsController < Api::BaseController
include Authorization
include DeprecationConcern
deprecate_api '2021-05-16', only: [:index]
deprecate_api '2021-05-16'
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index

View file

@ -72,10 +72,24 @@ class ApplicationController < ActionController::Base
def require_functional!
return if current_user.functional?
if current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
respond_to do |format|
format.any do
if current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
end
end
format.json do
if !current_user.confirmed?
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403
elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403
end
end
end
end

View file

@ -35,6 +35,13 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
misskey_license: { 'misskey' => 'https://misskey-hub.net/ns#', '_misskey_license' => 'misskey:_misskey_license' },
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
},
}.freeze
def full_context

View file

@ -4,9 +4,12 @@ import axios from 'axios';
import ready from '../mastodon/ready';
async function checkConfirmation() {
const response = await axios.get('/api/v1/emails/check_confirmation');
const response = await axios.get('/api/v1/emails/check_confirmation', {
headers: { Accept: 'application/json' },
withCredentials: true,
});
if (response.data) {
if (response.status === 200 && response.data === true) {
window.location.href = '/start';
}
}

View file

@ -1,11 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
export const openDropdownMenu = createAction<{
id: number;
id: string;
keyboard: boolean;
scrollKey?: string;
scrollKey: string;
}>('dropdownMenu/open');
export const closeDropdownMenu = createAction<{ id: number }>(
export const closeDropdownMenu = createAction<{ id: string }>(
'dropdownMenu/close',
);

View file

@ -94,6 +94,17 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
normalStatus.url ||= normalStatus.uri;
normalStatus.media_attachments.forEach(item => {
if (item.remote_url && !(item.remote_url.startsWith('http://') || item.remote_url.startsWith('https://')))
item.remote_url = null;
});
}
if (normalOldStatus) {

View file

@ -0,0 +1,81 @@
import api, { getLinks } from '../api';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const fetchFollowedHashtags = () => (dispatch) => {
dispatch(fetchFollowedHashtagsRequest());
api().get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
}
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
}
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
}
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api().get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
}
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
}
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
}
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
}

View file

@ -1,4 +1,4 @@
import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
export const apiGetTag = (tagId: string) =>
@ -9,15 +9,3 @@ export const apiFollowTag = (tagId: string) =>
export const apiUnfollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
export const apiGetFollowedTags = async (url?: string) => {
const response = await api().request<ApiHashtagJSON[]>({
method: 'GET',
url: url ?? '/api/v1/followed_tags',
});
return {
tags: response.data,
links: getLinks(response),
};
};

View file

@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -14,19 +14,18 @@ import {
muteAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
@ -49,14 +48,6 @@ const messages = defineMessages({
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
more: { id: 'status.more', defaultMessage: 'More' },
addToLists: {
id: 'account.add_or_remove_from_list',
defaultMessage: 'Add or Remove from lists',
},
openOriginalPage: {
id: 'account.open_original_page',
defaultMessage: 'Open original page',
},
});
export const Account: React.FC<{
@ -82,7 +73,6 @@ export const Account: React.FC<{
const account = useAppSelector((state) => state.accounts.get(id));
const relationship = useAppSelector((state) => state.relationships.get(id));
const dispatch = useAppDispatch();
const accountUrl = account?.url;
const handleBlock = useCallback(() => {
if (relationship?.blocking) {
@ -100,62 +90,13 @@ export const Account: React.FC<{
}
}, [dispatch, id, account, relationship]);
const menu = useMemo(() => {
let arr: MenuItem[] = [];
const handleMuteNotifications = useCallback(() => {
dispatch(muteAccount(id, true));
}, [dispatch, id]);
if (defaultAction === 'mute') {
const handleMuteNotifications = () => {
dispatch(muteAccount(id, true));
};
const handleUnmuteNotifications = () => {
dispatch(muteAccount(id, false));
};
arr = [
{
text: intl.formatMessage(
relationship?.muting_notifications
? messages.unmute_notifications
: messages.mute_notifications,
),
action: relationship?.muting_notifications
? handleUnmuteNotifications
: handleMuteNotifications,
},
];
} else if (defaultAction !== 'block') {
const handleAddToLists = () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: id,
},
}),
);
};
arr = [
{
text: intl.formatMessage(messages.addToLists),
action: handleAddToLists,
},
];
if (accountUrl) {
arr.unshift(
{
text: intl.formatMessage(messages.openOriginalPage),
href: accountUrl,
},
null,
);
}
}
return arr;
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
const handleUnmuteNotifications = useCallback(() => {
dispatch(muteAccount(id, false));
}, [dispatch, id]);
if (hidden) {
return (
@ -166,46 +107,73 @@ export const Account: React.FC<{
);
}
let button: React.ReactNode, dropdown: React.ReactNode;
let buttons;
if (menu.length > 0) {
dropdown = (
<Dropdown
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
);
}
if (account && account.id !== me && relationship) {
const { requested, blocking, muting } = relationship;
if (defaultAction === 'block') {
button = (
<Button
text={intl.formatMessage(
relationship?.blocking ? messages.unblock : messages.block,
)}
onClick={handleBlock}
/>
);
} else if (defaultAction === 'mute') {
button = (
<Button
text={intl.formatMessage(
relationship?.muting ? messages.unmute : messages.mute,
)}
onClick={handleMute}
/>
);
if (requested) {
buttons = <FollowButton accountId={id} />;
} else if (blocking) {
buttons = (
<Button
text={intl.formatMessage(messages.unblock)}
onClick={handleBlock}
/>
);
} else if (muting) {
const menu = [
{
text: intl.formatMessage(
relationship.muting_notifications
? messages.unmute_notifications
: messages.mute_notifications,
),
action: relationship.muting_notifications
? handleUnmuteNotifications
: handleMuteNotifications,
},
];
buttons = (
<>
<DropdownMenu
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
<Button
text={intl.formatMessage(messages.unmute)}
onClick={handleMute}
/>
</>
);
} else if (defaultAction === 'mute') {
buttons = (
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
);
} else if (defaultAction === 'block') {
buttons = (
<Button
text={intl.formatMessage(messages.block)}
onClick={handleBlock}
/>
);
} else {
buttons = <FollowButton accountId={id} />;
}
} else {
button = <FollowButton accountId={id} />;
buttons = <FollowButton accountId={id} />;
}
if (hideButtons) {
button = null;
buttons = null;
}
let muteTimeRemaining: React.ReactNode;
let muteTimeRemaining;
if (account?.mute_expires_at) {
muteTimeRemaining = (
@ -215,7 +183,7 @@ export const Account: React.FC<{
);
}
let verification: React.ReactNode;
let verification;
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
@ -265,17 +233,11 @@ export const Account: React.FC<{
{!minimal && children && (
<div>
<div>{children}</div>
<div className='account__relationship'>
{dropdown}
{button}
</div>
<div className='account__relationship'>{buttons}</div>
</div>
)}
{!minimal && !children && (
<div className='account__relationship'>
{dropdown}
{button}
</div>
<div className='account__relationship'>{buttons}</div>
)}
</div>

View file

@ -0,0 +1,345 @@
import PropTypes from 'prop-types';
import { PureComponent, cloneElement, Children } from 'react';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;
class DropdownMenu extends PureComponent {
static propTypes = {
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
e.preventDefault();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
handleKeyDown = e => {
const items = Array.from(this.node.querySelectorAll('a, button'));
const index = items.indexOf(document.activeElement);
let element = null;
switch(e.key) {
case 'ArrowDown':
element = items[index+1] || items[0];
break;
case 'ArrowUp':
element = items[index-1] || items[items.length-1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length-1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick = e => {
const { onItemClick } = this.props;
onItemClick(e);
};
renderItem = (option, i) => {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
);
};
render () {
const { items, scrollable, renderHeader, loading } = this.props;
let renderItem = this.props.renderItem || this.renderItem;
return (
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{loading && (
<CircularProgress size={30} strokeWidth={3.5} />
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
);
}
}
class Dropdown extends PureComponent {
static propTypes = {
children: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
size: PropTypes.number,
title: PropTypes.string,
disabled: PropTypes.bool,
scrollable: PropTypes.bool,
active: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func,
...WithRouterPropTypes
};
static defaultProps = {
title: 'Menu',
};
state = {
id: id++,
};
handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
} else {
this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
}
};
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}
this.props.onClose(this.state.id);
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick = e => {
const { onItemClick } = this.props;
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
this.handleClose();
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (item && typeof item.action === 'function') {
e.preventDefault();
item.action();
} else if (item && item.to) {
e.preventDefault();
this.props.history.push(item.to);
}
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target?.buttonRef?.current ?? this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
close = () => {
this.handleClose();
};
render () {
const {
icon,
iconComponent,
items,
size,
title,
disabled,
loading,
scrollable,
openDropdownId,
openedViaKeyboard,
children,
renderItem,
renderHeader,
active,
} = this.props;
const open = this.state.id === openDropdownId;
const button = children ? cloneElement(Children.only(children), {
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
}) : (
<IconButton
icon={!open ? icon : 'close'}
iconComponent={iconComponent}
title={title}
active={open || active}
disabled={disabled}
size={size}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
);
return (
<>
{button}
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
<DropdownMenu
items={items}
loading={loading}
scrollable={scrollable}
onClose={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View file

@ -1,551 +0,0 @@
import {
useState,
useEffect,
useRef,
useCallback,
cloneElement,
Children,
} from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { fetchRelationships } from 'mastodon/actions/accounts';
import {
openDropdownMenu,
closeDropdownMenu,
} from 'mastodon/actions/dropdown_menu';
import { openModal, closeModal } from 'mastodon/actions/modal';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { isUserTouching } from 'mastodon/is_mobile';
import type {
MenuItem,
ActionMenuItem,
ExternalLinkMenuItem,
} from 'mastodon/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { IconProp } from './icon';
import { IconButton } from './icon_button';
let id = 0;
const isMenuItem = (item: unknown): item is MenuItem => {
if (item === null) {
return true;
}
return typeof item === 'object' && 'text' in item;
};
const isActionItem = (item: unknown): item is ActionMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'action' in item;
};
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'href' in item;
};
type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
handlers: {
onClick: (e: React.MouseEvent) => void;
onKeyUp: (e: React.KeyboardEvent) => void;
},
) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
interface DropdownMenuProps<Item = MenuItem> {
items?: Item[];
loading?: boolean;
scrollable?: boolean;
onClose: () => void;
openedViaKeyboard: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onItemClick?: ItemClickFn<Item>;
}
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
scrollable,
onClose,
openedViaKeyboard,
renderItem,
renderHeader,
onItemClick,
}: DropdownMenuProps<Item>) => {
const nodeRef = useRef<HTMLDivElement>(null);
const focusedItemRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (
e.target instanceof Node &&
nodeRef.current &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
e.preventDefault();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!nodeRef.current) {
return;
}
const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
const index = document.activeElement
? items.indexOf(document.activeElement)
: -1;
let element: Element | undefined;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] ?? items[0];
break;
case 'ArrowUp':
element = items[index - 1] ?? items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] ?? items[items.length - 1];
} else {
element = items[index + 1] ?? items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
onClose();
break;
}
if (element && element instanceof HTMLElement) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('keydown', handleKeyDown, { capture: true });
if (focusedItemRef.current && openedViaKeyboard) {
focusedItemRef.current.focus({ preventScroll: true });
}
return () => {
document.removeEventListener('click', handleDocumentClick, {
capture: true,
});
document.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [onClose, openedViaKeyboard]);
const handleFocusedItemRef = useCallback(
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
focusedItemRef.current = c as HTMLElement;
},
[],
);
const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
onClose();
if (!item) {
return;
}
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
}
},
[onClose, onItemClick, items],
);
const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick(e);
}
},
[handleItemClick],
);
const nativeRenderItem = (option: Item, i: number) => {
if (!isMenuItem(option)) {
return null;
}
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</button>
);
} else if (isExternalLinkItem(option)) {
element = (
<a
href={option.href}
target={option.target ?? '_target'}
data-method={option.method}
rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</a>
);
} else {
element = (
<Link
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</Link>
);
}
return (
<li
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}
>
{element}
</li>
);
};
const renderItemMethod = renderItem ?? nativeRenderItem;
return (
<div
className={classNames('dropdown-menu__container', {
'dropdown-menu__container--loading': loading,
})}
ref={nodeRef}
>
{(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
{!loading && renderHeader && items && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && items && (
<ul
className={classNames('dropdown-menu__container__list', {
'dropdown-menu__container__list--scrollable': scrollable,
})}
>
{items.map((option, i) =>
renderItemMethod(option, i, {
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
}),
)}
</ul>
)}
</div>
);
};
interface DropdownProps<Item = MenuItem> {
children?: React.ReactElement;
icon?: string;
iconComponent?: IconProp;
items?: Item[];
loading?: boolean;
title?: string;
disabled?: boolean;
scrollable?: boolean;
active?: boolean;
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
onItemClick?: ItemClickFn<Item>;
}
const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const Dropdown = <Item = MenuItem,>({
children,
icon,
iconComponent,
items,
loading,
title = 'Menu',
disabled,
scrollable,
active,
status,
renderItem,
renderHeader,
onOpen,
onItemClick,
scrollKey,
}: DropdownProps<Item>) => {
const dispatch = useAppDispatch();
const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
const openedViaKeyboard = useAppSelector(
(state) => state.dropdownMenu.keyboard,
);
const [currentId] = useState(id++);
const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null);
const handleClose = useCallback(() => {
if (activeElement.current) {
activeElement.current.focus({ preventScroll: true });
activeElement.current = null;
}
dispatch(
closeModal({
modalType: 'ACTIONS',
ignoreFocus: false,
}),
);
dispatch(closeDropdownMenu({ id: currentId }));
}, [dispatch, currentId]);
const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
handleClose();
if (!item) {
return;
}
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
}
},
[handleClose, onItemClick, items],
);
const handleClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
if (open) {
handleClose();
} else {
onOpen?.();
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
}
if (isUserTouching()) {
dispatch(
openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: handleItemClick,
},
}),
);
} else {
dispatch(
openDropdownMenu({
id: currentId,
keyboard: type !== 'click',
scrollKey,
}),
);
}
}
},
[
dispatch,
currentId,
scrollKey,
onOpen,
handleItemClick,
open,
status,
items,
handleClose,
],
);
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(() => {
return () => {
if (currentId === openDropdownId) {
handleClose();
}
};
}, [currentId, openDropdownId, handleClose]);
let button: React.ReactElement;
if (children) {
button = cloneElement(Children.only(children), {
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: targetRef,
});
} else if (icon && iconComponent) {
button = (
<IconButton
icon={!open ? icon : 'close'}
iconComponent={iconComponent}
title={title}
active={open || active}
disabled={disabled}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={targetRef}
/>
);
} else {
return null;
}
return (
<>
{button}
<Overlay
show={open}
offset={offset}
placement='bottom'
flip
target={targetRef}
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}
{...arrowProps}
/>
<DropdownMenu
items={items}
loading={loading}
scrollable={scrollable}
onClose={handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={onItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
};

View file

@ -0,0 +1,32 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu';
/**
*
* @param {import('mastodon/store').RootState} state
* @param {*} props
*/
const mapStateToProps = (state, { statusId }) => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
items: state.getIn(['history', statusId, 'items']),
loading: state.getIn(['history', statusId, 'loading']),
});
const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu({ id, keyboard }));
},
onClose (id) {
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View file

@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import DropdownMenu from './containers/dropdown_menu_container';
const mapDispatchToProps = (dispatch, { statusId }) => ({
onItemClick (index) {
dispatch(openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}));
},
});
class EditedTimestamp extends PureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
onItemClick: PropTypes.func.isRequired,
};
handleItemClick = (item, i) => {
const { onItemClick } = this.props;
onItemClick(i);
};
renderHeader = items => {
return (
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
);
};
renderItem = (item, index, { onClick, onKeyPress }) => {
const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
const formattedName = <InlineAccount accountId={item.get('account')} />;
const label = item.get('original') ? (
<FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
) : (
<FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
);
return (
<li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
<button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
</li>
);
};
render () {
const { timestamp, statusId } = this.props;
return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
</button>
</DropdownMenu>
);
}
}
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));

View file

@ -1,140 +0,0 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { fetchHistory } from 'mastodon/actions/history';
import { openModal } from 'mastodon/actions/modal';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
type HistoryItem = ImmutableMap<string, unknown>;
export const EditedTimestamp: React.FC<{
statusId: string;
timestamp: string;
}> = ({ statusId, timestamp }) => {
const dispatch = useAppDispatch();
const items = useAppSelector(
(state) =>
(
state.history.getIn([statusId, 'items']) as
| ImmutableList<unknown>
| undefined
)?.toArray() as HistoryItem[],
);
const loading = useAppSelector(
(state) => state.history.getIn([statusId, 'loading']) as boolean,
);
const handleOpen = useCallback(() => {
dispatch(fetchHistory(statusId));
}, [dispatch, statusId]);
const handleItemClick = useCallback(
(_item: HistoryItem, index: number) => {
dispatch(
openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}),
);
},
[dispatch, statusId],
);
const renderHeader = useCallback((items: HistoryItem[]) => {
return (
<FormattedMessage
id='status.edited_x_times'
defaultMessage='Edited {count, plural, one {# time} other {# times}}'
values={{ count: items.length - 1 }}
/>
);
}, []);
const renderItem = useCallback(
(
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
const formattedDate = (
<RelativeTimestamp
timestamp={item.get('created_at') as string}
short={false}
/>
);
const formattedName = (
<InlineAccount accountId={item.get('account') as string} />
);
const label = (item.get('original') as boolean) ? (
<FormattedMessage
id='status.history.created'
defaultMessage='{name} created {date}'
values={{ name: formattedName, date: formattedDate }}
/>
) : (
<FormattedMessage
id='status.history.edited'
defaultMessage='{name} edited {date}'
values={{ name: formattedName, date: formattedDate }}
/>
);
return (
<li
className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string}
>
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
{label}
</button>
</li>
);
},
[],
);
return (
<Dropdown<HistoryItem>
items={items}
loading={loading}
renderItem={renderItem}
scrollable
renderHeader={renderHeader}
onOpen={handleOpen}
onItemClick={handleItemClick}
>
<button className='dropdown-menu__text-button'>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'
values={{
date: (
<FormattedDateWrapper
className='animated-number'
value={timestamp}
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
),
}}
/>
</button>
</Dropdown>
);
};

View file

@ -16,7 +16,8 @@ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
export const FollowButton: React.FC<{
@ -72,9 +73,15 @@ export const FollowButton: React.FC<{
if (!signedIn) {
label = intl.formatMessage(messages.follow);
} else if (accountId === me) {
label = intl.formatMessage(messages.editProfile);
label = intl.formatMessage(messages.edit_profile);
} else if (!relationship) {
label = <LoadingIndicator />;
} else if (
relationship.following &&
isShowItem('relationships') &&
relationship.followed_by
) {
label = intl.formatMessage(messages.mutual);
} else if (relationship.following || relationship.requested) {
label = intl.formatMessage(messages.unfollow);
} else if (relationship.followed_by && isShowItem('relationships')) {

View file

@ -102,11 +102,10 @@ export interface HashtagProps {
description?: React.ReactNode;
history?: number[];
name: string;
people?: number;
people: number;
to: string;
uses?: number;
withGraph?: boolean;
children?: React.ReactNode;
}
export const Hashtag: React.FC<HashtagProps> = ({
@ -118,7 +117,6 @@ export const Hashtag: React.FC<HashtagProps> = ({
className,
description,
withGraph = true,
children,
}) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
@ -160,7 +158,5 @@ export const Hashtag: React.FC<HashtagProps> = ({
</SilentErrorBoundary>
</div>
)}
{children && <div className='trends__item__buttons'>{children}</div>}
</div>
);

View file

@ -20,7 +20,6 @@ export type StatusLike = Record<{
contentHTML: string;
media_attachments: List<unknown>;
spoiler_text?: string;
account: Record<{ id: string }>;
}>;
function normalizeHashtag(hashtag: string) {
@ -196,36 +195,19 @@ export function getHashtagBarForStatus(status: StatusLike) {
return {
statusContentProps,
hashtagBar: (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.getIn(['account', 'id']) as string}
/>
),
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
};
}
export function getFeaturedHashtagBar(
accountId: string,
acct: string,
tags: string[],
) {
return (
<HashtagBar
acct={acct}
hashtags={tags}
accountId={accountId}
defaultExpanded
/>
);
export function getFeaturedHashtagBar(acct: string, tags: string[]) {
return <HashtagBar acct={acct} hashtags={tags} defaultExpanded />;
}
const HashtagBar: React.FC<{
hashtags: string[];
accountId: string;
acct?: string;
defaultExpanded?: boolean;
}> = ({ hashtags, accountId, acct, defaultExpanded }) => {
}> = ({ hashtags, acct, defaultExpanded }) => {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
setExpanded(true);
@ -246,7 +228,6 @@ const HashtagBar: React.FC<{
<Link
key={hashtag}
to={acct ? `/@${acct}/tagged/${hashtag}` : `/tags/${hashtag}`}
data-menu-hashtag={accountId}
>
#<span>{hashtag}</span>
</Link>

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, forwardRef } from 'react';
import { PureComponent, createRef } from 'react';
import classNames from 'classnames';
@ -15,110 +15,101 @@ interface Props {
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
active?: boolean;
active: boolean;
expanded?: boolean;
style?: React.CSSProperties;
activeStyle?: React.CSSProperties;
disabled?: boolean;
disabled: boolean;
inverted?: boolean;
animate?: boolean;
overlay?: boolean;
tabIndex?: number;
animate: boolean;
overlay: boolean;
tabIndex: number;
counter?: number;
href?: string;
ariaHidden?: boolean;
ariaHidden: boolean;
data_id?: string;
}
interface States {
activate: boolean;
deactivate: boolean;
}
export class IconButton extends PureComponent<Props, States> {
buttonRef = createRef<HTMLButtonElement>();
export const IconButton = forwardRef<HTMLButtonElement, Props>(
(
{
static defaultProps = {
active: false,
disabled: false,
animate: false,
overlay: false,
tabIndex: 0,
ariaHidden: false,
};
state = {
activate: false,
deactivate: false,
};
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (!nextProps.animate) return;
if (this.props.active && !nextProps.active) {
this.setState({ activate: false, deactivate: true });
} else if (!this.props.active && nextProps.active) {
this.setState({ activate: true, deactivate: false });
}
}
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!this.props.disabled && this.props.onClick != null) {
this.props.onClick(e);
}
};
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
};
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
};
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
};
render() {
const style = {
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
const {
active,
className,
disabled,
expanded,
icon,
iconComponent,
inverted,
overlay,
tabIndex,
title,
counter,
href,
style,
activeStyle,
onClick,
onKeyDown,
onKeyPress,
onMouseDown,
active = false,
disabled = false,
animate = false,
overlay = false,
tabIndex = 0,
ariaHidden = false,
data_id = undefined,
},
buttonRef,
) => {
const [activate, setActivate] = useState(false);
const [deactivate, setDeactivate] = useState(false);
ariaHidden,
data_id,
} = this.props;
useEffect(() => {
if (!animate) {
return;
}
if (activate && !active) {
setActivate(false);
setDeactivate(true);
} else if (!activate && active) {
setActivate(true);
setDeactivate(false);
}
}, [setActivate, setDeactivate, animate, active, activate]);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
e.preventDefault();
if (!disabled) {
onClick?.(e);
}
},
[disabled, onClick],
);
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyPress?.(e);
}
},
[disabled, onKeyPress],
);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onMouseDown?.(e);
}
},
[disabled, onMouseDown],
);
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyDown?.(e);
}
},
[disabled, onKeyDown],
);
const buttonStyle = {
...style,
...(active ? activeStyle : {}),
};
const { activate, deactivate } = this.state;
const classes = classNames(className, 'icon-button', {
active,
@ -157,20 +148,19 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
aria-hidden={ariaHidden}
title={title}
className={classes}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
style={buttonStyle}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}
disabled={disabled}
data-id={data_id}
ref={buttonRef}
ref={this.buttonRef}
>
{contents}
</button>
);
},
);
IconButton.displayName = 'IconButton';
}
}

View file

@ -1,6 +1,25 @@
import { Switch, Route } from 'react-router-dom';
import AccountNavigation from 'mastodon/features/account/navigation';
import Trends from 'mastodon/features/getting_started/containers/trends_container';
import { showTrends } from 'mastodon/initial_state';
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
export const NavigationPortal: React.FC = () => (
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
<div className='navigation-panel__portal'>
<Switch>
<Route path='/@:acct' exact component={AccountNavigation} />
<Route
path='/@:acct/tagged/:tagged?'
exact
component={AccountNavigation}
/>
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} />
</Switch>
</div>
);

View file

@ -1,43 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { useAppSelector } from 'mastodon/store';
import { TimelineHint } from './timeline_hint';
interface RemoteHintProps {
accountId?: string;
}
export const RemoteHint: React.FC<RemoteHintProps> = ({ accountId }) => {
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const domain = account?.acct ? account.acct.split('@')[1] : undefined;
if (
!account ||
!account.url ||
account.acct !== account.username ||
!domain
) {
return null;
}
return (
<TimelineHint
url={account.url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View file

@ -25,8 +25,9 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { enableEmojiReaction , bookmarkCategoryNeeded, simpleTimelineMenu, me, isHideItem, boostMenu, boostModal } from '../initial_state';
import { IconButton } from './icon_button';
@ -348,9 +349,10 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
menu.push(null);
if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
@ -496,7 +498,7 @@ class StatusActionBar extends ImmutablePureComponent {
</div>
<div className='status__action-bar__button-wrapper'>
{reblogMenu.length === 0 ? reblogButton : (
<Dropdown
<DropdownMenuContainer
className={classNames('status__action-bar__button', { reblogPrivate })}
scrollKey={scrollKey}
status={status}
@ -507,7 +509,9 @@ class StatusActionBar extends ImmutablePureComponent {
title={reblogTitle}
active={status.get('reblogged')}
disabled={!publicStatus && !reblogPrivate}
/>
>
{reblogButton}
</DropdownMenuContainer>
)}
</div>
<div className='status__action-bar__button-wrapper'>
@ -518,7 +522,7 @@ class StatusActionBar extends ImmutablePureComponent {
</div>
{emojiPickerDropdown}
<div className='status__action-bar__button-wrapper'>
<Dropdown
<DropdownMenuContainer
scrollKey={scrollKey}
status={status}
items={menu}

View file

@ -115,7 +115,6 @@ class StatusContent extends PureComponent {
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');

View file

@ -0,0 +1,50 @@
import { connect } from 'react-redux';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modal';
import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile';
/**
* @param {import('mastodon/store').RootState} state
*/
const mapStateToProps = state => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
}
dispatch(isUserTouching() ? openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: onItemClick,
},
}) : openDropdownMenu({ id, keyboard, scrollKey }));
},
onClose(id) {
dispatch(closeModal({
modalType: 'ACTIONS',
ignoreFocus: false,
}));
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View file

@ -0,0 +1,51 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Hashtag } from 'mastodon/components/hashtag';
const messages = defineMessages({
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
});
class FeaturedTags extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record,
featuredTags: ImmutablePropTypes.list,
tagged: PropTypes.string,
intl: PropTypes.object.isRequired,
};
render () {
const { account, featuredTags, intl } = this.props;
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
return null;
}
return (
<div className='getting-started__trends'>
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
{featuredTags.take(3).map(featuredTag => (
<Hashtag
key={featuredTag.get('name')}
name={featuredTag.get('name')}
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
uses={featuredTag.get('statuses_count') * 1}
withGraph={false}
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
/>
))}
</div>
);
}
}
export default injectIntl(FeaturedTags);

View file

@ -0,0 +1,17 @@
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import FeaturedTags from '../components/featured_tags';
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return (state, { accountId }) => ({
account: getAccount(state, accountId),
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
});
};
export default connect(mapStateToProps)(FeaturedTags);

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
const mapStateToProps = (state, { match: { params: { acct } } }) => {
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
isLoading: false,
};
};
class AccountNavigation extends PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
acct: PropTypes.string,
tagged: PropTypes.string,
}).isRequired,
}).isRequired,
accountId: PropTypes.string,
isLoading: PropTypes.bool,
};
render () {
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
if (isLoading) {
return null;
}
return (
<FeaturedTags accountId={accountId} tagged={tagged} />
);
}
}
export default connect(mapStateToProps)(AccountNavigation);

View file

@ -1,50 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
interface EmptyMessageProps {
suspended: boolean;
hidden: boolean;
blockedBy: boolean;
accountId?: string;
}
export const EmptyMessage: React.FC<EmptyMessageProps> = ({
accountId,
suspended,
hidden,
blockedBy,
}) => {
if (!accountId) {
return null;
}
let message: React.ReactNode = null;
if (suspended) {
message = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
message = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
message = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else {
message = (
<FormattedMessage
id='empty_column.account_featured'
defaultMessage='This list is empty'
/>
);
}
return <div className='empty-column-indicator'>{message}</div>;
};

View file

@ -1,51 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Hashtag } from 'mastodon/components/hashtag';
export type TagMap = ImmutableMap<
'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
string | null
>;
interface FeaturedTagProps {
tag: TagMap;
account: string;
}
const messages = defineMessages({
lastStatusAt: {
id: 'account.featured_tags.last_status_at',
defaultMessage: 'Last post on {date}',
},
empty: {
id: 'account.featured_tags.last_status_never',
defaultMessage: 'No posts',
},
});
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
const intl = useIntl();
const name = tag.get('name') ?? '';
const count = Number.parseInt(tag.get('statuses_count') ?? '');
return (
<Hashtag
key={name}
name={name}
to={`/@${account}/tagged/${name}`}
uses={count}
withGraph={false}
description={
count > 0
? intl.formatMessage(messages.lastStatusAt, {
date: intl.formatDate(tag.get('last_status_at') ?? '', {
month: 'short',
day: '2-digit',
}),
})
: intl.formatMessage(messages.empty)
}
/>
);
};

View file

@ -1,156 +0,0 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint';
import StatusContainer from 'mastodon/containers/status_container';
import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag';
interface Params {
acct?: string;
id?: string;
}
const AccountFeatured = () => {
const accountId = useAccountId();
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
const forceEmptyState = suspended || blockedBy || hidden;
const { acct = '' } = useParams<Params>();
const dispatch = useAppDispatch();
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
dispatch(fetchFeaturedTags(accountId));
}
}, [accountId, dispatch]);
const isLoading = useAppSelector(
(state) =>
!accountId ||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:pinned`,
'isLoading',
]) ||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
);
const featuredTags = useAppSelector(
(state) =>
state.user_lists.getIn(
['featured_tags', accountId, 'items'],
ImmutableList(),
) as ImmutableList<TagMap>,
);
const featuredStatusIds = useAppSelector(
(state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
if (isLoading) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<div className='scrollable__append'>
<LoadingIndicator />
</div>
</AccountFeaturedWrapper>
);
}
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
blockedBy={blockedBy}
hidden={hidden}
suspended={suspended}
accountId={accountId}
/>
<RemoteHint accountId={accountId} />
</AccountFeaturedWrapper>
);
}
return (
<Column>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
{accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)}
{!featuredTags.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.hashtags'
defaultMessage='Hashtags'
/>
</h4>
{featuredTags.map((tag) => (
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
))}
</>
)}
{!featuredStatusIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.posts'
defaultMessage='Posts'
/>
</h4>
{featuredStatusIds.map((statusId) => (
<StatusContainer
key={`f-${statusId}`}
// @ts-expect-error inferred props are wrong
id={statusId}
contextType='account'
/>
))}
</>
)}
<RemoteHint accountId={accountId} />
</div>
</Column>
);
};
const AccountFeaturedWrapper = ({
children,
accountId,
}: React.PropsWithChildren<{ accountId?: string }>) => {
return (
<Column>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
{accountId && <AccountHeader accountId={accountId} />}
{children}
</div>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountFeatured;

View file

@ -2,22 +2,25 @@ import { useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { RemoteHint } from 'mastodon/components/remote_hint';
import ScrollableList from 'mastodon/components/scrollable_list';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -53,11 +56,53 @@ const getAccountGallery = createSelector(
},
);
interface Params {
acct?: string;
id?: string;
}
const RemoteHint: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
const acct = account?.acct;
const url = account?.url;
const domain = acct ? acct.split('@')[1] : undefined;
if (!url) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};
export const AccountGallery: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAccountId();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const attachments = useAppSelector((state) =>
accountId
? getAccountGallery(state, accountId)
@ -78,15 +123,33 @@ export const AccountGallery: React.FC<{
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const blockedBy = useAppSelector(
(state) =>
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
);
const suspended = useAppSelector(
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
);
const isAccount = !!account;
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
const remote = account?.acct !== account?.username;
const hidden = useAppSelector((state) =>
accountId ? getAccountHidden(state, accountId) : false,
);
const maxId = attachments.last()?.getIn(['status', 'id']) as
| string
| undefined;
useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
}
}, [dispatch, accountId, acct]);
useEffect(() => {
if (accountId && !isAccount) {
dispatch(fetchAccount(accountId));
}
if (accountId && isAccount) {
void dispatch(expandAccountMediaTimeline(accountId));
}
@ -170,7 +233,7 @@ export const AccountGallery: React.FC<{
defaultMessage='Profile unavailable'
/>
);
} else if (attachments.isEmpty()) {
} else if (remote && attachments.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} />;
} else {
emptyMessage = (
@ -196,7 +259,7 @@ export const AccountGallery: React.FC<{
)
}
alwaysPrepend
append={accountId && <RemoteHint accountId={accountId} />}
append={remote && accountId && <RemoteHint accountId={accountId} />}
scrollKey='account_gallery'
isLoading={isLoading}
hasMore={!forceEmptyState && hasMore}

View file

@ -44,21 +44,27 @@ import {
FollowingCounter,
StatusesCounter,
} from 'mastodon/components/counters';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { getFeaturedHashtagBar } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import {
autoPlayGif,
me,
domain as localDomain,
isShowItem,
} from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
import type { Relationship } from 'mastodon/models/relationship';
import {
PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION,
@ -198,6 +204,24 @@ const titleFromAccount = (account: Account) => {
return `${prefix} (@${acct})`;
};
const messageForFollowButton = (relationship?: Relationship) => {
if (!relationship) return messages.follow;
if (
relationship.get('following') &&
relationship.get('followed_by') &&
isShowItem('relationships')
) {
return messages.mutual;
} else if (relationship.get('following') || relationship.get('requested')) {
return messages.unfollow;
} else if (relationship.get('followed_by') && isShowItem('relationships')) {
return messages.followBack;
} else {
return messages.follow;
}
};
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
@ -225,6 +249,20 @@ export const AccountHeader: React.FC<{
);
const handleLinkClick = useLinks();
const handleFollow = useCallback(() => {
if (!account) {
return;
}
if (relationship?.following || relationship?.requested) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
} else {
dispatch(followAccount(account.id));
}
}, [dispatch, account, relationship]);
const handleBlock = useCallback(() => {
if (!account) {
return;
@ -408,6 +446,23 @@ export const AccountHeader: React.FC<{
);
}, [dispatch, account]);
const handleInteractionModal = useCallback(() => {
if (!account) {
return;
}
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'follow',
accountId: account.id,
url: account.uri,
},
}),
);
}, [dispatch, account]);
const handleOpenAvatar = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
@ -443,6 +498,10 @@ export const AccountHeader: React.FC<{
});
}, [account]);
const handleEditProfile = useCallback(() => {
window.open('/settings/profile', '_blank');
}, []);
const handleMouseEnter = useCallback(
({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) {
@ -478,7 +537,7 @@ export const AccountHeader: React.FC<{
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => {
const arr: MenuItem[] = [];
const arr: DropdownMenu = [];
if (!account) {
return arr;
@ -567,7 +626,9 @@ export const AccountHeader: React.FC<{
arr.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
account.getIn(['relationship', 'endorsed'])
? messages.unendorse
: messages.endorse,
),
action: handleEndorseToggle,
});
@ -717,12 +778,9 @@ export const AccountHeader: React.FC<{
return null;
}
let actionBtn: React.ReactNode,
bellBtn: React.ReactNode,
lockedIcon: React.ReactNode,
shareBtn: React.ReactNode;
let actionBtn, bellBtn, lockedIcon, shareBtn;
const info: React.ReactNode[] = [];
const info = [];
if (me !== account.id && relationship?.blocking) {
info.push(
@ -790,17 +848,43 @@ export const AccountHeader: React.FC<{
);
}
if (relationship?.blocking) {
if (me !== account.id) {
if (signedIn && !relationship) {
// Wait until the relationship is loaded
actionBtn = (
<Button disabled>
<LoadingIndicator />
</Button>
);
} else if (!relationship?.blocking) {
actionBtn = (
<Button
disabled={relationship?.blocked_by}
className={classNames({
'button--destructive':
relationship?.following || relationship?.requested,
})}
text={intl.formatMessage(messageForFollowButton(relationship))}
onClick={signedIn ? handleFollow : handleInteractionModal}
/>
);
} else {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
/>
);
}
} else {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
text={intl.formatMessage(messages.edit_profile)}
onClick={handleEditProfile}
/>
);
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
@ -826,11 +910,7 @@ export const AccountHeader: React.FC<{
const isIndexable = !account.noindex;
const featuredTagsArr =
featuredTags?.map((tag: any) => tag.get('name')).toArray() || [];
const featuredTagsBar = getFeaturedHashtagBar(
account.id,
account.acct,
featuredTagsArr,
);
const featuredTagsBar = getFeaturedHashtagBar(account.acct, featuredTagsArr);
const badges = [];
@ -840,7 +920,7 @@ export const AccountHeader: React.FC<{
badges.push(<GroupBadge key='group-badge' />);
}
account.roles.forEach((role) => {
account.get('roles', []).forEach((role) => {
badges.push(
<Badge
key={`role-badge-${role.get('id')}`}
@ -900,11 +980,13 @@ export const AccountHeader: React.FC<{
<div className='account__header__tabs__buttons'>
{!hidden && bellBtn}
{!hidden && shareBtn}
<Dropdown
<DropdownMenuContainer
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
{!hidden && actionBtn}
</div>
@ -1059,9 +1141,6 @@ export const AccountHeader: React.FC<{
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
<NavLink exact to={`/@${account.acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<NavLink exact to={`/@${account.acct}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</NavLink>

View file

@ -7,10 +7,12 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { me } from 'mastodon/initial_state';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
@ -19,7 +21,6 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
import { RemoteHint } from 'mastodon/components/remote_hint';
import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint';
@ -46,8 +47,11 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
@ -56,6 +60,24 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
};
};
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class AccountTimeline extends ImmutablePureComponent {
static propTypes = {
@ -67,6 +89,7 @@ class AccountTimeline extends ImmutablePureComponent {
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list,
featuredStatusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
withReplies: PropTypes.bool,
@ -74,6 +97,8 @@ class AccountTimeline extends ImmutablePureComponent {
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
@ -136,7 +161,7 @@ class AccountTimeline extends ImmutablePureComponent {
};
render () {
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (isLoading && statusIds.isEmpty()) {
return (
@ -166,6 +191,8 @@ class AccountTimeline extends ImmutablePureComponent {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
}
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>
<ColumnBackButton />
@ -173,9 +200,10 @@ class AccountTimeline extends ImmutablePureComponent {
<StatusList
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
alwaysPrepend
append={<RemoteHint accountId={accountId} />}
append={remoteMessage}
scrollKey='account_timeline'
statusIds={forceEmptyState ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds}
isLoading={isLoading}
hasMore={!forceEmptyState && hasMore}
onLoadMore={this.handleLoadMore}

View file

@ -2,6 +2,7 @@ import {
useState,
useCallback,
useRef,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react';
@ -12,7 +13,6 @@ import classNames from 'classnames';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { useSpring, animated } from '@react-spring/web';
import Textarea from 'react-textarea-autosize';
import { length } from 'stringz';
// eslint-disable-next-line import/extensions
@ -31,7 +31,7 @@ import Audio from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { Video, getPointerPosition } from 'mastodon/features/video';
import { me, reduceMotion } from 'mastodon/initial_state';
import { me } from 'mastodon/initial_state';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { assetHost } from 'mastodon/utils/config';
@ -105,17 +105,6 @@ const Preview: React.FC<{
position: FocalPoint;
onPositionChange: (arg0: FocalPoint) => void;
}> = ({ mediaId, position, onPositionChange }) => {
const draggingRef = useRef<boolean>(false);
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const [x, y] = position;
const style = useSpring({
to: {
left: `${x * 100}%`,
top: `${y * 100}%`,
},
immediate: reduceMotion || draggingRef.current,
});
const media = useAppSelector((state) =>
(
(state.compose as ImmutableMap<string, unknown>).get(
@ -128,6 +117,9 @@ const Preview: React.FC<{
);
const [dragging, setDragging] = useState(false);
const [x, y] = position;
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const draggingRef = useRef<boolean>(false);
const setRef = useCallback(
(e: HTMLImageElement | HTMLVideoElement | null) => {
@ -142,30 +134,36 @@ const Preview: React.FC<{
return;
}
const handleMouseMove = (e: MouseEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e);
draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves
onPositionChange([x, y]);
};
const handleMouseUp = () => {
setDragging(false);
draggingRef.current = false;
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
},
[setDragging, onPositionChange],
);
useEffect(() => {
const handleMouseUp = () => {
setDragging(false);
draggingRef.current = false;
};
const handleMouseMove = (e: MouseEvent) => {
if (draggingRef.current) {
const { x, y } = getPointerPosition(nodeRef.current, e);
onPositionChange([x, y]);
}
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [setDragging, onPositionChange]);
if (!media) {
return null;
}
@ -181,7 +179,10 @@ const Preview: React.FC<{
role='presentation'
onMouseDown={handleMouseDown}
/>
<animated.div className='focal-point__reticle' style={style} />
<div
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div>
);
} else if (media.get('type') === 'gifv') {
@ -193,7 +194,10 @@ const Preview: React.FC<{
alt=''
onMouseDown={handleMouseDown}
/>
<animated.div className='focal-point__reticle' style={style} />
<div
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div>
);
} else if (media.get('type') === 'video') {

View file

@ -13,9 +13,9 @@ import { fetchAntennas } from 'mastodon/actions/antennas';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -96,11 +96,12 @@ const AntennaItem: React.FC<{
</span>
</Link>
<Dropdown
<DropdownMenuContainer
scrollKey='antennas'
items={menu}
icon='ellipsis-h'
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View file

@ -14,9 +14,9 @@ import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -76,11 +76,12 @@ const BookmarkCategoryItem: React.FC<{
<span>{title}</span>
</Link>
<Dropdown
<DropdownMenuContainer
scrollKey='bookmark_categories'
items={menu}
icon='ellipsis-h'
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View file

@ -0,0 +1,195 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses } from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_bookmark_category.message', defaultMessage: 'Are you sure you want to permanently delete this category?' },
deleteConfirm: { id: 'confirmations.delete_bookmark_category.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = (state, { params }) => ({
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
statusIds: getBookmarkCategoryStatusList(state, params.id),
isLoading: state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'next']),
});
class BookmarkCategoryStatuses extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
bookmarkCategory: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategory(this.props.params.id));
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchBookmarkCategory(id));
dispatch(fetchBookmarkCategoryStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', { id: this.props.params.id }));
this.props.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.history.push(`/bookmark_categories/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteBookmarkCategory(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/bookmark_categories');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkCategoryStatuses(this.props.params.id));
}, 300, { leading: true });
render () {
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof bookmarkCategory === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (bookmarkCategory === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmark'
iconComponent={BookmarkIcon}
title={bookmarkCategory.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='bookmark_categories.edit' defaultMessage='Edit category' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='bookmark_categories.delete' defaultMessage='Delete category' />
</button>
</section>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(BookmarkCategoryStatuses)));

View file

@ -1,121 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import {
expandBookmarkCategoryStatuses,
fetchBookmarkCategory,
fetchBookmarkCategoryStatuses,
} from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getSubStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const BookmarkCategoryStatuses: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getSubStatusList(state, 'bookmark_category', id),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['bookmark_category_statuses', id, 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) =>
!!state.status_lists.getIn(['bookmark_category_statuses', id, 'next']),
);
const bookmarkCategory = useAppSelector((state) =>
state.bookmark_categories.get(id),
);
useEffect(() => {
dispatch(fetchBookmarkCategory(id));
dispatch(fetchBookmarkCategoryStatuses(id));
}, [dispatch, id]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', { id }));
}
}, [dispatch, columnId, id]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandBookmarkCategoryStatuses(id));
}, [dispatch, id]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.bookmarked_statuses'
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={bookmarkCategory?.get('title')}
>
<ColumnHeader
icon='bookmark'
iconComponent={BookmarkIcon}
title={bookmarkCategory?.get('title')}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{bookmarkCategory?.get('title')}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkCategoryStatuses;

View file

@ -0,0 +1,117 @@
// Kmyblue tracking marker: copied bookmark_category_statuses, circle_statuses
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});
class Bookmarks extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkedStatuses());
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkedStatuses());
}, 300, { leading: true });
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='bookmarks'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Bookmarks));

View file

@ -1,116 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import {
fetchBookmarkedStatuses,
expandBookmarkedStatuses,
} from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const Bookmarks: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'bookmarks'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
);
useEffect(() => {
dispatch(fetchBookmarkedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandBookmarkedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.bookmarked_statuses'
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='bookmarks'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Bookmarks;

View file

@ -0,0 +1,195 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { getCircleStatusList } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.circles', defaultMessage: 'Circles' },
});
const mapStateToProps = (state, { params }) => ({
circle: state.getIn(['circles', params.id]),
statusIds: getCircleStatusList(state, params.id),
isLoading: state.getIn(['status_lists', 'circle_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'circle_statuses', params.id, 'next']),
});
class CircleStatuses extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircle(this.props.params.id));
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchCircle(id));
dispatch(fetchCircleStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id }));
this.props.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.history.push(`/circles/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/circles');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandCircleStatuses(this.props.params.id));
}, 300, { leading: true });
render () {
const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof circle === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (circle === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.circle_statuses' defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='user-circle'
iconComponent={CircleIcon}
title={circle.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='circles.edit' defaultMessage='Edit circle' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='circles.delete' defaultMessage='Delete circle' />
</button>
</section>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`circle_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(CircleStatuses)));

View file

@ -1,118 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import {
expandCircleStatuses,
fetchCircle,
fetchCircleStatuses,
} from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getSubStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const CircleStatuses: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getSubStatusList(state, 'circle', id),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['circle_statuses', id, 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['circle_statuses', id, 'next']),
);
const circle = useAppSelector((state) => state.circles.get(id));
useEffect(() => {
dispatch(fetchCircle(id));
dispatch(fetchCircleStatuses(id));
}, [dispatch, id]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id }));
}
}, [dispatch, columnId, id]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandCircleStatuses(id));
}, [dispatch, id]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.circle_statuses'
defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={circle?.get('title')}
>
<ColumnHeader
icon='bookmark'
iconComponent={CircleIcon}
title={circle?.get('title')}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{circle?.get('title')}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default CircleStatuses;

View file

@ -13,9 +13,9 @@ import { fetchCircles } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -60,11 +60,12 @@ const CircleItem: React.FC<{
<span>{title}</span>
</Link>
<Dropdown
<DropdownMenuContainer
scrollKey='circles'
items={menu}
icon='ellipsis-h'
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View file

@ -2,94 +2,67 @@ import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { useAppDispatch } from 'mastodon/store';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
reaction_deck: {
id: 'navigation_bar.reaction_deck',
defaultMessage: 'Reaction deck',
},
follow_requests: {
id: 'navigation_bar.follow_requests',
defaultMessage: 'Follow requests',
},
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
reaction_deck: { id: 'navigation_bar.reaction_deck', defaultMessage: 'Reaction deck' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
emoji_reactions: {
id: 'navigation_bar.emoji_reactions',
defaultMessage: 'Stamps',
},
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Stamps' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: {
id: 'navigation_bar.domain_blocks',
defaultMessage: 'Blocked domains',
},
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
});
export const ActionBar: React.FC = () => {
const dispatch = useAppDispatch();
export const ActionBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
const menu = useMemo(() => {
const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
};
return [
{
text: intl.formatMessage(messages.edit_profile),
href: '/settings/profile',
},
{
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
},
return ([
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
null,
{
text: intl.formatMessage(messages.follow_requests),
to: '/follow_requests',
},
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
{
text: intl.formatMessage(messages.emoji_reactions),
to: '/emoji_reactions',
},
{ text: intl.formatMessage(messages.emoji_reactions), to: '/emoji_reactions' },
{ text: intl.formatMessage(messages.lists), to: '/lists' },
{
text: intl.formatMessage(messages.followed_tags),
to: '/followed_tags',
},
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
null,
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{
text: intl.formatMessage(messages.domain_blocks),
to: '/domain_blocks',
},
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
{ text: intl.formatMessage(messages.filters), href: '/filters' },
null,
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
];
]);
}, [intl, dispatch]);
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
return (
<DropdownMenuContainer
items={menu}
icon='bars'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
);
};

View file

@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
@ -205,7 +205,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'>
<Dropdown
<DropdownMenuContainer
scrollKey={scrollKey}
status={lastStatus}
items={menu}

View file

@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchEmojiReactedStatuses, expandEmojiReactedStatuses } from 'mastodon/actions/emoji_reactions';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
const messages = defineMessages({
heading: { id: 'column.emoji_reactions', defaultMessage: 'Stamps' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'emoji_reactions', 'items']),
isLoading: state.getIn(['status_lists', 'emoji_reactions', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'emoji_reactions', 'next']),
});
class EmojiReactions extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchEmojiReactedStatuses());
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('EMOJI_REACTIONS', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandEmojiReactedStatuses());
}, 300, { leading: true });
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.emoji_reacted_statuses' defaultMessage="You don't have any emoji reacted posts yet. When you emoji react one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='smile-o'
iconComponent={EmojiReactionIcon}
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`emoji_reacted_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(EmojiReactions));

View file

@ -1,118 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import {
fetchEmojiReactedStatuses,
expandEmojiReactedStatuses,
} from 'mastodon/actions/emoji_reactions';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.emoji_reactions', defaultMessage: 'Stamps' },
});
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
columnId,
multiColumn,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'emoji_reactions'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['emoji_reactions', 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['emoji_reactions', 'next']),
);
useEffect(() => {
dispatch(fetchEmojiReactedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('EMOJI_REACTIONS', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandEmojiReactedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.emoji_reacted_statuses'
defaultMessage="You don't have any emoji reacted posts yet. When you emoji react one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='star'
iconComponent={EmojiReactionIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`emoji_reacted_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Favourites;

View file

@ -25,7 +25,7 @@ export const Card = ({ id, source }) => {
const dispatch = useDispatch();
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion({ accountId: id }));
dispatch(dismissSuggestion(id));
}, [id, dispatch]);
let label;

View file

@ -1,116 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import {
fetchFavouritedStatuses,
expandFavouritedStatuses,
} from 'mastodon/actions/favourites';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
});
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
columnId,
multiColumn,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'favourites'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['favourites', 'next']),
);
useEffect(() => {
dispatch(fetchFavouritedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('FAVOURITES', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandFavouritedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.favourited_statuses'
defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='star'
iconComponent={StarIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='favourites'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Favourites;

View file

@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
import ColumnHeader from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list';
import Column from 'mastodon/features/ui/components/column';
const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
});
const mapStateToProps = state => ({
hashtags: state.getIn(['followed_tags', 'items']),
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
hasMore: !!state.getIn(['followed_tags', 'next']),
});
class FollowedTags extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hashtags: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentDidMount() {
this.props.dispatch(fetchFollowedHashtags());
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowedHashtags());
}, 300, { leading: true });
render () {
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
icon='hashtag'
iconComponent={TagIcon}
title={intl.formatMessage(messages.heading)}
showBackButton
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
bindToDocument={!multiColumn}
>
{hashtags.map((hashtag) => (
<Hashtag
key={hashtag.get('name')}
name={hashtag.get('name')}
to={`/tags/${hashtag.get('name')}`}
withGraph={false}
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
))}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(FollowedTags));

View file

@ -1,161 +0,0 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { isFulfilled } from '@reduxjs/toolkit';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
import { apiGetFollowedTags } from 'mastodon/api/tags';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
});
const FollowedTag: React.FC<{
tag: ApiHashtagJSON;
onUnfollow: (arg0: string) => void;
}> = ({ tag, onUnfollow }) => {
const dispatch = useAppDispatch();
const tagId = tag.name;
const handleClick = useCallback(() => {
void dispatch(unfollowHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
onUnfollow(tagId);
}
return '';
});
}, [dispatch, onUnfollow, tagId]);
const people =
parseInt(tag.history[0].accounts) +
parseInt(tag.history[1]?.accounts ?? '');
return (
<Hashtag
name={tag.name}
to={`/tags/${tag.name}`}
withGraph={false}
people={people}
>
<Button onClick={handleClick}>
<FormattedMessage id='account.unfollow' defaultMessage='Unfollow' />
</Button>
</Hashtag>
);
};
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
const [loading, setLoading] = useState(false);
const [next, setNext] = useState<string | undefined>();
const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null);
useEffect(() => {
setLoading(true);
void apiGetFollowedTags()
.then(({ tags, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setTags(tags);
setLoading(false);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext]);
const handleLoadMore = useCallback(() => {
setLoading(true);
void apiGetFollowedTags(next)
.then(({ tags, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setLoading(false);
setTags((previousTags) => [...previousTags, ...tags]);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext, next]);
const handleUnfollow = useCallback(
(tagId: string) => {
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
},
[setTags],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const emptyMessage = (
<FormattedMessage
id='empty_column.followed_tags'
defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.'
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='hashtag'
iconComponent={TagIcon}
title={intl.formatMessage(messages.heading)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
hasMore={hasMore}
isLoading={loading}
showLoading={loading && tags.length === 0}
onLoadMore={handleLoadMore}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
>
{tags.map((tag) => (
<FollowedTag key={tag.name} tag={tag} onUnfollow={handleUnfollow} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default FollowedTags;

View file

@ -12,8 +12,8 @@ import {
} from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { useIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store';
@ -153,11 +153,13 @@ export const HashtagHeader: React.FC<{
<div className='hashtag-header__header__buttons'>
{menu.length > 0 && (
<Dropdown
<DropdownMenu
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
)}

View file

@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -72,11 +72,12 @@ const ListItem: React.FC<{
</span>
</Link>
<Dropdown
<DropdownMenuContainer
scrollKey='lists'
items={menu}
icon='ellipsis-h'
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View file

@ -17,7 +17,7 @@ import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { CheckBox } from 'mastodon/components/check_box';
import { IconButton } from 'mastodon/components/icon_button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers';
@ -105,10 +105,11 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
<div className='notification-request__actions'>
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<Dropdown
<DropdownMenuContainer
items={menu}
icon='ellipsis-h'
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View file

@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls';
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
<div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
</div>
<Dropdown
<DropdownMenuContainer
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
</span>
<Icon id='down' icon={ArrowDropDownIcon} />
</button>
</Dropdown>
</DropdownMenuContainer>
<div className='column-header__select-row__mode-button'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? (

View file

@ -27,7 +27,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { IconButton } from '../../../components/icon_button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { enableEmojiReaction , bookmarkCategoryNeeded, me, isHideItem, boostMenu, boostModal } from '../../../initial_state';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
@ -416,7 +416,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div>
) : (
<div className='detailed-status__button'>
<Dropdown
<DropdownMenuContainer
className={classNames({ reblogPrivate })}
icon='retweet'
iconComponent={reblogIconComponent}
@ -434,7 +434,7 @@ class ActionBar extends PureComponent {
{emojiPickerDropdown}
<div className='detailed-status__action-bar-dropdown'>
<Dropdown icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div>
</div>
);

View file

@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import type { StatusLike } from 'mastodon/components/hashtag_bar';

View file

@ -1,157 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useLocation } from 'react-router-dom';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { DropdownMenu } from 'mastodon/components/dropdown_menu';
import { useAppSelector } from 'mastodon/store';
const messages = defineMessages({
browseHashtag: {
id: 'hashtag.browse',
defaultMessage: 'Browse posts in #{hashtag}',
},
browseHashtagFromAccount: {
id: 'hashtag.browse_from_account',
defaultMessage: 'Browse posts from @{name} in #{hashtag}',
},
muteHashtag: { id: 'hashtag.mute', defaultMessage: 'Mute #{hashtag}' },
});
const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHashtagLink = (
element: HTMLAnchorElement | null,
): element is HTMLAnchorElement => {
if (!element) {
return false;
}
return element.matches('[data-menu-hashtag]');
};
interface TargetParams {
hashtag?: string;
accountId?: string;
}
export const HashtagMenuController: React.FC = () => {
const intl = useIntl();
const [open, setOpen] = useState(false);
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({});
const targetRef = useRef<HTMLAnchorElement | null>(null);
const location = useLocation();
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
useEffect(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen, location]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('a');
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
if (!isHashtagLink(target)) {
return;
}
const hashtag = target.text.replace(/^#/, '');
const accountId = target.getAttribute('data-menu-hashtag');
if (!hashtag || !accountId) {
return;
}
e.preventDefault();
e.stopPropagation();
targetRef.current = target;
setOpen(true);
setTargetParams({ hashtag, accountId });
};
document.addEventListener('click', handleClick, { capture: true });
return () => {
document.removeEventListener('click', handleClick);
};
}, [setTargetParams, setOpen]);
const handleClose = useCallback(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.browseHashtag, {
hashtag,
}),
to: `/tags/${hashtag}`,
},
{
text: intl.formatMessage(messages.browseHashtagFromAccount, {
hashtag,
name: account?.username,
}),
to: `/@${account?.acct}/tagged/${hashtag}`,
},
null,
{
text: intl.formatMessage(messages.muteHashtag, {
hashtag,
}),
href: '/filters',
dangerous: true,
},
],
[intl, hashtag, account],
);
if (!open) {
return null;
}
return (
<Overlay
show={open}
offset={offset}
placement='bottom'
flip
target={targetRef}
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}
{...arrowProps}
/>
<DropdownMenu
items={menu}
onClose={handleClose}
openedViaKeyboard={false}
/>
</div>
</div>
)}
</Overlay>
);
};

View file

@ -31,7 +31,6 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
import BundleColumnError from './components/bundle_column_error';
import Header from './components/header';
import { UploadArea } from './components/upload_area';
import { HashtagMenuController } from './components/hashtag_menu_controller';
import ColumnsAreaContainer from './containers/columns_area_container';
import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container';
@ -92,7 +91,6 @@ import {
BookmarkCategoryEdit,
ReactionDeck,
TermsOfService,
AccountFeatured,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
@ -274,7 +272,6 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
@ -661,7 +658,6 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />}
<AlertsController />
{!disableHoverCards && <HoverCardController />}
<HashtagMenuController />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View file

@ -82,10 +82,6 @@ export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
}
export function AccountFeatured() {
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
}
export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers');
}

View file

@ -1,37 +0,0 @@
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
interface Params {
acct?: string;
id?: string;
}
export function useAccountId() {
const { acct, id } = useParams<Params>();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const isAccount = !!account;
const dispatch = useAppDispatch();
useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
} else if (!isAccount) {
dispatch(fetchAccount(accountId));
}
}, [dispatch, accountId, acct, isAccount]);
return accountId;
}

View file

@ -1,20 +0,0 @@
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
export function useAccountVisibility(accountId?: string) {
const blockedBy = useAppSelector(
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
);
const suspended = useAppSelector(
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
);
const hidden = useAppSelector((state) =>
accountId ? Boolean(getAccountHidden(state, accountId)) : false,
);
return {
blockedBy,
suspended,
hidden,
};
}

View file

@ -25,6 +25,7 @@
"account.endorse": "Amostrar en perfil",
"account.featured_tags.last_status_at": "Zaguera publicación lo {date}",
"account.featured_tags.last_status_never": "Sin publicacions",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir",
"account.followers": "Seguidores",
"account.followers.empty": "Encara no sigue dengún a este usuario.",

View file

@ -29,6 +29,7 @@
"account.endorse": "أوصِ به على صفحتك الشخصية",
"account.featured_tags.last_status_at": "آخر منشور في {date}",
"account.featured_tags.last_status_never": "لا توجد رسائل",
"account.featured_tags.title": "وسوم {name} المميَّزة",
"account.follow": "متابعة",
"account.follow_back": "تابعه بالمثل",
"account.followers": "مُتابِعون",

View file

@ -27,6 +27,7 @@
"account.enable_notifications": "Avisame cuando @{name} espublice artículos",
"account.endorse": "Destacar nel perfil",
"account.featured_tags.last_status_never": "Nun hai nenguna publicación",
"account.featured_tags.title": "Etiquetes destacaes de: {name}",
"account.follow": "Siguir",
"account.follow_back": "Siguir tamién",
"account.followers": "Siguidores",

View file

@ -29,6 +29,7 @@
"account.endorse": "Profildə seçilmişlərə əlavə et",
"account.featured_tags.last_status_at": "Son paylaşım {date} tarixində olub",
"account.featured_tags.last_status_never": "Paylaşım yoxdur",
"account.featured_tags.title": "{name} istifadəçisinin seçilmiş heşteqləri",
"account.follow": "İzlə",
"account.follow_back": "Sən də izlə",
"account.followers": "İzləyicilər",

View file

@ -29,6 +29,7 @@
"account.endorse": "Паказваць у профілі",
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
"account.featured_tags.last_status_never": "Няма допісаў",
"account.featured_tags.title": "Тэгі, выбраныя {name}",
"account.follow": "Падпісацца",
"account.follow_back": "Падпісацца ў адказ",
"account.followers": "Падпісчыкі",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Редактиране на профила",
"account.enable_notifications": "Известяване при публикуване от @{name}",
"account.endorse": "Представи в профила",
"account.featured": "Препоръчано",
"account.featured.hashtags": "Хаштагове",
"account.featured.posts": "Публикации",
"account.featured_tags.last_status_at": "Последна публикация на {date}",
"account.featured_tags.last_status_never": "Няма публикации",
"account.featured_tags.title": "Главни хаштагове на {name}",
"account.follow": "Последване",
"account.follow_back": "Последване взаимно",
"account.followers": "Последователи",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
"account.unblock": "Отблокиране на @{name}",
"account.unblock_domain": "Отблокиране на домейн {domain}",
"account.unblock_domain_short": "Отблокиране",
"account.unblock_short": "Отблокиране",
"account.unendorse": "Не включвайте в профила",
"account.unfollow": "Стоп на следването",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Резултати от търсене",
"emoji_button.symbols": "Символи",
"emoji_button.travel": "Пътуване и места",
"empty_column.account_featured": "Списъкът е празен",
"empty_column.account_hides_collections": "Този потребител е избрал да не дава тази информация",
"empty_column.account_suspended": "Спрян акаунт",
"empty_column.account_timeline": "Тук няма публикации!",
@ -381,8 +377,6 @@
"generic.saved": "Запазено",
"getting_started.heading": "Първи стъпки",
"hashtag.admin_moderation": "Отваряне на модериращия интерфейс за #{name}",
"hashtag.browse": "Разглеждане на публикации в #{hashtag}",
"hashtag.browse_from_account": "Разглеждане на публикации от @{name} из #{hashtag}",
"hashtag.column_header.tag_mode.all": "и {additional}",
"hashtag.column_header.tag_mode.any": "или {additional}",
"hashtag.column_header.tag_mode.none": "без {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} публикация} other {{counter} публикации}} днес",
"hashtag.follow": "Следване на хаштаг",
"hashtag.mute": "Заглушаване на #{hashtag}",
"hashtag.unfollow": "Спиране на следване на хаштаг",
"hashtags.and_other": "…и {count, plural, other {# още}}",
"hints.profiles.followers_may_be_missing": "Последователи за този профил може да липсват.",
@ -681,7 +674,7 @@
"onboarding.follows.title": "Последвайте хора, за да започнете",
"onboarding.profile.discoverable": "Правене на моя профил откриваем",
"onboarding.profile.discoverable_hint": "Включвайки откриваемостта в Mastodon, вашите публикации може да се появят при резултатите от търсене и изгряващи неща, и вашия профил може да бъде предложен на хора с подобни интереси като вашите.",
"onboarding.profile.display_name": "Показвано име",
"onboarding.profile.display_name": "Името на показ",
"onboarding.profile.display_name_hint": "Вашето пълно име или псевдоним…",
"onboarding.profile.note": "Биография",
"onboarding.profile.note_hint": "Може да @споменавате други хора или #хаштагове…",

View file

@ -29,6 +29,7 @@
"account.endorse": "প্রোফাইলে ফিচার করুন",
"account.featured_tags.last_status_at": "{date} এ সর্বশেষ পোস্ট",
"account.featured_tags.last_status_never": "কোনো পোস্ট নেই",
"account.featured_tags.title": "{name} এর ফিচার করা Hashtag সমূহ",
"account.follow": "অনুসরণ",
"account.follow_back": "তাকে অনুসরণ করো",
"account.followers": "অনুসরণকারী",

View file

@ -28,6 +28,7 @@
"account.endorse": "Lakaat war-wel war ar profil",
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
"account.featured_tags.last_status_never": "Embannadur ebet",
"account.featured_tags.title": "Hashtagoù pennañ {name}",
"account.follow": "Heuliañ",
"account.follow_back": "Heuliañ d'ho tro",
"account.followers": "Tud koumanantet",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Edita el perfil",
"account.enable_notifications": "Notifica'm els tuts de @{name}",
"account.endorse": "Recomana en el perfil",
"account.featured": "Destacat",
"account.featured.hashtags": "Etiquetes",
"account.featured.posts": "Publicacions",
"account.featured_tags.last_status_at": "Darrer tut el {date}",
"account.featured_tags.last_status_never": "No hi ha tuts",
"account.featured_tags.title": "etiquetes destacades de {name}",
"account.follow": "Segueix",
"account.follow_back": "Segueix tu també",
"account.followers": "Seguidors",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
"account.unblock": "Desbloca @{name}",
"account.unblock_domain": "Desbloca el domini {domain}",
"account.unblock_domain_short": "Desbloca",
"account.unblock_short": "Desbloca",
"account.unendorse": "No recomanis en el perfil",
"account.unfollow": "Deixa de seguir",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Resultats de la cerca",
"emoji_button.symbols": "Símbols",
"emoji_button.travel": "Viatges i llocs",
"empty_column.account_featured": "Aquesta llista està buida",
"empty_column.account_hides_collections": "Aquest usuari ha decidit no mostrar aquesta informació",
"empty_column.account_suspended": "Compte suspès",
"empty_column.account_timeline": "No hi ha tuts aquí!",
@ -394,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} avui",
"hashtag.follow": "Segueix l'etiqueta",
"hashtag.mute": "Silencia #{hashtag}",
"hashtag.unfollow": "Deixa de seguir l'etiqueta",
"hashtags.and_other": "…i {count, plural, other {# més}}",
"hints.profiles.followers_may_be_missing": "Es poden haver perdut seguidors d'aquest perfil.",

View file

@ -28,6 +28,7 @@
"account.endorse": "ناساندن لە پرۆفایل",
"account.featured_tags.last_status_at": "دوایین پۆست لە {date}",
"account.featured_tags.last_status_never": "هیچ پۆستێک نییە",
"account.featured_tags.title": "هاشتاگە تایبەتەکانی {name}",
"account.follow": "بەدواداچوون",
"account.follow_back": "فۆڵۆو بکەنەوە",
"account.followers": "شوێنکەوتووان",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Upravit profil",
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
"account.endorse": "Zvýraznit na profilu",
"account.featured": "Doporučené",
"account.featured.hashtags": "Hashtagy",
"account.featured.posts": "Příspěvky",
"account.featured_tags.last_status_at": "Poslední příspěvek {date}",
"account.featured_tags.last_status_never": "Žádné příspěvky",
"account.featured_tags.title": "Hlavní hashtagy uživatele {name}",
"account.follow": "Sledovat",
"account.follow_back": "Také sledovat",
"account.followers": "Sledující",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"account.unblock": "Odblokovat @{name}",
"account.unblock_domain": "Odblokovat doménu {domain}",
"account.unblock_domain_short": "Odblokovat",
"account.unblock_short": "Odblokovat",
"account.unendorse": "Nezvýrazňovat na profilu",
"account.unfollow": "Přestat sledovat",
@ -82,7 +79,7 @@
"admin.dashboard.retention.cohort_size": "Noví uživatelé",
"admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny",
"admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by jejich uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.title": "Shrnutí dopadu",
"alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.",
"alert.rate_limited.title": "Spojení omezena",
@ -104,7 +101,7 @@
"annual_report.summary.archetype.replier": "Sociální motýlek",
"annual_report.summary.followers.followers": "sledujících",
"annual_report.summary.followers.total": "{count} celkem",
"annual_report.summary.here_it_is": "Zde je tvůj rok {year} v přehledu:",
"annual_report.summary.here_it_is": "Zde je tvůj {year} v přehledu:",
"annual_report.summary.highlighted_post.by_favourites": "nejvíce oblíbený příspěvek",
"annual_report.summary.highlighted_post.by_reblogs": "nejvíce boostovaný příspěvek",
"annual_report.summary.highlighted_post.by_replies": "příspěvek s nejvíce odpověďmi",
@ -270,7 +267,7 @@
"domain_pill.activitypub_like_language": "ActivityPub je jako jazyk, kterým Mastodon mluví s jinými sociálními sítěmi.",
"domain_pill.server": "Server",
"domain_pill.their_handle": "Handle:",
"domain_pill.their_server": "Jejich digitální domov, kde žijí všechny jejich příspěvky.",
"domain_pill.their_server": "Jejich digitální domov, kde žijí jejich všechny příspěvky.",
"domain_pill.their_username": "Jejich jedinečný identifikátor na jejich serveru. Je možné, že na jiných serverech jsou uživatelé se stejným uživatelským jménem.",
"domain_pill.username": "Uživatelské jméno",
"domain_pill.whats_in_a_handle": "Co obsahuje handle?",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Výsledky hledání",
"emoji_button.symbols": "Symboly",
"emoji_button.travel": "Cestování a místa",
"empty_column.account_featured": "Tento seznam je prázdný",
"empty_column.account_hides_collections": "Tento uživatel se rozhodl tuto informaci nezveřejňovat",
"empty_column.account_suspended": "Účet je pozastaven",
"empty_column.account_timeline": "Nejsou tu žádné příspěvky!",
@ -381,8 +377,6 @@
"generic.saved": "Uloženo",
"getting_started.heading": "Začínáme",
"hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}",
"hashtag.browse": "Procházet příspěvky na #{hashtag}",
"hashtag.browse_from_account": "Procházet příspěvky od @{name} v #{hashtag}",
"hashtag.column_header.tag_mode.all": "a {additional}",
"hashtag.column_header.tag_mode.any": "nebo {additional}",
"hashtag.column_header.tag_mode.none": "bez {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
"hashtag.counter_by_uses_today": "Dnes {count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
"hashtag.follow": "Sledovat hashtag",
"hashtag.mute": "Skrýt #{hashtag}",
"hashtag.unfollow": "Přestat sledovat hashtag",
"hashtags.and_other": "…a {count, plural, one {# další} few {# další} other {# dalších}}",
"hints.profiles.followers_may_be_missing": "Sledující mohou pro tento profil chybět.",
@ -566,7 +559,7 @@
"notification.admin.sign_up.name_and_others": "{name} a {count, plural, one {# další} few {# další} many {# dalších} other {# dalších}} se zaregistrovali",
"notification.annual_report.message": "Váš #Wrapstodon {year} na Vás čeká! Podívejte se, jak vypadal tento Váš rok na Mastodonu!",
"notification.annual_report.view": "Zobrazit #Wrapstodon",
"notification.favourite": "{name} si oblíbil váš příspěvek",
"notification.favourite": "{name} si oblíbil*a váš příspěvek",
"notification.favourite.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Váš příspěvek",
"notification.favourite_pm": "{name} si oblíbil vaši soukromou zmínku",
"notification.favourite_pm.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Vaši soukromou zmínku",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Golygu proffil",
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
"account.endorse": "Dangos ar fy mhroffil",
"account.featured": "Dethol",
"account.featured.hashtags": "Hashnodau",
"account.featured.posts": "Postiadau",
"account.featured_tags.last_status_at": "Y postiad olaf ar {date}",
"account.featured_tags.last_status_never": "Dim postiadau",
"account.featured_tags.title": "Prif hashnodau {name}",
"account.follow": "Dilyn",
"account.follow_back": "Dilyn nôl",
"account.followers": "Dilynwyr",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} postiad} two {{counter} bostiad} few {{counter} phostiad} many {{counter} postiad} other {{counter} postiad}}",
"account.unblock": "Dadrwystro @{name}",
"account.unblock_domain": "Dadrwystro parth {domain}",
"account.unblock_domain_short": "Dadrwystro",
"account.unblock_short": "Dadrwystro",
"account.unendorse": "Peidio a'i ddangos ar fy mhroffil",
"account.unfollow": "Dad-ddilyn",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Canlyniadau chwilio",
"emoji_button.symbols": "Symbolau",
"emoji_button.travel": "Teithio a Llefydd",
"empty_column.account_featured": "Mae'r rhestr hon yn wag",
"empty_column.account_hides_collections": "Mae'r defnyddiwr wedi dewis i beidio rhannu'r wybodaeth yma",
"empty_column.account_suspended": "Cyfrif wedi'i atal",
"empty_column.account_timeline": "Dim postiadau yma!",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Redigér profil",
"account.enable_notifications": "Advisér mig, når @{name} poster",
"account.endorse": "Fremhæv på profil",
"account.featured": "Fremhævet",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Indlæg",
"account.featured_tags.last_status_at": "Seneste indlæg {date}",
"account.featured_tags.last_status_never": "Ingen indlæg",
"account.featured_tags.title": "{name}s fremhævede etiketter",
"account.follow": "Følg",
"account.follow_back": "Følg tilbage",
"account.followers": "Følgere",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
"account.unblock": "Fjern blokering af @{name}",
"account.unblock_domain": "Fjern blokering af domænet {domain}",
"account.unblock_domain_short": "Afblokér",
"account.unblock_short": "Fjern blokering",
"account.unendorse": "Fjern visning på din profil",
"account.unfollow": "Følg ikke længere",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Søgeresultater",
"emoji_button.symbols": "Symboler",
"emoji_button.travel": "Rejser og steder",
"empty_column.account_featured": "Denne liste er tom",
"empty_column.account_hides_collections": "Brugeren har valgt ikke at gøre denne information tilgængelig",
"empty_column.account_suspended": "Konto suspenderet",
"empty_column.account_timeline": "Ingen indlæg her!",
@ -381,8 +377,6 @@
"generic.saved": "Gemt",
"getting_started.heading": "Startmenu",
"hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}",
"hashtag.browse": "Gennemse indlæg i #{hashtag}",
"hashtag.browse_from_account": "Gennemse indlæg fra @{name} i #{hashtag}",
"hashtag.column_header.tag_mode.all": "og {additional}",
"hashtag.column_header.tag_mode.any": "eller {additional}",
"hashtag.column_header.tag_mode.none": "uden {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}} i dag",
"hashtag.follow": "Følg etiket",
"hashtag.mute": "Tavsgør #{hashtag}",
"hashtag.unfollow": "Stop med at følge etiket",
"hashtags.and_other": "…og {count, plural, one {}other {# flere}}",
"hints.profiles.followers_may_be_missing": "Der kan mangle følgere for denne profil.",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Profil bearbeiten",
"account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
"account.endorse": "Im Profil empfehlen",
"account.featured": "Empfohlen",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Beiträge",
"account.featured_tags.last_status_at": "Letzter Beitrag am {date}",
"account.featured_tags.last_status_never": "Keine Beiträge",
"account.featured_tags.title": "Von {name} vorgestellte Hashtags",
"account.follow": "Folgen",
"account.follow_back": "Ebenfalls folgen",
"account.followers": "Follower",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
"account.unblock": "{name} nicht mehr blockieren",
"account.unblock_domain": "Blockierung von {domain} aufheben",
"account.unblock_domain_short": "Entsperren",
"account.unblock_short": "Blockierung aufheben",
"account.unendorse": "Im Profil nicht mehr empfehlen",
"account.unfollow": "Entfolgen",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Suchergebnisse",
"emoji_button.symbols": "Symbole",
"emoji_button.travel": "Reisen & Orte",
"empty_column.account_featured": "Diese Liste ist leer",
"empty_column.account_hides_collections": "Das Konto hat sich dazu entschieden, diese Information nicht zu veröffentlichen",
"empty_column.account_suspended": "Konto gesperrt",
"empty_column.account_timeline": "Keine Beiträge vorhanden!",
@ -381,8 +377,6 @@
"generic.saved": "Gespeichert",
"getting_started.heading": "Auf gehts!",
"hashtag.admin_moderation": "#{name} moderieren",
"hashtag.browse": "Beiträge mit #{hashtag} suchen",
"hashtag.browse_from_account": "Beiträge von @{name} mit #{hashtag} suchen",
"hashtag.column_header.tag_mode.all": "und {additional}",
"hashtag.column_header.tag_mode.any": "oder {additional}",
"hashtag.column_header.tag_mode.none": "ohne {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}} heute",
"hashtag.follow": "Hashtag folgen",
"hashtag.mute": "#{hashtag} stummschalten",
"hashtag.unfollow": "Hashtag entfolgen",
"hashtags.and_other": "… und {count, plural, one{# weiterer} other {# weitere}}",
"hints.profiles.followers_may_be_missing": "Möglicherweise werden für dieses Profil nicht alle Follower angezeigt.",

View file

@ -29,6 +29,7 @@
"account.endorse": "Προβολή στο προφίλ",
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
"account.featured_tags.last_status_never": "Καμία ανάρτηση",
"account.featured_tags.title": "προβεβλημένες ετικέτες του/της {name}",
"account.follow": "Ακολούθησε",
"account.follow_back": "Ακολούθησε και εσύ",
"account.followers": "Ακόλουθοι",

View file

@ -29,6 +29,7 @@
"account.endorse": "Feature on profile",
"account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts",
"account.featured_tags.title": "{name}'s featured hashtags",
"account.follow": "Follow",
"account.follow_back": "Follow back",
"account.followers": "Followers",

View file

@ -38,11 +38,9 @@
"account.edit_profile": "Edit profile",
"account.enable_notifications": "Notify me when @{name} posts",
"account.endorse": "Feature on profile",
"account.featured": "Featured",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Posts",
"account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts",
"account.featured_tags.title": "{name}'s featured hashtags",
"account.follow": "Follow",
"account.follow_back": "Follow back",
"account.followers": "Followers",
@ -435,7 +433,6 @@
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_featured": "This list is empty",
"empty_column.account_hides_collections": "This user has chosen to not make this information available",
"empty_column.account_suspended": "Account suspended",
"empty_column.account_timeline": "No posts here!",
@ -526,8 +523,6 @@
"generic.saved": "Saved",
"getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}",
"hashtag.browse": "Browse posts in #{hashtag}",
"hashtag.browse_from_account": "Browse posts from @{name} in #{hashtag}",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
@ -541,7 +536,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
"hashtag.follow": "Follow hashtag",
"hashtag.mute": "Mute #{hashtag}",
"hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",

View file

@ -27,10 +27,9 @@
"account.edit_profile": "Redakti la profilon",
"account.enable_notifications": "Sciigu min kiam @{name} afiŝos",
"account.endorse": "Prezenti ĉe via profilo",
"account.featured.hashtags": "Kradvortoj",
"account.featured.posts": "Afiŝoj",
"account.featured_tags.last_status_at": "Lasta afîŝo je {date}",
"account.featured_tags.last_status_never": "Neniu afiŝo",
"account.featured_tags.title": "Rekomendataj kradvortoj de {name}",
"account.follow": "Sekvi",
"account.follow_back": "Sekvu reen",
"account.followers": "Sekvantoj",
@ -66,7 +65,6 @@
"account.statuses_counter": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}}",
"account.unblock": "Malbloki @{name}",
"account.unblock_domain": "Malbloki la domajnon {domain}",
"account.unblock_domain_short": "Malbloki",
"account.unblock_short": "Malbloki",
"account.unendorse": "Ne plu rekomendi ĉe la profilo",
"account.unfollow": "Ĉesi sekvi",
@ -295,7 +293,6 @@
"emoji_button.search_results": "Serĉaj rezultoj",
"emoji_button.symbols": "Simboloj",
"emoji_button.travel": "Vojaĝoj kaj lokoj",
"empty_column.account_featured": "Ĉi tiu listo estas malplena",
"empty_column.account_hides_collections": "Ĉi tiu uzanto elektis ne disponebligi ĉi tiu informon",
"empty_column.account_suspended": "Konto suspendita",
"empty_column.account_timeline": "Neniuj afiŝoj ĉi tie!",
@ -908,8 +905,6 @@
"video.expand": "Pligrandigi la videon",
"video.fullscreen": "Igi plenekrana",
"video.hide": "Kaŝu la filmeton",
"video.mute": "Silentigi",
"video.pause": "Paŭzigi",
"video.play": "Ekigi",
"video.unmute": "Ne plu silentigi"
"video.play": "Ekigi"
}

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} envíe mensajes",
"account.endorse": "Destacar en el perfil",
"account.featured": "Destacados",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Mensajes",
"account.featured_tags.last_status_at": "Último mensaje: {date}",
"account.featured_tags.last_status_never": "Sin mensajes",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir",
"account.follow_back": "Seguir",
"account.followers": "Seguidores",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Desbloquear dominio {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear",
"account.unendorse": "No destacar en el perfil",
"account.unfollow": "Dejar de seguir",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario eligió no publicar esta información",
"empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay mensajes acá!",
@ -381,8 +377,6 @@
"generic.saved": "Guardado",
"getting_started.heading": "Inicio de Mastodon",
"hashtag.admin_moderation": "Abrir interface de moderación para #{name}",
"hashtag.browse": "Ver publicaciones con #{hashtag}",
"hashtag.browse_from_account": "Ver publicaciones de @{name} con #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}} hoy",
"hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Es posible que falten seguidores de este perfil.",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
"account.endorse": "Destacar en mi perfil",
"account.featured": "Destacado",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Publicaciones",
"account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "Sin publicaciones",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir",
"account.follow_back": "Seguir también",
"account.followers": "Seguidores",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Mostrar a {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear",
"account.unendorse": "No mostrar en el perfil",
"account.unfollow": "Dejar de seguir",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario ha elegido no hacer disponible esta información",
"empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay publicaciones aquí!",
@ -381,8 +377,6 @@
"generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
"hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",
@ -916,8 +909,8 @@
"video.pause": "Pausar",
"video.play": "Reproducir",
"video.skip_backward": "Saltar atrás",
"video.skip_forward": "Saltar adelante",
"video.skip_forward": "Adelantar",
"video.unmute": "Dejar de silenciar",
"video.volume_down": "Bajar el volumen",
"video.volume_up": "Subir el volumen"
"video.volume_down": "Bajar volumen",
"video.volume_up": "Subir volumen"
}

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
"account.endorse": "Destacar en el perfil",
"account.featured": "Destacado",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Publicaciones",
"account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "Sin publicaciones",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir",
"account.follow_back": "Seguir también",
"account.followers": "Seguidores",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Desbloquear dominio {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear",
"account.unendorse": "No mostrar en el perfil",
"account.unfollow": "Dejar de seguir",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario ha decidido no mostrar esta información",
"empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay publicaciones aquí!",
@ -381,8 +377,6 @@
"generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
"hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",

View file

@ -29,6 +29,7 @@
"account.endorse": "Too profiilil esile",
"account.featured_tags.last_status_at": "Viimane postitus {date}",
"account.featured_tags.last_status_never": "Postitusi pole",
"account.featured_tags.title": "{name} esiletõstetud sildid",
"account.follow": "Jälgi",
"account.follow_back": "Jälgi vastu",
"account.followers": "Jälgijad",

View file

@ -29,6 +29,7 @@
"account.endorse": "Nabarmendu profilean",
"account.featured_tags.last_status_at": "Azken bidalketa {date} datan",
"account.featured_tags.last_status_never": "Bidalketarik ez",
"account.featured_tags.title": "{name} erabiltzailearen nabarmendutako traolak",
"account.follow": "Jarraitu",
"account.follow_back": "Jarraitu bueltan",
"account.followers": "Jarraitzaileak",

View file

@ -29,6 +29,7 @@
"account.endorse": "معرّفی در نمایه",
"account.featured_tags.last_status_at": "آخرین فرسته در {date}",
"account.featured_tags.last_status_never": "بدون فرسته",
"account.featured_tags.title": "برچسب‌های برگزیدهٔ {name}",
"account.follow": "پی‌گرفتن",
"account.follow_back": "دنبال کردن متقابل",
"account.followers": "پی‌گیرندگان",
@ -64,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
"account.unblock": "رفع مسدودیت @{name}",
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
"account.unblock_domain_short": "آنبلاک",
"account.unblock_short": "رفع مسدودیت",
"account.unendorse": "معرّفی نکردن در نمایه",
"account.unfollow": "پی‌نگرفتن",

View file

@ -27,10 +27,9 @@
"account.edit_profile": "Muokkaa profiilia",
"account.enable_notifications": "Ilmoita minulle, kun @{name} julkaisee",
"account.endorse": "Suosittele profiilissasi",
"account.featured.hashtags": "Aihetunnisteet",
"account.featured.posts": "Julkaisut",
"account.featured_tags.last_status_at": "Viimeisin julkaisu {date}",
"account.featured_tags.last_status_never": "Ei julkaisuja",
"account.featured_tags.title": "Käyttäjän {name} suosittelemat aihetunnisteet",
"account.follow": "Seuraa",
"account.follow_back": "Seuraa takaisin",
"account.followers": "Seuraajat",
@ -66,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} julkaisu} other {{counter} julkaisua}}",
"account.unblock": "Kumoa käyttäjän @{name} esto",
"account.unblock_domain": "Kumoa verkkotunnuksen {domain} esto",
"account.unblock_domain_short": "Kumoa esto",
"account.unblock_short": "Kumoa esto",
"account.unendorse": "Kumoa suosittelu profiilissasi",
"account.unfollow": "Älä seuraa",
@ -241,7 +239,7 @@
"conversation.mark_as_read": "Merkitse luetuksi",
"conversation.open": "Näytä keskustelu",
"conversation.with": "{names} kanssa",
"copy_icon_button.copied": "Kopioitu leikepöydälle",
"copy_icon_button.copied": "Sisältö kopioitiin leikepöydälle",
"copypaste.copied": "Kopioitu",
"copypaste.copy_to_clipboard": "Kopioi leikepöydälle",
"directory.federated": "Tunnetusta fediversumista",
@ -295,7 +293,6 @@
"emoji_button.search_results": "Hakutulokset",
"emoji_button.symbols": "Symbolit",
"emoji_button.travel": "Matkailu ja paikat",
"empty_column.account_featured": "Tämä lista on tyhjä",
"empty_column.account_hides_collections": "Käyttäjä on päättänyt pitää nämä tiedot yksityisinä",
"empty_column.account_suspended": "Tili jäädytetty",
"empty_column.account_timeline": "Ei viestejä täällä.",
@ -380,8 +377,6 @@
"generic.saved": "Tallennettu",
"getting_started.heading": "Näin pääset alkuun",
"hashtag.admin_moderation": "Avaa tunnisteen #{name} moderointinäkymä",
"hashtag.browse": "Selaa julkaisuja tunnisteella #{hashtag}",
"hashtag.browse_from_account": "Selaa julkaisuja käyttäjältä @{name} tunnisteella #{hashtag}",
"hashtag.column_header.tag_mode.all": "ja {additional}",
"hashtag.column_header.tag_mode.any": "tai {additional}",
"hashtag.column_header.tag_mode.none": "ilman {additional}",
@ -395,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one{{counter} julkaisu} other {{counter} julkaisua}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} julkaisu} other {{counter} julkaisua}} tänään",
"hashtag.follow": "Seuraa aihetunnistetta",
"hashtag.mute": "Mykistä #{hashtag}",
"hashtag.unfollow": "Lopeta aihetunnisteen seuraaminen",
"hashtags.and_other": "…ja {count, plural, other {# lisää}}",
"hints.profiles.followers_may_be_missing": "Tämän profiilin seuraajia saattaa puuttua.",

View file

@ -29,6 +29,7 @@
"account.endorse": "I-tampok sa profile",
"account.featured_tags.last_status_at": "Huling post noong {date}",
"account.featured_tags.last_status_never": "Walang mga post",
"account.featured_tags.title": "Nakatampok na hashtag ni {name}",
"account.follow": "Sundan",
"account.follow_back": "Sundan pabalik",
"account.followers": "Mga tagasunod",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Broyt vanga",
"account.enable_notifications": "Boða mær frá, tá @{name} skrivar",
"account.endorse": "Víst á vangamyndini",
"account.featured": "Tikin fram",
"account.featured.hashtags": "Frámerki",
"account.featured.posts": "Postar",
"account.featured_tags.last_status_at": "Seinasta strongur skrivaður {date}",
"account.featured_tags.last_status_never": "Einki uppslag",
"account.featured_tags.title": "Tvíkrossar hjá {name}",
"account.follow": "Fylg",
"account.follow_back": "Fylg aftur",
"account.followers": "Fylgjarar",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} postur} other {{counter} postar}}",
"account.unblock": "Banna ikki @{name}",
"account.unblock_domain": "Banna ikki økisnavnið {domain}",
"account.unblock_domain_short": "Banna ikki",
"account.unblock_short": "Banna ikki",
"account.unendorse": "Vís ikki á vanga",
"account.unfollow": "Fylg ikki",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Leitiúrslit",
"emoji_button.symbols": "Ímyndir",
"emoji_button.travel": "Ferðing og støð",
"empty_column.account_featured": "Hesin listin er tómur",
"empty_column.account_hides_collections": "Hesin brúkarin hevur valt, at hesar upplýsingarnar ikki skulu vera tøkar",
"empty_column.account_suspended": "Kontan gjørd óvirkin",
"empty_column.account_timeline": "Einki uppslag her!",
@ -381,8 +377,6 @@
"generic.saved": "Goymt",
"getting_started.heading": "At byrja",
"hashtag.admin_moderation": "Lat umsjónarmarkamót upp fyri #{name}",
"hashtag.browse": "Blaða gjøgnum postar í #{hashtag}",
"hashtag.browse_from_account": "Blaða gjøgnum postar frá @{name} í #{hashtag}",
"hashtag.column_header.tag_mode.all": "og {additional}",
"hashtag.column_header.tag_mode.any": "ella {additional}",
"hashtag.column_header.tag_mode.none": "uttan {additional}",
@ -396,7 +390,6 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} postur} other {{counter} postar}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} postur} other {{counter} postar}} í dag",
"hashtag.follow": "Fylg frámerki",
"hashtag.mute": "Doyv @#{hashtag}",
"hashtag.unfollow": "Gevst at fylgja frámerki",
"hashtags.and_other": "…og {count, plural, other {# afturat}}",
"hints.profiles.followers_may_be_missing": "Fylgjarar hjá hesum vanganum kunnu mangla.",

View file

@ -27,11 +27,9 @@
"account.edit_profile": "Modifier le profil",
"account.enable_notifications": "Me notifier quand @{name} publie",
"account.endorse": "Inclure sur profil",
"account.featured": "En vedette",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Messages",
"account.featured_tags.last_status_at": "Dernière publication {date}",
"account.featured_tags.last_status_never": "Aucune publication",
"account.featured_tags.title": "Hashtags inclus de {name}",
"account.follow": "Suivre",
"account.follow_back": "Suivre en retour",
"account.followers": "abonné·e·s",
@ -67,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} message} other {{counter} messages}}",
"account.unblock": "Débloquer @{name}",
"account.unblock_domain": "Débloquer le domaine {domain}",
"account.unblock_domain_short": "Débloquer",
"account.unblock_short": "Débloquer",
"account.unendorse": "Ne pas inclure sur profil",
"account.unfollow": "Ne plus suivre",
@ -296,7 +293,6 @@
"emoji_button.search_results": "Résultats",
"emoji_button.symbols": "Symboles",
"emoji_button.travel": "Voyage et lieux",
"empty_column.account_featured": "Cette liste est vide",
"empty_column.account_hides_collections": "Cet utilisateur·ice préfère ne pas rendre publiques ces informations",
"empty_column.account_suspended": "Compte suspendu",
"empty_column.account_timeline": "Aucune publication ici!",
@ -909,10 +905,6 @@
"video.expand": "Agrandir la vidéo",
"video.fullscreen": "Plein écran",
"video.hide": "Masquer la vidéo",
"video.mute": "Couper le son",
"video.pause": "Pause",
"video.play": "Lecture",
"video.unmute": "Rétablir le son",
"video.volume_down": "Baisser le volume",
"video.volume_up": "Augmenter le volume"
"video.play": "Lecture"
}

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