commit
3952d17518
|
@ -330,8 +330,8 @@ module.exports = {
|
||||||
|
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/strict-type-checked',
|
||||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
'plugin:@typescript-eslint/stylistic-type-checked',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
|
@ -343,7 +343,7 @@ module.exports = {
|
||||||
],
|
],
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: './tsconfig.json',
|
project: true,
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -353,6 +353,7 @@ module.exports = {
|
||||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
||||||
'@typescript-eslint/consistent-type-exports': 'error',
|
'@typescript-eslint/consistent-type-exports': 'error',
|
||||||
'@typescript-eslint/consistent-type-imports': 'error',
|
'@typescript-eslint/consistent-type-imports': 'error',
|
||||||
|
"@typescript-eslint/prefer-nullish-coalescing": ['error', {ignorePrimitives: {boolean: true}}],
|
||||||
|
|
||||||
'jsdoc/require-jsdoc': 'off',
|
'jsdoc/require-jsdoc': 'off',
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.52.1.
|
# using RuboCop version 1.54.1.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
@ -28,7 +28,6 @@ Layout/ArgumentAlignment:
|
||||||
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
|
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
|
||||||
Layout/HashAlignment:
|
Layout/HashAlignment:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/boot.rb'
|
|
||||||
- 'config/environments/production.rb'
|
- 'config/environments/production.rb'
|
||||||
- 'config/initializers/rack_attack.rb'
|
- 'config/initializers/rack_attack.rb'
|
||||||
- 'config/routes.rb'
|
- 'config/routes.rb'
|
||||||
|
@ -254,7 +253,6 @@ RSpec/HookArgument:
|
||||||
- 'spec/serializers/activitypub/note_serializer_spec.rb'
|
- 'spec/serializers/activitypub/note_serializer_spec.rb'
|
||||||
- 'spec/serializers/activitypub/update_poll_serializer_spec.rb'
|
- 'spec/serializers/activitypub/update_poll_serializer_spec.rb'
|
||||||
- 'spec/services/import_service_spec.rb'
|
- 'spec/services/import_service_spec.rb'
|
||||||
- 'spec/spec_helper.rb'
|
|
||||||
|
|
||||||
# Configuration parameters: AssignmentOnly.
|
# Configuration parameters: AssignmentOnly.
|
||||||
RSpec/InstanceVariable:
|
RSpec/InstanceVariable:
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -4,7 +4,7 @@ source 'https://rubygems.org'
|
||||||
ruby '>= 3.0.0'
|
ruby '>= 3.0.0'
|
||||||
|
|
||||||
gem 'puma', '~> 6.3'
|
gem 'puma', '~> 6.3'
|
||||||
gem 'rails', '~> 6.1.7'
|
gem 'rails', '~> 7.0'
|
||||||
gem 'sprockets', '~> 3.7.2'
|
gem 'sprockets', '~> 3.7.2'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
gem 'rack', '~> 2.2.7'
|
gem 'rack', '~> 2.2.7'
|
||||||
|
@ -66,7 +66,7 @@ gem 'pundit', '~> 2.3'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
gem 'rack-attack', '~> 6.6'
|
gem 'rack-attack', '~> 6.6'
|
||||||
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
||||||
gem 'rails-i18n', '~> 6.0'
|
gem 'rails-i18n', '~> 7.0'
|
||||||
gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true'
|
gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true'
|
||||||
gem 'redcarpet', '~> 3.6'
|
gem 'redcarpet', '~> 3.6'
|
||||||
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
||||||
|
|
136
Gemfile.lock
136
Gemfile.lock
|
@ -18,40 +18,47 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (6.1.7.4)
|
actioncable (7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (6.1.7.4)
|
actionmailbox (7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
activejob (= 6.1.7.4)
|
activejob (= 7.0.6)
|
||||||
activerecord (= 6.1.7.4)
|
activerecord (= 7.0.6)
|
||||||
activestorage (= 6.1.7.4)
|
activestorage (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
actionmailer (6.1.7.4)
|
net-imap
|
||||||
actionpack (= 6.1.7.4)
|
net-pop
|
||||||
actionview (= 6.1.7.4)
|
net-smtp
|
||||||
activejob (= 6.1.7.4)
|
actionmailer (7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
|
actionview (= 7.0.6)
|
||||||
|
activejob (= 7.0.6)
|
||||||
|
activesupport (= 7.0.6)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
|
net-imap
|
||||||
|
net-pop
|
||||||
|
net-smtp
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (6.1.7.4)
|
actionpack (7.0.6)
|
||||||
actionview (= 6.1.7.4)
|
actionview (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
rack (~> 2.0, >= 2.0.9)
|
rack (~> 2.0, >= 2.2.4)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (6.1.7.4)
|
actiontext (7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
activerecord (= 6.1.7.4)
|
activerecord (= 7.0.6)
|
||||||
activestorage (= 6.1.7.4)
|
activestorage (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (6.1.7.4)
|
actionview (7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
|
@ -61,27 +68,26 @@ GEM
|
||||||
activemodel (>= 4.1, < 7.1)
|
activemodel (>= 4.1, < 7.1)
|
||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
activejob (6.1.7.4)
|
activejob (7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (6.1.7.4)
|
activemodel (7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
activerecord (6.1.7.4)
|
activerecord (7.0.6)
|
||||||
activemodel (= 6.1.7.4)
|
activemodel (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
activestorage (6.1.7.4)
|
activestorage (7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
activejob (= 6.1.7.4)
|
activejob (= 7.0.6)
|
||||||
activerecord (= 6.1.7.4)
|
activerecord (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
mini_mime (>= 1.1.0)
|
||||||
activesupport (6.1.7.4)
|
activesupport (7.0.6)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
zeitwerk (~> 2.3)
|
|
||||||
addressable (2.8.4)
|
addressable (2.8.4)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
|
@ -167,7 +173,7 @@ GEM
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.9.6)
|
cbor (0.5.9.6)
|
||||||
charlock_holmes (0.7.7)
|
charlock_holmes (0.7.7)
|
||||||
chewy (7.3.2)
|
chewy (7.3.3)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
elasticsearch (>= 7.12.0, < 7.14.0)
|
elasticsearch (>= 7.12.0, < 7.14.0)
|
||||||
elasticsearch-dsl
|
elasticsearch-dsl
|
||||||
|
@ -373,6 +379,7 @@ GEM
|
||||||
marcel (~> 1.0.1)
|
marcel (~> 1.0.1)
|
||||||
mime-types
|
mime-types
|
||||||
terrapin (~> 0.6.0)
|
terrapin (~> 0.6.0)
|
||||||
|
language_server-protocol (3.17.0.3)
|
||||||
launchy (2.5.2)
|
launchy (2.5.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
letter_opener (1.8.1)
|
letter_opener (1.8.1)
|
||||||
|
@ -508,21 +515,20 @@ GEM
|
||||||
rack
|
rack
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rails (6.1.7.4)
|
rails (7.0.6)
|
||||||
actioncable (= 6.1.7.4)
|
actioncable (= 7.0.6)
|
||||||
actionmailbox (= 6.1.7.4)
|
actionmailbox (= 7.0.6)
|
||||||
actionmailer (= 6.1.7.4)
|
actionmailer (= 7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
actiontext (= 6.1.7.4)
|
actiontext (= 7.0.6)
|
||||||
actionview (= 6.1.7.4)
|
actionview (= 7.0.6)
|
||||||
activejob (= 6.1.7.4)
|
activejob (= 7.0.6)
|
||||||
activemodel (= 6.1.7.4)
|
activemodel (= 7.0.6)
|
||||||
activerecord (= 6.1.7.4)
|
activerecord (= 7.0.6)
|
||||||
activestorage (= 6.1.7.4)
|
activestorage (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 6.1.7.4)
|
railties (= 7.0.6)
|
||||||
sprockets-rails (>= 2.0.0)
|
|
||||||
rails-controller-testing (1.0.5)
|
rails-controller-testing (1.0.5)
|
||||||
actionpack (>= 5.0.1.rc1)
|
actionpack (>= 5.0.1.rc1)
|
||||||
actionview (>= 5.0.1.rc1)
|
actionview (>= 5.0.1.rc1)
|
||||||
|
@ -533,15 +539,16 @@ GEM
|
||||||
rails-html-sanitizer (1.6.0)
|
rails-html-sanitizer (1.6.0)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (~> 1.14)
|
nokogiri (~> 1.14)
|
||||||
rails-i18n (6.0.0)
|
rails-i18n (7.0.7)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 6.0.0, < 7)
|
railties (>= 6.0.0, < 8)
|
||||||
railties (6.1.7.4)
|
railties (7.0.6)
|
||||||
actionpack (= 6.1.7.4)
|
actionpack (= 7.0.6)
|
||||||
activesupport (= 6.1.7.4)
|
activesupport (= 7.0.6)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
|
zeitwerk (~> 2.5)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.0.6)
|
rake (13.0.6)
|
||||||
rdf (3.2.11)
|
rdf (3.2.11)
|
||||||
|
@ -589,8 +596,9 @@ GEM
|
||||||
sidekiq (>= 2.4.0)
|
sidekiq (>= 2.4.0)
|
||||||
rspec-support (3.12.0)
|
rspec-support (3.12.0)
|
||||||
rspec_chunked (0.6)
|
rspec_chunked (0.6)
|
||||||
rubocop (1.52.1)
|
rubocop (1.54.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
|
language_server-protocol (>= 3.17.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.2.2.3)
|
parser (>= 3.2.2.3)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
@ -608,7 +616,7 @@ GEM
|
||||||
rubocop-performance (1.18.0)
|
rubocop-performance (1.18.0)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
rubocop-ast (>= 0.4.0)
|
rubocop-ast (>= 0.4.0)
|
||||||
rubocop-rails (2.19.1)
|
rubocop-rails (2.20.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.33.0, < 2.0)
|
||||||
|
@ -688,7 +696,7 @@ GEM
|
||||||
climate_control (>= 0.0.3, < 1.0)
|
climate_control (>= 0.0.3, < 1.0)
|
||||||
thor (1.2.2)
|
thor (1.2.2)
|
||||||
tilt (2.2.0)
|
tilt (2.2.0)
|
||||||
timeout (0.3.2)
|
timeout (0.4.0)
|
||||||
tpm-key_attestation (0.12.0)
|
tpm-key_attestation (0.12.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
|
@ -839,9 +847,9 @@ DEPENDENCIES
|
||||||
rack-attack (~> 6.6)
|
rack-attack (~> 6.6)
|
||||||
rack-cors (~> 2.0)
|
rack-cors (~> 2.0)
|
||||||
rack-test (~> 2.1)
|
rack-test (~> 2.1)
|
||||||
rails (~> 6.1.7)
|
rails (~> 7.0)
|
||||||
rails-controller-testing (~> 1.0)
|
rails-controller-testing (~> 1.0)
|
||||||
rails-i18n (~> 6.0)
|
rails-i18n (~> 7.0)
|
||||||
rails-settings-cached (~> 0.6)!
|
rails-settings-cached (~> 0.6)!
|
||||||
rdf-normalize (~> 0.5)
|
rdf-normalize (~> 0.5)
|
||||||
redcarpet (~> 3.6)
|
redcarpet (~> 3.6)
|
||||||
|
|
|
@ -7,7 +7,10 @@ class Api::V1::MarkersController < Api::BaseController
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@markers = current_user.markers.where(timeline: Array(params[:timeline])).index_by(&:timeline)
|
with_read_replica do
|
||||||
|
@markers = current_user.markers.where(timeline: Array(params[:timeline])).index_by(&:timeline)
|
||||||
|
end
|
||||||
|
|
||||||
render json: serialize_map(@markers)
|
render json: serialize_map(@markers)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,12 @@ class Api::V1::NotificationsController < Api::BaseController
|
||||||
DEFAULT_NOTIFICATIONS_LIMIT = 40
|
DEFAULT_NOTIFICATIONS_LIMIT = 40
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@notifications = load_notifications
|
with_read_replica do
|
||||||
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
@notifications = load_notifications
|
||||||
|
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|
|
@ -6,7 +6,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController
|
||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
def show
|
def show
|
||||||
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
|
with_read_replica do
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
@relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
@relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
|
||||||
include CacheConcern
|
include CacheConcern
|
||||||
include DomainControlHelper
|
include DomainControlHelper
|
||||||
include ThemingConcern
|
include ThemingConcern
|
||||||
|
include DatabaseHelper
|
||||||
|
|
||||||
helper_method :current_account
|
helper_method :current_account
|
||||||
helper_method :current_session
|
helper_method :current_session
|
||||||
|
|
|
@ -22,7 +22,7 @@ module AccountsHelper
|
||||||
def account_action_button(account)
|
def account_action_button(account)
|
||||||
return if account.memorial? || account.moved?
|
return if account.memorial? || account.moved?
|
||||||
|
|
||||||
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button', target: '_new' do
|
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
|
||||||
safe_join([logo_as_symbol, t('accounts.follow')])
|
safe_join([logo_as_symbol, t('accounts.follow')])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DatabaseHelper
|
||||||
|
def with_read_replica(&block)
|
||||||
|
ApplicationRecord.connected_to(role: :read, prevent_writes: true, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_primary(&block)
|
||||||
|
ApplicationRecord.connected_to(role: :primary, &block)
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
module DomainControlHelper
|
module DomainControlHelper
|
||||||
def domain_not_allowed?(uri_or_domain)
|
def domain_not_allowed?(uri_or_domain)
|
||||||
return if uri_or_domain.blank?
|
return false if uri_or_domain.blank?
|
||||||
|
|
||||||
domain = if uri_or_domain.include?('://')
|
domain = if uri_or_domain.include?('://')
|
||||||
Addressable::URI.parse(uri_or_domain).host
|
Addressable::URI.parse(uri_or_domain).host
|
||||||
|
|
|
@ -86,10 +86,9 @@ const DIGIT_CHARACTERS = [
|
||||||
|
|
||||||
export const decode83 = (str: string) => {
|
export const decode83 = (str: string) => {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
let c, digit;
|
let digit;
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (const c of str) {
|
||||||
c = str[i];
|
|
||||||
digit = DIGIT_CHARACTERS.indexOf(c);
|
digit = DIGIT_CHARACTERS.indexOf(c);
|
||||||
value = value * 83 + digit;
|
value = value * 83 + digit;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
|
||||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||||
const willLeave = useCallback(
|
const willLeave = useCallback(
|
||||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||||
[direction]
|
[direction],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
|
|
|
@ -6,11 +6,11 @@ interface Props {
|
||||||
tag: {
|
tag: {
|
||||||
name: string;
|
name: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
history?: Array<{
|
history?: {
|
||||||
uses: number;
|
uses: number;
|
||||||
accounts: string;
|
accounts: string;
|
||||||
day: string;
|
day: string;
|
||||||
}>;
|
}[];
|
||||||
following?: boolean;
|
following?: boolean;
|
||||||
type: 'hashtag';
|
type: 'hashtag';
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const Avatar: React.FC<Props> = ({
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
style.backgroundImage = `url(${account.get(
|
style.backgroundImage = `url(${account.get(
|
||||||
hovering ? 'avatar' : 'avatar_static'
|
hovering ? 'avatar' : 'avatar_static',
|
||||||
)})`;
|
)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ export const Avatar: React.FC<Props> = ({
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'account__avatar',
|
'account__avatar',
|
||||||
{ 'account__avatar-inline': inline },
|
{ 'account__avatar-inline': inline },
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export const StatusesCounter = (
|
export const StatusesCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.statuses_counter'
|
id='account.statuses_counter'
|
||||||
|
@ -18,7 +18,7 @@ export const StatusesCounter = (
|
||||||
|
|
||||||
export const FollowingCounter = (
|
export const FollowingCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.following_counter'
|
id='account.following_counter'
|
||||||
|
@ -32,7 +32,7 @@ export const FollowingCounter = (
|
||||||
|
|
||||||
export const FollowersCounter = (
|
export const FollowersCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.followers_counter'
|
id='account.followers_counter'
|
||||||
|
|
|
@ -11,11 +11,12 @@ import { autoPlayGif } from '../initial_state';
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: Account;
|
account?: Account;
|
||||||
others: List<Account>;
|
others?: List<Account>;
|
||||||
localDomain: string;
|
localDomain?: string;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DisplayName extends React.PureComponent<Props> {
|
export class DisplayName extends React.PureComponent<Props> {
|
||||||
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
|
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
|
||||||
currentTarget,
|
currentTarget,
|
||||||
|
@ -52,7 +53,15 @@ export class DisplayName extends React.PureComponent<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { others, localDomain, inline } = this.props;
|
const { others, localDomain, inline } = this.props;
|
||||||
|
|
||||||
let displayName: React.ReactNode, suffix: React.ReactNode, account: Account;
|
let displayName: React.ReactNode,
|
||||||
|
suffix: React.ReactNode,
|
||||||
|
account: Account | undefined;
|
||||||
|
|
||||||
|
if (others && others.size > 0) {
|
||||||
|
account = others.first();
|
||||||
|
} else if (this.props.account) {
|
||||||
|
account = this.props.account;
|
||||||
|
}
|
||||||
|
|
||||||
if (others && others.size > 1) {
|
if (others && others.size > 1) {
|
||||||
displayName = others
|
displayName = others
|
||||||
|
@ -70,16 +79,10 @@ export class DisplayName extends React.PureComponent<Props> {
|
||||||
if (others.size - 2 > 0) {
|
if (others.size - 2 > 0) {
|
||||||
suffix = `+${others.size - 2}`;
|
suffix = `+${others.size - 2}`;
|
||||||
}
|
}
|
||||||
} else if ((others && others.size > 0) || this.props.account) {
|
} else if (account) {
|
||||||
if (others && others.size > 0) {
|
|
||||||
account = others.first();
|
|
||||||
} else {
|
|
||||||
account = this.props.account;
|
|
||||||
}
|
|
||||||
|
|
||||||
let acct = account.get('acct');
|
let acct = account.get('acct');
|
||||||
|
|
||||||
if (acct.indexOf('@') === -1 && localDomain) {
|
if (!acct.includes('@') && localDomain) {
|
||||||
acct = `${acct}@${localDomain}`;
|
acct = `${acct}@${localDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const GIFV: React.FC<Props> = ({
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClick]
|
[onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -108,7 +108,7 @@ export const timeAgoString = (
|
||||||
now: number,
|
now: number,
|
||||||
year: number,
|
year: number,
|
||||||
timeGiven: boolean,
|
timeGiven: boolean,
|
||||||
short?: boolean
|
short?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const delta = now - date.getTime();
|
const delta = now - date.getTime();
|
||||||
|
|
||||||
|
@ -118,28 +118,28 @@ export const timeAgoString = (
|
||||||
relativeTime = intl.formatMessage(messages.today);
|
relativeTime = intl.formatMessage(messages.today);
|
||||||
} else if (delta < 10 * SECOND) {
|
} else if (delta < 10 * SECOND) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.just_now : messages.just_now_full
|
short ? messages.just_now : messages.just_now_full,
|
||||||
);
|
);
|
||||||
} else if (delta < 7 * DAY) {
|
} else if (delta < 7 * DAY) {
|
||||||
if (delta < MINUTE) {
|
if (delta < MINUTE) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.seconds : messages.seconds_full,
|
short ? messages.seconds : messages.seconds_full,
|
||||||
{ number: Math.floor(delta / SECOND) }
|
{ number: Math.floor(delta / SECOND) },
|
||||||
);
|
);
|
||||||
} else if (delta < HOUR) {
|
} else if (delta < HOUR) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.minutes : messages.minutes_full,
|
short ? messages.minutes : messages.minutes_full,
|
||||||
{ number: Math.floor(delta / MINUTE) }
|
{ number: Math.floor(delta / MINUTE) },
|
||||||
);
|
);
|
||||||
} else if (delta < DAY) {
|
} else if (delta < DAY) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.hours : messages.hours_full,
|
short ? messages.hours : messages.hours_full,
|
||||||
{ number: Math.floor(delta / HOUR) }
|
{ number: Math.floor(delta / HOUR) },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.days : messages.days_full,
|
short ? messages.days : messages.days_full,
|
||||||
{ number: Math.floor(delta / DAY) }
|
{ number: Math.floor(delta / DAY) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (date.getFullYear() === year) {
|
} else if (date.getFullYear() === year) {
|
||||||
|
@ -158,7 +158,7 @@ const timeRemainingString = (
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
date: Date,
|
date: Date,
|
||||||
now: number,
|
now: number,
|
||||||
timeGiven = true
|
timeGiven = true,
|
||||||
) => {
|
) => {
|
||||||
const delta = date.getTime() - now;
|
const delta = date.getTime() - now;
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
|
||||||
|
|
||||||
type ShortNumberRenderer = (
|
type ShortNumberRenderer = (
|
||||||
displayNumber: JSX.Element,
|
displayNumber: JSX.Element,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
|
|
||||||
interface ShortNumberProps {
|
interface ShortNumberProps {
|
||||||
|
@ -25,16 +25,16 @@ export const ShortNumberRenderer: React.FC<ShortNumberProps> = ({
|
||||||
|
|
||||||
if (children && renderer) {
|
if (children && renderer) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'
|
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customRenderer = children || renderer || null;
|
const customRenderer = children ?? renderer ?? null;
|
||||||
|
|
||||||
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
customRenderer?.(displayNumber, pluralReady(value, division)) ||
|
customRenderer?.(displayNumber, pluralReady(value, division)) ??
|
||||||
displayNumber
|
displayNumber
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,12 +25,13 @@ export type SearchData = [
|
||||||
BaseEmoji['native'],
|
BaseEmoji['native'],
|
||||||
Emoji['short_names'],
|
Emoji['short_names'],
|
||||||
Search,
|
Search,
|
||||||
Emoji['unified']
|
Emoji['unified'],
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ShortCodesToEmojiData {
|
export type ShortCodesToEmojiData = Record<
|
||||||
[key: ShortCodesToEmojiDataKey]: [FilenameData, SearchData];
|
ShortCodesToEmojiDataKey,
|
||||||
}
|
[FilenameData, SearchData]
|
||||||
|
>;
|
||||||
export type EmojisWithoutShortCodes = FilenameData[];
|
export type EmojisWithoutShortCodes = FilenameData[];
|
||||||
|
|
||||||
export type EmojiCompressed = [
|
export type EmojiCompressed = [
|
||||||
|
@ -38,7 +39,7 @@ export type EmojiCompressed = [
|
||||||
Skins,
|
Skins,
|
||||||
Category[],
|
Category[],
|
||||||
Data['aliases'],
|
Data['aliases'],
|
||||||
EmojisWithoutShortCodes
|
EmojisWithoutShortCodes,
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -9,7 +9,7 @@ import emojiCompressed from './emoji_compressed';
|
||||||
import { unicodeToUnifiedName } from './unicode_to_unified_name';
|
import { unicodeToUnifiedName } from './unicode_to_unified_name';
|
||||||
|
|
||||||
type Emojis = {
|
type Emojis = {
|
||||||
[key in keyof ShortCodesToEmojiData]: {
|
[key in NonNullable<keyof ShortCodesToEmojiData>]: {
|
||||||
native: BaseEmoji['native'];
|
native: BaseEmoji['native'];
|
||||||
search: Search;
|
search: Search;
|
||||||
short_names: Emoji['short_names'];
|
short_names: Emoji['short_names'];
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
import SettingText from 'flavours/glitch/components/setting_text';
|
|
||||||
import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
|
|
||||||
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class ColumnSettings extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { settings, onChange, intl } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show private mentions' />} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingText prefix='home_timeline' settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(ColumnSettings);
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call,
|
||||||
|
@typescript-eslint/no-unsafe-return,
|
||||||
|
@typescript-eslint/no-unsafe-assignment,
|
||||||
|
@typescript-eslint/no-unsafe-member-access
|
||||||
|
-- the settings store is not yet typed */
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import SettingText from 'flavours/glitch/components/setting_text';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { changeSetting } from '../../../actions/settings';
|
||||||
|
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
filter_regex: {
|
||||||
|
id: 'home.column_settings.filter_regex',
|
||||||
|
defaultMessage: 'Filter out by regular expressions',
|
||||||
|
},
|
||||||
|
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ColumnSettings: React.FC = () => {
|
||||||
|
const settings = useAppSelector((state) => state.settings.get('home'));
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const onChange = useCallback(
|
||||||
|
(key: string, checked: boolean) => {
|
||||||
|
dispatch(changeSetting(['home', ...key], checked));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className='column-settings__section'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.basic'
|
||||||
|
defaultMessage='Basic'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['shows', 'reblog']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.show_reblogs'
|
||||||
|
defaultMessage='Show boosts'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['shows', 'reply']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.show_replies'
|
||||||
|
defaultMessage='Show replies'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['shows', 'direct']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.show_direct'
|
||||||
|
defaultMessage='Show private mentions'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className='column-settings__section'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.advanced'
|
||||||
|
defaultMessage='Advanced'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingText
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['regex', 'body']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={intl.formatMessage(messages.filter_regex)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
|
||||||
import background from 'mastodon/../images/friends-cropped.png';
|
|
||||||
|
|
||||||
|
|
||||||
export const ExplorePrompt = () => (
|
|
||||||
<DismissableBanner id='home.explore_prompt'>
|
|
||||||
<img src={background} alt='' className='dismissable-banner__background-image' />
|
|
||||||
|
|
||||||
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
|
|
||||||
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
|
|
||||||
|
|
||||||
<div className='dismissable-banner__message__actions__wrapper'>
|
|
||||||
<div className='dismissable-banner__message__actions'>
|
|
||||||
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
|
|
||||||
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DismissableBanner>
|
|
||||||
);
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
||||||
|
import background from 'mastodon/../images/friends-cropped.png';
|
||||||
|
|
||||||
|
export const ExplorePrompt = () => (
|
||||||
|
<DismissableBanner id='home.explore_prompt'>
|
||||||
|
<img
|
||||||
|
src={background}
|
||||||
|
alt=''
|
||||||
|
className='dismissable-banner__background-image'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.title'
|
||||||
|
defaultMessage='This is your home base within Mastodon.'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.body'
|
||||||
|
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='dismissable-banner__message__wrapper'>
|
||||||
|
<div className='dismissable-banner__message__actions'>
|
||||||
|
<Link to='/explore' className='button'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_explore'
|
||||||
|
defaultMessage="See what's trending"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link to='/explore/suggestions' className='button button-tertiary'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_suggestions'
|
||||||
|
defaultMessage='Find people to follow'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DismissableBanner>
|
||||||
|
);
|
|
@ -1,23 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
|
|
||||||
|
|
||||||
import ColumnSettings from '../components/column_settings';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
settings: state.getIn(['settings', 'home']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onChange (path, checked) {
|
|
||||||
dispatch(changeSetting(['home', ...path], checked));
|
|
||||||
},
|
|
||||||
|
|
||||||
onSave () {
|
|
||||||
dispatch(saveSettings());
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
|
|
|
@ -22,8 +22,8 @@ import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import StatusListContainer from '../ui/containers/status_list_container';
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
|
|
||||||
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { ExplorePrompt } from './components/explore_prompt';
|
import { ExplorePrompt } from './components/explore_prompt';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
|
@ -192,7 +192,7 @@ class HomeTimeline extends PureComponent {
|
||||||
extraButton={announcementsButton}
|
extraButton={announcementsButton}
|
||||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
||||||
>
|
>
|
||||||
<ColumnSettingsContainer />
|
<ColumnSettings />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
|
|
|
@ -3,15 +3,19 @@ export interface LocaleData {
|
||||||
messages: Record<string, string>;
|
messages: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadedLocale: LocaleData;
|
let loadedLocale: LocaleData | undefined;
|
||||||
|
|
||||||
export function setLocale(locale: LocaleData) {
|
export function setLocale(locale: LocaleData) {
|
||||||
loadedLocale = locale;
|
loadedLocale = locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocale() {
|
export function getLocale(): LocaleData {
|
||||||
if (!loadedLocale && process.env.NODE_ENV === 'development') {
|
if (!loadedLocale) {
|
||||||
throw new Error('getLocale() called before any locale has been set');
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
throw new Error('getLocale() called before any locale has been set');
|
||||||
|
} else {
|
||||||
|
return { locale: 'unknown', messages: {} };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadedLocale;
|
return loadedLocale;
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { isLocaleLoaded, setLocale } from './global_locale';
|
||||||
const localeLoadingSemaphore = new Semaphore(1);
|
const localeLoadingSemaphore = new Semaphore(1);
|
||||||
|
|
||||||
export async function loadLocale() {
|
export async function loadLocale() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
|
||||||
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
|
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
|
||||||
|
|
||||||
// We use a Semaphore here so only one thing can try to load the locales at
|
// We use a Semaphore here so only one thing can try to load the locales at
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'core-js/features/symbol';
|
||||||
import 'core-js/features/promise/finally';
|
import 'core-js/features/promise/finally';
|
||||||
import { decode as decodeBase64 } from '../utils/base64';
|
import { decode as decodeBase64 } from '../utils/base64';
|
||||||
|
|
||||||
if (!HTMLCanvasElement.prototype.toBlob) {
|
if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
|
||||||
const BASE64_MARKER = ';base64,';
|
const BASE64_MARKER = ';base64,';
|
||||||
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
||||||
|
@ -12,12 +12,12 @@ if (!HTMLCanvasElement.prototype.toBlob) {
|
||||||
this: HTMLCanvasElement,
|
this: HTMLCanvasElement,
|
||||||
callback: BlobCallback,
|
callback: BlobCallback,
|
||||||
type = 'image/png',
|
type = 'image/png',
|
||||||
quality: unknown
|
quality: unknown,
|
||||||
) {
|
) {
|
||||||
const dataURL: string = this.toDataURL(type, quality);
|
const dataURL: string = this.toDataURL(type, quality);
|
||||||
let data;
|
let data;
|
||||||
|
|
||||||
if (dataURL.indexOf(BASE64_MARKER) >= 0) {
|
if (dataURL.includes(BASE64_MARKER)) {
|
||||||
const [, base64] = dataURL.split(BASE64_MARKER);
|
const [, base64] = dataURL.split(BASE64_MARKER);
|
||||||
data = decodeBase64(base64);
|
data = decodeBase64(base64);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -24,6 +24,7 @@ export function loadPolyfills() {
|
||||||
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
||||||
// Edge does not have requestIdleCallback.
|
// Edge does not have requestIdleCallback.
|
||||||
// This avoids shipping them all the polyfills.
|
// This avoids shipping them all the polyfills.
|
||||||
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */
|
||||||
const needsExtraPolyfills = !(
|
const needsExtraPolyfills = !(
|
||||||
window.AbortController &&
|
window.AbortController &&
|
||||||
window.IntersectionObserver &&
|
window.IntersectionObserver &&
|
||||||
|
@ -31,6 +32,7 @@ export function loadPolyfills() {
|
||||||
'isIntersecting' in IntersectionObserverEntry.prototype &&
|
'isIntersecting' in IntersectionObserverEntry.prototype &&
|
||||||
window.requestIdleCallback
|
window.requestIdleCallback
|
||||||
);
|
);
|
||||||
|
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
loadIntlPolyfills(),
|
loadIntlPolyfills(),
|
||||||
|
|
|
@ -80,6 +80,7 @@ async function loadIntlPluralRulesPolyfills(locale: string) {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export async function loadIntlPolyfills() {
|
export async function loadIntlPolyfills() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
|
||||||
const locale = document.querySelector('html')?.lang || 'en';
|
const locale = document.querySelector('html')?.lang || 'en';
|
||||||
|
|
||||||
// order is important here
|
// order is important here
|
||||||
|
|
|
@ -105,7 +105,7 @@ const initialRootState = Object.fromEntries(
|
||||||
reducer(undefined, {
|
reducer(undefined, {
|
||||||
// empty action
|
// empty action
|
||||||
}),
|
}),
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
||||||
|
|
|
@ -35,7 +35,7 @@ interface PopModalOption {
|
||||||
}
|
}
|
||||||
const popModal = (
|
const popModal = (
|
||||||
state: State,
|
state: State,
|
||||||
{ modalType, ignoreFocus }: PopModalOption
|
{ modalType, ignoreFocus }: PopModalOption,
|
||||||
): State => {
|
): State => {
|
||||||
if (
|
if (
|
||||||
modalType === undefined ||
|
modalType === undefined ||
|
||||||
|
@ -52,12 +52,12 @@ const popModal = (
|
||||||
const pushModal = (
|
const pushModal = (
|
||||||
state: State,
|
state: State,
|
||||||
modalType: ModalType,
|
modalType: ModalType,
|
||||||
modalProps: ModalProps
|
modalProps: ModalProps,
|
||||||
): State => {
|
): State => {
|
||||||
return state.withMutations((record) => {
|
return state.withMutations((record) => {
|
||||||
record.set('ignoreFocus', false);
|
record.set('ignoreFocus', false);
|
||||||
record.update('stack', (stack) =>
|
record.update('stack', (stack) =>
|
||||||
stack.unshift(Modal({ modalType, modalProps }))
|
stack.unshift(Modal({ modalType, modalProps })),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -68,14 +68,14 @@ export function modalReducer(
|
||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
ignoreFocus: boolean;
|
ignoreFocus: boolean;
|
||||||
modalProps: Record<string, unknown>;
|
modalProps: Record<string, unknown>;
|
||||||
}>
|
}>,
|
||||||
) {
|
) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case openModal.type:
|
case openModal.type:
|
||||||
return pushModal(
|
return pushModal(
|
||||||
state,
|
state,
|
||||||
action.payload.modalType,
|
action.payload.modalType,
|
||||||
action.payload.modalProps
|
action.payload.modalProps,
|
||||||
);
|
);
|
||||||
case closeModal.type:
|
case closeModal.type:
|
||||||
return popModal(state, action.payload);
|
return popModal(state, action.payload);
|
||||||
|
@ -85,8 +85,8 @@ export function modalReducer(
|
||||||
return state.update('stack', (stack) =>
|
return state.update('stack', (stack) =>
|
||||||
stack.filterNot(
|
stack.filterNot(
|
||||||
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
|
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
|
||||||
(modal) => modal.get('modalProps').statusId === action.id
|
(modal) => modal.get('modalProps').statusId === action.id,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -3,12 +3,12 @@ const easingOutQuint = (
|
||||||
t: number,
|
t: number,
|
||||||
b: number,
|
b: number,
|
||||||
c: number,
|
c: number,
|
||||||
d: number
|
d: number,
|
||||||
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
|
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
|
||||||
const scroll = (
|
const scroll = (
|
||||||
node: Element,
|
node: Element,
|
||||||
key: 'scrollTop' | 'scrollLeft',
|
key: 'scrollTop' | 'scrollLeft',
|
||||||
target: number
|
target: number,
|
||||||
) => {
|
) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const offset = node[key];
|
const offset = node[key];
|
||||||
|
@ -38,11 +38,13 @@ const scroll = (
|
||||||
const isScrollBehaviorSupported =
|
const isScrollBehaviorSupported =
|
||||||
'scrollBehavior' in document.documentElement.style;
|
'scrollBehavior' in document.documentElement.style;
|
||||||
|
|
||||||
export const scrollRight = (node: Element, position: number) =>
|
export const scrollRight = (node: Element, position: number) => {
|
||||||
isScrollBehaviorSupported
|
if (isScrollBehaviorSupported)
|
||||||
? node.scrollTo({ left: position, behavior: 'smooth' })
|
node.scrollTo({ left: position, behavior: 'smooth' });
|
||||||
: scroll(node, 'scrollLeft', position);
|
else scroll(node, 'scrollLeft', position);
|
||||||
export const scrollTop = (node: Element) =>
|
};
|
||||||
isScrollBehaviorSupported
|
|
||||||
? node.scrollTo({ top: 0, behavior: 'smooth' })
|
export const scrollTop = (node: Element) => {
|
||||||
: scroll(node, 'scrollTop', 0);
|
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
else scroll(node, 'scrollTop', 0);
|
||||||
|
};
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const store = configureStore({
|
||||||
.concat(
|
.concat(
|
||||||
loadingBarMiddleware({
|
loadingBarMiddleware({
|
||||||
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.concat(errorsMiddleware)
|
.concat(errorsMiddleware)
|
||||||
.concat(soundsMiddleware()),
|
.concat(soundsMiddleware()),
|
||||||
|
|
|
@ -14,9 +14,9 @@ const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loadingBarMiddleware = (
|
export const loadingBarMiddleware = (
|
||||||
config: Config = {}
|
config: Config = {},
|
||||||
): Middleware<Record<string, never>, RootState> => {
|
): Middleware<Record<string, never>, RootState> => {
|
||||||
const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
|
const promiseTypeSuffixes = config.promiseTypeSuffixes ?? defaultTypeSuffixes;
|
||||||
|
|
||||||
return ({ dispatch }) =>
|
return ({ dispatch }) =>
|
||||||
(next) =>
|
(next) =>
|
||||||
|
@ -32,7 +32,7 @@ export const loadingBarMiddleware = (
|
||||||
if (action.type.match(isPending)) {
|
if (action.type.match(isPending)) {
|
||||||
dispatch(showLoading());
|
dispatch(showLoading());
|
||||||
} else if (
|
} else if (
|
||||||
action.type.match(isFulfilled) ||
|
action.type.match(isFulfilled) ??
|
||||||
action.type.match(isRejected)
|
action.type.match(isRejected)
|
||||||
) {
|
) {
|
||||||
dispatch(hideLoading());
|
dispatch(hideLoading());
|
||||||
|
|
|
@ -38,7 +38,7 @@ export const soundsMiddleware = (): Middleware<
|
||||||
Record<string, never>,
|
Record<string, never>,
|
||||||
RootState
|
RootState
|
||||||
> => {
|
> => {
|
||||||
const soundCache: { [key: string]: HTMLAudioElement } = {};
|
const soundCache: Record<string, HTMLAudioElement> = {};
|
||||||
|
|
||||||
void ready(() => {
|
void ready(() => {
|
||||||
soundCache.boop = createAudio([
|
soundCache.boop = createAudio([
|
||||||
|
@ -56,9 +56,9 @@ export const soundsMiddleware = (): Middleware<
|
||||||
return () =>
|
return () =>
|
||||||
(next) =>
|
(next) =>
|
||||||
(action: AnyAction & { meta?: { sound?: string } }) => {
|
(action: AnyAction & { meta?: { sound?: string } }) => {
|
||||||
const sound = action?.meta?.sound;
|
const sound = action.meta?.sound;
|
||||||
|
|
||||||
if (sound && soundCache[sound]) {
|
if (sound && Object.hasOwn(soundCache, sound)) {
|
||||||
play(soundCache[sound]);
|
play(soundCache[sound]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,9 +31,19 @@ body {
|
||||||
// Droid Sans => Older Androids (<4.0)
|
// Droid Sans => Older Androids (<4.0)
|
||||||
// Helvetica Neue => Older macOS <10.11
|
// Helvetica Neue => Older macOS <10.11
|
||||||
// $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
|
// $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family:
|
||||||
Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
system-ui,
|
||||||
$font-sans-serif, sans-serif;
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
$font-sans-serif,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.app-body {
|
&.app-body {
|
||||||
|
|
|
@ -480,7 +480,9 @@ $ui-header-height: 55px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
transition: max-height 150ms ease-in-out, opacity 300ms linear;
|
transition:
|
||||||
|
max-height 150ms ease-in-out,
|
||||||
|
opacity 300ms linear;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -26,7 +26,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-reduce-motion .spoiler-input {
|
.no-reduce-motion .spoiler-input {
|
||||||
transition: height 0.4s ease, opacity 0.4s ease;
|
transition:
|
||||||
|
height 0.4s ease,
|
||||||
|
opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler-input {
|
.spoiler-input {
|
||||||
|
|
|
@ -253,14 +253,16 @@
|
||||||
@for $i from 0 through 3 {
|
@for $i from 0 through 3 {
|
||||||
.mbstobon-#{$i} .drawer__inner__mastodon {
|
.mbstobon-#{$i} .drawer__inner__mastodon {
|
||||||
@if $i == 3 {
|
@if $i == 3 {
|
||||||
background: url('~flavours/glitch/images/wave-drawer.png')
|
background:
|
||||||
|
url('~flavours/glitch/images/wave-drawer.png')
|
||||||
no-repeat
|
no-repeat
|
||||||
bottom /
|
bottom /
|
||||||
100%
|
100%
|
||||||
auto,
|
auto,
|
||||||
lighten($ui-base-color, 13%);
|
lighten($ui-base-color, 13%);
|
||||||
} @else {
|
} @else {
|
||||||
background: url('~flavours/glitch/images/wave-drawer-glitched.png')
|
background:
|
||||||
|
url('~flavours/glitch/images/wave-drawer-glitched.png')
|
||||||
no-repeat
|
no-repeat
|
||||||
bottom /
|
bottom /
|
||||||
100%
|
100%
|
||||||
|
|
|
@ -73,6 +73,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.logo-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-end: 5px;
|
||||||
|
fill: $primary-text-color;
|
||||||
|
|
||||||
|
@media screen and (max-width: $no-gap-breakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.embed {
|
.embed {
|
||||||
.status__content[data-spoiler='folded'] {
|
.status__content[data-spoiler='folded'] {
|
||||||
.e-content {
|
.e-content {
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const toServerSideType = (columnType: string) => {
|
||||||
case 'account':
|
case 'account':
|
||||||
return columnType;
|
return columnType;
|
||||||
default:
|
default:
|
||||||
if (columnType.indexOf('list:') > -1) {
|
if (columnType.includes('list:')) {
|
||||||
return 'home';
|
return 'home';
|
||||||
} else {
|
} else {
|
||||||
return 'public'; // community, account, hashtag
|
return 'public'; // community, account, hashtag
|
||||||
|
|
|
@ -55,7 +55,7 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
|
||||||
*/
|
*/
|
||||||
export function pluralReady(
|
export function pluralReady(
|
||||||
sourceNumber: number,
|
sourceNumber: number,
|
||||||
division: DecimalUnits
|
division: DecimalUnits | null,
|
||||||
): number {
|
): number {
|
||||||
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
|
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
|
||||||
return sourceNumber;
|
return sourceNumber;
|
||||||
|
|
|
@ -4,6 +4,5 @@ export function uuid(a?: string): string {
|
||||||
(a as unknown as number) ^
|
(a as unknown as number) ^
|
||||||
((Math.random() * 16) >> ((a as unknown as number) / 4))
|
((Math.random() * 16) >> ((a as unknown as number) / 4))
|
||||||
).toString(16)
|
).toString(16)
|
||||||
: // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
: ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
||||||
('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,10 +86,9 @@ const DIGIT_CHARACTERS = [
|
||||||
|
|
||||||
export const decode83 = (str: string) => {
|
export const decode83 = (str: string) => {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
let c, digit;
|
let digit;
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (const c of str) {
|
||||||
c = str[i];
|
|
||||||
digit = DIGIT_CHARACTERS.indexOf(c);
|
digit = DIGIT_CHARACTERS.indexOf(c);
|
||||||
value = value * 83 + digit;
|
value = value * 83 + digit;
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
|
||||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||||
const willLeave = useCallback(
|
const willLeave = useCallback(
|
||||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||||
[direction]
|
[direction],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
|
|
|
@ -6,11 +6,11 @@ interface Props {
|
||||||
tag: {
|
tag: {
|
||||||
name: string;
|
name: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
history?: Array<{
|
history?: {
|
||||||
uses: number;
|
uses: number;
|
||||||
accounts: string;
|
accounts: string;
|
||||||
day: string;
|
day: string;
|
||||||
}>;
|
}[];
|
||||||
following?: boolean;
|
following?: boolean;
|
||||||
type: 'hashtag';
|
type: 'hashtag';
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,7 +5,7 @@ import type { Account } from '../../types/resources';
|
||||||
import { autoPlayGif } from '../initial_state';
|
import { autoPlayGif } from '../initial_state';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: Account;
|
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||||
size: number;
|
size: number;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
|
|
|
@ -3,8 +3,8 @@ import type { Account } from '../../types/resources';
|
||||||
import { autoPlayGif } from '../initial_state';
|
import { autoPlayGif } from '../initial_state';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: Account;
|
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||||
friend: Account;
|
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||||
size?: number;
|
size?: number;
|
||||||
baseSize?: number;
|
baseSize?: number;
|
||||||
overlaySize?: number;
|
overlaySize?: number;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export const StatusesCounter = (
|
export const StatusesCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.statuses_counter'
|
id='account.statuses_counter'
|
||||||
|
@ -18,7 +18,7 @@ export const StatusesCounter = (
|
||||||
|
|
||||||
export const FollowingCounter = (
|
export const FollowingCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.following_counter'
|
id='account.following_counter'
|
||||||
|
@ -32,7 +32,7 @@ export const FollowingCounter = (
|
||||||
|
|
||||||
export const FollowersCounter = (
|
export const FollowersCounter = (
|
||||||
displayNumber: React.ReactNode,
|
displayNumber: React.ReactNode,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => (
|
) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account.followers_counter'
|
id='account.followers_counter'
|
||||||
|
|
|
@ -78,7 +78,7 @@ export class DisplayName extends React.PureComponent<Props> {
|
||||||
} else if (account) {
|
} else if (account) {
|
||||||
let acct = account.get('acct');
|
let acct = account.get('acct');
|
||||||
|
|
||||||
if (acct.indexOf('@') === -1 && localDomain) {
|
if (!acct.includes('@') && localDomain) {
|
||||||
acct = `${acct}@${localDomain}`;
|
acct = `${acct}@${localDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const GIFV: React.FC<Props> = ({
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClick]
|
[onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -108,7 +108,7 @@ export const timeAgoString = (
|
||||||
now: number,
|
now: number,
|
||||||
year: number,
|
year: number,
|
||||||
timeGiven: boolean,
|
timeGiven: boolean,
|
||||||
short?: boolean
|
short?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const delta = now - date.getTime();
|
const delta = now - date.getTime();
|
||||||
|
|
||||||
|
@ -118,28 +118,28 @@ export const timeAgoString = (
|
||||||
relativeTime = intl.formatMessage(messages.today);
|
relativeTime = intl.formatMessage(messages.today);
|
||||||
} else if (delta < 10 * SECOND) {
|
} else if (delta < 10 * SECOND) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.just_now : messages.just_now_full
|
short ? messages.just_now : messages.just_now_full,
|
||||||
);
|
);
|
||||||
} else if (delta < 7 * DAY) {
|
} else if (delta < 7 * DAY) {
|
||||||
if (delta < MINUTE) {
|
if (delta < MINUTE) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.seconds : messages.seconds_full,
|
short ? messages.seconds : messages.seconds_full,
|
||||||
{ number: Math.floor(delta / SECOND) }
|
{ number: Math.floor(delta / SECOND) },
|
||||||
);
|
);
|
||||||
} else if (delta < HOUR) {
|
} else if (delta < HOUR) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.minutes : messages.minutes_full,
|
short ? messages.minutes : messages.minutes_full,
|
||||||
{ number: Math.floor(delta / MINUTE) }
|
{ number: Math.floor(delta / MINUTE) },
|
||||||
);
|
);
|
||||||
} else if (delta < DAY) {
|
} else if (delta < DAY) {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.hours : messages.hours_full,
|
short ? messages.hours : messages.hours_full,
|
||||||
{ number: Math.floor(delta / HOUR) }
|
{ number: Math.floor(delta / HOUR) },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
relativeTime = intl.formatMessage(
|
relativeTime = intl.formatMessage(
|
||||||
short ? messages.days : messages.days_full,
|
short ? messages.days : messages.days_full,
|
||||||
{ number: Math.floor(delta / DAY) }
|
{ number: Math.floor(delta / DAY) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (date.getFullYear() === year) {
|
} else if (date.getFullYear() === year) {
|
||||||
|
@ -158,7 +158,7 @@ const timeRemainingString = (
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
date: Date,
|
date: Date,
|
||||||
now: number,
|
now: number,
|
||||||
timeGiven = true
|
timeGiven = true,
|
||||||
) => {
|
) => {
|
||||||
const delta = date.getTime() - now;
|
const delta = date.getTime() - now;
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
|
||||||
|
|
||||||
type ShortNumberRenderer = (
|
type ShortNumberRenderer = (
|
||||||
displayNumber: JSX.Element,
|
displayNumber: JSX.Element,
|
||||||
pluralReady: number
|
pluralReady: number,
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
|
|
||||||
interface ShortNumberProps {
|
interface ShortNumberProps {
|
||||||
|
@ -25,16 +25,16 @@ export const ShortNumberRenderer: React.FC<ShortNumberProps> = ({
|
||||||
|
|
||||||
if (children && renderer) {
|
if (children && renderer) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'
|
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customRenderer = children || renderer || null;
|
const customRenderer = children ?? renderer ?? null;
|
||||||
|
|
||||||
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
customRenderer?.(displayNumber, pluralReady(value, division)) ||
|
customRenderer?.(displayNumber, pluralReady(value, division)) ??
|
||||||
displayNumber
|
displayNumber
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,12 +25,13 @@ export type SearchData = [
|
||||||
BaseEmoji['native'],
|
BaseEmoji['native'],
|
||||||
Emoji['short_names'],
|
Emoji['short_names'],
|
||||||
Search,
|
Search,
|
||||||
Emoji['unified']
|
Emoji['unified'],
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ShortCodesToEmojiData {
|
export type ShortCodesToEmojiData = Record<
|
||||||
[key: ShortCodesToEmojiDataKey]: [FilenameData, SearchData];
|
ShortCodesToEmojiDataKey,
|
||||||
}
|
[FilenameData, SearchData]
|
||||||
|
>;
|
||||||
export type EmojisWithoutShortCodes = FilenameData[];
|
export type EmojisWithoutShortCodes = FilenameData[];
|
||||||
|
|
||||||
export type EmojiCompressed = [
|
export type EmojiCompressed = [
|
||||||
|
@ -38,7 +39,7 @@ export type EmojiCompressed = [
|
||||||
Skins,
|
Skins,
|
||||||
Category[],
|
Category[],
|
||||||
Data['aliases'],
|
Data['aliases'],
|
||||||
EmojisWithoutShortCodes
|
EmojisWithoutShortCodes,
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -9,7 +9,7 @@ import emojiCompressed from './emoji_compressed';
|
||||||
import { unicodeToUnifiedName } from './unicode_to_unified_name';
|
import { unicodeToUnifiedName } from './unicode_to_unified_name';
|
||||||
|
|
||||||
type Emojis = {
|
type Emojis = {
|
||||||
[key in keyof ShortCodesToEmojiData]: {
|
[key in NonNullable<keyof ShortCodesToEmojiData>]: {
|
||||||
native: BaseEmoji['native'];
|
native: BaseEmoji['native'];
|
||||||
search: Search;
|
search: Search;
|
||||||
short_names: Emoji['short_names'];
|
short_names: Emoji['short_names'];
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
|
||||||
|
|
||||||
class ColumnSettings extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { settings, onChange } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(ColumnSettings);
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call,
|
||||||
|
@typescript-eslint/no-unsafe-return,
|
||||||
|
@typescript-eslint/no-unsafe-assignment,
|
||||||
|
@typescript-eslint/no-unsafe-member-access
|
||||||
|
-- the settings store is not yet typed */
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { changeSetting } from '../../../actions/settings';
|
||||||
|
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||||
|
|
||||||
|
export const ColumnSettings: React.FC = () => {
|
||||||
|
const settings = useAppSelector((state) => state.settings.get('home'));
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const onChange = useCallback(
|
||||||
|
(key: string, checked: boolean) => {
|
||||||
|
dispatch(changeSetting(['home', ...key], checked));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className='column-settings__section'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.basic'
|
||||||
|
defaultMessage='Basic'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['shows', 'reblog']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.show_reblogs'
|
||||||
|
defaultMessage='Show boosts'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle
|
||||||
|
prefix='home_timeline'
|
||||||
|
settings={settings}
|
||||||
|
settingPath={['shows', 'reply']}
|
||||||
|
onChange={onChange}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.column_settings.show_replies'
|
||||||
|
defaultMessage='Show replies'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import background from 'mastodon/../images/friends-cropped.png';
|
|
||||||
import { DismissableBanner } from 'mastodon/components/dismissable_banner';
|
|
||||||
|
|
||||||
|
|
||||||
export const ExplorePrompt = () => (
|
|
||||||
<DismissableBanner id='home.explore_prompt'>
|
|
||||||
<img src={background} alt='' className='dismissable-banner__background-image' />
|
|
||||||
|
|
||||||
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
|
|
||||||
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
|
|
||||||
|
|
||||||
<div className='dismissable-banner__message__actions__wrapper'>
|
|
||||||
<div className='dismissable-banner__message__actions'>
|
|
||||||
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
|
|
||||||
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DismissableBanner>
|
|
||||||
);
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import background from 'mastodon/../images/friends-cropped.png';
|
||||||
|
import { DismissableBanner } from 'mastodon/components/dismissable_banner';
|
||||||
|
|
||||||
|
export const ExplorePrompt = () => (
|
||||||
|
<DismissableBanner id='home.explore_prompt'>
|
||||||
|
<img
|
||||||
|
src={background}
|
||||||
|
alt=''
|
||||||
|
className='dismissable-banner__background-image'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.title'
|
||||||
|
defaultMessage='This is your home base within Mastodon.'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.body'
|
||||||
|
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='dismissable-banner__message__wrapper'>
|
||||||
|
<div className='dismissable-banner__message__actions'>
|
||||||
|
<Link to='/explore' className='button'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_explore'
|
||||||
|
defaultMessage="See what's trending"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link to='/explore/suggestions' className='button button-tertiary'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_suggestions'
|
||||||
|
defaultMessage='Find people to follow'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DismissableBanner>
|
||||||
|
);
|
|
@ -1,22 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { changeSetting, saveSettings } from '../../../actions/settings';
|
|
||||||
import ColumnSettings from '../components/column_settings';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
settings: state.getIn(['settings', 'home']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onChange (key, checked) {
|
|
||||||
dispatch(changeSetting(['home', ...key], checked));
|
|
||||||
},
|
|
||||||
|
|
||||||
onSave () {
|
|
||||||
dispatch(saveSettings());
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
|
|
|
@ -22,8 +22,8 @@ import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import StatusListContainer from '../ui/containers/status_list_container';
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
|
|
||||||
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { ExplorePrompt } from './components/explore_prompt';
|
import { ExplorePrompt } from './components/explore_prompt';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
|
@ -191,7 +191,7 @@ class HomeTimeline extends PureComponent {
|
||||||
extraButton={announcementsButton}
|
extraButton={announcementsButton}
|
||||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
||||||
>
|
>
|
||||||
<ColumnSettingsContainer />
|
<ColumnSettings />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
|
|
|
@ -3,15 +3,19 @@ export interface LocaleData {
|
||||||
messages: Record<string, string>;
|
messages: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadedLocale: LocaleData;
|
let loadedLocale: LocaleData | undefined;
|
||||||
|
|
||||||
export function setLocale(locale: LocaleData) {
|
export function setLocale(locale: LocaleData) {
|
||||||
loadedLocale = locale;
|
loadedLocale = locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocale() {
|
export function getLocale(): LocaleData {
|
||||||
if (!loadedLocale && process.env.NODE_ENV === 'development') {
|
if (!loadedLocale) {
|
||||||
throw new Error('getLocale() called before any locale has been set');
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
throw new Error('getLocale() called before any locale has been set');
|
||||||
|
} else {
|
||||||
|
return { locale: 'unknown', messages: {} };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadedLocale;
|
return loadedLocale;
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { isLocaleLoaded, setLocale } from './global_locale';
|
||||||
const localeLoadingSemaphore = new Semaphore(1);
|
const localeLoadingSemaphore = new Semaphore(1);
|
||||||
|
|
||||||
export async function loadLocale() {
|
export async function loadLocale() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
|
||||||
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
|
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
|
||||||
|
|
||||||
// We use a Semaphore here so only one thing can try to load the locales at
|
// We use a Semaphore here so only one thing can try to load the locales at
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'core-js/features/symbol';
|
||||||
import 'core-js/features/promise/finally';
|
import 'core-js/features/promise/finally';
|
||||||
import { decode as decodeBase64 } from '../utils/base64';
|
import { decode as decodeBase64 } from '../utils/base64';
|
||||||
|
|
||||||
if (!HTMLCanvasElement.prototype.toBlob) {
|
if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
|
||||||
const BASE64_MARKER = ';base64,';
|
const BASE64_MARKER = ';base64,';
|
||||||
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
||||||
|
@ -12,12 +12,12 @@ if (!HTMLCanvasElement.prototype.toBlob) {
|
||||||
this: HTMLCanvasElement,
|
this: HTMLCanvasElement,
|
||||||
callback: BlobCallback,
|
callback: BlobCallback,
|
||||||
type = 'image/png',
|
type = 'image/png',
|
||||||
quality: unknown
|
quality: unknown,
|
||||||
) {
|
) {
|
||||||
const dataURL: string = this.toDataURL(type, quality);
|
const dataURL: string = this.toDataURL(type, quality);
|
||||||
let data;
|
let data;
|
||||||
|
|
||||||
if (dataURL.indexOf(BASE64_MARKER) >= 0) {
|
if (dataURL.includes(BASE64_MARKER)) {
|
||||||
const [, base64] = dataURL.split(BASE64_MARKER);
|
const [, base64] = dataURL.split(BASE64_MARKER);
|
||||||
data = decodeBase64(base64);
|
data = decodeBase64(base64);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -24,6 +24,7 @@ export function loadPolyfills() {
|
||||||
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
||||||
// Edge does not have requestIdleCallback.
|
// Edge does not have requestIdleCallback.
|
||||||
// This avoids shipping them all the polyfills.
|
// This avoids shipping them all the polyfills.
|
||||||
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */
|
||||||
const needsExtraPolyfills = !(
|
const needsExtraPolyfills = !(
|
||||||
window.AbortController &&
|
window.AbortController &&
|
||||||
window.IntersectionObserver &&
|
window.IntersectionObserver &&
|
||||||
|
@ -31,6 +32,7 @@ export function loadPolyfills() {
|
||||||
'isIntersecting' in IntersectionObserverEntry.prototype &&
|
'isIntersecting' in IntersectionObserverEntry.prototype &&
|
||||||
window.requestIdleCallback
|
window.requestIdleCallback
|
||||||
);
|
);
|
||||||
|
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
loadIntlPolyfills(),
|
loadIntlPolyfills(),
|
||||||
|
|
|
@ -80,6 +80,7 @@ async function loadIntlPluralRulesPolyfills(locale: string) {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export async function loadIntlPolyfills() {
|
export async function loadIntlPolyfills() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
|
||||||
const locale = document.querySelector('html')?.lang || 'en';
|
const locale = document.querySelector('html')?.lang || 'en';
|
||||||
|
|
||||||
// order is important here
|
// order is important here
|
||||||
|
|
|
@ -99,7 +99,7 @@ const initialRootState = Object.fromEntries(
|
||||||
reducer(undefined, {
|
reducer(undefined, {
|
||||||
// empty action
|
// empty action
|
||||||
}),
|
}),
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
||||||
|
|
|
@ -35,7 +35,7 @@ interface PopModalOption {
|
||||||
}
|
}
|
||||||
const popModal = (
|
const popModal = (
|
||||||
state: State,
|
state: State,
|
||||||
{ modalType, ignoreFocus }: PopModalOption
|
{ modalType, ignoreFocus }: PopModalOption,
|
||||||
): State => {
|
): State => {
|
||||||
if (
|
if (
|
||||||
modalType === undefined ||
|
modalType === undefined ||
|
||||||
|
@ -52,12 +52,12 @@ const popModal = (
|
||||||
const pushModal = (
|
const pushModal = (
|
||||||
state: State,
|
state: State,
|
||||||
modalType: ModalType,
|
modalType: ModalType,
|
||||||
modalProps: ModalProps
|
modalProps: ModalProps,
|
||||||
): State => {
|
): State => {
|
||||||
return state.withMutations((record) => {
|
return state.withMutations((record) => {
|
||||||
record.set('ignoreFocus', false);
|
record.set('ignoreFocus', false);
|
||||||
record.update('stack', (stack) =>
|
record.update('stack', (stack) =>
|
||||||
stack.unshift(Modal({ modalType, modalProps }))
|
stack.unshift(Modal({ modalType, modalProps })),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -68,14 +68,14 @@ export function modalReducer(
|
||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
ignoreFocus: boolean;
|
ignoreFocus: boolean;
|
||||||
modalProps: Record<string, unknown>;
|
modalProps: Record<string, unknown>;
|
||||||
}>
|
}>,
|
||||||
) {
|
) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case openModal.type:
|
case openModal.type:
|
||||||
return pushModal(
|
return pushModal(
|
||||||
state,
|
state,
|
||||||
action.payload.modalType,
|
action.payload.modalType,
|
||||||
action.payload.modalProps
|
action.payload.modalProps,
|
||||||
);
|
);
|
||||||
case closeModal.type:
|
case closeModal.type:
|
||||||
return popModal(state, action.payload);
|
return popModal(state, action.payload);
|
||||||
|
@ -85,8 +85,8 @@ export function modalReducer(
|
||||||
return state.update('stack', (stack) =>
|
return state.update('stack', (stack) =>
|
||||||
stack.filterNot(
|
stack.filterNot(
|
||||||
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
|
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
|
||||||
(modal) => modal.get('modalProps').statusId === action.id
|
(modal) => modal.get('modalProps').statusId === action.id,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -3,12 +3,12 @@ const easingOutQuint = (
|
||||||
t: number,
|
t: number,
|
||||||
b: number,
|
b: number,
|
||||||
c: number,
|
c: number,
|
||||||
d: number
|
d: number,
|
||||||
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
|
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
|
||||||
const scroll = (
|
const scroll = (
|
||||||
node: Element,
|
node: Element,
|
||||||
key: 'scrollTop' | 'scrollLeft',
|
key: 'scrollTop' | 'scrollLeft',
|
||||||
target: number
|
target: number,
|
||||||
) => {
|
) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const offset = node[key];
|
const offset = node[key];
|
||||||
|
@ -38,11 +38,13 @@ const scroll = (
|
||||||
const isScrollBehaviorSupported =
|
const isScrollBehaviorSupported =
|
||||||
'scrollBehavior' in document.documentElement.style;
|
'scrollBehavior' in document.documentElement.style;
|
||||||
|
|
||||||
export const scrollRight = (node: Element, position: number) =>
|
export const scrollRight = (node: Element, position: number) => {
|
||||||
isScrollBehaviorSupported
|
if (isScrollBehaviorSupported)
|
||||||
? node.scrollTo({ left: position, behavior: 'smooth' })
|
node.scrollTo({ left: position, behavior: 'smooth' });
|
||||||
: scroll(node, 'scrollLeft', position);
|
else scroll(node, 'scrollLeft', position);
|
||||||
export const scrollTop = (node: Element) =>
|
};
|
||||||
isScrollBehaviorSupported
|
|
||||||
? node.scrollTo({ top: 0, behavior: 'smooth' })
|
export const scrollTop = (node: Element) => {
|
||||||
: scroll(node, 'scrollTop', 0);
|
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
else scroll(node, 'scrollTop', 0);
|
||||||
|
};
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const store = configureStore({
|
||||||
.concat(
|
.concat(
|
||||||
loadingBarMiddleware({
|
loadingBarMiddleware({
|
||||||
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.concat(errorsMiddleware)
|
.concat(errorsMiddleware)
|
||||||
.concat(soundsMiddleware()),
|
.concat(soundsMiddleware()),
|
||||||
|
|
|
@ -14,9 +14,9 @@ const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loadingBarMiddleware = (
|
export const loadingBarMiddleware = (
|
||||||
config: Config = {}
|
config: Config = {},
|
||||||
): Middleware<Record<string, never>, RootState> => {
|
): Middleware<Record<string, never>, RootState> => {
|
||||||
const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
|
const promiseTypeSuffixes = config.promiseTypeSuffixes ?? defaultTypeSuffixes;
|
||||||
|
|
||||||
return ({ dispatch }) =>
|
return ({ dispatch }) =>
|
||||||
(next) =>
|
(next) =>
|
||||||
|
@ -32,7 +32,7 @@ export const loadingBarMiddleware = (
|
||||||
if (action.type.match(isPending)) {
|
if (action.type.match(isPending)) {
|
||||||
dispatch(showLoading());
|
dispatch(showLoading());
|
||||||
} else if (
|
} else if (
|
||||||
action.type.match(isFulfilled) ||
|
action.type.match(isFulfilled) ??
|
||||||
action.type.match(isRejected)
|
action.type.match(isRejected)
|
||||||
) {
|
) {
|
||||||
dispatch(hideLoading());
|
dispatch(hideLoading());
|
||||||
|
|
|
@ -38,7 +38,7 @@ export const soundsMiddleware = (): Middleware<
|
||||||
Record<string, never>,
|
Record<string, never>,
|
||||||
RootState
|
RootState
|
||||||
> => {
|
> => {
|
||||||
const soundCache: { [key: string]: HTMLAudioElement } = {};
|
const soundCache: Record<string, HTMLAudioElement> = {};
|
||||||
|
|
||||||
void ready(() => {
|
void ready(() => {
|
||||||
soundCache.boop = createAudio([
|
soundCache.boop = createAudio([
|
||||||
|
@ -56,9 +56,9 @@ export const soundsMiddleware = (): Middleware<
|
||||||
return () =>
|
return () =>
|
||||||
(next) =>
|
(next) =>
|
||||||
(action: AnyAction & { meta?: { sound?: string } }) => {
|
(action: AnyAction & { meta?: { sound?: string } }) => {
|
||||||
const sound = action?.meta?.sound;
|
const sound = action.meta?.sound;
|
||||||
|
|
||||||
if (sound && soundCache[sound]) {
|
if (sound && Object.hasOwn(soundCache, sound)) {
|
||||||
play(soundCache[sound]);
|
play(soundCache[sound]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const toServerSideType = (columnType: string) => {
|
||||||
case 'account':
|
case 'account':
|
||||||
return columnType;
|
return columnType;
|
||||||
default:
|
default:
|
||||||
if (columnType.indexOf('list:') > -1) {
|
if (columnType.includes('list:')) {
|
||||||
return 'home';
|
return 'home';
|
||||||
} else {
|
} else {
|
||||||
return 'public'; // community, account, hashtag
|
return 'public'; // community, account, hashtag
|
||||||
|
|
|
@ -6,7 +6,7 @@ const buildHashtagPatternRegex = () => {
|
||||||
try {
|
try {
|
||||||
return new RegExp(
|
return new RegExp(
|
||||||
`(?:^|[^\\/\\)\\w])#(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))`,
|
`(?:^|[^\\/\\)\\w])#(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))`,
|
||||||
'iu'
|
'iu',
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
||||||
|
@ -17,7 +17,7 @@ const buildHashtagRegex = () => {
|
||||||
try {
|
try {
|
||||||
return new RegExp(
|
return new RegExp(
|
||||||
`^(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`,
|
`^(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`,
|
||||||
'iu'
|
'iu',
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return /^(\w*[a-zA-Z·]\w*)$/i;
|
return /^(\w*[a-zA-Z·]\w*)$/i;
|
||||||
|
|
|
@ -55,7 +55,7 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
|
||||||
*/
|
*/
|
||||||
export function pluralReady(
|
export function pluralReady(
|
||||||
sourceNumber: number,
|
sourceNumber: number,
|
||||||
division: DecimalUnits
|
division: DecimalUnits | null,
|
||||||
): number {
|
): number {
|
||||||
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
|
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
|
||||||
return sourceNumber;
|
return sourceNumber;
|
||||||
|
|
|
@ -4,6 +4,5 @@ export function uuid(a?: string): string {
|
||||||
(a as unknown as number) ^
|
(a as unknown as number) ^
|
||||||
((Math.random() * 16) >> ((a as unknown as number) / 4))
|
((Math.random() * 16) >> ((a as unknown as number) / 4))
|
||||||
).toString(16)
|
).toString(16)
|
||||||
: // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
: ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
||||||
('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: mastodon-font-monospace;
|
font-family: mastodon-font-monospace;
|
||||||
src: local('Roboto Mono'),
|
src:
|
||||||
|
local('Roboto Mono'),
|
||||||
url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
|
url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
|
||||||
url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
|
url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
|
||||||
url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
|
url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: mastodon-font-sans-serif;
|
font-family: mastodon-font-sans-serif;
|
||||||
src: local('Roboto Italic'),
|
src:
|
||||||
|
local('Roboto Italic'),
|
||||||
url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
|
url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
|
||||||
url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'),
|
url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'),
|
||||||
url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
|
||||||
|
@ -13,7 +14,8 @@
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: mastodon-font-sans-serif;
|
font-family: mastodon-font-sans-serif;
|
||||||
src: local('Roboto Bold'),
|
src:
|
||||||
|
local('Roboto Bold'),
|
||||||
url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
|
url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
|
||||||
url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'),
|
url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'),
|
||||||
url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
|
||||||
|
@ -26,7 +28,8 @@
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: mastodon-font-sans-serif;
|
font-family: mastodon-font-sans-serif;
|
||||||
src: local('Roboto Medium'),
|
src:
|
||||||
|
local('Roboto Medium'),
|
||||||
url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
|
url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
|
||||||
url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'),
|
url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'),
|
||||||
url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
|
||||||
|
@ -39,7 +42,8 @@
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: mastodon-font-sans-serif;
|
font-family: mastodon-font-sans-serif;
|
||||||
src: local('Roboto'),
|
src:
|
||||||
|
local('Roboto'),
|
||||||
url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
|
url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
|
||||||
url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'),
|
url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'),
|
||||||
url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
|
||||||
|
|
|
@ -31,9 +31,19 @@ body {
|
||||||
// Droid Sans => Older Androids (<4.0)
|
// Droid Sans => Older Androids (<4.0)
|
||||||
// Helvetica Neue => Older macOS <10.11
|
// Helvetica Neue => Older macOS <10.11
|
||||||
// $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
|
// $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family:
|
||||||
Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
system-ui,
|
||||||
$font-sans-serif, sans-serif;
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
$font-sans-serif,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.app-body {
|
&.app-body {
|
||||||
|
|
|
@ -747,7 +747,9 @@ body > [data-popper-placement] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-reduce-motion .spoiler-input {
|
.no-reduce-motion .spoiler-input {
|
||||||
transition: height 0.4s ease, opacity 0.4s ease;
|
transition:
|
||||||
|
height 0.4s ease,
|
||||||
|
opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sign-in-banner {
|
.sign-in-banner {
|
||||||
|
@ -3954,7 +3956,9 @@ a.status-card.compact:hover {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
transition: max-height 150ms ease-in-out, opacity 300ms linear;
|
transition:
|
||||||
|
max-height 150ms ease-in-out,
|
||||||
|
opacity 300ms linear;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -6935,7 +6939,8 @@ noscript {
|
||||||
.navigation-bar {
|
.navigation-bar {
|
||||||
& > a:first-child {
|
& > a:first-child {
|
||||||
will-change: margin-top, margin-inline-start, margin-inline-end, width;
|
will-change: margin-top, margin-inline-start, margin-inline-end, width;
|
||||||
transition: margin-top $duration $delay,
|
transition:
|
||||||
|
margin-top $duration $delay,
|
||||||
margin-inline-start $duration ($duration + $delay),
|
margin-inline-start $duration ($duration + $delay),
|
||||||
margin-inline-end $duration ($duration + $delay);
|
margin-inline-end $duration ($duration + $delay);
|
||||||
}
|
}
|
||||||
|
@ -6948,12 +6953,15 @@ noscript {
|
||||||
.navigation-bar__actions {
|
.navigation-bar__actions {
|
||||||
& > .icon-button.close {
|
& > .icon-button.close {
|
||||||
will-change: opacity transform;
|
will-change: opacity transform;
|
||||||
transition: opacity $duration * 0.5 $delay, transform $duration $delay;
|
transition:
|
||||||
|
opacity $duration * 0.5 $delay,
|
||||||
|
transform $duration $delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .compose__action-bar .icon-button {
|
& > .compose__action-bar .icon-button {
|
||||||
will-change: opacity transform;
|
will-change: opacity transform;
|
||||||
transition: opacity $duration * 0.5 $delay + $duration * 0.5,
|
transition:
|
||||||
|
opacity $duration * 0.5 $delay + $duration * 0.5,
|
||||||
transform $duration $delay;
|
transform $duration $delay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9094,7 +9102,8 @@ noscript {
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid rgba(lighten($classic-base-color, 4%), 0.85);
|
border: 1px solid rgba(lighten($classic-base-color, 4%), 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25),
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba($base-shadow-color, 0.25),
|
||||||
0 4px 6px -4px rgba($base-shadow-color, 0.25);
|
0 4px 6px -4px rgba($base-shadow-color, 0.25);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
|
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
|
||||||
|
|
|
@ -77,6 +77,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.logo-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-end: 5px;
|
||||||
|
fill: $primary-text-color;
|
||||||
|
|
||||||
|
@media screen and (max-width: $no-gap-breakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.embed {
|
.embed {
|
||||||
.status__content[data-spoiler='folded'] {
|
.status__content[data-spoiler='folded'] {
|
||||||
.e-content {
|
.e-content {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'connection_pool'
|
require 'connection_pool'
|
||||||
require_relative './shared_timed_stack'
|
require_relative 'shared_timed_stack'
|
||||||
|
|
||||||
class ConnectionPool::SharedConnectionPool < ConnectionPool
|
class ConnectionPool::SharedConnectionPool < ConnectionPool
|
||||||
def initialize(options = {}, &block)
|
def initialize(options = {}, &block)
|
||||||
|
|
|
@ -37,7 +37,7 @@ class InlineRenderer
|
||||||
private
|
private
|
||||||
|
|
||||||
def preload_associations_for_status
|
def preload_associations_for_status
|
||||||
ActiveRecord::Associations::Preloader.new.preload(@object, {
|
ActiveRecord::Associations::Preloader.new(records: @object, associations: {
|
||||||
active_mentions: :account,
|
active_mentions: :account,
|
||||||
|
|
||||||
reblog: {
|
reblog: {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative './connection_pool/shared_connection_pool'
|
require_relative 'connection_pool/shared_connection_pool'
|
||||||
|
|
||||||
class RequestPool
|
class RequestPool
|
||||||
def self.current
|
def self.current
|
||||||
|
|
|
@ -16,7 +16,7 @@ class RSS::Channel < RSS::Element
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_build_date(date)
|
def last_build_date(date)
|
||||||
append_element('lastBuildDate', date.to_formatted_s(:rfc822))
|
append_element('lastBuildDate', date.to_fs(:rfc822))
|
||||||
end
|
end
|
||||||
|
|
||||||
def image(url, title, link)
|
def image(url, title, link)
|
||||||
|
|
|
@ -20,7 +20,7 @@ class RSS::Item < RSS::Element
|
||||||
end
|
end
|
||||||
|
|
||||||
def pub_date(date)
|
def pub_date(date)
|
||||||
append_element('pubDate', date.to_formatted_s(:rfc822))
|
append_element('pubDate', date.to_fs(:rfc822))
|
||||||
end
|
end
|
||||||
|
|
||||||
def description(str)
|
def description(str)
|
||||||
|
|
|
@ -80,7 +80,7 @@ class Announcement < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji)
|
||||||
records
|
records
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -122,7 +122,7 @@ module AccountSearch
|
||||||
tsquery = generate_query_for_search(terms)
|
tsquery = generate_query_for_search(terms)
|
||||||
|
|
||||||
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ module AccountSearch
|
||||||
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
||||||
|
|
||||||
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,41 +4,41 @@ module StatusSafeReblogInsert
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
# This is a hack to ensure that no reblogs of discarded statuses are created,
|
# This patch overwrites the built-in ActiveRecord `_insert_record` method to
|
||||||
# as this cannot be enforced through database constraints the same way we do
|
# ensure that no reblogs of discarded statuses are created, as this cannot be
|
||||||
# for reblogs of deleted statuses.
|
# enforced through DB constraints the same way as reblogs of deleted statuses
|
||||||
#
|
#
|
||||||
# To achieve this, we redefine the internal method responsible for issuing
|
# We redefine the internal method responsible for issuing the `INSERT`
|
||||||
# the "INSERT" statement and replace the "INSERT INTO ... VALUES ..." query
|
# statement and replace the `INSERT INTO ... VALUES ...` query with an `INSERT
|
||||||
# with an "INSERT INTO ... SELECT ..." query with a "WHERE deleted_at IS NULL"
|
# INTO ... SELECT ...` query with a `WHERE deleted_at IS NULL` clause on the
|
||||||
# clause on the reblogged status to ensure consistency at the database level.
|
# reblogged status to ensure consistency at the database level.
|
||||||
#
|
#
|
||||||
# Otherwise, the code is kept as close as possible to ActiveRecord::Persistence
|
# The code is kept similar to ActiveRecord::Persistence code and calls it
|
||||||
# code, and actually calls it if we are not handling a reblog.
|
# directly when we are not handling a reblog.
|
||||||
def _insert_record(values)
|
def _insert_record(values)
|
||||||
return super unless values.is_a?(Hash) && values['reblog_of_id'].present?
|
return super unless values.is_a?(Hash) && values['reblog_of_id']&.value.present?
|
||||||
|
|
||||||
primary_key = self.primary_key
|
primary_key = self.primary_key
|
||||||
primary_key_value = nil
|
primary_key_value = nil
|
||||||
|
|
||||||
if primary_key
|
if prefetch_primary_key? && primary_key
|
||||||
primary_key_value = values[primary_key]
|
values[primary_key] ||= begin
|
||||||
|
|
||||||
if !primary_key_value && prefetch_primary_key?
|
|
||||||
primary_key_value = next_sequence_value
|
primary_key_value = next_sequence_value
|
||||||
values[primary_key] = primary_key_value
|
_default_attributes[primary_key].with_cast_value(primary_key_value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# The following line is where we differ from stock ActiveRecord implementation
|
# The following line departs from stock ActiveRecord
|
||||||
|
# Original code was:
|
||||||
|
# im.insert(values.transform_keys { |name| arel_table[name] })
|
||||||
|
# Instead, we use a custom builder when a reblog is happening:
|
||||||
im = _compile_reblog_insert(values)
|
im = _compile_reblog_insert(values)
|
||||||
|
|
||||||
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
connection.insert(im, "#{self} Create", primary_key || false, primary_key_value).tap do |result|
|
||||||
# For our purposes, it's equivalent to a foreign key constraint violation
|
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
||||||
result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
|
# For our purposes, it's equivalent to a foreign key constraint violation
|
||||||
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
|
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
|
||||||
|
end
|
||||||
result
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def _compile_reblog_insert(values)
|
def _compile_reblog_insert(values)
|
||||||
|
@ -54,9 +54,9 @@ module StatusSafeReblogInsert
|
||||||
|
|
||||||
binds = []
|
binds = []
|
||||||
reblog_bind = nil
|
reblog_bind = nil
|
||||||
values.each do |name, value|
|
values.each do |name, attribute|
|
||||||
attr = arel_table[name]
|
attr = arel_table[name]
|
||||||
bind = predicate_builder.build_bind_attribute(attr.name, value)
|
bind = predicate_builder.build_bind_attribute(attr.name, attribute.value)
|
||||||
|
|
||||||
im.columns << attr
|
im.columns << attr
|
||||||
binds << bind
|
binds << bind
|
||||||
|
|
|
@ -111,7 +111,7 @@ class Notification < ApplicationRecord
|
||||||
|
|
||||||
# Instead of using the usual `includes`, manually preload each type.
|
# Instead of using the usual `includes`, manually preload each type.
|
||||||
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
|
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
|
||||||
ActiveRecord::Associations::Preloader.new.preload(grouped_notifications, associations)
|
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations)
|
||||||
end
|
end
|
||||||
|
|
||||||
unique_target_statuses = notifications.filter_map(&:target_status).uniq
|
unique_target_statuses = notifications.filter_map(&:target_status).uniq
|
||||||
|
|
|
@ -100,7 +100,10 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||||
def accounts
|
def accounts
|
||||||
store = {}
|
store = {}
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload([object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact, [:account_stat, :user, { moved_to_account: [:account_stat, :user] }])
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: [object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact,
|
||||||
|
associations: [:account_stat, :user, { moved_to_account: [:account_stat, :user] }]
|
||||||
|
)
|
||||||
|
|
||||||
store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
|
store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
|
||||||
store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
|
store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
|
||||||
|
|
|
@ -33,7 +33,7 @@ class Web::NotificationSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def body
|
def body
|
||||||
str = strip_tags(object.target_status&.spoiler_text&.presence || object.target_status&.text || object.from_account.note)
|
str = strip_tags(object.target_status&.spoiler_text.presence || object.target_status&.text || object.from_account.note)
|
||||||
truncate(HTMLEntities.new.decode(str.to_str), length: 140, escape: false) # Do not encode entities, since this value will not be used in HTML
|
truncate(HTMLEntities.new.decode(str.to_str), length: 140, escape: false) # Do not encode entities, since this value will not be used in HTML
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -93,7 +93,7 @@ class AccountSearchService < BaseService
|
||||||
.objects
|
.objects
|
||||||
.compact
|
.compact
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
||||||
|
|
||||||
records
|
records
|
||||||
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
||||||
|
|
|
@ -8,7 +8,10 @@ class BatchedRemoveStatusService < BaseService
|
||||||
# @param [Hash] options
|
# @param [Hash] options
|
||||||
# @option [Boolean] :skip_side_effects Do not modify feeds and send updates to streaming API
|
# @option [Boolean] :skip_side_effects Do not modify feeds and send updates to streaming API
|
||||||
def call(statuses, **options)
|
def call(statuses, **options)
|
||||||
ActiveRecord::Associations::Preloader.new.preload(statuses, options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account])
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: statuses,
|
||||||
|
associations: options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account]
|
||||||
|
)
|
||||||
|
|
||||||
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
|
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
|
||||||
|
|
||||||
|
@ -17,7 +20,10 @@ class BatchedRemoveStatusService < BaseService
|
||||||
# rely on direct visibility statuses being relatively rare.
|
# rely on direct visibility statuses being relatively rare.
|
||||||
statuses_with_account_conversations = statuses.select(&:direct_visibility?)
|
statuses_with_account_conversations = statuses.select(&:direct_visibility?)
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(statuses_with_account_conversations, [mentions: :account])
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: statuses_with_account_conversations,
|
||||||
|
associations: [mentions: :account]
|
||||||
|
)
|
||||||
|
|
||||||
statuses_with_account_conversations.each do |status|
|
statuses_with_account_conversations.each do |status|
|
||||||
status.unlink_from_conversations!
|
status.unlink_from_conversations!
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
- Trends::PreviewCardProviderFilter::KEYS.each do |key|
|
- Trends::PreviewCardProviderFilter::KEYS.each do |key|
|
||||||
= hidden_field_tag key, params[key] if params[key].present?
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.batch-table.optional
|
.batch-table
|
||||||
.batch-table__toolbar
|
.batch-table__toolbar
|
||||||
%label.batch-table__toolbar__select.batch-checkbox-all
|
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||||
= check_box_tag :batch_checkbox_all, nil, false
|
= check_box_tag :batch_checkbox_all, nil, false
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
class FeedInsertWorker
|
class FeedInsertWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
include DatabaseHelper
|
||||||
|
|
||||||
def perform(status_id, id, type = 'home', options = {})
|
def perform(status_id, id, type = 'home', options = {})
|
||||||
ApplicationRecord.connected_to(role: :primary) do
|
with_primary do
|
||||||
@type = type.to_sym
|
@type = type.to_sym
|
||||||
@status = Status.find(status_id)
|
@status = Status.find(status_id)
|
||||||
@options = options.symbolize_keys
|
@options = options.symbolize_keys
|
||||||
|
@ -20,7 +21,7 @@ class FeedInsertWorker
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
|
with_read_replica do
|
||||||
check_and_insert
|
check_and_insert
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue