Merge branch 'main' into standalone

This commit is contained in:
Kopper 2024-08-04 09:03:46 +03:00
commit 911833f860
631 changed files with 17275 additions and 5147 deletions

View file

@ -39,7 +39,7 @@
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"postCreateCommand": "bin/setup", "postCreateCommand": "COREPACK_ENABLE_DOWNLOAD_PROMPT=0 bin/setup",
"waitFor": "postCreateCommand", "waitFor": "postCreateCommand",
"customizations": { "customizations": {

View file

@ -19,6 +19,7 @@ on:
jobs: jobs:
upload-translations: upload-translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon'
steps: steps:
- name: Checkout - name: Checkout

2
.nvmrc
View file

@ -1 +1 @@
20.15 20.16

View file

@ -50,6 +50,11 @@ You can contribute in the following ways:
If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
Please review the org-level [contribution guidelines] for high-level acceptance
criteria guidance.
[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md
## API Changes and Additions ## API Changes and Additions
Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation).

View file

@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.8 # syntax=docker/dockerfile:1.9
# This file is designed for production server deployment, not local development work # This file is designed for production server deployment, not local development work
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker

View file

@ -88,7 +88,7 @@ gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'simple_form', '~> 5.2' gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4' gem 'simple-navigation', '~> 4.4'
gem 'stoplight', '~> 4.1' gem 'stoplight', '~> 4.1'
gem 'strong_migrations', '1.8.0' gem 'strong_migrations'
gem 'tty-prompt', '~> 0.23', require: false gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0' gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023' gem 'tzinfo-data', '~> 1.2023'
@ -100,7 +100,7 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2' gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5' gem 'rdf-normalize', '~> 0.5'
gem 'opentelemetry-api', '~> 1.2.5' gem 'opentelemetry-api', '~> 1.3.0'
group :opentelemetry do group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false

View file

@ -222,16 +222,16 @@ GEM
elasticsearch-transport (7.17.10) elasticsearch-transport (7.17.10)
faraday (>= 1, < 3) faraday (>= 1, < 3)
multi_json multi_json
email_spec (2.2.2) email_spec (2.3.0)
htmlentities (~> 4.3.3) htmlentities (~> 4.3.3)
launchy (~> 2.1) launchy (>= 2.1, < 4.0)
mail (~> 2.7) mail (~> 2.7)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
excon (0.110.0) excon (0.110.0)
fabrication (2.31.0) fabrication (2.31.0)
faker (3.4.1) faker (3.4.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@ -289,7 +289,7 @@ GEM
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (3.25.3) google-protobuf (3.25.4)
googleapis-common-protos-types (1.14.0) googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18) google-protobuf (~> 3.18)
haml (6.3.0) haml (6.3.0)
@ -357,17 +357,18 @@ GEM
aes_key_wrap aes_key_wrap
bindata bindata
httpclient httpclient
json-ld (3.3.1) json-ld (3.3.2)
htmlentities (~> 4.3) htmlentities (~> 4.3)
json-canonicalization (~> 1.0) json-canonicalization (~> 1.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.15) multi_json (~> 1.15)
rack (>= 2.2, < 4) rack (>= 2.2, < 4)
rdf (~> 3.3) rdf (~> 3.3)
rexml (~> 3.2)
json-ld-preloaded (3.3.0) json-ld-preloaded (3.3.0)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (4.3.0) json-schema (4.3.1)
addressable (>= 2.8) addressable (>= 2.8)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.7.1) jwt (2.7.1)
@ -440,7 +441,7 @@ GEM
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.12) net-imap (0.4.14)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -451,7 +452,7 @@ GEM
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.16.6) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.3.0) nsa (0.3.0)
@ -492,10 +493,10 @@ GEM
openssl (3.2.0) openssl (3.2.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.2.5) opentelemetry-api (1.3.0)
opentelemetry-common (0.20.1) opentelemetry-common (0.20.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.28.0) opentelemetry-exporter-otlp (0.28.1)
google-protobuf (>= 3.18) google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3) googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
@ -512,14 +513,14 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (~> 0.21) opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.7.0) opentelemetry-instrumentation-action_view (0.7.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_job (0.7.2) opentelemetry-instrumentation-active_job (0.7.4)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_model_serializers (0.20.1) opentelemetry-instrumentation-active_model_serializers (0.20.2)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_record (0.7.2) opentelemetry-instrumentation-active_record (0.7.2)
@ -531,32 +532,32 @@ GEM
opentelemetry-instrumentation-base (0.22.3) opentelemetry-instrumentation-base (0.22.3)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-registry (~> 0.1) opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.21.3) opentelemetry-instrumentation-concurrent_ruby (0.21.4)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-excon (0.22.3) opentelemetry-instrumentation-excon (0.22.4)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-faraday (0.24.5) opentelemetry-instrumentation-faraday (0.24.6)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http (0.23.3) opentelemetry-instrumentation-http (0.23.4)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http_client (0.22.6) opentelemetry-instrumentation-http_client (0.22.7)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-net_http (0.22.6) opentelemetry-instrumentation-net_http (0.22.7)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-pg (0.27.3) opentelemetry-instrumentation-pg (0.27.4)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (0.24.5) opentelemetry-instrumentation-rack (0.24.6)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rails (0.31.0) opentelemetry-instrumentation-rails (0.31.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.1.0) opentelemetry-instrumentation-action_mailer (~> 0.1.0)
opentelemetry-instrumentation-action_pack (~> 0.9.0) opentelemetry-instrumentation-action_pack (~> 0.9.0)
@ -565,20 +566,20 @@ GEM
opentelemetry-instrumentation-active_record (~> 0.7.0) opentelemetry-instrumentation-active_record (~> 0.7.0)
opentelemetry-instrumentation-active_support (~> 0.6.0) opentelemetry-instrumentation-active_support (~> 0.6.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-redis (0.25.6) opentelemetry-instrumentation-redis (0.25.7)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-sidekiq (0.25.6) opentelemetry-instrumentation-sidekiq (0.25.7)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-registry (0.3.1) opentelemetry-registry (0.3.1)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.4.1) opentelemetry-sdk (1.5.0)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2) opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.10.0) opentelemetry-semantic_conventions (1.10.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.14.18) ox (2.14.18)
@ -589,7 +590,7 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.6) pg (1.5.7)
pghero (3.6.0) pghero (3.6.0)
activerecord (>= 6.1) activerecord (>= 6.1)
premailer (1.23.0) premailer (1.23.0)
@ -607,13 +608,13 @@ GEM
railties (>= 7.0.0) railties (>= 7.0.0)
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (6.0.0) public_suffix (6.0.1)
puma (6.4.2) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.2) pundit (2.3.2)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.0) racc (1.8.1)
rack (2.2.9) rack (2.2.9)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
@ -675,8 +676,9 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rdf (3.3.1) rdf (3.3.2)
bcp47_spec (~> 0.2) bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
@ -696,7 +698,7 @@ GEM
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.3.1) rexml (3.3.4)
strscan strscan
rotp (6.3.0) rotp (6.3.0)
rouge (4.2.1) rouge (4.2.1)
@ -733,7 +735,7 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.13.1) rspec-support (3.13.1)
rubocop (1.65.0) rubocop (1.65.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
@ -766,21 +768,22 @@ GEM
ruby-saml (1.16.0) ruby-saml (1.16.0)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.1) ruby-vips (2.2.2)
ffi (~> 1.12) ffi (~> 1.12)
logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
rufus-scheduler (3.9.1) rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
sanitize (6.1.1) sanitize (6.1.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
scenic (1.8.0) scenic (1.8.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.22.0) selenium-webdriver (4.23.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -820,8 +823,8 @@ GEM
stoplight (4.1.0) stoplight (4.1.0)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.1) stringio (3.1.1)
strong_migrations (1.8.0) strong_migrations (2.0.0)
activerecord (>= 5.2) activerecord (>= 6.1)
strscan (3.1.0) strscan (3.1.0)
swd (1.3.0) swd (1.3.0)
activesupport (>= 3) activesupport (>= 3)
@ -893,7 +896,7 @@ GEM
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webrick (1.8.1) webrick (1.8.1)
websocket (1.2.10) websocket (1.2.11)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@ -982,7 +985,7 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0) omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1) omniauth_openid_connect (~> 0.6.1)
opentelemetry-api (~> 1.2.5) opentelemetry-api (~> 1.3.0)
opentelemetry-exporter-otlp (~> 0.28.0) opentelemetry-exporter-otlp (~> 0.28.0)
opentelemetry-instrumentation-active_job (~> 0.7.1) opentelemetry-instrumentation-active_job (~> 0.7.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
@ -1045,7 +1048,7 @@ DEPENDENCIES
simplecov-lcov (~> 0.8) simplecov-lcov (~> 0.8)
stackprof stackprof
stoplight (~> 4.1) stoplight (~> 4.1)
strong_migrations (= 1.8.0) strong_migrations
test-prof test-prof
thor (~> 1.2) thor (~> 1.2)
tty-prompt (~> 0.23) tty-prompt (~> 0.23)

View file

@ -30,7 +30,7 @@ Should anything break, open `https://masto.example.com/logout.html` or clear loc
--- ---
# <img src="https://github.com/TheEssem/mastodon/raw/main/app/javascript/icons/android-chrome-256x256.png" width="128"> Chuckya # <img src="https://github.com/TheEssem/mastodon/raw/main/public/chuckya.svg" width="128"> Chuckya
Chuckya is a close-to-upstream soft fork of Mastodon Glitch Edition (more commonly known as glitch-soc) that aims to introduce more experimental features/fixes with the goal of making the overall experience more enjoyable. Although it's mainly developed for and used on the [wetdry.world](https://wetdry.world) instance, it can be deployed by any server admin as a drop-in, backwards-compatible replacement for Mastodon. Chuckya is a close-to-upstream soft fork of Mastodon Glitch Edition (more commonly known as glitch-soc) that aims to introduce more experimental features/fixes with the goal of making the overall experience more enjoyable. Although it's mainly developed for and used on the [wetdry.world](https://wetdry.world) instance, it can be deployed by any server admin as a drop-in, backwards-compatible replacement for Mastodon.

View file

@ -13,6 +13,7 @@ module Admin
def show def show
authorize :instance, :show? authorize :instance, :show?
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
end end
def destroy def destroy

View file

@ -2,7 +2,15 @@
module Admin module Admin
class TagsController < BaseController class TagsController < BaseController
before_action :set_tag before_action :set_tag, except: [:index]
PER_PAGE = 20
def index
authorize :tag, :index?
@tags = filtered_tags.page(params[:page]).per(PER_PAGE)
end
def show def show
authorize @tag, :show? authorize @tag, :show?
@ -31,5 +39,13 @@ module Admin
def tag_params def tag_params
params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable)
end end
def filtered_tags
TagFilter.new(filter_params.with_defaults(order: 'newest')).results
end
def filter_params
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
end
end end
end end

View file

@ -30,10 +30,10 @@ class Api::BaseController < ApplicationController
protected protected
def limit_param(default_limit) def limit_param(default_limit, max_limit = nil)
return default_limit unless params[:limit] return default_limit unless params[:limit]
[params[:limit].to_i.abs, default_limit * 2].min [params[:limit].to_i.abs, max_limit || (default_limit * 2)].min
end end
def params_slice(*keys) def params_slice(*keys)

View file

@ -5,6 +5,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
include AccountableConcern include AccountableConcern
LIMIT = 100 LIMIT = 100
MAX_LIMIT = 500
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show]
@ -47,18 +48,13 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
private private
def set_domain_allows def set_domain_allows
@domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) @domain_allows = DomainAllow.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def set_domain_allow def set_domain_allow
@domain_allow = DomainAllow.find(params[:id]) @domain_allow = DomainAllow.find(params[:id])
end end
def filtered_domain_allows
# TODO: no filtering yet
DomainAllow.all
end
def next_path def next_path
api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -72,7 +68,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
end end
def records_continue? def records_continue?
@domain_allows.size == limit_param(LIMIT) @domain_allows.size == limit_param(LIMIT, MAX_LIMIT)
end end
def resource_params def resource_params

View file

@ -5,6 +5,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
include AccountableConcern include AccountableConcern
LIMIT = 100 LIMIT = 100
MAX_LIMIT = 500
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show]
@ -59,18 +60,13 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
end end
def set_domain_blocks def set_domain_blocks
@domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) @domain_blocks = DomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def set_domain_block def set_domain_block
@domain_block = DomainBlock.find(params[:id]) @domain_block = DomainBlock.find(params[:id])
end end
def filtered_domain_blocks
# TODO: no filtering yet
DomainBlock.all
end
def domain_block_params def domain_block_params
params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
end end
@ -88,7 +84,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
end end
def records_continue? def records_continue?
@domain_blocks.size == limit_param(LIMIT) @domain_blocks.size == limit_param(LIMIT, MAX_LIMIT)
end end
def resource_params def resource_params

View file

@ -5,7 +5,8 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index
before_action :require_user! before_action :require_user!
before_action :set_request, except: :index before_action :set_request, only: [:show, :accept, :dismiss]
before_action :set_requests, only: [:accept_bulk, :dismiss_bulk]
after_action :insert_pagination_headers, only: :index after_action :insert_pagination_headers, only: :index
@ -32,6 +33,16 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
render_empty render_empty
end end
def accept_bulk
@requests.each { |request| AcceptNotificationRequestService.new.call(request) }
render_empty
end
def dismiss_bulk
@requests.each(&:destroy!)
render_empty
end
private private
def load_requests def load_requests
@ -53,6 +64,10 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
@request = NotificationRequest.where(account: current_account).find(params[:id]) @request = NotificationRequest.where(account: current_account).find(params[:id])
end end
def set_requests
@requests = NotificationRequest.where(account: current_account, id: Array(params[:id]).uniq.map(&:to_i))
end
def next_path def next_path
api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty?
end end

View file

@ -7,6 +7,8 @@ class Api::V1::NotificationsController < Api::BaseController
after_action :insert_pagination_headers, only: :index after_action :insert_pagination_headers, only: :index
DEFAULT_NOTIFICATIONS_LIMIT = 40 DEFAULT_NOTIFICATIONS_LIMIT = 40
DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100
MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000
def index def index
with_read_replica do with_read_replica do
@ -17,6 +19,14 @@ class Api::V1::NotificationsController < Api::BaseController
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships
end end
def unread_count
limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT)
with_read_replica do
render json: { count: browserable_account_notifications.paginate_by_min_id(limit, notification_marker&.last_read_id).count }
end
end
def show def show
@notification = current_account.notifications.without_suspended.find(params[:id]) @notification = current_account.notifications.without_suspended.find(params[:id])
render json: @notification, serializer: REST::NotificationSerializer render json: @notification, serializer: REST::NotificationSerializer
@ -63,6 +73,10 @@ class Api::V1::NotificationsController < Api::BaseController
) )
end end
def notification_marker
current_user.markers.find_by(timeline: 'notifications')
end
def target_statuses_from_notifications def target_statuses_from_notifications
@notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status)
end end

View file

@ -10,7 +10,7 @@ class Api::V1::ReportsController < Api::BaseController
@report = ReportService.new.call( @report = ReportService.new.call(
current_account, current_account,
reported_account, reported_account,
report_params report_params.merge(application: doorkeeper_token.application)
) )
render json: @report, serializer: REST::ReportSerializer render json: @report, serializer: REST::ReportSerializer

View file

@ -7,20 +7,49 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
after_action :insert_pagination_headers, only: :index after_action :insert_pagination_headers, only: :index
DEFAULT_NOTIFICATIONS_LIMIT = 40 DEFAULT_NOTIFICATIONS_LIMIT = 40
DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100
MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000
def index def index
with_read_replica do with_read_replica do
@notifications = load_notifications @notifications = load_notifications
@group_metadata = load_group_metadata @group_metadata = load_group_metadata
@grouped_notifications = load_grouped_notifications
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
@sample_accounts = @grouped_notifications.flat_map(&:sample_accounts)
# Preload associations to avoid N+1s
ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
end end
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
span.add_attributes(
'app.notification_grouping.count' => @grouped_notifications.size,
'app.notification_grouping.sample_account.count' => @sample_accounts.size,
'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size,
'app.notification_grouping.status.count' => statuses.size,
'app.notification_grouping.status.unique_count' => statuses.uniq.size
)
presenter = GroupedNotificationsPresenter.new(@grouped_notifications)
render json: presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
end
end
def unread_count
limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT)
with_read_replica do
render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id).count }
end
end end
def show def show
@notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id]) @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id])
render json: NotificationGroup.from_notification(@notification), serializer: REST::NotificationGroupSerializer presenter = GroupedNotificationsPresenter.new([NotificationGroup.from_notification(@notification)])
render json: presenter, serializer: REST::DedupNotificationGroupSerializer
end end
def clear def clear
@ -36,25 +65,35 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
private private
def load_notifications def load_notifications
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
limit_param(DEFAULT_NOTIFICATIONS_LIMIT), notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
params_slice(:max_id, :since_id, :min_id) limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
) params_slice(:max_id, :since_id, :min_id)
)
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
preload_collection(target_statuses, Status) preload_collection(target_statuses, Status)
end
end end
end end
def load_group_metadata def load_group_metadata
return {} if @notifications.empty? return {} if @notifications.empty?
browserable_account_notifications MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
.where(group_key: @notifications.filter_map(&:group_key)) browserable_account_notifications
.where(id: (@notifications.last.id)..(@notifications.first.id)) .where(group_key: @notifications.filter_map(&:group_key))
.group(:group_key) .where(id: (@notifications.last.id)..(@notifications.first.id))
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at') .group(:group_key)
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] } .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
end
end
def load_grouped_notifications
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
end
end end
def browserable_account_notifications def browserable_account_notifications
@ -65,6 +104,10 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
) )
end end
def notification_marker
current_user.markers.find_by(timeline: 'notifications')
end
def target_statuses_from_notifications def target_statuses_from_notifications
@notifications.filter_map(&:target_status) @notifications.filter_map(&:target_status)
end end

View file

@ -23,7 +23,6 @@ class ApplicationController < ActionController::Base
helper_method :current_theme helper_method :current_theme
helper_method :single_user_mode? helper_method :single_user_mode?
helper_method :use_seamless_external_login? helper_method :use_seamless_external_login?
helper_method :omniauth_only?
helper_method :sso_account_settings helper_method :sso_account_settings
helper_method :limited_federation_mode? helper_method :limited_federation_mode?
helper_method :body_class_string helper_method :body_class_string
@ -140,10 +139,6 @@ class ApplicationController < ActionController::Base
Devise.pam_authentication || Devise.ldap_authentication Devise.pam_authentication || Devise.ldap_authentication
end end
def omniauth_only?
ENV['OMNIAUTH_ONLY'] == 'true'
end
def sso_account_settings def sso_account_settings
ENV.fetch('SSO_ACCOUNT_SETTINGS', nil) ENV.fetch('SSO_ACCOUNT_SETTINGS', nil)
end end

View file

@ -5,7 +5,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth' layout 'auth'
before_action :set_body_classes
before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? before_action :redirect_confirmed_user, if: :signed_in_confirmed_user?
@ -73,10 +72,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
end end
def set_body_classes
@body_classes = 'lighter'
end
def after_resending_confirmation_instructions_path_for(_resource_name) def after_resending_confirmation_instructions_path_for(_resource_name)
if user_signed_in? if user_signed_in?
if current_user.confirmed? && current_user.approved? if current_user.confirmed? && current_user.approved?

View file

@ -3,7 +3,6 @@
class Auth::PasswordsController < Devise::PasswordsController class Auth::PasswordsController < Devise::PasswordsController
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid?
before_action :set_body_classes
layout 'auth' layout 'auth'
@ -24,10 +23,6 @@ class Auth::PasswordsController < Devise::PasswordsController
redirect_to new_password_path(resource_name) redirect_to new_password_path(resource_name)
end end
def set_body_classes
@body_classes = 'lighter'
end
def reset_password_token_is_valid? def reset_password_token_is_valid?
resource_class.with_reset_password_token(params[:reset_password_token]).present? resource_class.with_reset_password_token(params[:reset_password_token]).present?
end end

View file

@ -105,7 +105,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private private
def set_body_classes def set_body_classes
@body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' @body_classes = 'admin' if %w(edit update).include?(action_name)
end end
def set_invite def set_invite

View file

@ -16,8 +16,6 @@ class Auth::SessionsController < Devise::SessionsController
include Auth::TwoFactorAuthenticationConcern include Auth::TwoFactorAuthenticationConcern
before_action :set_body_classes
content_security_policy only: :new do |p| content_security_policy only: :new do |p|
p.form_action(false) p.form_action(false)
end end
@ -103,10 +101,6 @@ class Auth::SessionsController < Devise::SessionsController
private private
def set_body_classes
@body_classes = 'lighter'
end
def home_paths(resource) def home_paths(resource)
paths = [about_path, '/explore'] paths = [about_path, '/explore']

View file

@ -5,7 +5,6 @@ class Auth::SetupController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :require_unconfirmed_or_pending! before_action :require_unconfirmed_or_pending!
before_action :set_body_classes
before_action :set_user before_action :set_user
skip_before_action :require_functional! skip_before_action :require_functional!
@ -35,10 +34,6 @@ class Auth::SetupController < ApplicationController
@user = current_user @user = current_user
end end
def set_body_classes
@body_classes = 'lighter'
end
def user_params def user_params
params.require(:user).permit(:email) params.require(:user).permit(:email)
end end

View file

@ -83,7 +83,6 @@ module Auth::TwoFactorAuthenticationConcern
def prompt_for_two_factor(user) def prompt_for_two_factor(user)
register_attempt_in_session(user) register_attempt_in_session(user)
@body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled? @webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn' 'webauthn'

View file

@ -42,7 +42,6 @@ module ChallengableConcern
end end
def render_challenge def render_challenge
@body_classes = 'lighter'
render 'auth/challenges/new', layout: 'auth' render 'auth/challenges/new', layout: 'auth'
end end

View file

@ -5,7 +5,6 @@ class MailSubscriptionsController < ApplicationController
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :set_body_classes
before_action :set_user before_action :set_user
before_action :set_type before_action :set_type
@ -25,10 +24,6 @@ class MailSubscriptionsController < ApplicationController
not_found unless @user not_found unless @user
end end
def set_body_classes
@body_classes = 'lighter'
end
def set_type def set_type
@type = email_type_from_param @type = email_type_from_param
end end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Admin::TagsHelper
def admin_tags_moderation_options
[
[t('admin.tags.moderation.reviewed'), 'reviewed'],
[t('admin.tags.moderation.review_requested'), 'review_requested'],
[t('admin.tags.moderation.unreviewed'), 'unreviewed'],
[t('admin.tags.moderation.trendable'), 'trendable'],
[t('admin.tags.moderation.not_trendable'), 'not_trendable'],
[t('admin.tags.moderation.usable'), 'usable'],
[t('admin.tags.moderation.not_usable'), 'not_usable'],
]
end
end

View file

@ -5,8 +5,10 @@ module ThemeHelper
flavour, theme = flavour_and_skin flavour, theme = flavour_and_skin
if theme == 'system' if theme == 'system'
stylesheet_pack_tag("skins/#{flavour}/mastodon-light", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + ''.html_safe.tap do |tags|
stylesheet_pack_tag("skins/#{flavour}/default", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') tags << stylesheet_pack_tag("skins/#{flavour}/mastodon-light", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << stylesheet_pack_tag("skins/#{flavour}/default", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
end
else else
stylesheet_pack_tag "skins/#{flavour}/#{theme}", media: 'all', crossorigin: 'anonymous' stylesheet_pack_tag "skins/#{flavour}/#{theme}", media: 'all', crossorigin: 'anonymous'
end end
@ -16,8 +18,10 @@ module ThemeHelper
_, theme = flavour_and_skin _, theme = flavour_and_skin
if theme == 'system' if theme == 'system'
tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + ''.html_safe.tap do |tags|
tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)')
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)')
end
else else
tag.meta name: 'theme-color', content: theme_color_for(theme) tag.meta name: 'theme-color', content: theme_color_for(theme)
end end

View file

@ -316,8 +316,8 @@ function loaded() {
const message = const message =
statusEl.dataset.spoiler === 'expanded' statusEl.dataset.spoiler === 'expanded'
? localeData['status.show_less'] ?? 'Show less' ? (localeData['status.show_less'] ?? 'Show less')
: localeData['status.show_more'] ?? 'Show more'; : (localeData['status.show_more'] ?? 'Show more');
spoilerLink.textContent = new IntlMessageFormat( spoilerLink.textContent = new IntlMessageFormat(
message, message,
locale, locale,

View file

@ -1,3 +1,5 @@
import { browserHistory } from 'flavours/glitch/components/router';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { import {
@ -722,6 +724,16 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable,
}); });
}; };
export const navigateToProfile = (accountId) => {
return (_dispatch, getState) => {
const acct = getState().accounts.getIn([accountId, 'acct']);
if (acct) {
browserHistory.push(`/@${acct}`);
}
};
};
export function fetchPinnedAccountsSuggestions(q) { export function fetchPinnedAccountsSuggestions(q) {
return (dispatch) => { return (dispatch) => {
dispatch(fetchPinnedAccountsSuggestionsRequest()); dispatch(fetchPinnedAccountsSuggestionsRequest());

View file

@ -2,6 +2,9 @@ import { createAction } from '@reduxjs/toolkit';
import type { LayoutType } from '../is_mobile'; import type { LayoutType } from '../is_mobile';
export const focusApp = createAction('APP_FOCUS');
export const unfocusApp = createAction('APP_UNFOCUS');
interface ChangeLayoutPayload { interface ChangeLayoutPayload {
layout: LayoutType; layout: LayoutType;
} }

View file

@ -4,6 +4,7 @@ import axios from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import api from 'flavours/glitch/api'; import api from 'flavours/glitch/api';
import { browserHistory } from 'flavours/glitch/components/router';
import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'flavours/glitch/settings'; import { tagHistory } from 'flavours/glitch/settings';
import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
@ -94,9 +95,9 @@ const messages = defineMessages({
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
}); });
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState) => {
if (!getState().getIn(['compose', 'mounted'])) { if (!getState().getIn(['compose', 'mounted'])) {
routerHistory.push('/publish'); browserHistory.push('/publish');
} }
}; };
@ -117,7 +118,7 @@ export function changeCompose(text) {
}; };
} }
export function replyCompose(status, routerHistory) { export function replyCompose(status) {
return (dispatch, getState) => { return (dispatch, getState) => {
const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
dispatch({ dispatch({
@ -126,7 +127,19 @@ export function replyCompose(status, routerHistory) {
prependCWRe: prependCWRe, prependCWRe: prependCWRe,
}); });
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
};
}
export function replyComposeById(statusId) {
return (dispatch, getState) => {
const state = getState();
const status = state.statuses.get(statusId);
if (status) {
const account = state.accounts.get(status.get('account'));
dispatch(replyCompose(status.set('account', account)));
}
}; };
} }
@ -142,38 +155,44 @@ export function resetCompose() {
}; };
} }
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => { export const focusCompose = (defaultText) => (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_FOCUS, type: COMPOSE_FOCUS,
defaultText, defaultText,
}); });
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
}; };
export function mentionCompose(account, routerHistory) { export function mentionCompose(account) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_MENTION, type: COMPOSE_MENTION,
account: account, account: account,
}); });
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
}; };
} }
export function directCompose(account, routerHistory) { export function mentionComposeById(accountId) {
return (dispatch, getState) => {
dispatch(mentionCompose(getState().accounts.get(accountId)));
};
}
export function directCompose(account) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_DIRECT, type: COMPOSE_DIRECT,
account: account, account: account,
}); });
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
}; };
} }
export function submitCompose(routerHistory, overridePrivacy = null) { export function submitCompose(overridePrivacy = null) {
return function (dispatch, getState) { return function (dispatch, getState) {
let status = getState().getIn(['compose', 'text'], ''); let status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
@ -230,11 +249,10 @@ export function submitCompose(routerHistory, overridePrivacy = null) {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
}, },
}).then(function (response) { }).then(function (response) {
if (routerHistory if ((browserHistory.location.pathname === '/publish' || browserHistory.location.pathname === '/statuses/new')
&& (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new')
&& window.history.state && window.history.state
&& !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) { && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) {
routerHistory.goBack(); browserHistory.goBack();
} }
dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(insertIntoTagHistory(response.data.tags, status));
@ -272,7 +290,7 @@ export function submitCompose(routerHistory, overridePrivacy = null) {
message: statusId === null ? messages.published : messages.saved, message: statusId === null ? messages.published : messages.saved,
action: messages.open, action: messages.open,
dismissAfter: 10000, dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`),
})); }));
} }
}).catch(function (error) { }).catch(function (error) {

View file

@ -1,7 +1,11 @@
import { boostModal, favouriteModal } from 'flavours/glitch/initial_state';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
import { unreblog, reblog } from './interactions_typed';
import { openModal } from './modal';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
@ -443,6 +447,64 @@ export function unpinFail(status, error) {
}; };
} }
function toggleReblogWithoutConfirmation(status, privacy) {
return (dispatch) => {
if (status.get('reblogged')) {
dispatch(unreblog({ statusId: status.get('id') }));
} else {
dispatch(reblog({ statusId: status.get('id'), privacy }));
}
};
}
export function toggleReblog(statusId, skipModal = false) {
return (dispatch, getState) => {
const state = getState();
let status = state.statuses.get(statusId);
if (!status)
return;
// The reblog modal expects a pre-filled account in status
// TODO: fix this by having the reblog modal get a statusId and do the work itself
status = status.set('account', state.accounts.get(status.get('account')));
const missing_description_setting = state.getIn(['local_settings', 'confirm_boost_missing_media_description']);
const missing_description = status.get('media_attachments').some(item => !item.get('description'));
if (missing_description_setting && missing_description && !status.get('reblogged')) {
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)), missingMediaDescription: true } }));
} else if (boostModal && !skipModal) {
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)) } }));
} else {
dispatch(toggleReblogWithoutConfirmation(status));
}
};
}
export function toggleFavourite(statusId, skipModal = false) {
return (dispatch, getState) => {
const state = getState();
let status = state.statuses.get(statusId);
if (!status)
return;
// The favourite modal expects a pre-filled account in status
// TODO: fix this by having the reblog modal get a statusId and do the work itself
status = status.set('account', state.accounts.get(status.get('account')));
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
if (favouriteModal && !skipModal) {
dispatch(openModal({ modalType: 'FAVOURITE', modalProps: { status, onFavourite: (status) => dispatch(favourite(status)) } }));
} else {
dispatch(favourite(status));
}
}
};
}
export const addReaction = (statusId, name, url) => (dispatch, getState) => { export const addReaction = (statusId, name, url) => (dispatch, getState) => {
const status = getState().get('statuses').get(statusId); const status = getState().get('statuses').get(statusId);
let alreadyAdded = false; let alreadyAdded = false;

View file

@ -75,9 +75,17 @@ interface MarkerParam {
} }
function getLastNotificationId(state: RootState): string | undefined { function getLastNotificationId(state: RootState): string | undefined {
// @ts-expect-error state.notifications is not yet typed // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call const enableBeta = state.settings.getIn(
return state.getIn(['notifications', 'lastReadId']); ['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta
? state.notificationGroups.lastReadId
: // @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
state.getIn(['notifications', 'lastReadId']);
} }
const buildPostMarkersParams = (state: RootState) => { const buildPostMarkersParams = (state: RootState) => {

View file

@ -0,0 +1,144 @@
import { createAction } from '@reduxjs/toolkit';
import {
apiClearNotifications,
apiFetchNotifications,
} from 'flavours/glitch/api/notifications';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import type {
ApiNotificationGroupJSON,
ApiNotificationJSON,
} from 'flavours/glitch/api_types/notifications';
import { allNotificationTypes } from 'flavours/glitch/api_types/notifications';
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
} from 'flavours/glitch/selectors/settings';
import type { AppDispatch } from 'flavours/glitch/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from 'flavours/glitch/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';
function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
}
function dispatchAssociatedRecords(
dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
) {
const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = [];
notifications.forEach((notification) => {
if (notification.type === 'admin.report') {
fetchedAccounts.push(notification.report.target_account);
}
if (notification.type === 'moderation_warning') {
fetchedAccounts.push(notification.moderation_warning.target_account);
}
if ('status' in notification) {
fetchedStatuses.push(notification.status);
}
});
if (fetchedAccounts.length > 0)
dispatch(importFetchedAccounts(fetchedAccounts));
if (fetchedStatuses.length > 0)
dispatch(importFetchedStatuses(fetchedStatuses));
}
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) => {
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
dispatchAssociatedRecords(dispatch, notifications);
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
notifications;
// TODO: might be worth not using gaps for that…
// if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
if (notifications.length > 1)
payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });
return payload;
// dispatch(submitMarkers());
},
);
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) =>
apiFetchNotifications({ max_id: params.gap.maxId }),
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
dispatchAssociatedRecords(dispatch, notifications);
return { notifications };
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch }) => {
dispatchAssociatedRecords(dispatch, [notification]);
return notification;
},
);
export const loadPending = createAction('notificationGroups/loadPending');
export const updateScrollPosition = createAction<{ top: boolean }>(
'notificationGroups/updateScrollPosition',
);
export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => {
dispatch({
type: NOTIFICATIONS_FILTER_SET,
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications());
dispatch(saveSettings());
},
);
export const clearNotifications = createDataLoadingThunk(
'notifications/clear',
() => apiClearNotifications(),
);
export const markNotificationsAsRead = createAction(
'notificationGroups/markAsRead',
);
export const mountNotifications = createAction('notificationGroups/mount');
export const unmountNotifications = createAction('notificationGroups/unmount');

View file

@ -1,3 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { import {
apiGetNotificationPolicy, apiGetNotificationPolicy,
apiUpdateNotificationsPolicy, apiUpdateNotificationsPolicy,
@ -14,3 +16,7 @@ export const updateNotificationsPolicy = createDataLoadingThunk(
'notificationPolicy/update', 'notificationPolicy/update',
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy), (policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
); );
export const decreasePendingNotificationsCount = createAction<number>(
'notificationPolicy/decreasePendingNotificationCount',
);

View file

@ -18,6 +18,7 @@ import {
importFetchedStatuses, importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { decreasePendingNotificationsCount } from './notification_policies';
import { notificationsUpdate } from "./notifications_typed"; import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications'; import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
@ -43,7 +44,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
@ -97,6 +97,12 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
} }
}; };
const selectNotificationCountForRequest = (state, id) => {
const requests = state.getIn(['notificationRequests', 'items']);
const thisRequest = requests.find(request => request.get('id') === id);
return thisRequest ? thisRequest.get('notifications_count') : 0;
};
export const loadPending = () => ({ export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING, type: NOTIFICATIONS_LOAD_PENDING,
}); });
@ -187,7 +193,7 @@ const noOp = () => {};
let expandNotificationsController = new AbortController(); let expandNotificationsController = new AbortController();
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
return (dispatch, getState) => { return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications'); const notifications = getState().get('notifications');
@ -270,16 +276,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
}; };
} }
export function clearNotifications() {
return (dispatch) => {
dispatch({
type: NOTIFICATIONS_CLEAR,
});
api().post('/api/v1/notifications/clear');
};
}
export function scrollTopNotifications(top) { export function scrollTopNotifications(top) {
return { return {
type: NOTIFICATIONS_SCROLL_TOP, type: NOTIFICATIONS_SCROLL_TOP,
@ -533,11 +529,13 @@ export const fetchNotificationRequestFail = (id, error) => ({
error, error,
}); });
export const acceptNotificationRequest = id => (dispatch) => { export const acceptNotificationRequest = (id) => (dispatch, getState) => {
const count = selectNotificationCountForRequest(getState(), id);
dispatch(acceptNotificationRequestRequest(id)); dispatch(acceptNotificationRequestRequest(id));
api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => { api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
dispatch(acceptNotificationRequestSuccess(id)); dispatch(acceptNotificationRequestSuccess(id));
dispatch(decreasePendingNotificationsCount(count));
}).catch(err => { }).catch(err => {
dispatch(acceptNotificationRequestFail(id, err)); dispatch(acceptNotificationRequestFail(id, err));
}); });
@ -559,11 +557,13 @@ export const acceptNotificationRequestFail = (id, error) => ({
error, error,
}); });
export const dismissNotificationRequest = id => (dispatch) => { export const dismissNotificationRequest = (id) => (dispatch, getState) => {
const count = selectNotificationCountForRequest(getState(), id);
dispatch(dismissNotificationRequestRequest(id)); dispatch(dismissNotificationRequestRequest(id));
api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
dispatch(dismissNotificationRequestSuccess(id)); dispatch(dismissNotificationRequestSuccess(id));
dispatch(decreasePendingNotificationsCount(count));
}).catch(err => { }).catch(err => {
dispatch(dismissNotificationRequestFail(id, err)); dispatch(dismissNotificationRequestFail(id, err));
}); });

View file

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'flavours/glitch/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = getState().settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
if (enableBeta) void dispatch(fetchNotifications());
else dispatch(expandNotifications());
},
);

View file

@ -1,11 +1,6 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from '../api_types/accounts'; import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications';
// To be replaced once ApiNotificationJSON type exists
interface FakeApiNotificationJSON {
type: string;
account: ApiAccountJSON;
}
export const notificationsUpdate = createAction( export const notificationsUpdate = createAction(
'notifications/update', 'notifications/update',
@ -13,7 +8,7 @@ export const notificationsUpdate = createAction(
playSound, playSound,
...args ...args
}: { }: {
notification: FakeApiNotificationJSON; notification: ApiNotificationJSON;
usePendingItems: boolean; usePendingItems: boolean;
playSound: boolean; playSound: boolean;
}) => ({ }) => ({

View file

@ -1,3 +1,5 @@
import { browserHistory } from 'flavours/glitch/components/router';
import api from '../api'; import api from '../api';
import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { ensureComposeIsVisible, setComposeToStatus } from './compose';
@ -94,7 +96,7 @@ export function redraft(status, raw_text, content_type) {
}; };
} }
export const editStatus = (id, routerHistory) => (dispatch, getState) => { export const editStatus = (id) => (dispatch, getState) => {
let status = getState().getIn(['statuses', id]); let status = getState().getIn(['statuses', id]);
if (status.get('poll')) { if (status.get('poll')) {
@ -105,7 +107,7 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => {
api().get(`/api/v1/statuses/${id}/source`).then(response => { api().get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch(fetchStatusSourceSuccess()); dispatch(fetchStatusSourceSuccess());
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type)); dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type));
}).catch(error => { }).catch(error => {
dispatch(fetchStatusSourceFail(error)); dispatch(fetchStatusSourceFail(error));
@ -125,7 +127,7 @@ export const fetchStatusSourceFail = error => ({
error, error,
}); });
export function deleteStatus(id, routerHistory, withRedraft = false) { export function deleteStatus(id, withRedraft = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]); let status = getState().getIn(['statuses', id]);
@ -142,7 +144,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status, response.data.text, response.data.content_type)); dispatch(redraft(status, response.data.text, response.data.content_type));
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState);
} }
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));
@ -309,6 +311,21 @@ export function revealStatus(ids) {
}; };
} }
export function toggleStatusSpoilers(statusId) {
return (dispatch, getState) => {
const status = getState().statuses.get(statusId);
if (!status)
return;
if (status.get('hidden')) {
dispatch(revealStatus(statusId));
} else {
dispatch(hideStatus(statusId));
}
};
}
export function toggleStatusCollapse(id, isCollapsed) { export function toggleStatusCollapse(id, isCollapsed) {
return { return {
type: STATUS_COLLAPSE, type: STATUS_COLLAPSE,
@ -349,3 +366,15 @@ export const undoStatusTranslation = (id, pollId) => ({
id, id,
pollId, pollId,
}); });
export const navigateToStatus = (statusId) => {
return (_dispatch, getState) => {
const state = getState();
const accountId = state.statuses.getIn([statusId, 'account']);
const acct = state.accounts.getIn([accountId, 'acct']);
if (acct) {
browserHistory.push(`/@${acct}/${statusId}`);
}
};
};

View file

@ -10,6 +10,7 @@ import {
deleteAnnouncement, deleteAnnouncement,
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { processNewNotificationForGroups } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'delete': case 'delete':
dispatch(deleteFromTimelines(data.payload)); dispatch(deleteFromTimelines(data.payload));
break; break;
case 'notification': case 'notification': {
// @ts-expect-error // @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one
if(getState().notificationGroups.groups.length > 0) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
break; break;
}
case 'conversation': case 'conversation':
// @ts-expect-error // @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload))); dispatch(updateConversations(JSON.parse(data.payload)));

View file

@ -0,0 +1,25 @@
import api, { apiRequest, getLinks } from 'flavours/glitch/api';
import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/notifications';
export const apiFetchNotifications = async (params?: {
exclude_types?: string[];
max_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET',
url: '/api/v2_alpha/notifications',
params,
});
const { statuses, accounts, notification_groups } = response.data;
return {
statuses,
accounts,
notifications: notification_groups,
links: getLinks(response),
};
};
export const apiClearNotifications = () =>
apiRequest<undefined>('POST', 'v1/notifications/clear');

View file

@ -0,0 +1,153 @@
// See app/serializers/rest/notification_group_serializer.rb
import type { AccountWarningAction } from 'flavours/glitch/models/notification_group';
import type { ApiAccountJSON } from './accounts';
import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb
export const allNotificationTypes = [
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',
'status',
'update',
'admin.sign_up',
'admin.report',
'moderation_warning',
'severed_relationships',
];
export type NotificationWithStatusType =
| 'favourite'
| 'reaction'
| 'reblog'
| 'status'
| 'mention'
| 'poll'
| 'update';
export type NotificationType =
| NotificationWithStatusType
| 'follow'
| 'follow_request'
| 'moderation_warning'
| 'severed_relationships'
| 'admin.sign_up'
| 'admin.report';
export interface BaseNotificationJSON {
id: string;
type: NotificationType;
created_at: string;
group_key: string;
account: ApiAccountJSON;
}
export interface BaseNotificationGroupJSON {
group_key: string;
notifications_count: number;
type: NotificationType;
sample_account_ids: string[];
latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly
most_recent_notification_id: string;
page_min_id?: string;
page_max_id?: string;
}
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
type: NotificationWithStatusType;
status_id: string;
}
interface NotificationWithStatusJSON extends BaseNotificationJSON {
type: NotificationWithStatusType;
status: ApiStatusJSON;
}
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'admin.report';
report: ApiReportJSON;
}
interface ReportNotificationJSON extends BaseNotificationJSON {
type: 'admin.report';
report: ApiReportJSON;
}
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
type: SimpleNotificationTypes;
}
interface SimpleNotificationJSON extends BaseNotificationJSON {
type: SimpleNotificationTypes;
}
export interface ApiAccountWarningJSON {
id: string;
action: AccountWarningAction;
text: string;
status_ids: string[];
created_at: string;
target_account: ApiAccountJSON;
appeal: unknown;
}
interface ModerationWarningNotificationGroupJSON
extends BaseNotificationGroupJSON {
type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON;
}
interface ModerationWarningNotificationJSON extends BaseNotificationJSON {
type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON;
}
export interface ApiAccountRelationshipSeveranceEventJSON {
id: string;
type: 'account_suspension' | 'domain_block' | 'user_domain_block';
purged: boolean;
target_name: string;
followers_count: number;
following_count: number;
created_at: string;
}
interface AccountRelationshipSeveranceNotificationGroupJSON
extends BaseNotificationGroupJSON {
type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON;
}
interface AccountRelationshipSeveranceNotificationJSON
extends BaseNotificationJSON {
type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON;
}
export type ApiNotificationJSON =
| SimpleNotificationJSON
| ReportNotificationJSON
| AccountRelationshipSeveranceNotificationJSON
| NotificationWithStatusJSON
| ModerationWarningNotificationJSON;
export type ApiNotificationGroupJSON =
| SimpleNotificationGroupJSON
| ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON
| ModerationWarningNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[];
statuses: ApiStatusJSON[];
notification_groups: ApiNotificationGroupJSON[];
}

View file

@ -0,0 +1,16 @@
import type { ApiAccountJSON } from './accounts';
export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation';
export interface ApiReportJSON {
id: string;
action_taken: unknown;
action_taken_at: unknown;
category: ReportCategory;
comment: string;
forwarded: boolean;
created_at: string;
status_ids: string[];
rule_ids: string[];
target_account: ApiAccountJSON;
}

View file

@ -11,6 +11,8 @@ interface Props {
style?: React.CSSProperties; style?: React.CSSProperties;
inline?: boolean; inline?: boolean;
animate?: boolean; animate?: boolean;
counter?: number | string;
counterBorderColor?: string;
} }
export const Avatar: React.FC<Props> = ({ export const Avatar: React.FC<Props> = ({
@ -19,6 +21,8 @@ export const Avatar: React.FC<Props> = ({
size = 20, size = 20,
inline = false, inline = false,
style: styleFromParent, style: styleFromParent,
counter,
counterBorderColor,
}) => { }) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
@ -43,7 +47,15 @@ export const Avatar: React.FC<Props> = ({
style={style} style={style}
data-avatar-of={account && `@${account.get('acct')}`} data-avatar-of={account && `@${account.get('acct')}`}
> >
{src && <img src={src} alt={account?.get('acct')} />} {src && <img src={src} alt='' />}
{counter && (
<div
className='account__avatar__counter'
style={{ borderColor: counterBorderColor }}
>
{counter}
</div>
)}
</div> </div>
); );
}; };

View file

@ -3,6 +3,8 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react'; import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import { unblockDomain } from 'flavours/glitch/actions/domain_blocks';
import { useAppDispatch } from 'flavours/glitch/store';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@ -13,17 +15,15 @@ const messages = defineMessages({
}, },
}); });
interface Props { export const Domain: React.FC<{
domain: string; domain: string;
onUnblockDomain: (domain: string) => void; }> = ({ domain }) => {
}
export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const handleDomainUnblock = useCallback(() => { const handleDomainUnblock = useCallback(() => {
onUnblockDomain(domain); dispatch(unblockDomain(domain));
}, [domain, onUnblockDomain]); }, [dispatch, domain]);
return ( return (
<div className='domain'> <div className='domain'>

View file

@ -20,7 +20,7 @@ let id = 0;
class DropdownMenu extends PureComponent { class DropdownMenu extends PureComponent {
static propTypes = { static propTypes = {
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, items: PropTypes.array.isRequired,
loading: PropTypes.bool, loading: PropTypes.bool,
scrollable: PropTypes.bool, scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
@ -39,6 +39,7 @@ class DropdownMenu extends PureComponent {
if (this.node && !this.node.contains(e.target)) { if (this.node && !this.node.contains(e.target)) {
this.props.onClose(); this.props.onClose();
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
} }
}; };
@ -164,7 +165,7 @@ class Dropdown extends PureComponent {
children: PropTypes.node, children: PropTypes.node,
icon: PropTypes.string, icon: PropTypes.string,
iconComponent: PropTypes.func, iconComponent: PropTypes.func,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]), items: PropTypes.array.isRequired,
loading: PropTypes.bool, loading: PropTypes.bool,
size: PropTypes.number, size: PropTypes.number,
title: PropTypes.string, title: PropTypes.string,

View file

@ -1,12 +1,11 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { useIdentity } from '@/flavours/glitch/identity_context'; import { useIdentity } from '@/flavours/glitch/identity_context';
import { import {
fetchRelationships, fetchRelationships,
followAccount, followAccount,
unfollowAccount,
} from 'flavours/glitch/actions/accounts'; } from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
@ -59,29 +58,14 @@ export const FollowButton: React.FC<{
if (accountId === me) { if (accountId === me) {
return; return;
} else if (relationship.following || relationship.requested) { } else if (account && (relationship.following || relationship.requested)) {
dispatch( dispatch(
openModal({ openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollow),
onConfirm: () => {
dispatch(unfollowAccount(accountId));
},
},
}),
); );
} else { } else {
dispatch(followAccount(accountId)); dispatch(followAccount(accountId));
} }
}, [dispatch, intl, accountId, relationship, account, signedIn]); }, [dispatch, accountId, relationship, account, signedIn]);
let label; let label;

View file

@ -9,18 +9,18 @@ const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
}); });
interface Props { interface Props<T> {
disabled: boolean; disabled: boolean;
maxId: string; param: T;
onClick: (maxId: string) => void; onClick: (params: T) => void;
} }
export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => { export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
const intl = useIntl(); const intl = useIntl();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(maxId); onClick(param);
}, [maxId, onClick]); }, [param, onClick]);
return ( return (
<button <button

View file

@ -22,7 +22,7 @@ type LocationState = MastodonLocationState | null | undefined;
type HistoryPath = Path | LocationDescriptor<LocationState>; type HistoryPath = Path | LocationDescriptor<LocationState>;
const browserHistory = createBrowserHistory<LocationState>(); export const browserHistory = createBrowserHistory<LocationState>();
const originalReplace = browserHistory.replace.bind(browserHistory); const originalReplace = browserHistory.replace.bind(browserHistory);
export function useAppHistory() { export function useAppHistory() {

View file

@ -120,6 +120,8 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
skipPrepend: PropTypes.bool,
avatarSize: PropTypes.number,
deployPictureInPicture: PropTypes.func, deployPictureInPicture: PropTypes.func,
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
pictureInPicture: ImmutablePropTypes.contains({ pictureInPicture: ImmutablePropTypes.contains({
@ -445,7 +447,7 @@ class Status extends ImmutablePureComponent {
handleHotkeyReply = e => { handleHotkeyReply = e => {
e.preventDefault(); e.preventDefault();
this.props.onReply(this.props.status, this.props.history); this.props.onReply(this.props.status);
}; };
handleHotkeyFavourite = (e) => { handleHotkeyFavourite = (e) => {
@ -462,7 +464,7 @@ class Status extends ImmutablePureComponent {
handleHotkeyMention = e => { handleHotkeyMention = e => {
e.preventDefault(); e.preventDefault();
this.props.onMention(this.props.status.get('account'), this.props.history); this.props.onMention(this.props.status.get('account'));
}; };
handleHotkeyOpen = () => { handleHotkeyOpen = () => {
@ -523,12 +525,14 @@ class Status extends ImmutablePureComponent {
} }
render () { render () {
const { intl, hidden, featured, unfocusable, unread, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
const { const {
parseClick, parseClick,
setCollapsed, setCollapsed,
} = this; } = this;
const { const {
intl,
status, status,
account, account,
settings, settings,
@ -538,13 +542,6 @@ class Status extends ImmutablePureComponent {
onOpenVideo, onOpenVideo,
onOpenMedia, onOpenMedia,
notification, notification,
hidden,
unread,
featured,
pictureInPicture,
previousId,
nextInReplyToId,
rootId,
history, history,
identity, identity,
...other ...other
@ -594,8 +591,8 @@ class Status extends ImmutablePureComponent {
if (hidden) { if (hidden) {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
<div ref={this.handleRef} className='status focusable' tabIndex={0}> <div ref={this.handleRef} className='status focusable' tabIndex={unfocusable ? null : 0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span> <span>{status.get('content')}</span>
</div> </div>
@ -615,8 +612,8 @@ class Status extends ImmutablePureComponent {
}; };
return ( return (
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers} tabIndex={unfocusable ? null : -1}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}> <div className='status__wrapper status__wrapper--filtered focusable' tabIndex={unfocusable ? null : 0} ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}. <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
{' '} {' '}
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
@ -796,17 +793,17 @@ class Status extends ImmutablePureComponent {
contentMedia.push(hashtagBar); contentMedia.push(hashtagBar);
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
<div <div
className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, collapsed: isCollapsed })} className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, collapsed: isCollapsed })}
{...selectorAttribs} {...selectorAttribs}
tabIndex={0} tabIndex={unfocusable ? null : 0}
data-featured={featured ? 'true' : null} data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
ref={this.handleRef} ref={this.handleRef}
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
> >
{prepend} {!skipPrepend && prepend}
<div <div
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background })} className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background })}
@ -822,6 +819,7 @@ class Status extends ImmutablePureComponent {
friend={account} friend={account}
collapsed={isCollapsed} collapsed={isCollapsed}
parseClick={parseClick} parseClick={parseClick}
avatarSize={avatarSize}
/> />
<StatusIcons <StatusIcons
status={status} status={status}

View file

@ -112,7 +112,7 @@ class StatusActionBar extends ImmutablePureComponent {
const { signedIn } = this.props.identity; const { signedIn } = this.props.identity;
if (signedIn) { if (signedIn) {
this.props.onReply(this.props.status, this.props.history); this.props.onReply(this.props.status);
} else { } else {
this.props.onInteractionModal('reply', this.props.status); this.props.onInteractionModal('reply', this.props.status);
} }
@ -153,15 +153,15 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleDeleteClick = () => { handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.props.history); this.props.onDelete(this.props.status);
}; };
handleRedraftClick = () => { handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.props.history, true); this.props.onDelete(this.props.status, true);
}; };
handleEditClick = () => { handleEditClick = () => {
this.props.onEdit(this.props.status, this.props.history); this.props.onEdit(this.props.status);
}; };
handlePinClick = () => { handlePinClick = () => {
@ -169,11 +169,11 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleMentionClick = () => { handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.props.history); this.props.onMention(this.props.status.get('account'));
}; };
handleDirectClick = () => { handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.props.history); this.props.onDirect(this.props.status.get('account'));
}; };
handleMuteClick = () => { handleMuteClick = () => {

View file

@ -14,6 +14,7 @@ export default class StatusHeader extends PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map, friend: ImmutablePropTypes.map,
avatarSize: PropTypes.number,
parseClick: PropTypes.func.isRequired, parseClick: PropTypes.func.isRequired,
}; };
@ -33,13 +34,14 @@ export default class StatusHeader extends PureComponent {
const { const {
status, status,
friend, friend,
avatarSize,
} = this.props; } = this.props;
const account = status.get('account'); const account = status.get('account');
let statusAvatar; let statusAvatar;
if (friend === undefined || friend === null) { if (friend === undefined || friend === null) {
statusAvatar = <Avatar account={account} size={46} />; statusAvatar = <Avatar account={account} size={avatarSize} />;
} else { } else {
statusAvatar = <AvatarOverlay account={account} friend={friend} />; statusAvatar = <AvatarOverlay account={account} friend={friend} />;
} }

View file

@ -108,7 +108,7 @@ export default class StatusList extends ImmutablePureComponent {
<LoadGap <LoadGap
key={'gap:' + statusIds.get(index + 1)} key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading} disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null} param={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore} onClick={onLoadMore}
/> />
); );

View file

@ -107,7 +107,7 @@ export default class StatusPrepend extends PureComponent {
return ( return (
<FormattedMessage <FormattedMessage
id='notification.poll' id='notification.poll'
defaultMessage='A poll you have voted in has ended' defaultMessage='A poll you voted in has ended'
/> />
); );
} }

View file

@ -1,24 +1,20 @@
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { import {
followAccount, followAccount,
unfollowAccount,
blockAccount, blockAccount,
unblockAccount, unblockAccount,
muteAccount, muteAccount,
unmuteAccount, unmuteAccount,
} from '../actions/accounts'; } from '../actions/accounts';
import { openModal } from '../actions/modal';
import { initMuteModal } from '../actions/mutes'; import { initMuteModal } from '../actions/mutes';
import Account from '../components/account'; import Account from '../components/account';
import { makeGetAccount } from '../selectors'; import { makeGetAccount } from '../selectors';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
@ -29,18 +25,11 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch) => ({
onFollow (account) { onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
modalType: 'CONFIRM',
modalProps: {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
},
}));
} else { } else {
dispatch(followAccount(account.get('id'))); dispatch(followAccount(account.get('id')));
} }

View file

@ -1,36 +0,0 @@
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { blockDomain, unblockDomain } from '../actions/domain_blocks';
import { openModal } from '../actions/modal';
import { Domain } from '../components/domain';
const messages = defineMessages({
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});
const makeMapStateToProps = () => {
const mapStateToProps = () => ({});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onBlockDomain (domain) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
confirm: intl.formatMessage(messages.blockDomainConfirm),
onConfirm: () => dispatch(blockDomain(domain)),
},
}));
},
onUnblockDomain (domain) {
dispatch(unblockDomain(domain));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));

View file

@ -1,5 +1,3 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
@ -12,18 +10,15 @@ import {
initAddFilter, initAddFilter,
} from 'flavours/glitch/actions/filters'; } from 'flavours/glitch/actions/filters';
import { import {
reblog, toggleReblog,
favourite, toggleFavourite,
bookmark, bookmark,
unreblog,
unfavourite,
unbookmark, unbookmark,
pin, pin,
unpin, unpin,
addReaction, addReaction,
removeReaction, removeReaction,
} from 'flavours/glitch/actions/interactions'; } from 'flavours/glitch/actions/interactions';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
@ -32,33 +27,17 @@ import {
muteStatus, muteStatus,
unmuteStatus, unmuteStatus,
deleteStatus, deleteStatus,
hideStatus, toggleStatusSpoilers,
revealStatus,
editStatus, editStatus,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
} from 'flavours/glitch/actions/statuses'; } from 'flavours/glitch/actions/statuses';
import Status from 'flavours/glitch/components/status'; import Status from 'flavours/glitch/components/status';
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state'; import { deleteModal } from 'flavours/glitch/initial_state';
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors'; import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import { showAlertForError } from '../actions/alerts'; import { showAlertForError } from '../actions/alerts';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
});
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); const getPictureInPicture = makeGetPictureInPicture();
@ -93,47 +72,22 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ const mapDispatchToProps = (dispatch, { contextType }) => ({
onReply (status, router) { onReply (status) {
dispatch((_, getState) => { dispatch((_, getState) => {
let state = getState(); let state = getState();
if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
onConfirm: () => dispatch(replyCompose(status, router)),
},
}));
} else { } else {
dispatch(replyCompose(status, router)); dispatch(replyCompose(status));
} }
}); });
}, },
onModalReblog (status, privacy) {
if (status.get('reblogged')) {
dispatch(unreblog({ statusId: status.get('id') }));
} else {
dispatch(reblog({ statusId: status.get('id'), visibility: privacy }));
}
},
onReblog (status, e) { onReblog (status, e) {
dispatch((_, getState) => { dispatch(toggleReblog(status.get('id'), e.shiftKey));
let state = getState();
if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog, missingMediaDescription: true } }));
} else if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
}
});
}, },
onBookmark (status) { onBookmark (status) {
@ -144,26 +98,8 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
} }
}, },
onModalFavourite (status) {
dispatch(favourite(status));
},
onFavourite (status, e) { onFavourite (status, e) {
if (status.get('favourited')) { dispatch(toggleFavourite(status.get('id'), e.shiftKey));
dispatch(unfavourite(status));
} else {
if (e.shiftKey || !favouriteModal) {
this.onModalFavourite(status);
} else {
dispatch(openModal({
modalType: 'FAVOURITE',
modalProps: {
status,
onFavourite: this.onModalFavourite,
},
}));
}
}
}, },
onPin (status) { onPin (status) {
@ -192,35 +128,21 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
})); }));
}, },
onDelete (status, history, withRedraft = false) { onDelete (status, withRedraft = false) {
if (!deleteModal) { if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft)); dispatch(deleteStatus(status.get('id'), withRedraft));
} else { } else {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
},
}));
} }
}, },
onEdit (status, history) { onEdit (status) {
dispatch((_, getState) => { dispatch((_, getState) => {
let state = getState(); let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) { if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_EDIT_STATUS', modalProps: { statusId: status.get('id') } }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.editMessage),
confirm: intl.formatMessage(messages.editConfirm),
onConfirm: () => dispatch(editStatus(status.get('id'), history)),
},
}));
} else { } else {
dispatch(editStatus(status.get('id'), history)); dispatch(editStatus(status.get('id')));
} }
}); });
}, },
@ -233,12 +155,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
} }
}, },
onDirect (account, router) { onDirect (account) {
dispatch(directCompose(account, router)); dispatch(directCompose(account));
}, },
onMention (account, router) { onMention (account) {
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account));
}, },
onOpenMedia (statusId, media, index, lang) { onOpenMedia (statusId, media, index, lang) {
@ -281,11 +203,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}, },
onToggleHidden (status) { onToggleHidden (status) {
if (status.get('hidden')) { dispatch(toggleStatusSpoilers(status.get('id')));
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
}, },
deployPictureInPicture (status, type, mediaProps) { deployPictureInPicture (status, type, mediaProps) {
@ -309,4 +227,4 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default connect(makeMapStateToProps, mapDispatchToProps)(Status);

View file

@ -316,8 +316,8 @@ function loaded() {
const message = const message =
statusEl.dataset.spoiler === 'expanded' statusEl.dataset.spoiler === 'expanded'
? localeData['status.show_less'] ?? 'Show less' ? (localeData['status.show_less'] ?? 'Show less')
: localeData['status.show_more'] ?? 'Show more'; : (localeData['status.show_more'] ?? 'Show more');
spoilerLink.textContent = new IntlMessageFormat( spoilerLink.textContent = new IntlMessageFormat(
message, message,
locale, locale,

View file

@ -2,13 +2,11 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { NavLink, withRouter } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import ActionBar from '../../account/components/action_bar'; import ActionBar from '../../account/components/action_bar';
import InnerHeader from '../../account/components/header'; import InnerHeader from '../../account/components/header';
@ -36,7 +34,6 @@ class Header extends ImmutablePureComponent {
hideTabs: PropTypes.bool, hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
...WithRouterPropTypes,
}; };
handleFollow = () => { handleFollow = () => {
@ -48,11 +45,11 @@ class Header extends ImmutablePureComponent {
}; };
handleMention = () => { handleMention = () => {
this.props.onMention(this.props.account, this.props.history); this.props.onMention(this.props.account);
}; };
handleDirect = () => { handleDirect = () => {
this.props.onDirect(this.props.account, this.props.history); this.props.onDirect(this.props.account);
}; };
handleReport = () => { handleReport = () => {
@ -158,4 +155,4 @@ class Header extends ImmutablePureComponent {
} }
export default withRouter(Header); export default Header;

View file

@ -1,10 +1,9 @@
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
followAccount, followAccount,
unfollowAccount,
unblockAccount, unblockAccount,
unmuteAccount, unmuteAccount,
pinAccount, pinAccount,
@ -22,11 +21,6 @@ import { initReport } from '../../../actions/reports';
import { makeGetAccount, getAccountHidden } from '../../../selectors'; import { makeGetAccount, getAccountHidden } from '../../../selectors';
import Header from '../components/header'; import Header from '../components/header';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
@ -39,18 +33,11 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch) => ({
onFollow (account) { onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
modalType: 'CONFIRM',
modalProps: {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
},
}));
} else { } else {
dispatch(followAccount(account.get('id'))); dispatch(followAccount(account.get('id')));
} }
@ -75,12 +62,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onMention (account, router) { onMention (account) {
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account));
}, },
onDirect (account, router) { onDirect (account) {
dispatch(directCompose(account, router)); dispatch(directCompose(account));
}, },
onReblogToggle (account) { onReblogToggle (account) {

View file

@ -7,7 +7,6 @@ import { useDispatch } from 'react-redux';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { logOut } from 'flavours/glitch/utils/log_out';
const messages = defineMessages({ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -23,8 +22,6 @@ const messages = defineMessages({
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
}); });
export const ActionBar = () => { export const ActionBar = () => {
@ -32,16 +29,8 @@ export const ActionBar = () => {
const intl = useIntl(); const intl = useIntl();
const handleLogoutClick = useCallback(() => { const handleLogoutClick = useCallback(() => {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
modalType: 'CONFIRM', }, [dispatch]);
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
}, [dispatch, intl]);
let menu = []; let menu = [];

View file

@ -10,8 +10,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz'; import { length } from 'stringz';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { Button } from '../../../components/button'; import { Button } from '../../../components/button';
@ -81,7 +79,6 @@ class ComposeForm extends ImmutablePureComponent {
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
maxChars: PropTypes.number, maxChars: PropTypes.number,
...WithOptionalRouterPropTypes
}; };
static defaultProps = { static defaultProps = {
@ -141,9 +138,9 @@ class ComposeForm extends ImmutablePureComponent {
// Submit unless there are media with missing descriptions // Submit unless there are media with missing descriptions
if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) { if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) {
const firstWithoutDescription = this.props.media.find(item => !item.get('description')); const firstWithoutDescription = this.props.media.find(item => !item.get('description'));
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overridePrivacy); this.props.onMediaDescriptionConfirm(firstWithoutDescription.get('id'), overridePrivacy);
} else { } else {
this.props.onSubmit(this.props.history || null, overridePrivacy); this.props.onSubmit(overridePrivacy);
} }
}; };
@ -351,4 +348,4 @@ class ComposeForm extends ImmutablePureComponent {
} }
export default withOptionalRouter(injectIntl(ComposeForm)); export default injectIntl(ComposeForm);

View file

@ -12,6 +12,7 @@ import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { Permalink } from 'flavours/glitch/components/permalink'; import { Permalink } from 'flavours/glitch/components/permalink';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { EmbeddedStatusContent } from 'flavours/glitch/features/notifications_v2/components/embedded_status_content';
const messages = defineMessages({ const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
@ -32,8 +33,6 @@ export const EditIndicator = () => {
return null; return null;
} }
const content = { __html: status.get('contentHtml') };
return ( return (
<div className='edit-indicator'> <div className='edit-indicator'>
<div className='edit-indicator__header'> <div className='edit-indicator__header'>
@ -48,7 +47,12 @@ export const EditIndicator = () => {
</div> </div>
</div> </div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} /> <EmbeddedStatusContent
className='edit-indicator__content translate'
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'> <div className='edit-indicator__attachments'>

View file

@ -8,6 +8,7 @@ import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { Permalink } from 'flavours/glitch/components/permalink'; import { Permalink } from 'flavours/glitch/components/permalink';
import { EmbeddedStatusContent } from 'flavours/glitch/features/notifications_v2/components/embedded_status_content';
export const ReplyIndicator = () => { export const ReplyIndicator = () => {
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to'])); const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
@ -18,8 +19,6 @@ export const ReplyIndicator = () => {
return null; return null;
} }
const content = { __html: status.get('contentHtml') };
return ( return (
<div className='reply-indicator'> <div className='reply-indicator'>
<div className='reply-indicator__line' /> <div className='reply-indicator__line' />
@ -33,7 +32,12 @@ export const ReplyIndicator = () => {
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> <EmbeddedStatusContent
className='reply-indicator__content translate'
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='reply-indicator__attachments'> <div className='reply-indicator__attachments'>

View file

@ -82,8 +82,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit (router, overridePrivacy = null) { onSubmit (overridePrivacy = null) {
dispatch(submitCompose(router, overridePrivacy)); dispatch(submitCompose(overridePrivacy));
}, },
onClearSuggestions () { onClearSuggestions () {
@ -110,14 +110,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(insertEmojiCompose(position, data, needsSpace)); dispatch(insertEmojiCompose(position, data, needsSpace));
}, },
onMediaDescriptionConfirm (routerHistory, mediaId, overridePrivacy = null) { onMediaDescriptionConfirm (mediaId, overridePrivacy = null) {
dispatch(openModal({ dispatch(openModal({
modalType: 'CONFIRM', modalType: 'CONFIRM',
modalProps: { modalProps: {
message: intl.formatMessage(messages.missingDescriptionMessage), message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm), confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => { onConfirm: () => {
dispatch(submitCompose(routerHistory, overridePrivacy)); dispatch(submitCompose(overridePrivacy));
}, },
secondary: intl.formatMessage(messages.missingDescriptionEdit), secondary: intl.formatMessage(messages.missingDescriptionEdit),
onSecondary: () => dispatch(openModal({ onSecondary: () => dispatch(openModal({

View file

@ -24,7 +24,6 @@ import { Icon } from 'flavours/glitch/components/icon';
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png'; import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png'; import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png'; import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
import { logOut } from 'flavours/glitch/utils/log_out';
import elephantUIPlane from '../../../../images/elephant_ui_plane.svg'; import elephantUIPlane from '../../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
@ -45,8 +44,6 @@ const messages = defineMessages({
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
}); });
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
@ -88,20 +85,12 @@ class Compose extends PureComponent {
} }
handleLogoutClick = e => { handleLogoutClick = e => {
const { dispatch, intl } = this.props; const { dispatch } = this.props;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
return false; return false;
}; };

View file

@ -18,7 +18,7 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'flavours/glitch/actions/compose'; import { replyCompose } from 'flavours/glitch/actions/compose';
import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'flavours/glitch/actions/statuses'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
import AttachmentList from 'flavours/glitch/components/attachment_list'; import AttachmentList from 'flavours/glitch/components/attachment_list';
import AvatarComposite from 'flavours/glitch/components/avatar_composite'; import AvatarComposite from 'flavours/glitch/components/avatar_composite';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
@ -37,8 +37,6 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
}); });
const getAccounts = createSelector( const getAccounts = createSelector(
@ -121,19 +119,12 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
let state = getState(); let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) { if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status: lastStatus } }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(lastStatus, history)),
},
}));
} else { } else {
dispatch(replyCompose(lastStatus, history)); dispatch(replyCompose(lastStatus));
} }
}); });
}, [dispatch, lastStatus, history, intl]); }, [dispatch, lastStatus]);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
dispatch(deleteConversation(id)); dispatch(deleteConversation(id));
@ -156,11 +147,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
}, [dispatch, lastStatus]); }, [dispatch, lastStatus]);
const handleShowMore = useCallback(() => { const handleShowMore = useCallback(() => {
if (lastStatus.get('hidden')) { dispatch(toggleStatusSpoilers(lastStatus.get('id')));
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
if (lastStatus.get('spoiler_text')) { if (lastStatus.get('spoiler_text')) {
setExpanded(!expanded); setExpanded(!expanded);

View file

@ -7,7 +7,6 @@ import classNames from 'classnames';
import { import {
followAccount, followAccount,
unfollowAccount,
unblockAccount, unblockAccount,
unmuteAccount, unmuteAccount,
} from 'flavours/glitch/actions/accounts'; } from 'flavours/glitch/actions/accounts';
@ -29,20 +28,12 @@ const messages = defineMessages({
id: 'account.cancel_follow_request', id: 'account.cancel_follow_request',
defaultMessage: 'Withdraw follow request', defaultMessage: 'Withdraw follow request',
}, },
cancelFollowRequestConfirm: {
id: 'confirmations.cancel_follow_request.confirm',
defaultMessage: 'Withdraw request',
},
requested: { requested: {
id: 'account.requested', id: 'account.requested',
defaultMessage: 'Awaiting approval. Click to cancel follow request', defaultMessage: 'Awaiting approval. Click to cancel follow request',
}, },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
}); });
@ -89,48 +80,17 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
const handleFollow = useCallback(() => { const handleFollow = useCallback(() => {
if (!account) return; if (!account) return;
if (account.getIn(['relationship', 'following'])) { if (
account.getIn(['relationship', 'following']) ||
account.getIn(['relationship', 'requested'])
) {
dispatch( dispatch(
openModal({ openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => {
dispatch(unfollowAccount(account.get('id')));
},
},
}),
);
} else if (account.getIn(['relationship', 'requested'])) {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.cancel_follow_request.message'
defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
onConfirm: () => {
dispatch(unfollowAccount(account.get('id')));
},
},
}),
); );
} else { } else {
dispatch(followAccount(account.get('id'))); dispatch(followAccount(account.get('id')));
} }
}, [account, dispatch, intl]); }, [account, dispatch]);
const handleBlock = useCallback(() => { const handleBlock = useCallback(() => {
if (account?.relationship?.blocking) { if (account?.relationship?.blocking) {

View file

@ -11,16 +11,15 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react'; import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
import { Domain } from 'flavours/glitch/components/domain';
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import { LoadingIndicator } from '../../components/loading_indicator'; import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import DomainContainer from '../../containers/domain_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' }, heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
@ -70,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{domains.map(domain => {domains.map(domain =>
<DomainContainer key={domain} domain={domain} />, <Domain key={domain} domain={domain} />,
)} )}
</ScrollableList> </ScrollableList>

View file

@ -15,7 +15,7 @@ import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists'; import { fetchList, updateList } from 'flavours/glitch/actions/lists';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { connectListStream } from 'flavours/glitch/actions/streaming'; import { connectListStream } from 'flavours/glitch/actions/streaming';
import { expandListTimeline } from 'flavours/glitch/actions/timelines'; import { expandListTimeline } from 'flavours/glitch/actions/timelines';
@ -29,8 +29,6 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
const messages = defineMessages({ const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
@ -125,25 +123,10 @@ class ListTimeline extends PureComponent {
}; };
handleDeleteClick = () => { handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props; const { dispatch, columnId } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteList(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/lists');
}
},
},
}));
}; };
handleRepliesPolicyChange = ({ target }) => { handleRepliesPolicyChange = ({ target }) => {

View file

@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import Toggle from 'react-toggle';
export const CheckboxWithLabel = ({ checked, disabled, children, onChange }) => {
const handleChange = useCallback(({ target }) => {
onChange(target.checked);
}, [onChange]);
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
{children}
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={checked} onChange={handleChange} disabled={disabled} />
</div>
</div>
</label>
);
};
CheckboxWithLabel.propTypes = {
checked: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.children,
onChange: PropTypes.func,
};

View file

@ -0,0 +1,40 @@
import type { PropsWithChildren } from 'react';
import { useCallback } from 'react';
import Toggle from 'react-toggle';
interface Props {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}
export const CheckboxWithLabel: React.FC<PropsWithChildren<Props>> = ({
checked,
disabled,
children,
onChange,
}) => {
const handleChange = useCallback(
({ target }: React.ChangeEvent<HTMLInputElement>) => {
onChange(target.checked);
},
[onChange],
);
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>{children}</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={checked}
onChange={handleChange}
disabled={disabled}
/>
</div>
</div>
</label>
);
};

View file

@ -8,10 +8,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
import { CheckboxWithLabel } from './checkbox_with_label';
import ClearColumnButton from './clear_column_button'; import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button'; import GrantPermissionButton from './grant_permission_button';
import PillBarButton from './pill_bar_button'; import PillBarButton from './pill_bar_button';
import { PolicyControls } from './policy_controls';
import SettingToggle from './setting_toggle'; import SettingToggle from './setting_toggle';
class ColumnSettings extends PureComponent { class ColumnSettings extends PureComponent {
@ -25,34 +25,17 @@ class ColumnSettings extends PureComponent {
alertsEnabled: PropTypes.bool, alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool, browserSupport: PropTypes.bool,
browserPermission: PropTypes.string, browserPermission: PropTypes.string,
notificationPolicy: PropTypes.object.isRequired,
onChangePolicy: PropTypes.func.isRequired,
}; };
onPushChange = (path, checked) => { onPushChange = (path, checked) => {
this.props.onChange(['push', ...path], checked); this.props.onChange(['push', ...path], checked);
}; };
handleFilterNotFollowing = checked => {
this.props.onChangePolicy('filter_not_following', checked);
};
handleFilterNotFollowers = checked => {
this.props.onChangePolicy('filter_not_followers', checked);
};
handleFilterNewAccounts = checked => {
this.props.onChangePolicy('filter_new_accounts', checked);
};
handleFilterPrivateMentions = checked => {
this.props.onChangePolicy('filter_private_mentions', checked);
};
render () { render () {
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission, notificationPolicy } = this.props; const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />; const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
const groupingShowStr = <FormattedMessage id='notifications.column_settings.beta.grouping' defaultMessage='Group notifications' />;
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />; const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
@ -80,31 +63,7 @@ class ColumnSettings extends PureComponent {
</section> </section>
)} )}
<section> <PolicyControls />
<h3><FormattedMessage id='notifications.policy.title' defaultMessage='Filter out notifications from…' /></h3>
<div className='column-settings__row'>
<CheckboxWithLabel checked={notificationPolicy.filter_not_following} onChange={this.handleFilterNotFollowing}>
<strong><FormattedMessage id='notifications.policy.filter_not_following_title' defaultMessage="People you don't follow" /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.filter_not_followers} onChange={this.handleFilterNotFollowers}>
<strong><FormattedMessage id='notifications.policy.filter_not_followers_title' defaultMessage='People not following you' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_followers_hint' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' values={{ days: 3 }} /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.filter_new_accounts} onChange={this.handleFilterNewAccounts}>
<strong><FormattedMessage id='notifications.policy.filter_new_accounts_title' defaultMessage='New accounts' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_new_accounts.hint' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' values={{ days: 30 }} /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.filter_private_mentions} onChange={this.handleFilterPrivateMentions}>
<strong><FormattedMessage id='notifications.policy.filter_private_mentions_title' defaultMessage='Unsolicited private mentions' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_private_mentions_hint' defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /></span>
</CheckboxWithLabel>
</div>
</section>
<section role='group' aria-labelledby='notifications-filter-bar'> <section role='group' aria-labelledby='notifications-filter-bar'>
<h3 id='notifications-filter-bar'><FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /></h3> <h3 id='notifications-filter-bar'><FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /></h3>
@ -115,6 +74,16 @@ class ColumnSettings extends PureComponent {
</div> </div>
</section> </section>
<section role='group' aria-labelledby='notifications-beta'>
<h3 id='notifications-beta'>
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
</h3>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-unread-markers'> <section role='group' aria-labelledby='notifications-unread-markers'>
<h3 id='notifications-unread-markers'> <h3 id='notifications-unread-markers'>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' /> <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />

View file

@ -1,18 +1,62 @@
import { useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import { fetchNotificationPolicy } from 'flavours/glitch/actions/notification_policies'; import { fetchNotificationPolicy } from 'flavours/glitch/actions/notification_policies';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { selectSettingsNotificationsMinimizeFilteredBanner } from 'flavours/glitch/selectors/settings';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
const messages = defineMessages({
filteredNotifications: {
id: 'notification_requests.title',
defaultMessage: 'Filtered notifications',
},
});
export const FilteredNotificationsIconButton: React.FC<{
className?: string;
}> = ({ className }) => {
const intl = useIntl();
const history = useHistory();
const policy = useAppSelector((state) => state.notificationPolicy);
const minimizeSetting = useAppSelector(
selectSettingsNotificationsMinimizeFilteredBanner,
);
const handleClick = useCallback(() => {
history.push('/notifications/requests');
}, [history]);
if (policy === null || policy.summary.pending_notifications_count === 0) {
return null;
}
if (!minimizeSetting) {
return null;
}
return (
<button
aria-label={intl.formatMessage(messages.filteredNotifications)}
title={intl.formatMessage(messages.filteredNotifications)}
onClick={handleClick}
className={className}
>
<Icon id='filtered-notifications' icon={InventoryIcon} />
</button>
);
};
export const FilteredNotificationsBanner: React.FC = () => { export const FilteredNotificationsBanner: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const policy = useAppSelector((state) => state.notificationPolicy); const policy = useAppSelector((state) => state.notificationPolicy);
const minimizeSetting = useAppSelector(
selectSettingsNotificationsMinimizeFilteredBanner,
);
useEffect(() => { useEffect(() => {
void dispatch(fetchNotificationPolicy()); void dispatch(fetchNotificationPolicy());
@ -30,12 +74,18 @@ export const FilteredNotificationsBanner: React.FC = () => {
return null; return null;
} }
if (minimizeSetting) {
return null;
}
return ( return (
<Link <Link
className='filtered-notifications-banner' className='filtered-notifications-banner'
to='/notifications/requests' to='/notifications/requests'
> >
<Icon icon={InventoryIcon} id='filtered-notifications' /> <div className='notification-group__icon'>
<Icon icon={InventoryIcon} id='filtered-notifications' />
</div>
<div className='filtered-notifications-banner__text'> <div className='filtered-notifications-banner__text'>
<strong> <strong>
@ -47,22 +97,11 @@ export const FilteredNotificationsBanner: React.FC = () => {
<span> <span>
<FormattedMessage <FormattedMessage
id='filtered_notifications_banner.pending_requests' id='filtered_notifications_banner.pending_requests'
defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know' defaultMessage='From {count, plural, =0 {no one} one {one person} other {# people}} you may know'
values={{ count: policy.summary.pending_requests_count }} values={{ count: policy.summary.pending_requests_count }}
/> />
</span> </span>
</div> </div>
<div className='filtered-notifications-banner__badge'>
<div className='filtered-notifications-banner__badge__badge'>
{toCappedNumber(policy.summary.pending_notifications_count)}
</div>
<FormattedMessage
id='filtered_notifications_banner.mentions'
defaultMessage='{count, plural, one {mention} other {mentions}}'
values={{ count: policy.summary.pending_notifications_count }}
/>
</div>
</Link> </Link>
); );
}; };

View file

@ -1,7 +1,10 @@
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import GavelIcon from '@/material-icons/400-24px/gavel.svg?react'; import GavelIcon from '@/material-icons/400-24px/gavel.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import type { AccountWarningAction } from 'flavours/glitch/models/notification_group';
// This needs to be kept in sync with app/models/account_warning.rb // This needs to be kept in sync with app/models/account_warning.rb
const messages = defineMessages({ const messages = defineMessages({
@ -36,19 +39,18 @@ const messages = defineMessages({
}); });
interface Props { interface Props {
action: action: AccountWarningAction;
| 'none'
| 'disable'
| 'mark_statuses_as_sensitive'
| 'delete_statuses'
| 'sensitive'
| 'silence'
| 'suspend';
id: string; id: string;
hidden: boolean; hidden?: boolean;
unread?: boolean;
} }
export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => { export const ModerationWarning: React.FC<Props> = ({
action,
id,
hidden,
unread,
}) => {
const intl = useIntl(); const intl = useIntl();
if (hidden) { if (hidden) {
@ -56,23 +58,32 @@ export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
} }
return ( return (
<a <div
href={`/disputes/strikes/${id}`} role='button'
target='_blank' className={classNames(
rel='noopener noreferrer' 'notification-group notification-group--link notification-group--moderation-warning focusable',
className='notification__moderation-warning' { 'notification-group--unread': unread },
)}
tabIndex={0}
> >
<Icon id='warning' icon={GavelIcon} /> <div className='notification-group__icon'>
<Icon id='warning' icon={GavelIcon} />
</div>
<div className='notification__moderation-warning__content'> <div className='notification-group__main'>
<p>{intl.formatMessage(messages[action])}</p> <p>{intl.formatMessage(messages[action])}</p>
<span className='link-button'> <a
href={`/disputes/strikes/${id}`}
target='_blank'
rel='noopener noreferrer'
className='link-button'
>
<FormattedMessage <FormattedMessage
id='notification.moderation-warning.learn_more' id='notification.moderation-warning.learn_more'
defaultMessage='Learn more' defaultMessage='Learn more'
/> />
</span> </a>
</div> </div>
</a> </div>
); );
}; };

View file

@ -92,7 +92,7 @@ class Notification extends ImmutablePureComponent {
e.preventDefault(); e.preventDefault();
const { notification, onMention } = this.props; const { notification, onMention } = this.props;
onMention(notification.get('account'), this.props.history); onMention(notification.get('account'));
}; };
handleHotkeyFavourite = () => { handleHotkeyFavourite = () => {

View file

@ -38,12 +38,11 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
return ( return (
<div className='notification-request'> <div className='notification-request'>
<Link to={`/notifications/requests/${id}`} className='notification-request__link'> <Link to={`/notifications/requests/${id}`} className='notification-request__link'>
<Avatar account={account} size={36} /> <Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} />
<div className='notification-request__name'> <div className='notification-request__name'>
<div className='notification-request__name__display-name'> <div className='notification-request__name__display-name'>
<bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi> <bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi>
<span className='filtered-notifications-banner__badge'>{toCappedNumber(notificationsCount)}</span>
</div> </div>
<span>@{account?.get('acct')}</span> <span>@{account?.get('acct')}</span>

View file

@ -0,0 +1,141 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { CheckboxWithLabel } from './checkbox_with_label';
export const PolicyControls: React.FC = () => {
const dispatch = useAppDispatch();
const notificationPolicy = useAppSelector(
(state) => state.notificationPolicy,
);
const handleFilterNotFollowing = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_not_following: checked }),
);
},
[dispatch],
);
const handleFilterNotFollowers = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_not_followers: checked }),
);
},
[dispatch],
);
const handleFilterNewAccounts = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_new_accounts: checked }),
);
},
[dispatch],
);
const handleFilterPrivateMentions = useCallback(
(checked: boolean) => {
void dispatch(
updateNotificationsPolicy({ filter_private_mentions: checked }),
);
},
[dispatch],
);
if (!notificationPolicy) return null;
return (
<section>
<h3>
<FormattedMessage
id='notifications.policy.title'
defaultMessage='Filter out notifications from…'
/>
</h3>
<div className='column-settings__row'>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_following}
onChange={handleFilterNotFollowing}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_not_following_title'
defaultMessage="People you don't follow"
/>
</strong>
<span className='hint'>
<FormattedMessage
id='notifications.policy.filter_not_following_hint'
defaultMessage='Until you manually approve them'
/>
</span>
</CheckboxWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_not_followers}
onChange={handleFilterNotFollowers}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_not_followers_title'
defaultMessage='People not following you'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='notifications.policy.filter_not_followers_hint'
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
values={{ days: 3 }}
/>
</span>
</CheckboxWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_new_accounts}
onChange={handleFilterNewAccounts}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_new_accounts_title'
defaultMessage='New accounts'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='notifications.policy.filter_new_accounts.hint'
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
values={{ days: 30 }}
/>
</span>
</CheckboxWithLabel>
<CheckboxWithLabel
checked={notificationPolicy.filter_private_mentions}
onChange={handleFilterPrivateMentions}
>
<strong>
<FormattedMessage
id='notifications.policy.filter_private_mentions_title'
defaultMessage='Unsolicited private mentions'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='notifications.policy.filter_private_mentions_hint'
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
/>
</span>
</CheckboxWithLabel>
</div>
</section>
);
};

View file

@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react'; import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { domain } from 'flavours/glitch/initial_state'; import { domain } from 'flavours/glitch/initial_state';
@ -13,7 +15,7 @@ const messages = defineMessages({
user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' }, user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' },
}); });
export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden }) => { export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden, unread }) => {
const intl = useIntl(); const intl = useIntl();
if (hidden) { if (hidden) {
@ -21,14 +23,14 @@ export const RelationshipsSeveranceEvent = ({ type, target, followingCount, foll
} }
return ( return (
<a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='notification__relationships-severance-event'> <div role='button' className={classNames('notification-group notification-group--link notification-group--relationships-severance-event focusable', { 'notification-group--unread': unread })} tabIndex='0'>
<Icon id='heart_broken' icon={HeartBrokenIcon} /> <div className='notification-group__icon'><Icon id='heart_broken' icon={HeartBrokenIcon} /></div>
<div className='notification__relationships-severance-event__content'> <div className='notification-group__main'>
<p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p> <p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p>
<span className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></span> <a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
</div> </div>
</a> </div>
); );
}; };
@ -42,4 +44,5 @@ RelationshipsSeveranceEvent.propTypes = {
followersCount: PropTypes.number.isRequired, followersCount: PropTypes.number.isRequired,
followingCount: PropTypes.number.isRequired, followingCount: PropTypes.number.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool,
}; };

View file

@ -2,17 +2,16 @@ import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { initializeNotifications } from 'flavours/glitch/actions/notifications_migration';
import { showAlert } from '../../../actions/alerts'; import { showAlert } from '../../../actions/alerts';
import { openModal } from '../../../actions/modal'; import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
import { updateNotificationsPolicy } from '../../../actions/notification_policies';
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { changeSetting } from '../../../actions/settings'; import { changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
const messages = defineMessages({ const messages = defineMessages({
clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
}); });
@ -25,10 +24,9 @@ const mapStateToProps = state => ({
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
browserSupport: state.getIn(['notifications', 'browserSupport']), browserSupport: state.getIn(['notifications', 'browserSupport']),
browserPermission: state.getIn(['notifications', 'browserPermission']), browserPermission: state.getIn(['notifications', 'browserPermission']),
notificationPolicy: state.notificationPolicy,
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch) => ({
onChange (path, checked) { onChange (path, checked) {
if (path[0] === 'push') { if (path[0] === 'push') {
@ -58,32 +56,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
} else if(path[0] === 'groupingBeta') {
dispatch(changeSetting(['notifications', ...path], checked));
dispatch(initializeNotifications());
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
}, },
onClear () { onClear () {
dispatch(openModal({ dispatch(openModal({ modalType: 'CONFIRM_CLEAR_NOTIFICATIONS' }));
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(clearNotifications()),
},
}));
}, },
onRequestNotificationPermission () { onRequestNotificationPermission () {
dispatch(requestBrowserPermission()); dispatch(requestBrowserPermission());
}, },
onChangePolicy (param, checked) {
dispatch(updateNotificationsPolicy({
[param]: checked,
}));
},
}); });
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

View file

@ -2,13 +2,9 @@ import { connect } from 'react-redux';
import { mentionCompose } from '../../../actions/compose'; import { mentionCompose } from '../../../actions/compose';
import { import {
reblog, toggleReblog,
favourite, toggleFavourite,
unreblog,
unfavourite,
} from '../../../actions/interactions'; } from '../../../actions/interactions';
import { openModal } from '../../../actions/modal';
import { boostModal } from '../../../initial_state';
import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors'; import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors';
import Notification from '../components/notification'; import Notification from '../components/notification';
@ -31,32 +27,16 @@ const makeMapStateToProps = () => {
}; };
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onMention: (account, router) => { onMention: (account) => {
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account));
},
onModalReblog (status, privacy) {
dispatch(reblog({ statusId: status.get('id'), visibility: privacy }));
}, },
onReblog (status, e) { onReblog (status, e) {
if (status.get('reblogged')) { dispatch(toggleReblog(status.get('id'), e.shiftKey));
dispatch(unreblog({ statusId: status.get('id') }));
} else {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
}
}
}, },
onFavourite (status) { onFavourite (status, e) {
if (status.get('favourited')) { dispatch(toggleFavourite(status.get('id'), e.shiftKey));
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
}, },
}); });

View file

@ -38,7 +38,10 @@ import { LoadGap } from '../../components/load_gap';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container'; import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container';
import { FilteredNotificationsBanner } from './components/filtered_notifications_banner'; import {
FilteredNotificationsBanner,
FilteredNotificationsIconButton,
} from './components/filtered_notifications_banner';
import NotificationsPermissionBanner from './components/notifications_permission_banner'; import NotificationsPermissionBanner from './components/notifications_permission_banner';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import FilterBarContainer from './containers/filter_bar_container'; import FilterBarContainer from './containers/filter_bar_container';
@ -237,7 +240,7 @@ class Notifications extends PureComponent {
<LoadGap <LoadGap
key={'gap:' + notifications.getIn([index + 1, 'id'])} key={'gap:' + notifications.getIn([index + 1, 'id'])}
disabled={isLoading} disabled={isLoading}
maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
onClick={this.handleLoadGap} onClick={this.handleLoadGap}
/> />
) : ( ) : (
@ -258,6 +261,13 @@ class Notifications extends PureComponent {
let scrollContainer; let scrollContainer;
const prepend = (
<>
{needsNotificationPermission && <NotificationsPermissionBanner />}
<FilteredNotificationsBanner />
</>
);
if (signedIn) { if (signedIn) {
scrollContainer = ( scrollContainer = (
<ScrollableList <ScrollableList
@ -267,7 +277,7 @@ class Notifications extends PureComponent {
showLoading={isLoading && notifications.size === 0} showLoading={isLoading && notifications.size === 0}
hasMore={hasMore} hasMore={hasMore}
numPending={numPending} numPending={numPending}
prepend={needsNotificationPermission && <NotificationsPermissionBanner />} prepend={prepend}
alwaysPrepend alwaysPrepend
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
onLoadMore={this.handleLoadOlder} onLoadMore={this.handleLoadOlder}
@ -283,7 +293,9 @@ class Notifications extends PureComponent {
scrollContainer = <NotSignedInIndicator />; scrollContainer = <NotSignedInIndicator />;
} }
const extraButtons = []; const extraButtons = [
<FilteredNotificationsIconButton key='filtered-notifications-icon' className='column-header__button' />,
];
if (canMarkAsRead) { if (canMarkAsRead) {
extraButtons.push( extraButtons.push(
@ -356,8 +368,6 @@ class Notifications extends PureComponent {
{filterBarContainer} {filterBarContainer}
<FilteredNotificationsBanner />
{scrollContainer} {scrollContainer}
<Helmet> <Helmet>

View file

@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react'; import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { fetchNotificationRequest, fetchNotificationsForRequest, expandNotificationsForRequest, acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications'; import { fetchNotificationRequest, fetchNotificationsForRequest, expandNotificationsForRequest, acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
import Column from 'flavours/glitch/components/column'; import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
@ -101,7 +101,7 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
showBackButton showBackButton
extraButton={!removed && ( extraButton={!removed && (
<> <>
<IconButton className='column-header__button' iconComponent={VolumeOffIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} /> <IconButton className='column-header__button' iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton className='column-header__button' iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} /> <IconButton className='column-header__button' iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
</> </>
)} )}

View file

@ -9,16 +9,52 @@ import { useSelector, useDispatch } from 'react-redux';
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
import { fetchNotificationRequests, expandNotificationRequests } from 'flavours/glitch/actions/notifications'; import { fetchNotificationRequests, expandNotificationRequests } from 'flavours/glitch/actions/notifications';
import { changeSetting } from 'flavours/glitch/actions/settings';
import Column from 'flavours/glitch/components/column'; import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { NotificationRequest } from './components/notification_request'; import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls';
import SettingToggle from './components/setting_toggle';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' }, title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' }
}); });
const ColumnSettings = () => {
const dispatch = useDispatch();
const settings = useSelector((state) => state.settings.get('notifications'));
const onChange = useCallback(
(key, checked) => {
dispatch(changeSetting(['notifications', ...key], checked));
},
[dispatch],
);
return (
<div className='column-settings'>
<section>
<div className='column-settings__row'>
<SettingToggle
prefix='notifications'
settings={settings}
settingPath={['minimizeFilteredBanner']}
onChange={onChange}
label={
<FormattedMessage id='notification_requests.minimize_banner' defaultMessage='Minimize filtred notifications banner' />
}
/>
</div>
</section>
<PolicyControls />
</div>
);
};
export const NotificationRequests = ({ multiColumn }) => { export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef(); const columnRef = useRef();
const intl = useIntl(); const intl = useIntl();
@ -48,7 +84,9 @@ export const NotificationRequests = ({ multiColumn }) => {
onClick={handleHeaderClick} onClick={handleHeaderClick}
multiColumn={multiColumn} multiColumn={multiColumn}
showBackButton showBackButton
/> >
<ColumnSettings />
</ColumnHeader>
<ScrollableList <ScrollableList
scrollKey='notification_requests' scrollKey='notification_requests'

View file

@ -0,0 +1,31 @@
import { Link } from 'react-router-dom';
import { Avatar } from 'flavours/glitch/components/avatar';
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) return null;
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<Avatar account={account} size={28} />
</Link>
);
};
export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
accountIds,
}) => (
<div className='notification-group__avatar-group'>
{accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
<AvatarWrapper key={accountId} accountId={accountId} />
))}
</div>
);

View file

@ -0,0 +1,159 @@
import { useCallback, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon';
import type { Status } from 'flavours/glitch/models/status';
import { useAppSelector } from 'flavours/glitch/store';
import { EmbeddedStatusContent } from './embedded_status_content';
export type Mention = RecordOf<{ url: string; acct: string }>;
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
statusId,
}) => {
const history = useHistory();
const clickCoordinatesRef = useRef<[number, number] | null>();
const status = useAppSelector(
(state) => state.statuses.get(statusId) as Status | undefined,
);
const account = useAppSelector((state) =>
state.accounts.get(status?.get('account') as string),
);
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY }) => {
clickCoordinatesRef.current = [clientX, clientY];
},
[clickCoordinatesRef],
);
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY, target, button }) => {
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
const [deltaX, deltaY] = [
Math.abs(clientX - startX),
Math.abs(clientY - startY),
];
let element: HTMLDivElement | null = target as HTMLDivElement;
while (element) {
if (
element.localName === 'button' ||
element.localName === 'a' ||
element.localName === 'label'
) {
return;
}
element = element.parentNode as HTMLDivElement | null;
}
if (deltaX + deltaY < 5 && button === 0 && account) {
history.push(`/@${account.acct}/${statusId}`);
}
clickCoordinatesRef.current = null;
},
[clickCoordinatesRef, statusId, account, history],
);
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-original');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-static');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
if (!status) {
return null;
}
// Assign status attributes to variables with a forced type, as status is not yet properly typed
const contentHtml = status.get('contentHtml') as string;
const poll = status.get('poll');
const language = status.get('language') as string;
const mentions = status.get('mentions') as ImmutableList<Mention>;
const mediaAttachmentsSize = (
status.get('media_attachments') as ImmutableList<unknown>
).size;
return (
<div
className='notification-group__embedded-status'
role='button'
tabIndex={-1}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='notification-group__embedded-status__account'>
<Avatar account={account} size={16} />
<DisplayName account={account} />
</div>
<EmbeddedStatusContent
className='notification-group__embedded-status__content reply-indicator__content translate'
content={contentHtml}
language={language}
mentions={mentions}
/>
{(poll || mediaAttachmentsSize > 0) && (
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
{!!poll && (
<>
<Icon icon={BarChart4BarsIcon} id='bar-chart-4-bars' />
<FormattedMessage
id='reply_indicator.poll'
defaultMessage='Poll'
/>
</>
)}
{mediaAttachmentsSize > 0 && (
<>
<Icon icon={PhotoLibraryIcon} id='photo-library' />
<FormattedMessage
id='reply_indicator.attachments'
defaultMessage='{count, plural, one {# attachment} other {# attachments}}'
values={{ count: mediaAttachmentsSize }}
/>
</>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,93 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import type { List } from 'immutable';
import type { History } from 'history';
import type { Mention } from './embedded_status';
const handleMentionClick = (
history: History,
mention: Mention,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/@${mention.get('acct')}`);
}
};
const handleHashtagClick = (
history: History,
hashtag: string,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/tags/${hashtag.replace(/^#/, '')}`);
}
};
export const EmbeddedStatusContent: React.FC<{
content: string;
mentions: List<Mention>;
language: string;
className?: string;
}> = ({ content, mentions, language, className }) => {
const history = useHistory();
const handleContentRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
return;
}
const links = node.querySelectorAll<HTMLAnchorElement>('a');
for (const link of links) {
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
const mention = mentions.find((item) => link.href === item.get('url'));
if (mention) {
link.addEventListener(
'click',
handleMentionClick.bind(null, history, mention),
false,
);
link.setAttribute('title', `@${mention.get('acct')}`);
link.setAttribute('href', `/@${mention.get('acct')}`);
} else if (
link.textContent?.[0] === '#' ||
link.previousSibling?.textContent?.endsWith('#')
) {
link.addEventListener(
'click',
handleHashtagClick.bind(null, history, link.text),
false,
);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
}
},
[mentions, history],
);
return (
<div
className={className}
ref={handleContentRef}
lang={language}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
};

View file

@ -0,0 +1,51 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from 'flavours/glitch/store';
export const NamesList: React.FC<{
accountIds: string[];
total: number;
seeMoreHref?: string;
}> = ({ accountIds, total, seeMoreHref }) => {
const lastAccountId = accountIds[0] ?? '0';
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
if (!account) return null;
const displayedName = (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
if (total === 1) {
return displayedName;
}
if (seeMoreHref)
return (
<FormattedMessage
id='name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a>'
values={{
name: displayedName,
count: total - 1,
a: (chunks) => <Link to={seeMoreHref}>{chunks}</Link>,
}}
/>
);
return (
<FormattedMessage
id='name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}}'
values={{ name: displayedName, count: total - 1 }}
/>
);
};

View file

@ -0,0 +1,132 @@
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import type { NotificationGroupAdminReport } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
// This needs to be kept in sync with app/models/report.rb
const messages = defineMessages({
other: {
id: 'report_notification.categories.other_sentence',
defaultMessage: 'other',
},
spam: {
id: 'report_notification.categories.spam_sentence',
defaultMessage: 'spam',
},
legal: {
id: 'report_notification.categories.legal_sentence',
defaultMessage: 'illegal content',
},
violation: {
id: 'report_notification.categories.violation_sentence',
defaultMessage: 'rule violation',
},
});
export const NotificationAdminReport: React.FC<{
notification: NotificationGroupAdminReport;
unread?: boolean;
}> = ({ notification, notification: { report }, unread }) => {
const intl = useIntl();
const targetAccount = useAppSelector((state) =>
state.accounts.get(report.targetAccountId),
);
const account = useAppSelector((state) =>
state.accounts.get(notification.sampleAccountIds[0] ?? '0'),
);
if (!account || !targetAccount) return null;
const values = {
name: (
<bdi
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
/>
),
target: (
<bdi
dangerouslySetInnerHTML={{
__html: targetAccount.get('display_name_html'),
}}
/>
),
category: intl.formatMessage(messages[report.category]),
count: report.status_ids.length,
};
let message;
if (report.status_ids.length > 0) {
if (report.category === 'other') {
message = (
<FormattedMessage
id='notification.admin.report_account_other'
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target}'
values={values}
/>
);
} else {
message = (
<FormattedMessage
id='notification.admin.report_account'
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}'
values={values}
/>
);
}
} else {
if (report.category === 'other') {
message = (
<FormattedMessage
id='notification.admin.report_statuses_other'
defaultMessage='{name} reported {target}'
values={values}
/>
);
} else {
message = (
<FormattedMessage
id='notification.admin.report_statuses'
defaultMessage='{name} reported {target} for {category}'
values={values}
/>
);
}
}
return (
<a
href={`/admin/reports/${report.id}`}
target='_blank'
rel='noopener noreferrer'
className={classNames(
'notification-group notification-group--link notification-group--admin-report focusable',
{ 'notification-group--unread': unread },
)}
>
<div className='notification-group__icon'>
<Icon id='flag' icon={FlagIcon} />
</div>
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__label'>
{message}
<RelativeTimestamp timestamp={report.created_at} />
</div>
</div>
{report.comment.length > 0 && (
<div className='notification-group__embedded-status__content'>
{report.comment}
</div>
)}
</div>
</a>
);
};

View file

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import type { NotificationGroupAdminSignUp } from 'flavours/glitch/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.admin.sign_up'
defaultMessage='{name} signed up'
values={values}
/>
);
export const NotificationAdminSignUp: React.FC<{
notification: NotificationGroupAdminSignUp;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationGroupWithStatus
type='admin-sign-up'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View file

@ -0,0 +1,45 @@
import { FormattedMessage } from 'react-intl';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import type { NotificationGroupFavourite } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favorited your status'
values={values}
/>
);
export const NotificationFavourite: React.FC<{
notification: NotificationGroupFavourite;
unread: boolean;
}> = ({ notification, unread }) => {
const { statusId } = notification;
const statusAccount = useAppSelector(
(state) =>
state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
?.acct,
);
return (
<NotificationGroupWithStatus
type='favourite'
icon={StarIcon}
iconId='star'
accountIds={notification.sampleAccountIds}
statusId={notification.statusId}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
labelSeeMoreHref={
statusAccount ? `/@${statusAccount}/${statusId}/favourites` : undefined
}
unread={unread}
/>
);
};

