Compare commits
38 commits
kb_develop
...
nas_dev
Author | SHA1 | Date | |
---|---|---|---|
|
15c51c7afb | ||
|
36751b6b0a | ||
|
a48a693145 | ||
|
be07032ad7 | ||
|
ab55cad02e | ||
|
3438454d2a | ||
|
181ce90aa1 | ||
|
c2ef6ac354 | ||
|
b8aa1ec133 | ||
5c122a035b | |||
|
75c4430778 | ||
571f037d8a | |||
|
86191f3ce1 | ||
|
19c8848507 | ||
b768870895 | |||
3e7972c5b1 | |||
7a74c056ee | |||
750f5f4885 | |||
3daed614d5 | |||
f600d9f95b | |||
5267d19474 | |||
|
59657684e1 | ||
|
44efbc7141 | ||
|
25d176c280 | ||
|
f6447c0f4d | ||
|
da60c95317 | ||
|
49e05d9f2b | ||
|
9524487909 | ||
|
d21a4f8c39 | ||
|
d510b5d8b9 | ||
|
ff90575e2c | ||
|
f41c09f291 | ||
|
a7bc288569 | ||
|
843a5446e8 | ||
|
038d3b1513 | ||
|
b091cbdbea | ||
|
8992602acf | ||
|
c68762e2bf |
270 changed files with 6347 additions and 3899 deletions
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.4.3
|
||||
3.4.2
|
||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -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)
|
||||
|
|
56
Gemfile.lock
56
Gemfile.lock
|
@ -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
124
README.md
|
@ -1,27 +1,123 @@
|
|||
NAS is an KMY & Mastodon Fork
|
||||
#  kmyblue
|
||||
|
||||
The following are just a few of the most common features. There are many other minor changes to the specifications.
|
||||
[](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)は、用途別にアカウントを分けるようなことはせず、すべての発言を1つのアカウントで行っています。そのため、kmyblueの開発だけでなく、成人向け同人作品の話も混ざっています。
|
||||
|
||||
このうち、公開範囲「公開」「ローカル公開」「非収載」であるkmyblueフォークの開発に関係する投稿に限り抽出し、翻訳の有無に関係なく公開することを許可します。これはkmyblueフォークの利用者にとって公共性の高いコンテンツであると思われます。これは、日本と欧米では一般的に考えられている児童ポルノの基準が異なり、欧米のサーバーの中にはこのアカウントをフォローしづらいものもあるという懸念を考慮したものです。
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
30
app/controllers/api/v1/accounts/pins_controller.rb
Normal file
30
app/controllers/api/v1/accounts/pins_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -72,6 +72,8 @@ class ApplicationController < ActionController::Base
|
|||
def require_functional!
|
||||
return if current_user.functional?
|
||||
|
||||
respond_to do |format|
|
||||
format.any do
|
||||
if current_user.confirmed?
|
||||
redirect_to edit_user_registration_path
|
||||
else
|
||||
|
@ -79,6 +81,18 @@ class ApplicationController < ActionController::Base
|
|||
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
|
||||
|
||||
def skip_csrf_meta_tags?
|
||||
false
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
81
app/javascript/mastodon/actions/tags.js
Normal file
81
app/javascript/mastodon/actions/tags.js
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
if (defaultAction === 'mute') {
|
||||
const handleMuteNotifications = () => {
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, true));
|
||||
};
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleUnmuteNotifications = () => {
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
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]);
|
||||
}, [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 = (
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
} else if (blocking) {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.blocking ? messages.unblock : messages.block,
|
||||
)}
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
button = (
|
||||
} 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(
|
||||
relationship?.muting ? messages.unmute : messages.mute,
|
||||
)}
|
||||
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 {
|
||||
button = <FollowButton accountId={id} />;
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
} else {
|
||||
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>
|
||||
|
||||
|
|
345
app/javascript/mastodon/components/dropdown_menu.jsx
Normal file
345
app/javascript/mastodon/components/dropdown_menu.jsx
Normal 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);
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
|
@ -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));
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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')) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
|
|||
return (
|
||||
<div className='server-banner'>
|
||||
<div className='server-banner__introduction'>
|
||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
|
||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
|
||||
</div>
|
||||
|
||||
<Link to='/about'>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
|
@ -20,7 +20,7 @@ import Column from 'mastodon/components/column';
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
|
@ -40,6 +40,10 @@ const messages = defineMessages({
|
|||
enabled: { id: 'about.enabled', defaultMessage: 'Enabled' },
|
||||
disabled: { id: 'about.disabled', defaultMessage: 'Disabled' },
|
||||
capabilities: { id: 'about.kmyblue_capabilities', defaultMessage: 'Features available in this server' },
|
||||
joinFediverse: {
|
||||
id: 'about.join_fediverse',
|
||||
defaultMessage: "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse."
|
||||
},
|
||||
});
|
||||
|
||||
const severityMessages = {
|
||||
|
@ -59,14 +63,13 @@ const severityMessages = {
|
|||
},
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
const mapStateToProps = (state) => ({
|
||||
server: state.getIn(['server', 'server']),
|
||||
extendedDescription: state.getIn(['server', 'extendedDescription']),
|
||||
domainBlocks: state.getIn(['server', 'domainBlocks']),
|
||||
});
|
||||
|
||||
class Section extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
|
@ -85,49 +88,64 @@ class Section extends PureComponent {
|
|||
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
|
||||
};
|
||||
|
||||
render () {
|
||||
handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { title, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('about__section', { active: !collapsed })}>
|
||||
<div className='about__section__title' role='button' tabIndex={0} onClick={this.handleClick}>
|
||||
<div
|
||||
className="about__section__title"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} icon={collapsed ? ChevronRightIcon : ExpandMoreIcon} /> {title}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className='about__section__body'>{children}</div>
|
||||
)}
|
||||
{!collapsed && <div className="about__section__body">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CapabilityIcon extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
state: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { intl, state } = this.props;
|
||||
|
||||
if (state) {
|
||||
return (
|
||||
<span className='capability-icon enabled'><Icon id='check' icon={EnabledIcon} title={intl.formatMessage(messages.enabled)} />{intl.formatMessage(messages.enabled)}</span>
|
||||
<span className="capability-icon enabled">
|
||||
<Icon id="check" icon={EnabledIcon} title={intl.formatMessage(messages.enabled)} />
|
||||
{intl.formatMessage(messages.enabled)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className='capability-icon disabled'><Icon id='times' icon={DisabledIcon} title={intl.formatMessage(messages.disabled)} />{intl.formatMessage(messages.disabled)}</span>
|
||||
<span className="capability-icon disabled">
|
||||
<Icon id="times" icon={DisabledIcon} title={intl.formatMessage(messages.disabled)} />
|
||||
{intl.formatMessage(messages.disabled)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class About extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
server: ImmutablePropTypes.map,
|
||||
extendedDescription: ImmutablePropTypes.map,
|
||||
|
@ -141,7 +159,7 @@ class About extends PureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
componentDidMount() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchServer());
|
||||
dispatch(fetchExtendedDescription());
|
||||
|
@ -152,11 +170,11 @@ class About extends PureComponent {
|
|||
dispatch(fetchDomainBlocks());
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
|
||||
const isLoading = server.get('isLoading');
|
||||
|
||||
const fedibirdCapabilities = server.get('fedibird_capabilities') || []; // thinking about isLoading is true
|
||||
const fedibirdCapabilities = server.get('fedibird_capabilities') || [];
|
||||
const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted');
|
||||
const isPublicVisibility = !fedibirdCapabilities.includes('kmyblue_no_public_visibility');
|
||||
const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction');
|
||||
|
@ -169,59 +187,88 @@ class About extends PureComponent {
|
|||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
<div className='about__header'>
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {domain}' /></p>
|
||||
<div className="scrollable about">
|
||||
<div className="about__header">
|
||||
<ServerHeroImage
|
||||
blurhash={server.getIn(['thumbnail', 'blurhash'])}
|
||||
src={server.getIn(['thumbnail', 'url'])}
|
||||
srcSet={server
|
||||
.getIn(['thumbnail', 'versions'])
|
||||
?.map((value, key) => `${value} ${key.replace('@', '')}`)
|
||||
.join(', ')}
|
||||
className="about__header__hero"
|
||||
/>
|
||||
<h1>{isLoading ? <Skeleton width="10ch" /> : server.get('domain')}</h1>
|
||||
<p>
|
||||
<FormattedMessage id="about.powered_by" defaultMessage="Social media powered by You!" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='about__meta'>
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
|
||||
<div className="about__meta">
|
||||
<div className="about__meta__column">
|
||||
<h4>
|
||||
<FormattedMessage id="server_banner.administered_by" defaultMessage="Administered by:" />
|
||||
</h4>
|
||||
|
||||
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
|
||||
</div>
|
||||
|
||||
<hr className='about__meta__divider' />
|
||||
<hr className="about__meta__divider" />
|
||||
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
|
||||
<div className="about__meta__column">
|
||||
<h4>
|
||||
<FormattedMessage id="about.contact" defaultMessage="Contact:" />
|
||||
</h4>
|
||||
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={emailLink}>{server.getIn(['contact', 'email'])}</a>}
|
||||
{isLoading ? (
|
||||
<Skeleton width="10ch" />
|
||||
) : (
|
||||
<a className="about__mail" href={emailLink}>
|
||||
{server.getIn(['contact', 'email'])}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section open title={intl.formatMessage(messages.title)}>
|
||||
{extendedDescription.get('isLoading') ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width="100%" />
|
||||
<br />
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width="100%" />
|
||||
<br />
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width="100%" />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
<Skeleton width="70%" />
|
||||
</>
|
||||
) : (extendedDescription.get('content')?.length > 0 ? (
|
||||
<div
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
|
||||
/>
|
||||
) : extendedDescription.get('content')?.length > 0 ? (
|
||||
<div className="prose" dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }} />
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
))}
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="about.not_available"
|
||||
defaultMessage="This information has not been made available on this server."
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={intl.formatMessage(messages.rules)}>
|
||||
{!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="about.not_available"
|
||||
defaultMessage="This information has not been made available on this server."
|
||||
/>
|
||||
</p>
|
||||
) : (
|
||||
<ol className='rules-list'>
|
||||
{server.get('rules').map(rule => (
|
||||
<ol className="rules-list">
|
||||
{server.get('rules').map((rule) => (
|
||||
<li key={rule.get('id')}>
|
||||
<div className='rules-list__text'>{rule.get('text')}</div>
|
||||
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
|
||||
<div className="rules-list__text">{rule.get('text')}</div>
|
||||
{!!rule.get('hint') && rule.get('hint').length > 0 && (
|
||||
<div className="rules-list__hint">{rule.get('hint')}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
@ -229,23 +276,38 @@ class About extends PureComponent {
|
|||
</Section>
|
||||
|
||||
<Section title={intl.formatMessage(messages.capabilities)}>
|
||||
<p><FormattedMessage id='about.kmyblue_capability' defaultMessage='This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.' /></p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="about.kmyblue_capability"
|
||||
defaultMessage="This server is using unique features are configured as follows."
|
||||
/>
|
||||
</p>
|
||||
{!isLoading && (
|
||||
<ol className='rules-list'>
|
||||
<ol className="rules-list">
|
||||
<li>
|
||||
<span className='rules-list__text'>{intl.formatMessage(messages.emojiReaction)}: <CapabilityIcon state={isEmojiReaction} intl={intl} /></span>
|
||||
<span className="rules-list__text">
|
||||
{intl.formatMessage(messages.emojiReaction)}: <CapabilityIcon state={isEmojiReaction} intl={intl} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='rules-list__text'>{intl.formatMessage(messages.publicVisibility)}: <CapabilityIcon state={isPublicVisibility} intl={intl} /></span>
|
||||
<span className="rules-list__text">
|
||||
{intl.formatMessage(messages.publicVisibility)}: <CapabilityIcon state={isPublicVisibility} intl={intl} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='rules-list__text'>{intl.formatMessage(messages.publicUnlistedVisibility)}: <CapabilityIcon state={isPublicUnlistedVisibility} intl={intl} /></span>
|
||||
<span className="rules-list__text">
|
||||
{intl.formatMessage(messages.publicUnlistedVisibility)}: <CapabilityIcon state={isPublicUnlistedVisibility} intl={intl} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='rules-list__text'>{intl.formatMessage(messages.localTimeline)}: <CapabilityIcon state={isLocalTimeline} intl={intl} /></span>
|
||||
<span className="rules-list__text">
|
||||
{intl.formatMessage(messages.localTimeline)}: <CapabilityIcon state={isLocalTimeline} intl={intl} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='rules-list__text'>{intl.formatMessage(messages.fullTextSearch)}: <CapabilityIcon state={isFullTextSearch} intl={intl} /></span>
|
||||
<span className="rules-list__text">
|
||||
{intl.formatMessage(messages.fullTextSearch)}: <CapabilityIcon state={isFullTextSearch} intl={intl} />
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
)}
|
||||
|
@ -254,49 +316,75 @@ class About extends PureComponent {
|
|||
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width="100%" />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
<Skeleton width="70%" />
|
||||
</>
|
||||
) : (domainBlocks.get('isAvailable') ? (
|
||||
) : domainBlocks.get('isAvailable') ? (
|
||||
<>
|
||||
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="about.domain_blocks.preamble"
|
||||
defaultMessage="Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server."
|
||||
/>
|
||||
</p>
|
||||
|
||||
{domainBlocks.get('items').size > 0 && (
|
||||
<div className='about__domain-blocks'>
|
||||
{domainBlocks.get('items').map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.get('domain')}>
|
||||
<div className='about__domain-blocks__domain__header'>
|
||||
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity_ex') || block.get('severity')].title)}</span>
|
||||
<div className="about__domain-blocks">
|
||||
{domainBlocks.get('items').map((block) => (
|
||||
<div className="about__domain-blocks__domain" key={block.get('domain')}>
|
||||
<div className="about__domain-blocks__domain__header">
|
||||
<h6>
|
||||
<span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span>
|
||||
</h6>
|
||||
<span
|
||||
className="about__domain-blocks__domain__type"
|
||||
title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
severityMessages[block.get('severity_ex') || block.get('severity')].title
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
<p>
|
||||
{(block.get('comment') || '').length > 0 ? (
|
||||
block.get('comment')
|
||||
) : (
|
||||
<FormattedMessage id="about.domain_blocks.no_reason_available" defaultMessage="Reason not available" />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
))}
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="about.not_available"
|
||||
defaultMessage="This information has not been made available on this server."
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<LinkFooter />
|
||||
|
||||
<div className='about__footer'>
|
||||
<p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
|
||||
<div className="about__footer">
|
||||
<p>
|
||||
<FormattedMessage {...messages.joinFediverse} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
<meta name="robots" content="all" />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(About));
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
52
app/javascript/mastodon/features/account/navigation.jsx
Normal file
52
app/javascript/mastodon/features/account/navigation.jsx
Normal 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);
|
|
@ -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>;
|
||||
};
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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,7 +848,27 @@ 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, {
|
||||
|
@ -799,8 +877,14 @@ export const AccountHeader: React.FC<{
|
|||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
actionBtn = <FollowButton accountId={accountId} />;
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.edit_profile)}
|
||||
onClick={handleEditProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)));
|
|
@ -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;
|
117
app/javascript/mastodon/features/bookmarked_statuses/index.jsx
Normal file
117
app/javascript/mastodon/features/bookmarked_statuses/index.jsx
Normal 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));
|
|
@ -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;
|
195
app/javascript/mastodon/features/circle_statuses/index.jsx
Normal file
195
app/javascript/mastodon/features/circle_statuses/index.jsx
Normal 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)));
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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));
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
95
app/javascript/mastodon/features/followed_tags/index.jsx
Normal file
95
app/javascript/mastodon/features/followed_tags/index.jsx
Normal 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));
|
|
@ -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;
|
|
@ -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'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,101 +1,63 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
domain,
|
||||
version,
|
||||
source_url,
|
||||
statusPageUrl,
|
||||
profile_directory as canProfileDirectory,
|
||||
termsOfServiceEnabled,
|
||||
} from 'mastodon/initial_state';
|
||||
|
||||
const DividingCircle: React.FC = () => <span aria-hidden>{' · '}</span>;
|
||||
const DividingCircle: React.FC = () => <span aria-hidden={true}>{' · '}</span>;
|
||||
|
||||
export const LinkFooter: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
export const LinkFooter: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
return (
|
||||
<div className='link-footer'>
|
||||
<footer className='link-footer' role='contentinfo'>
|
||||
<p>
|
||||
<strong>{domain}</strong>:{' '}
|
||||
<Link to='/about' target={multiColumn ? '_blank' : undefined}>
|
||||
<FormattedMessage id='footer.about' defaultMessage='About' />
|
||||
</Link>
|
||||
|
||||
{statusPageUrl && (
|
||||
<>
|
||||
<DividingCircle />
|
||||
<a href={statusPageUrl} target='_blank' rel='noopener'>
|
||||
<a href={statusPageUrl} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedMessage id='footer.status' defaultMessage='Status' />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{canProfileDirectory && (
|
||||
<>
|
||||
|
||||
<DividingCircle />
|
||||
<Link to='/directory'>
|
||||
<FormattedMessage
|
||||
id='footer.directory'
|
||||
defaultMessage='Profiles directory'
|
||||
/>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<DividingCircle />
|
||||
<Link
|
||||
to='/privacy-policy'
|
||||
target={multiColumn ? '_blank' : undefined}
|
||||
rel='privacy-policy'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='footer.privacy_policy'
|
||||
defaultMessage='Privacy policy'
|
||||
/>
|
||||
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}>
|
||||
<FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' />
|
||||
</Link>
|
||||
|
||||
{termsOfServiceEnabled && (
|
||||
<>
|
||||
<DividingCircle />
|
||||
<Link
|
||||
to='/terms-of-service'
|
||||
target={multiColumn ? '_blank' : undefined}
|
||||
rel='terms-of-service'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='footer.terms_of_service'
|
||||
defaultMessage='Terms of service'
|
||||
/>
|
||||
<Link to='/terms-of-service' target={multiColumn ? '_blank' : undefined}>
|
||||
<FormattedMessage id='footer.terms_of_service' defaultMessage='Terms of service' />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DividingCircle />
|
||||
<Link to='/keyboard-shortcuts'>
|
||||
<FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' />
|
||||
</Link>
|
||||
|
||||
<DividingCircle />
|
||||
<a href={source_url} rel='noopener noreferrer' target='_blank'>
|
||||
<FormattedMessage id='footer.source_code' defaultMessage='View source code' />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank' rel='noopener'>
|
||||
<FormattedMessage id='footer.about' defaultMessage='About' />
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<a href='https://joinmastodon.org/apps' target='_blank' rel='noopener'>
|
||||
<FormattedMessage id='footer.get_app' defaultMessage='Get the app' />
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<Link to='/keyboard-shortcuts'>
|
||||
<FormattedMessage
|
||||
id='footer.keyboard_shortcuts'
|
||||
defaultMessage='Keyboard shortcuts'
|
||||
/>
|
||||
</Link>
|
||||
<DividingCircle />
|
||||
<a href={source_url} rel='noopener' target='_blank'>
|
||||
<FormattedMessage
|
||||
id='footer.source_code'
|
||||
defaultMessage='View source code'
|
||||
/>
|
||||
</a>
|
||||
<DividingCircle />
|
||||
<span className='version'>v{version}</span>
|
||||
<span title="Crafted with love for the fediverse">
|
||||
Made with <span aria-label='heart' role='img'>❤️</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ const SignInBanner = () => {
|
|||
if (sso_redirect) {
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
|
||||
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse." /></strong></p>
|
||||
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
|
||||
<a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a>
|
||||
</div>
|
||||
|
@ -45,7 +45,7 @@ const SignInBanner = () => {
|
|||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
|
||||
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse." /></strong></p>
|
||||
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
|
||||
{signupButton}
|
||||
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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.",
|
||||
|
|
|
@ -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": "مُتابِعون",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Падпісчыкі",
|
||||
|
|
|
@ -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": "Може да @споменавате други хора или #хаштагове…",
|
||||
|
|
|
@ -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": "অনুসরণকারী",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "شوێنکەوتووان",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "Ακόλουθοι",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Moderated servers",
|
||||
"about.contact": "Contact:",
|
||||
"about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
|
||||
"about.disclaimer": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.",
|
||||
"about.domain_blocks.no_reason_available": "Reason not available",
|
||||
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the Fediverse. These are the exceptions that have been made on this particular server.",
|
||||
"about.domain_blocks.silenced.explanation": "You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.",
|
||||
|
@ -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",
|
||||
|
@ -801,11 +802,11 @@
|
|||
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
|
||||
"server_banner.active_users": "active users",
|
||||
"server_banner.administered_by": "Administered by:",
|
||||
"server_banner.is_one_of_many": "{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.",
|
||||
"server_banner.is_one_of_many": "{domain} is one of the many independent servers you can use to participate in the fediverse.",
|
||||
"server_banner.server_stats": "Server stats:",
|
||||
"sign_in_banner.create_account": "Create account",
|
||||
"sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
|
||||
"sign_in_banner.mastodon_is": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.",
|
||||
"sign_in_banner.sign_in": "Sign in",
|
||||
"sign_in_banner.sso_redirect": "Login or Register",
|
||||
"status.admin_account": "Open moderation interface for @{name}",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"about.blocks": "Moderated servers",
|
||||
"about.contact": "Contact:",
|
||||
"about.disabled": "Disabled",
|
||||
"about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
|
||||
"about.disclaimer": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.",
|
||||
"about.domain_blocks.no_reason_available": "Reason not available",
|
||||
"about.domain_blocks.noop.explanation": "This server is limited partically.",
|
||||
"about.domain_blocks.noop.title": "Soft limited",
|
||||
|
@ -14,9 +14,9 @@
|
|||
"about.enabled": "Enabled",
|
||||
"about.full_text_search": "Full text search",
|
||||
"about.kmyblue_capabilities": "Features available in this server",
|
||||
"about.kmyblue_capability": "This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.",
|
||||
"about.kmyblue_capability": "Server unique features are configured as follows.",
|
||||
"about.not_available": "This information has not been made available on this server.",
|
||||
"about.powered_by": "Decentralized social media powered by {domain}",
|
||||
"about.powered_by": "Social media powered by You!",
|
||||
"about.public_visibility": "Public visibility",
|
||||
"about.rules": "Server rules",
|
||||
"account.account_note_header": "Personal note",
|
||||
|
@ -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.",
|
||||
|
@ -1006,11 +1000,11 @@
|
|||
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
|
||||
"server_banner.active_users": "active users",
|
||||
"server_banner.administered_by": "Administered by:",
|
||||
"server_banner.is_one_of_many": "{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.",
|
||||
"server_banner.is_one_of_many": "{domain} is one of the many independent servers you can use to participate in the fediverse.",
|
||||
"server_banner.server_stats": "Server stats:",
|
||||
"sign_in_banner.create_account": "Create account",
|
||||
"sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
|
||||
"sign_in_banner.mastodon_is": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.",
|
||||
"sign_in_banner.sign_in": "Login",
|
||||
"sign_in_banner.sso_redirect": "Login or Register",
|
||||
"status.admin_account": "Open moderation interface for @{name}",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "پینگرفتن",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue