Compare commits
94 commits
kb18.0
...
kb_develop
Author | SHA1 | Date | |
---|---|---|---|
972e89558c | |||
b7b57bcc1a | |||
d7f7a2ed7f | |||
de70fca70d | |||
8c73a671ab | |||
9f711f854a | |||
|
69fb60271d | ||
|
ebcd0950fd | ||
|
c3ff0887d5 | ||
|
4eb1c8305c | ||
|
03818781b6 | ||
|
f677d534dc | ||
|
51c8f9592e | ||
|
1f8b3fcc92 | ||
|
bb7024bf3f | ||
db3dc2ab83 | |||
820fff38d7 | |||
3f9dc3747b | |||
d7a8574c6e | |||
|
3f97292e58 | ||
|
2af5f750ad | ||
|
5c5a35be24 | ||
|
16a2f4fb9e | ||
|
725811a2e3 | ||
41a7f3427c | |||
|
620a895184 | ||
|
94eb912030 | ||
|
a324edabdf | ||
|
2fc38f524e | ||
|
ff0990ec9f | ||
|
ba0bd3da4a | ||
|
f115c7b3a2 | ||
|
2eaef09166 | ||
|
5991caae87 | ||
|
5e61c1cf74 | ||
|
6530844c94 | ||
|
abcb9b8a61 | ||
|
f47ad7814a | ||
|
aadff24099 | ||
|
6d94c9f4e7 | ||
|
3d2f47498e | ||
|
20f4b76389 | ||
|
0f26ab9eee | ||
|
e991a3c757 | ||
|
ebc6c9d6fb | ||
|
dba5f3b93f | ||
|
a9cfaa6eed | ||
|
a296facdea | ||
|
00cd218741 | ||
|
eb695e6b17 | ||
|
5bf6b82625 | ||
|
720889cc97 | ||
|
23edac59ec | ||
|
7d50942b36 | ||
|
e753776930 | ||
|
1d7b45093d | ||
|
d43bfa95aa | ||
|
678c8dfeec | ||
|
5d817a758d | ||
|
de19af3650 | ||
|
4c2f64907b | ||
|
e74d682b21 | ||
|
a89ddcfd2d | ||
|
6deadd596d | ||
|
498372fd06 | ||
|
22d33244ee | ||
|
b7c3235349 | ||
|
0e5be63fb3 | ||
|
6a39f00745 | ||
|
bdf9baa2e8 | ||
|
887336f2c6 | ||
|
f66d092b32 | ||
|
36afb4557a | ||
|
d81983f181 | ||
|
ebfd48b0f2 | ||
|
f31b533435 | ||
|
94ae96b9bc | ||
|
ff7230df06 | ||
|
54aefa9014 | ||
|
264ecdcc13 | ||
|
9a05688326 | ||
|
0b900339b0 | ||
|
8451b36a72 | ||
|
107a94cf6b | ||
|
b4317faee2 | ||
|
5f87ae101c | ||
|
4ed9778c85 | ||
|
9b596dbc78 | ||
|
4d3758308a | ||
|
58e3e43e06 | ||
|
5859abf2ff | ||
|
d65c3e95ad | ||
|
e1d6748422 | ||
|
7b9ad2c416 |
|
@ -110,3 +110,6 @@ 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.1.
|
||||
# using RuboCop version 1.75.2.
|
||||
# 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,12 +62,6 @@ 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.2
|
||||
3.4.3
|
||||
|
|
23
CHANGELOG.md
|
@ -2,6 +2,29 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.7] - 2025-04-02
|
||||
|
||||
### Add
|
||||
|
||||
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
|
||||
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire)
|
||||
- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski)
|
||||
- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire)
|
||||
- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire)
|
||||
- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire)
|
||||
- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire)
|
||||
- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire)
|
||||
- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire)
|
||||
- Fix “x” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire)
|
||||
|
||||
## [4.3.6] - 2025-03-13
|
||||
|
||||
### Security
|
||||
|
|
52
Gemfile.lock
|
@ -94,7 +94,7 @@ GEM
|
|||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1066.0)
|
||||
aws-partitions (1.1087.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.0)
|
||||
bindata (2.5.1)
|
||||
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.0)
|
||||
brakeman (7.0.2)
|
||||
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.3)
|
||||
csv (3.3.4)
|
||||
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.0)
|
||||
diff-lcs (1.6.1)
|
||||
discard (1.4.0)
|
||||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.8.1)
|
||||
doorkeeper (5.8.2)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.7)
|
||||
dotenv (3.1.8)
|
||||
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.12.2)
|
||||
faraday (2.13.0)
|
||||
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.1)
|
||||
ffi (1.17.2)
|
||||
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.1)
|
||||
google-protobuf (4.30.2)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.18.0)
|
||||
googleapis-common-protos-types (1.19.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.61.1)
|
||||
haml_lint (0.62.0)
|
||||
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.1)
|
||||
irb (1.15.2)
|
||||
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.3)
|
||||
linzer (0.6.5)
|
||||
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.0318)
|
||||
mime-types-data (3.2025.0408)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
|
@ -585,8 +585,8 @@ GEM
|
|||
ostruct (0.6.1)
|
||||
ox (2.14.22)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
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.12.0)
|
||||
rdoc (6.13.1)
|
||||
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.0)
|
||||
reline (0.6.1)
|
||||
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.1)
|
||||
rubocop (1.75.2)
|
||||
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.43.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.43.0)
|
||||
rubocop-ast (1.44.1)
|
||||
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.30.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
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.2.1)
|
||||
strong_migrations (2.3.0)
|
||||
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.0.1)
|
||||
terrapin (1.1.0)
|
||||
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.6
|
||||
2.6.8
|
||||
|
|
124
README.md
|
@ -1,123 +1,27 @@
|
|||
#  kmyblue
|
||||
NAS is an KMY & Mastodon Fork
|
||||
|
||||
[](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml)
|
||||
The following are just a few of the most common features. There are many other minor changes to the specifications.
|
||||
|
||||
! 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.
|
||||
Emoji reactions
|
||||
|
||||
kmyblueは、ActivityPubに接続するSNSの1つである[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。
|
||||
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はフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。
|
||||
Bookmark classification
|
||||
|
||||
kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。確かにサーバーkmyblueは創作作家向けの利用規約が設定されていますが、フォークとしてのkmyblueのルールは全くの別物です。いかなるコミュニティにも平等にお使いいただけます。
|
||||
kmyblueは、閉鎖的なコミュニティ、あまり目立ちたくないコミュニティには特に強力な機能を提供します。kmyblueはプライバシーを考慮したうえで強力な独自機能を提供するため、汎用サーバーとして利用するにもある程度十分な機能が揃っています。
|
||||
Set who can search your posts for each post (Searchability)
|
||||
|
||||
テストコード、Lint どちらも動いています。
|
||||
Quote posts, modest quotes (references)
|
||||
|
||||
### アジェンダ
|
||||
Record posts that meet certain conditions such as domains, accounts, and keywords (Subscriptions/Antennas)
|
||||
|
||||
- 利用方法
|
||||
- kmyblueの開発方針
|
||||
- kmyblueは何でないか
|
||||
- kmyblueの独自機能
|
||||
- 英語のサポートについて
|
||||
Send posts to a designated set of followers (Circles) (different from direct messages)
|
||||
|
||||
## 利用方法
|
||||
Notification of new posts on lists
|
||||
|
||||
### インストール方法
|
||||
Exclude posts from people you follow when filtering posts
|
||||
|
||||
[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。
|
||||
Hide number of followers and followings
|
||||
|
||||
### 開発への参加方法
|
||||
Automatically delete posts after a specified time has passed
|
||||
|
||||
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フォークの利用者にとって公共性の高いコンテンツであると思われます。これは、日本と欧米では一般的に考えられている児童ポルノの基準が異なり、欧米のサーバーの中にはこのアカウントをフォローしづらいものもあるという懸念を考慮したものです。
|
||||
Expanding moderation functions
|
||||
|
|
66
app/controllers/api/v1/accounts/endorsements_controller.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
# 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.suspended? ? [] : @account.featured_tags
|
||||
@featured_tags = @account.unavailable? ? [] : @account.featured_tags
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
# 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,10 +7,6 @@ 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'
|
||||
deprecate_api '2021-05-16', only: [:index]
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||
|
|
|
@ -7,7 +7,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
|
|||
|
||||
after_action :insert_pagination_headers
|
||||
|
||||
DEFAULT_TAGS_LIMIT = 10
|
||||
DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i
|
||||
|
||||
deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }
|
||||
|
||||
|
|
BIN
app/javascript/icons/android-chrome-144x144.png
Executable file → Normal file
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 5.7 KiB |
BIN
app/javascript/icons/android-chrome-192x192.png
Executable file → Normal file
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.5 KiB |
BIN
app/javascript/icons/android-chrome-256x256.png
Executable file → Normal file
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
BIN
app/javascript/icons/android-chrome-36x36.png
Executable file → Normal file
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 950 B |
BIN
app/javascript/icons/android-chrome-384x384.png
Executable file → Normal file
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
BIN
app/javascript/icons/android-chrome-48x48.png
Executable file → Normal file
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
app/javascript/icons/android-chrome-512x512.png
Executable file → Normal file
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 31 KiB |
BIN
app/javascript/icons/android-chrome-72x72.png
Executable file → Normal file
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/javascript/icons/android-chrome-96x96.png
Executable file → Normal file
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
app/javascript/icons/apple-touch-icon-1024x1024.png
Executable file → Normal file
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 76 KiB |
BIN
app/javascript/icons/apple-touch-icon-114x114.png
Executable file → Normal file
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4 KiB |
BIN
app/javascript/icons/apple-touch-icon-120x120.png
Executable file → Normal file
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.3 KiB |
BIN
app/javascript/icons/apple-touch-icon-144x144.png
Executable file → Normal file
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 5.7 KiB |
BIN
app/javascript/icons/apple-touch-icon-152x152.png
Executable file → Normal file
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 6 KiB |
BIN
app/javascript/icons/apple-touch-icon-167x167.png
Executable file → Normal file
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 6.9 KiB |
BIN
app/javascript/icons/apple-touch-icon-180x180.png
Executable file → Normal file
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.5 KiB |
BIN
app/javascript/icons/apple-touch-icon-192x192.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
app/javascript/icons/apple-touch-icon-256x256.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
app/javascript/icons/apple-touch-icon-36x36.png
Normal file
After Width: | Height: | Size: 950 B |
BIN
app/javascript/icons/apple-touch-icon-384x384.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
app/javascript/icons/apple-touch-icon-48x48.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/javascript/icons/apple-touch-icon-512x512.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
app/javascript/icons/apple-touch-icon-57x57.png
Executable file → Normal file
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
app/javascript/icons/apple-touch-icon-60x60.png
Executable file → Normal file
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
app/javascript/icons/apple-touch-icon-72x72.png
Executable file → Normal file
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/javascript/icons/apple-touch-icon-76x76.png
Executable file → Normal file
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.3 KiB |
BIN
app/javascript/icons/apple-touch-icon-96x96.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
app/javascript/icons/favicon-16x16.png
Executable file → Normal file
Before Width: | Height: | Size: 986 B After Width: | Height: | Size: 6 KiB |
BIN
app/javascript/icons/favicon-32x32.png
Executable file → Normal file
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6 KiB |
BIN
app/javascript/icons/favicon-48x48.png
Executable file → Normal file
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 6 KiB |
|
@ -1,11 +1,11 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openDropdownMenu = createAction<{
|
||||
id: string;
|
||||
id: number;
|
||||
keyboard: boolean;
|
||||
scrollKey: string;
|
||||
scrollKey?: string;
|
||||
}>('dropdownMenu/open');
|
||||
|
||||
export const closeDropdownMenu = createAction<{ id: string }>(
|
||||
export const closeDropdownMenu = createAction<{ id: number }>(
|
||||
'dropdownMenu/close',
|
||||
);
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
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 { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
|
@ -9,3 +9,15 @@ 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 } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -14,18 +14,19 @@ 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 DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -48,6 +49,14 @@ 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<{
|
||||
|
@ -73,6 +82,7 @@ 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) {
|
||||
|
@ -90,13 +100,62 @@ export const Account: React.FC<{
|
|||
}
|
||||
}, [dispatch, id, account, relationship]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, true));
|
||||
}, [dispatch, id]);
|
||||
const menu = useMemo(() => {
|
||||
let arr: MenuItem[] = [];
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, false));
|
||||
}, [dispatch, id]);
|
||||
if (defaultAction === 'mute') {
|
||||
const handleMuteNotifications = () => {
|
||||
dispatch(muteAccount(id, true));
|
||||
};
|
||||
|
||||
const handleUnmuteNotifications = () => {
|
||||
dispatch(muteAccount(id, false));
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship?.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship?.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
const handleAddToLists = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
},
|
||||
];
|
||||
|
||||
if (accountUrl) {
|
||||
arr.unshift(
|
||||
{
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: accountUrl,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
|
@ -107,73 +166,46 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
let button: React.ReactNode, dropdown: React.ReactNode;
|
||||
|
||||
if (account && account.id !== me && relationship) {
|
||||
const { requested, blocking, muting } = relationship;
|
||||
if (menu.length > 0) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
} else if (blocking) {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (muting) {
|
||||
const menu = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unmute)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = (
|
||||
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
|
||||
);
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.block)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
if (defaultAction === 'block') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.blocking ? messages.unblock : messages.block,
|
||||
)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.muting ? messages.unmute : messages.mute,
|
||||
)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
button = <FollowButton accountId={id} />;
|
||||
}
|
||||
|
||||
if (hideButtons) {
|
||||
buttons = null;
|
||||
button = null;
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
let muteTimeRemaining: React.ReactNode;
|
||||
|
||||
if (account?.mute_expires_at) {
|
||||
muteTimeRemaining = (
|
||||
|
@ -183,7 +215,7 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
let verification: React.ReactNode;
|
||||
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
|
||||
|
@ -233,11 +265,17 @@ export const Account: React.FC<{
|
|||
{!minimal && children && (
|
||||
<div>
|
||||
<div>{children}</div>
|
||||
<div className='account__relationship'>{buttons}</div>
|
||||
<div className='account__relationship'>
|
||||
{dropdown}
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!minimal && !children && (
|
||||
<div className='account__relationship'>{buttons}</div>
|
||||
<div className='account__relationship'>
|
||||
{dropdown}
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,345 +0,0 @@
|
|||
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);
|
551
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
|
@ -0,0 +1,551 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
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);
|
|
@ -1,77 +0,0 @@
|
|||
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));
|
140
app/javascript/mastodon/components/edited_timestamp/index.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
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,8 +16,7 @@ const messages = defineMessages({
|
|||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
});
|
||||
|
||||
export const FollowButton: React.FC<{
|
||||
|
@ -73,15 +72,9 @@ export const FollowButton: React.FC<{
|
|||
if (!signedIn) {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
} else if (accountId === me) {
|
||||
label = intl.formatMessage(messages.edit_profile);
|
||||
label = intl.formatMessage(messages.editProfile);
|
||||
} 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,10 +102,11 @@ 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> = ({
|
||||
|
@ -117,6 +118,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
className,
|
||||
description,
|
||||
withGraph = true,
|
||||
children,
|
||||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
|
@ -158,5 +160,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='trends__item__buttons'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ export type StatusLike = Record<{
|
|||
contentHTML: string;
|
||||
media_attachments: List<unknown>;
|
||||
spoiler_text?: string;
|
||||
account: Record<{ id: string }>;
|
||||
}>;
|
||||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
|
@ -195,19 +196,36 @@ export function getHashtagBarForStatus(status: StatusLike) {
|
|||
|
||||
return {
|
||||
statusContentProps,
|
||||
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
|
||||
hashtagBar: (
|
||||
<HashtagBar
|
||||
hashtags={hashtagsInBar}
|
||||
accountId={status.getIn(['account', 'id']) as string}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFeaturedHashtagBar(acct: string, tags: string[]) {
|
||||
return <HashtagBar acct={acct} hashtags={tags} defaultExpanded />;
|
||||
export function getFeaturedHashtagBar(
|
||||
accountId: string,
|
||||
acct: string,
|
||||
tags: string[],
|
||||
) {
|
||||
return (
|
||||
<HashtagBar
|
||||
acct={acct}
|
||||
hashtags={tags}
|
||||
accountId={accountId}
|
||||
defaultExpanded
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const HashtagBar: React.FC<{
|
||||
hashtags: string[];
|
||||
accountId: string;
|
||||
acct?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}> = ({ hashtags, acct, defaultExpanded }) => {
|
||||
}> = ({ hashtags, accountId, acct, defaultExpanded }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded(true);
|
||||
|
@ -228,6 +246,7 @@ 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 { PureComponent, createRef } from 'react';
|
||||
import { useState, useEffect, useCallback, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
@ -15,101 +15,110 @@ 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>();
|
||||
|
||||
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,
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
iconComponent,
|
||||
inverted,
|
||||
overlay,
|
||||
tabIndex,
|
||||
title,
|
||||
counter,
|
||||
href,
|
||||
ariaHidden,
|
||||
data_id,
|
||||
} = this.props;
|
||||
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);
|
||||
|
||||
const { activate, deactivate } = this.state;
|
||||
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 classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
|
@ -148,19 +157,20 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
aria-hidden={ariaHidden}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
style={buttonStyle}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
data-id={data_id}
|
||||
ref={this.buttonRef}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
|
|
@ -1,25 +1,6 @@
|
|||
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'>
|
||||
<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>
|
||||
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
|
||||
);
|
||||
|
|
43
app/javascript/mastodon/components/remote_hint.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
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> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -25,9 +25,8 @@ 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';
|
||||
|
@ -349,10 +348,9 @@ 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);
|
||||
|
@ -498,7 +496,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
{reblogMenu.length === 0 ? reblogButton : (
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
className={classNames('status__action-bar__button', { reblogPrivate })}
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
|
@ -509,9 +507,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
title={reblogTitle}
|
||||
active={status.get('reblogged')}
|
||||
disabled={!publicStatus && !reblogPrivate}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
|
@ -522,7 +518,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
</div>
|
||||
{emojiPickerDropdown}
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
|
|
|
@ -115,6 +115,7 @@ 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');
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
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);
|
|
@ -173,7 +173,7 @@ class About extends PureComponent {
|
|||
<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 {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {domain}' /></p>
|
||||
</div>
|
||||
|
||||
<div className='about__meta'>
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
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);
|
|
@ -1,17 +0,0 @@
|
|||
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);
|
|
@ -1,52 +0,0 @@
|
|||
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);
|
|
@ -0,0 +1,50 @@
|
|||
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>;
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
156
app/javascript/mastodon/features/account_featured/index.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
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,25 +2,22 @@ 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';
|
||||
|
||||
|
@ -56,53 +53,11 @@ 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 = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
);
|
||||
const accountId = useAccountId();
|
||||
const attachments = useAppSelector((state) =>
|
||||
accountId
|
||||
? getAccountGallery(state, accountId)
|
||||
|
@ -123,33 +78,15 @@ 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 remote = account?.acct !== account?.username;
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? getAccountHidden(state, accountId) : false,
|
||||
);
|
||||
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
|
||||
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));
|
||||
}
|
||||
|
@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
|
|||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (remote && attachments.isEmpty()) {
|
||||
} else if (attachments.isEmpty()) {
|
||||
emptyMessage = <RemoteHint accountId={accountId} />;
|
||||
} else {
|
||||
emptyMessage = (
|
||||
|
@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
|
|||
)
|
||||
}
|
||||
alwaysPrepend
|
||||
append={remote && accountId && <RemoteHint accountId={accountId} />}
|
||||
append={accountId && <RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_gallery'
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
|
|
|
@ -44,27 +44,21 @@ 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,
|
||||
isShowItem,
|
||||
} from 'mastodon/initial_state';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
|
@ -204,24 +198,6 @@ 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',
|
||||
|
@ -249,20 +225,6 @@ 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;
|
||||
|
@ -446,23 +408,6 @@ 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) {
|
||||
|
@ -498,10 +443,6 @@ export const AccountHeader: React.FC<{
|
|||
});
|
||||
}, [account]);
|
||||
|
||||
const handleEditProfile = useCallback(() => {
|
||||
window.open('/settings/profile', '_blank');
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
|
@ -537,7 +478,7 @@ export const AccountHeader: React.FC<{
|
|||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: DropdownMenu = [];
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
|
@ -626,9 +567,7 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
account.getIn(['relationship', 'endorsed'])
|
||||
? messages.unendorse
|
||||
: messages.endorse,
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
|
@ -778,9 +717,12 @@ export const AccountHeader: React.FC<{
|
|||
return null;
|
||||
}
|
||||
|
||||
let actionBtn, bellBtn, lockedIcon, shareBtn;
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info = [];
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship?.blocking) {
|
||||
info.push(
|
||||
|
@ -848,43 +790,17 @@ export const AccountHeader: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
if (me !== account.id) {
|
||||
if (signedIn && !relationship) {
|
||||
// Wait until the relationship is loaded
|
||||
actionBtn = (
|
||||
<Button disabled>
|
||||
<LoadingIndicator />
|
||||
</Button>
|
||||
);
|
||||
} else if (!relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
disabled={relationship?.blocked_by}
|
||||
className={classNames({
|
||||
'button--destructive':
|
||||
relationship?.following || relationship?.requested,
|
||||
})}
|
||||
text={intl.formatMessage(messageForFollowButton(relationship))}
|
||||
onClick={signedIn ? handleFollow : handleInteractionModal}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.edit_profile)}
|
||||
onClick={handleEditProfile}
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = <FollowButton accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (account.moved && !relationship?.following) {
|
||||
|
@ -910,7 +826,11 @@ export const AccountHeader: React.FC<{
|
|||
const isIndexable = !account.noindex;
|
||||
const featuredTagsArr =
|
||||
featuredTags?.map((tag: any) => tag.get('name')).toArray() || [];
|
||||
const featuredTagsBar = getFeaturedHashtagBar(account.acct, featuredTagsArr);
|
||||
const featuredTagsBar = getFeaturedHashtagBar(
|
||||
account.id,
|
||||
account.acct,
|
||||
featuredTagsArr,
|
||||
);
|
||||
|
||||
const badges = [];
|
||||
|
||||
|
@ -920,7 +840,7 @@ export const AccountHeader: React.FC<{
|
|||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.get('roles', []).forEach((role) => {
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
|
@ -980,13 +900,11 @@ export const AccountHeader: React.FC<{
|
|||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
{!hidden && actionBtn}
|
||||
</div>
|
||||
|
@ -1141,6 +1059,9 @@ 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,12 +7,10 @@ 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';
|
||||
|
@ -21,6 +19,7 @@ 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';
|
||||
|
@ -47,11 +46,8 @@ 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),
|
||||
|
@ -60,24 +56,6 @@ 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 = {
|
||||
|
@ -89,7 +67,6 @@ 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,
|
||||
|
@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
isAccount: PropTypes.bool,
|
||||
suspended: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
remote: PropTypes.bool,
|
||||
remoteUrl: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
|
@ -191,8 +166,6 @@ 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 />
|
||||
|
@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
<StatusList
|
||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={forceEmptyState ? emptyList : statusIds}
|
||||
featuredStatusIds={featuredStatusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
@ -13,6 +12,7 @@ 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 } from 'mastodon/initial_state';
|
||||
import { me, reduceMotion } 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,6 +105,17 @@ 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(
|
||||
|
@ -117,9 +128,6 @@ 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) => {
|
||||
|
@ -134,36 +142,30 @@ 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;
|
||||
}
|
||||
|
@ -179,10 +181,7 @@ const Preview: React.FC<{
|
|||
role='presentation'
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
<animated.div className='focal-point__reticle' style={style} />
|
||||
</div>
|
||||
);
|
||||
} else if (media.get('type') === 'gifv') {
|
||||
|
@ -194,10 +193,7 @@ const Preview: React.FC<{
|
|||
alt=''
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
<animated.div className='focal-point__reticle' style={style} />
|
||||
</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,12 +96,11 @@ const AntennaItem: React.FC<{
|
|||
</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='antennas'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='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,12 +76,11 @@ const BookmarkCategoryItem: React.FC<{
|
|||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='bookmark_categories'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,195 +0,0 @@
|
|||
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)));
|
|
@ -0,0 +1,121 @@
|
|||
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;
|
|
@ -1,117 +0,0 @@
|
|||
// 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));
|
116
app/javascript/mastodon/features/bookmarked_statuses/index.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
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;
|
|
@ -1,195 +0,0 @@
|
|||
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)));
|
118
app/javascript/mastodon/features/circle_statuses/index.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
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,12 +60,11 @@ const CircleItem: React.FC<{
|
|||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='circles'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -2,67 +2,94 @@ 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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
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 = () => {
|
||||
const dispatch = useDispatch();
|
||||
export const ActionBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='bars'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
);
|
||||
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||
};
|
|
@ -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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
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'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={lastStatus}
|
||||
items={menu}
|
||||
|
|
|
@ -1,114 +0,0 @@
|
|||
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));
|
|
@ -0,0 +1,118 @@
|
|||
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(id));
|
||||
dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
let label;
|
||||
|
|
116
app/javascript/mastodon/features/favourited_statuses/index.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
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;
|
|
@ -1,95 +0,0 @@
|
|||
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));
|
161
app/javascript/mastodon/features/followed_tags/index.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
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;
|
|
@ -14,6 +14,8 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import HashtagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import Directory from '@/material-icons/400-24px/group.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
|
@ -42,6 +44,8 @@ const messages = defineMessages({
|
|||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed Hashtags' },
|
||||
directory: { id: 'navigation_bar.directory', defaultMessage: 'Profile directory' },
|
||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
deep_timeline: { id: 'navigation_bar.deep_timeline', defaultMessage: 'Deep timeline' },
|
||||
|
@ -144,6 +148,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
<ColumnLink key='direct' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} to='/conversations' />,
|
||||
<ColumnLink key='bookmark' icon='bookmarks' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} to='/bookmark_categories' />,
|
||||
<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key='followed_tags' icon='tag' iconComponent={followed_tagsIcon} text={intl.formatMessage(messages.followed_tags)} to='/followed_tags' />,
|
||||
<ColumnLink key='lists' icon='list-ul' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||
<ColumnLink key='antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} to='/antennas' />,
|
||||
<ColumnLink key='circles' icon='user-circle' iconComponent={CirclesIcon} text={intl.formatMessage(messages.circles)} to='/circles' />,
|
||||
|
|
|
@ -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,13 +153,11 @@ export const HashtagHeader: React.FC<{
|
|||
|
||||
<div className='hashtag-header__header__buttons'>
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu
|
||||
<Dropdown
|
||||
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,12 +72,11 @@ const ListItem: React.FC<{
|
|||
</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='lists'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||
|
||||
|
@ -105,11 +105,10 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
|
||||
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>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
|
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
|||
</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
</DropdownMenuContainer>
|
||||
</Dropdown>
|
||||
<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 DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
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'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
className={classNames({ reblogPrivate })}
|
||||
icon='retweet'
|
||||
iconComponent={reblogIconComponent}
|
||||
|
@ -434,7 +434,7 @@ class ActionBar extends PureComponent {
|
|||
{emojiPickerDropdown}
|
||||
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
<Dropdown 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';
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
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,10 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react';
|
||||
|
@ -13,6 +10,8 @@ import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?re
|
|||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import HashtagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import DirectoryIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
|
@ -31,6 +30,7 @@ import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
|||
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
|
||||
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { WordmarkLogo } from 'mastodon/components/logo';
|
||||
|
@ -50,6 +50,8 @@ const messages = defineMessages({
|
|||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
explore: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
directory: { id: 'navigation_bar.directory', defaultMessage: 'Profiles directory' },
|
||||
local: { id: 'column.local', defaultMessage: 'Local' },
|
||||
deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' },
|
||||
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
|
||||
|
@ -71,7 +73,6 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
const NotificationsLink = () => {
|
||||
|
||||
const count = useSelector(selectUnreadNotificationGroupsCount);
|
||||
const intl = useIntl();
|
||||
|
||||
|
@ -129,25 +130,20 @@ class NavigationPanel extends Component {
|
|||
const { intl } = this.props;
|
||||
const { signedIn, disabledAccountId, permissions } = this.props.identity;
|
||||
|
||||
const explorer = (trendsEnabled ? (
|
||||
const explorer = trendsEnabled ? (
|
||||
<ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} />
|
||||
) : (
|
||||
<ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} />
|
||||
));
|
||||
|
||||
let banner = undefined;
|
||||
);
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const banner = transientSingleColumn ? (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
|
@ -155,90 +151,59 @@ class NavigationPanel extends Component {
|
|||
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
|
||||
</div>
|
||||
|
||||
{banner &&
|
||||
<div className='navigation-panel__banner'>
|
||||
{banner}
|
||||
</div>
|
||||
}
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
||||
<NotificationsLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && enableLocalTimeline && (
|
||||
<ColumnLink transparent to='/public/local/fixed' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.local)} />
|
||||
)}
|
||||
|
||||
{signedIn && enableDtlMenu && dtlTag && (
|
||||
<ColumnLink transparent to={`/tags/${dtlTag}`} icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.deepLocal)} />
|
||||
)}
|
||||
|
||||
{!signedIn && explorer}
|
||||
|
||||
{signedIn && (
|
||||
<ColumnLink transparent to='/public' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
|
||||
{(!signedIn && timelinePreview) && (
|
||||
<ColumnLink transparent to={enableLocalTimeline ? '/public/local' : '/public'} isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
{enableLocalTimeline && <ColumnLink transparent to='/public/local/fixed' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.local)} />}
|
||||
{enableDtlMenu && dtlTag && <ColumnLink transparent to={`/tags/${dtlTag}`} icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.deepLocal)} />}
|
||||
<ColumnLink transparent to='/public' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
<ListPanel />
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
|
||||
<ColumnLink transparent to='/antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} isActive={this.isAntennasActive} />
|
||||
<ColumnLink transparent to='/circles' icon='user-circle' iconComponent={CirclesIcon} text={intl.formatMessage(messages.circles)} />
|
||||
<ColumnLink transparent to='/followed_tags' icon='tag' iconComponent={HashtagIcon} text={intl.formatMessage(messages.followed_tags)} />
|
||||
<ColumnLink transparent to='/directory' icon='group' iconComponent={DirectoryIcon} text={intl.formatMessage(messages.directory)} />
|
||||
<FollowRequestsLink />
|
||||
<ColumnLink transparent to='/conversations' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && explorer}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
{explorer}
|
||||
<ColumnLink transparent to='/bookmark_categories' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
|
||||
{ !isHideItem('favourite_menu') && <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} /> }
|
||||
{!isHideItem('favourite_menu') && <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} />}
|
||||
<hr />
|
||||
|
||||
<ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />
|
||||
|
||||
{canManageReports(permissions) && <ColumnLink transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
||||
{canViewAdminDashboard(permissions) && <ColumnLink transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
|
||||
</div>
|
||||
<>
|
||||
{explorer}
|
||||
{(timelinePreview || enableLocalTimeline) && (
|
||||
<ColumnLink transparent to={enableLocalTimeline ? '/public/local' : '/public'} isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
{disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<hr />
|
||||
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
||||
</div>
|
||||
<div className='navigation-panel__legal'>
|
||||
<hr />
|
||||
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<NavigationPortal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(withIdentity(NavigationPanel));
|
||||
|
|
|
@ -31,6 +31,7 @@ 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';
|
||||
|
@ -91,6 +92,7 @@ import {
|
|||
BookmarkCategoryEdit,
|
||||
ReactionDeck,
|
||||
TermsOfService,
|
||||
AccountFeatured,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
|
@ -272,6 +274,7 @@ 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} />
|
||||
|
@ -658,6 +661,7 @@ class UI extends PureComponent {
|
|||
{layout !== 'mobile' && <PictureInPicture />}
|
||||
<AlertsController />
|
||||
{!disableHoverCards && <HoverCardController />}
|
||||
<HashtagMenuController />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
|
|
|
@ -82,6 +82,10 @@ 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');
|
||||
}
|
||||
|
|
37
app/javascript/mastodon/hooks/useAccountId.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
}
|