View file

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import type { NotificationGroupFollow } from 'flavours/glitch/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={values}
/>
);
export const NotificationFollow: React.FC<{
notification: NotificationGroupFollow;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationGroupWithStatus
type='follow'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View file

@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import {
authorizeFollowRequest,
rejectFollowRequest,
} from 'flavours/glitch/actions/accounts';
import { IconButton } from 'flavours/glitch/components/icon_button';
import type { NotificationGroupFollowRequest } from 'flavours/glitch/models/notification_group';
import { useAppDispatch } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={values}
/>
);
export const NotificationFollowRequest: React.FC<{
notification: NotificationGroupFollowRequest;
unread: boolean;
}> = ({ notification, unread }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onAuthorize = useCallback(() => {
dispatch(authorizeFollowRequest(notification.sampleAccountIds[0]));
}, [dispatch, notification.sampleAccountIds]);
const onReject = useCallback(() => {
dispatch(rejectFollowRequest(notification.sampleAccountIds[0]));
}, [dispatch, notification.sampleAccountIds]);
const actions = (
<div className='notification-group__actions'>
<IconButton
title={intl.formatMessage(messages.reject)}
icon='times'
iconComponent={CloseIcon}
onClick={onReject}
/>
<IconButton
title={intl.formatMessage(messages.authorize)}
icon='check'
iconComponent={CheckIcon}
onClick={onAuthorize}
/>
</div>
);
return (
<NotificationGroupWithStatus
type='follow-request'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
actions={actions}
unread={unread}
/>
);
};

