Compare commits

..

3 commits

Author SHA1 Message Date
KMY(雪あすか)
b091cbdbea
Merge pull request #1001 from kmycode/kb-draft-18.0
Release: kb18.0
2025-04-14 17:43:53 +09:00
KMY
8992602acf Bump version to 18.0 2025-04-14 13:34:50 +09:00
KMY
c68762e2bf Fix: フルダークで文字が薄く表示される問題 2025-04-14 10:52:33 +09:00
286 changed files with 3236 additions and 3799 deletions

View file

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

View file

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

View file

@ -1 +1 @@
3.4.3
3.4.2

View file

@ -2,29 +2,6 @@
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

View file

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

124
README.md
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
after_action :insert_pagination_headers
DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i
DEFAULT_TAGS_LIMIT = 10
deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }

BIN
app/javascript/icons/android-chrome-144x144.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-192x192.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-256x256.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-36x36.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-384x384.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-48x48.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-512x512.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-72x72.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

BIN
app/javascript/icons/android-chrome-96x96.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-1024x1024.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-114x114.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-120x120.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-144x144.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-152x152.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-167x167.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-180x180.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
app/javascript/icons/apple-touch-icon-57x57.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-60x60.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-72x72.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

BIN
app/javascript/icons/apple-touch-icon-76x76.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

BIN
app/javascript/icons/favicon-16x16.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 986 B

Before After
Before After

BIN
app/javascript/icons/favicon-32x32.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

BIN
app/javascript/icons/favicon-48x48.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {domain}' /></p>
<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>
</div>
<div className='about__meta'>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,8 +14,6 @@ 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';
@ -44,8 +42,6 @@ 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' },
@ -148,7 +144,6 @@ 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' />,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,10 @@
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';
@ -10,8 +13,6 @@ 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';
@ -30,7 +31,6 @@ 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,8 +50,6 @@ 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' },
@ -73,6 +71,7 @@ const messages = defineMessages({
});
const NotificationsLink = () => {
const count = useSelector(selectUnreadNotificationGroupsCount);
const intl = useIntl();
@ -130,20 +129,25 @@ 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)} />
);
));
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;
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>
);
}
return (
<div className='navigation-panel'>
@ -151,59 +155,90 @@ 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 />
{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)} />
</>
)}
{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 && (
<>
<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)} />
{explorer}
</>
)}
{signedIn && explorer}
{signedIn && (
<>
<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 && (
<>
{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 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 className='navigation-panel__legal'>
<hr />
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
</div>
</div>
<div className='flex-spacer' />
<NavigationPortal />
</div>
);
}
}
export default injectIntl(withIdentity(NavigationPanel));

View file

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

View file

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

View file

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

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