View file

@ -0,0 +1,160 @@
import { useMemo } from 'react';
import { HotKeys } from 'react-hotkeys';
import { navigateToProfile } from 'flavours/glitch/actions/accounts';
import { mentionComposeById } from 'flavours/glitch/actions/compose';
import type { NotificationGroup as NotificationGroupModel } from 'flavours/glitch/models/notification_group';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up';
import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request';
import { NotificationMention } from './notification_mention';
import { NotificationModerationWarning } from './notification_moderation_warning';
import { NotificationPoll } from './notification_poll';
import { NotificationReaction } from './notification_reaction';
import { NotificationReblog } from './notification_reblog';
import { NotificationSeveredRelationships } from './notification_severed_relationships';
import { NotificationStatus } from './notification_status';
import { NotificationUpdate } from './notification_update';
export const NotificationGroup: React.FC<{
notificationGroupId: NotificationGroupModel['group_key'];
unread: boolean;
onMoveUp: (groupId: string) => void;
onMoveDown: (groupId: string) => void;
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
const notificationGroup = useAppSelector((state) =>
state.notificationGroups.groups.find(
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
),
);
const dispatch = useAppDispatch();
const accountId =
notificationGroup?.type === 'gap'
? undefined
: notificationGroup?.sampleAccountIds[0];
const handlers = useMemo(
() => ({
moveUp: () => {
onMoveUp(notificationGroupId);
},
moveDown: () => {
onMoveDown(notificationGroupId);
},
openProfile: () => {
if (accountId) dispatch(navigateToProfile(accountId));
},
mention: () => {
if (accountId) dispatch(mentionComposeById(accountId));
},
}),
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
);
if (!notificationGroup || notificationGroup.type === 'gap') return null;
let content;
switch (notificationGroup.type) {
case 'reblog':
content = (
<NotificationReblog unread={unread} notification={notificationGroup} />
);
break;
case 'favourite':
content = (
<NotificationFavourite
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'reaction':
content = (
<NotificationReaction
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'severed_relationships':
content = (
<NotificationSeveredRelationships
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'mention':
content = (
<NotificationMention unread={unread} notification={notificationGroup} />
);
break;
case 'follow':
content = (
<NotificationFollow unread={unread} notification={notificationGroup} />
);
break;
case 'follow_request':
content = (
<NotificationFollowRequest
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'poll':
content = (
<NotificationPoll unread={unread} notification={notificationGroup} />
);
break;
case 'status':
content = (
<NotificationStatus unread={unread} notification={notificationGroup} />
);
break;
case 'update':
content = (
<NotificationUpdate unread={unread} notification={notificationGroup} />
);
break;
case 'admin.sign_up':
content = (
<NotificationAdminSignUp
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'admin.report':
content = (
<NotificationAdminReport
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'moderation_warning':
content = (
<NotificationModerationWarning
unread={unread}
notification={notificationGroup}
/>
);
break;
default:
return null;
}
return <HotKeys handlers={handlers}>{content}</HotKeys>;
};

View file

@ -0,0 +1,113 @@
import { useMemo } from 'react';
import classNames from 'classnames';
import { HotKeys } from 'react-hotkeys';
import { replyComposeById } from 'flavours/glitch/actions/compose';
import { navigateToStatus } from 'flavours/glitch/actions/statuses';
import type { IconProp } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { useAppDispatch } from 'flavours/glitch/store';
import { AvatarGroup } from './avatar_group';
import { EmbeddedStatus } from './embedded_status';
import { NamesList } from './names_list';
export type LabelRenderer = (
values: Record<string, React.ReactNode>,
) => JSX.Element;
export const NotificationGroupWithStatus: React.FC<{
icon: IconProp;
iconId: string;
statusId?: string;
actions?: JSX.Element;
count: number;
accountIds: string[];
timestamp: string;
labelRenderer: LabelRenderer;
labelSeeMoreHref?: string;
type: string;
unread: boolean;
}> = ({
icon,
iconId,
timestamp,
accountIds,
actions,
count,
statusId,
labelRenderer,
labelSeeMoreHref,
type,
unread,
}) => {
const dispatch = useAppDispatch();
const label = useMemo(
() =>
labelRenderer({
name: (
<NamesList
accountIds={accountIds}
total={count}
seeMoreHref={labelSeeMoreHref}
/>
),
}),
[labelRenderer, accountIds, count, labelSeeMoreHref],
);
const handlers = useMemo(
() => ({
open: () => {
dispatch(navigateToStatus(statusId));
},
reply: () => {
dispatch(replyComposeById(statusId));
},
}),
[dispatch, statusId],
);
return (
<HotKeys handlers={handlers}>
<div
role='button'
className={classNames(
`notification-group focusable notification-group--${type}`,
{ 'notification-group--unread': unread },
)}
tabIndex={0}
>
<div className='notification-group__icon'>
<Icon icon={icon} id={iconId} />
</div>
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__wrapper'>
<AvatarGroup accountIds={accountIds} />
{actions}
</div>
<div className='notification-group__main__header__label'>
{label}
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
</div>
</div>
{statusId && (
<div className='notification-group__main__status'>
<EmbeddedStatus statusId={statusId} />
</div>
)}
</div>
</div>
</HotKeys>
);
};

View file

@ -0,0 +1,56 @@
import { FormattedMessage } from 'react-intl';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import type { StatusVisibility } from 'flavours/glitch/api_types/statuses';
import type { NotificationGroupMention } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.mention'
defaultMessage='{name} mentioned you'
values={values}
/>
);
const privateMentionLabelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.private_mention'
defaultMessage='{name} privately mentioned you'
values={values}
/>
);
export const NotificationMention: React.FC<{
notification: NotificationGroupMention;
unread: boolean;
}> = ({ notification, unread }) => {
const statusVisibility = useAppSelector(
(state) =>
state.statuses.getIn([
notification.statusId,
'visibility',
]) as StatusVisibility,
);
return (
<NotificationWithStatus
type='mention'
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
iconId='reply'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={
statusVisibility === 'direct'
? privateMentionLabelRenderer
: labelRenderer
}
unread={unread}
/>
);
};

View file

@ -0,0 +1,13 @@
import { ModerationWarning } from 'flavours/glitch/features/notifications/components/moderation_warning';
import type { NotificationGroupModerationWarning } from 'flavours/glitch/models/notification_group';
export const NotificationModerationWarning: React.FC<{
notification: NotificationGroupModerationWarning;
unread: boolean;
}> = ({ notification: { moderationWarning }, unread }) => (
<ModerationWarning
action={moderationWarning.action}
id={moderationWarning.id}
unread={unread}
/>
);

View file

@ -0,0 +1,41 @@
import { FormattedMessage } from 'react-intl';
import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
import { me } from 'flavours/glitch/initial_state';
import type { NotificationGroupPoll } from 'flavours/glitch/models/notification_group';
import { NotificationWithStatus } from './notification_with_status';
const labelRendererOther = () => (
<FormattedMessage
id='notification.poll'
defaultMessage='A poll you voted in has ended'
/>
);
const labelRendererOwn = () => (
<FormattedMessage
id='notification.own_poll'
defaultMessage='Your poll has ended'
/>
);
export const NotificationPoll: React.FC<{
notification: NotificationGroupPoll;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationWithStatus
type='poll'
icon={BarChart4BarsIcon}
iconId='bar-chart-4-bars'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={
notification.sampleAccountIds[0] === me
? labelRendererOwn
: labelRendererOther
}
unread={unread}
/>
);

View file

@ -0,0 +1,34 @@
import { FormattedMessage } from 'react-intl';
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
import type { NotificationGroupReaction } from 'flavours/glitch/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your status'
values={values}
/>
);
export const NotificationReaction: React.FC<{
notification: NotificationGroupReaction;
unread: boolean;
}> = ({ notification, unread }) => {
return (
<NotificationGroupWithStatus
type='reaction'
icon={MoodIcon}
iconId='react'
accountIds={notification.sampleAccountIds}
statusId={notification.statusId}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
unread={unread}
/>
);
};

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