Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `README.md`:
  Upstream updated copyright year, we don't mention it so kept our version.
- `app/controllers/admin/dashboard_controller.rb`:
  Not really a conflict, upstream change (removing the spam checker) too close
  to glitch-soc changes. Ported upstream changes.
- `app/models/form/admin_settings.rb`:
  Same.
- `app/services/remove_status_service.rb`:
  Same.
- `app/views/admin/settings/edit.html.haml`:
  Same.
- `config/settings.yml`:
  Same.
- `config/environments/production.rb`:
  Not a real conflict, upstream added a default HTTP header, but we have
  extra headers in glitch-soc.
  Added the header.
master
Claire 2 years ago
commit e2a2bc9021
  1. 2
      .ruby-version
  2. 2
      Dockerfile
  3. 12
      Gemfile
  4. 100
      Gemfile.lock
  5. 1
      app/controllers/admin/dashboard_controller.rb
  6. 53
      app/controllers/admin/follow_recommendations_controller.rb
  7. 28
      app/controllers/api/v1/push/subscriptions_controller.rb
  8. 2
      app/controllers/api/v1/suggestions_controller.rb
  9. 19
      app/controllers/api/v2/suggestions_controller.rb
  10. 25
      app/controllers/api/web/push_subscriptions_controller.rb
  11. 2
      app/helpers/application_helper.rb
  12. 18
      app/helpers/email_helper.rb
  13. 1
      app/javascript/mastodon/actions/importer/normalizer.js
  14. 13
      app/javascript/mastodon/actions/onboarding.js
  15. 24
      app/javascript/mastodon/actions/suggestions.js
  16. 6
      app/javascript/mastodon/components/account.js
  17. 9
      app/javascript/mastodon/components/logo.js
  18. 47
      app/javascript/mastodon/containers/mastodon.js
  19. 10
      app/javascript/mastodon/features/compose/components/search_results.js
  20. 2
      app/javascript/mastodon/features/emoji/emoji.js
  21. 85
      app/javascript/mastodon/features/follow_recommendations/components/account.js
  22. 95
      app/javascript/mastodon/features/follow_recommendations/index.js
  23. 11
      app/javascript/mastodon/features/ui/index.js
  24. 4
      app/javascript/mastodon/features/ui/util/async-components.js
  25. 8
      app/javascript/mastodon/reducers/suggestions.js
  26. 66
      app/javascript/styles/mastodon/components.scss
  27. 25
      app/lib/account_reach_finder.rb
  28. 5
      app/lib/activitypub/activity/create.rb
  29. 2
      app/lib/activitypub/activity/flag.rb
  30. 1
      app/lib/admin/system_check/sidekiq_process_check.rb
  31. 4
      app/lib/application_extension.rb
  32. 35
      app/lib/formatter.rb
  33. 12
      app/lib/potential_friendship_tracker.rb
  34. 198
      app/lib/spam_check.rb
  35. 25
      app/lib/status_reach_finder.rb
  36. 8
      app/lib/tag_manager.rb
  37. 17
      app/models/account.rb
  38. 17
      app/models/account_suggestions.rb
  39. 25
      app/models/account_summary.rb
  40. 27
      app/models/canonical_email_block.rb
  41. 3
      app/models/concerns/account_associations.rb
  42. 39
      app/models/follow_recommendation.rb
  43. 26
      app/models/follow_recommendation_filter.rb
  44. 28
      app/models/follow_recommendation_suppression.rb
  45. 18
      app/models/form/account_batch.rb
  46. 2
      app/models/form/admin_settings.rb
  47. 112
      app/models/web/push_subscription.rb
  48. 15
      app/policies/follow_recommendation_policy.rb
  49. 7
      app/serializers/rest/suggestion_serializer.rb
  50. 5
      app/services/process_mentions_service.rb
  51. 39
      app/services/remove_status_service.rb
  52. 2
      app/services/report_service.rb
  53. 12
      app/services/suspend_account_service.rb
  54. 15
      app/services/unsuspend_account_service.rb
  55. 30
      app/validators/blacklisted_email_validator.rb
  56. 2
      app/views/admin/dashboard/index.html.haml
  57. 20
      app/views/admin/follow_recommendations/_account.html.haml
  58. 41
      app/views/admin/follow_recommendations/show.html.haml
  59. 5
      app/views/admin/rules/index.html.haml
  60. 3
      app/views/admin/settings/edit.html.haml
  61. 4
      app/views/user_mailer/webauthn_enabled.text.erb
  62. 61
      app/workers/scheduler/follow_recommendations_scheduler.rb
  63. 65
      app/workers/web/push_notification_worker.rb
  64. 1
      config/application.rb
  65. 9
      config/environments/production.rb
  66. 14
      config/initializers/content_security_policy.rb
  67. 5
      config/initializers/doorkeeper.rb
  68. 4
      config/initializers/paperclip.rb
  69. 4
      config/initializers/suppress_csrf_warnings.rb
  70. 21
      config/locales/en.yml
  71. 14
      config/locales/simple_form.en.yml
  72. 1
      config/navigation.rb
  73. 4
      config/routes.rb
  74. 1
      config/settings.yml
  75. 4
      config/sidekiq.yml
  76. 17
      db/migrate/20210306164523_account_ids_to_timestamp_ids.rb
  77. 9
      db/migrate/20210322164601_create_account_summaries.rb
  78. 5
      db/migrate/20210323114347_create_follow_recommendations.rb
  79. 9
      db/migrate/20210324171613_create_follow_recommendation_suppressions.rb
  80. 10
      db/migrate/20210416200740_create_canonical_email_blocks.rb
  81. 75
      db/schema.rb
  82. 22
      db/views/account_summaries_v01.sql
  83. 38
      db/views/follow_recommendations_v01.sql
  84. 44
      lib/active_record/batches.rb
  85. 2
      lib/tasks/emojis.rake
  86. 34
      package.json
  87. 19
      public/emoji/1f6b2_border.svg
  88. 78
      spec/controllers/api/v1/apps_controller_spec.rb
  89. 28
      spec/controllers/api/v1/push/subscriptions_controller_spec.rb
  90. 23
      spec/controllers/api/web/push_subscriptions_controller_spec.rb
  91. 4
      spec/fabricators/canonical_email_block_fabricator.rb
  92. 3
      spec/fabricators/follow_recommendation_suppression_fabricator.rb
  93. 192
      spec/lib/spam_check_spec.rb
  94. 36
      spec/lib/tag_manager_spec.rb
  95. 47
      spec/models/canonical_email_block_spec.rb
  96. 4
      spec/models/follow_recommendation_suppression_spec.rb
  97. 94
      spec/models/web/push_subscription_spec.rb
  98. 29
      spec/validators/blacklisted_email_validator_spec.rb
  99. 48
      spec/workers/web/push_notification_worker_spec.rb
  100. 427
      yarn.lock

@ -1 +1 @@
2.7.2
2.7.3

@ -26,7 +26,7 @@ RUN ARCH= && \
mv node-v$NODE_VER-linux-$ARCH /opt/node
# Install Ruby
ENV RUBY_VER="2.7.2"
ENV RUBY_VER="2.7.3"
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \

@ -32,9 +32,9 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639'
gem 'chewy', '~> 5.2'
gem 'cld3', '~> 3.4.1'
gem 'cld3', '~> 3.4.2'
gem 'devise', '~> 4.7'
gem 'devise-two-factor', git: 'https://github.com/ClearlyClaire/devise-two-factor', ref: '594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d'
gem 'devise-two-factor', '~> 4.0'
group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.2'
@ -62,9 +62,8 @@ gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
gem 'nokogiri', '~> 1.11'
gem 'nsa', git: 'https://github.com/Gargron/nsa', ref: 'd1079e0cdafdfed7f9f35478d13b9bdaa65965c0'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.11'
gem 'ox', '~> 2.14'
gem 'parslet'
@ -95,7 +94,7 @@ gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2021'
gem 'webpacker', '~> 5.2'
gem 'webpush'
gem 'webpush', '~> 0.3'
gem 'webauthn', '~> 3.0.0.alpha1'
gem 'json-ld'
@ -126,7 +125,7 @@ group :test do
gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.12'
gem 'parallel_tests', '~> 3.6'
gem 'parallel_tests', '~> 3.7'
gem 'rspec_junit_formatter', '~> 0.4'
end
@ -160,4 +159,3 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'pluck_each', git: 'https://github.com/nsommer/pluck_each', ref: '73be0947c52fc54bf6d7085378db008358aac5eb'

@ -1,42 +1,3 @@
GIT
remote: https://github.com/ClearlyClaire/devise-two-factor
revision: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
ref: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
specs:
devise-two-factor (3.1.0)
activesupport (< 7.0)
attr_encrypted (>= 1.3, < 4, != 2)
devise
railties (< 7.0)
rotp (~> 6)
GIT
remote: https://github.com/Gargron/nsa
revision: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
ref: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
specs:
nsa (0.2.8)
activesupport (>= 4.2, < 7)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
GIT
remote: https://github.com/nsommer/pluck_each
revision: 73be0947c52fc54bf6d7085378db008358aac5eb
ref: 73be0947c52fc54bf6d7085378db008358aac5eb
specs:
pluck_each (0.1.3)
activerecord (>= 6.1.0)
activesupport (>= 6.1.0)
GIT
remote: https://github.com/witgo/nilsimsa
revision: fd184883048b922b176939f851338d0a4971a532
ref: fd184883048b922b176939f851338d0a4971a532
specs:
nilsimsa (1.1.2)
GEM
remote: https://rubygems.org/
specs:
@ -120,8 +81,8 @@ GEM
cocaine (~> 0.5.3)
awrence (1.1.1)
aws-eventstream (1.1.1)
aws-partitions (1.436.0)
aws-sdk-core (3.113.0)
aws-partitions (1.445.0)
aws-sdk-core (3.114.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
@ -129,7 +90,7 @@ GEM
aws-sdk-kms (1.43.0)
aws-sdk-core (~> 3, >= 3.112.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.93.0)
aws-sdk-s3 (1.93.1)
aws-sdk-core (~> 3, >= 3.112.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
@ -192,15 +153,15 @@ GEM
elasticsearch (>= 2.0.0)
elasticsearch-dsl
chunky_png (1.3.15)
cld3 (3.4.1)
ffi (>= 1.1.0, < 1.15.0)
cld3 (3.4.2)
ffi (>= 1.1.0, < 1.16.0)
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.3)
color_diff (0.1)
concurrent-ruby (1.1.8)
connection_pool (2.2.3)
connection_pool (2.2.5)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
@ -216,6 +177,12 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (4.0.0)
activesupport (< 6.2)
attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0)
railties (< 6.2)
rotp (~> 6.0)
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
@ -225,7 +192,7 @@ GEM
docile (1.3.4)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.5.0)
doorkeeper (5.5.1)
railties (>= 5)
dotenv (2.7.6)
dotenv-rails (2.7.6)
@ -257,7 +224,7 @@ GEM
faraday-net_http (1.0.1)
fast_blank (1.0.0)
fastimage (2.2.3)
ffi (1.14.2)
ffi (1.15.0)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@ -313,7 +280,7 @@ GEM
httplog (1.4.3)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.8.9)
i18n (1.8.10)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.34)
activesupport (>= 4.0.2)
@ -369,7 +336,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.9.0)
loofah (2.9.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -401,12 +368,17 @@ GEM
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0)
nio4r (2.5.7)
nokogiri (1.11.2)
nokogiri (1.11.3)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
nokogumbo (2.0.4)
nokogiri (~> 1.8, >= 1.8.4)
oj (3.11.3)
nsa (0.2.8)
activesupport (>= 4.2, < 7)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.11.5)
omniauth (1.9.1)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
@ -434,9 +406,9 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.20.1)
parallel_tests (3.6.0)
parallel_tests (3.7.0)
parallel
parser (3.0.0.0)
parser (3.0.1.0)
ast (~> 2.4.1)
parslet (2.0.0)
pastel (0.8.0)
@ -444,7 +416,7 @@ GEM
pg (1.2.3)
pghero (2.8.1)
activerecord (>= 5)
pkg-config (1.4.5)
pkg-config (1.4.6)
posix-spawn (0.3.15)
premailer (1.14.2)
addressable
@ -530,7 +502,7 @@ GEM
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.4)
rexml (3.2.5)
rotp (6.2.0)
rpam2 (4.0.2)
rqrcode (1.2.0)
@ -591,7 +563,7 @@ GEM
railties (>= 4.0.0)
securecompare (1.0.0)
semantic_range (2.3.0)
sidekiq (6.2.0)
sidekiq (6.2.1)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
@ -604,7 +576,7 @@ GEM
sidekiq (>= 3)
thwait
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.0.7)
sidekiq-unique-jobs (7.0.8)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 7.0)
@ -651,7 +623,7 @@ GEM
openssl-signature_algorithm (~> 0.4.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.0)
tty-prompt (0.23.1)
pastel (~> 0.8)
tty-reader (~> 0.8)
tty-reader (0.9.0)
@ -728,13 +700,13 @@ DEPENDENCIES
capybara (~> 3.35)
charlock_holmes (~> 0.7.7)
chewy (~> 5.2)
cld3 (~> 3.4.1)
cld3 (~> 3.4.2)
climate_control (~> 0.2)
color_diff (~> 0.1)
concurrent-ruby
connection_pool
devise (~> 4.7)
devise-two-factor!
devise-two-factor (~> 4.0)
devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.2)
doorkeeper (~> 5.5)
@ -769,9 +741,8 @@ DEPENDENCIES
microformats (~> 4.2)
mime-types (~> 3.3.1)
net-ldap (~> 0.17)
nilsimsa!
nokogiri (~> 1.11)
nsa!
nsa (~> 0.2)
oj (~> 3.11)
omniauth (~> 1.9)
omniauth-cas (~> 2.0)
@ -781,12 +752,11 @@ DEPENDENCIES
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel (~> 1.20)
parallel_tests (~> 3.6)
parallel_tests (~> 3.7)
parslet
pg (~> 1.2)
pghero (~> 2.8)
pkg-config (~> 1.4)
pluck_each!
posix-spawn
premailer-rails
private_address_check (~> 0.5)
@ -834,5 +804,5 @@ DEPENDENCIES
webauthn (~> 3.0.0.alpha1)
webmock (~> 3.12)
webpacker (~> 5.2)
webpush
webpush (~> 0.3)
xorcist (~> 1.1)

@ -36,7 +36,6 @@ module Admin
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@keybase_integration = Setting.enable_keybase
@spam_check_enabled = Setting.spam_check_enabled
@trends_enabled = Setting.trends
end

@ -0,0 +1,53 @@
# frozen_string_literal: true
module Admin
class FollowRecommendationsController < BaseController
before_action :set_language
def show
authorize :follow_recommendation, :show?
@form = Form::AccountBatch.new
@accounts = filtered_follow_recommendations
end
def update
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
# Do nothing
ensure
redirect_to admin_follow_recommendations_path(filter_params)
end
private
def set_language
@language = follow_recommendation_filter.language
end
def filtered_follow_recommendations
follow_recommendation_filter.results
end
def follow_recommendation_filter
@follow_recommendation_filter ||= FollowRecommendationFilter.new(filter_params)
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def filter_params
params.slice(*FollowRecommendationFilter::KEYS).permit(*FollowRecommendationFilter::KEYS)
end
def action_from_button
if params[:suppress]
'suppress_follow_recommendation'
elsif params[:unsuppress]
'unsuppress_follow_recommendation'
end
end
end
end

@ -3,13 +3,13 @@
class Api::V1::Push::SubscriptionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :push }
before_action :require_user!
before_action :set_web_push_subscription
before_action :check_web_push_subscription, only: [:show, :update]
before_action :set_push_subscription
before_action :check_push_subscription, only: [:show, :update]
def create
@web_subscription&.destroy!
@push_subscription&.destroy!
@web_subscription = ::Web::PushSubscription.create!(
@push_subscription = Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth],
@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
access_token_id: doorkeeper_token.id
)
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end
def show
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end
def update
@web_subscription.update!(data: data_params)
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
@push_subscription.update!(data: data_params)
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end
def destroy
@web_subscription&.destroy!
@push_subscription&.destroy!
render_empty
end
private
def set_web_push_subscription
@web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
def set_push_subscription
@push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
end
def check_web_push_subscription
not_found if @web_subscription.nil?
def check_push_subscription
not_found if @push_subscription.nil?
end
def subscription_params
@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params
return {} if params[:data].blank?
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end
end

@ -19,6 +19,6 @@ class Api::V1::SuggestionsController < Api::BaseController
private
def set_accounts
@accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
@accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
end
end

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Api::V2::SuggestionsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
before_action :set_suggestions
def index
render json: @suggestions, each_serializer: REST::SuggestionSerializer
end
private
def set_suggestions
@suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
end
end

@ -2,6 +2,7 @@
class Api::Web::PushSubscriptionsController < Api::Web::BaseController
before_action :require_user!
before_action :set_push_subscription, only: :update
def create
active_session = current_session
@ -15,9 +16,11 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet?
data = {
policy: 'all',
alerts: {
follow: alerts_enabled,
follow_request: false,
follow_request: alerts_enabled,
favourite: alerts_enabled,
reblog: alerts_enabled,
mention: alerts_enabled,
@ -28,7 +31,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
data.deep_merge!(data_params) if params[:data]
web_subscription = ::Web::PushSubscription.create!(
push_subscription = ::Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth],
@ -37,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
access_token_id: active_session.access_token_id
)
active_session.update!(web_push_subscription: web_subscription)
active_session.update!(web_push_subscription: push_subscription)
render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer
end
def update
params.require([:id])
web_subscription = ::Web::PushSubscription.find(params[:id])
web_subscription.update!(data: data_params)
render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
@push_subscription.update!(data: data_params)
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end
private
def set_push_subscription
@push_subscription = ::Web::PushSubscription.find(params[:id])
end
def subscription_params
@subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
end
def data_params
@data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
@data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end
end

@ -91,8 +91,6 @@ module ApplicationHelper
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
elsif status.private_visibility? || status.limited_visibility?
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
elsif status.direct_visibility?
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
end
end

@ -0,0 +1,18 @@
# frozen_string_literal: true
module EmailHelper
def self.included(base)
base.extend(self)
end
def email_to_canonical_email(str)
username, domain = str.downcase.split('@', 2)
username, = username.gsub('.', '').split('+', 2)
"#{username}@#{domain}"
end
def email_to_canonical_email_hash(str)
Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
end
end

@ -24,6 +24,7 @@ export function normalizeAccount(account) {
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
account.note_emojified = emojify(account.note, emojiMap);
account.note_plain = unescapeHTML(account.note);
if (account.fields) {
account.fields = account.fields.map(pair => ({

@ -1,21 +1,8 @@
import { changeSetting, saveSettings } from './settings';
import { requestBrowserPermission } from './notifications';
export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings());
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
};

@ -1,5 +1,6 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
@ -7,13 +8,17 @@ export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions() {
export function fetchSuggestions(withRelationships = false) {
return (dispatch, getState) => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v1/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data));
api(getState).get('/api/v2/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
if (withRelationships) {
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
}
}).catch(error => dispatch(fetchSuggestionsFail(error)));
};
};
@ -25,10 +30,10 @@ export function fetchSuggestionsRequest() {
};
};
export function fetchSuggestionsSuccess(accounts) {
export function fetchSuggestionsSuccess(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
accounts,
suggestions,
skipLoading: true,
};
};
@ -48,5 +53,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
id: accountId,
});
api(getState).delete(`/api/v1/suggestions/${accountId}`);
api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v2/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
}).catch(error => dispatch(fetchSuggestionsFail(error)));
}).catch(() => {});
};

@ -78,8 +78,10 @@ class Account extends ImmutablePureComponent {
let buttons;
if (onActionClick && actionIcon) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
if (actionIcon) {
if (onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
}
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);

@ -0,0 +1,9 @@
import React from 'react';
const Logo = () => (
<svg viewBox='0 0 216.4144 232.00976' className='logo'>
<use xlinkHref='#mastodon-svg-logo' />
</svg>
);
export default Logo;

@ -1,12 +1,10 @@
import React from 'react';
import { Provider, connect } from 'react-redux';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import { INTRODUCTION_VERSION } from '../actions/onboarding';
import { BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll-4';
import UI from '../features/ui';
import Introduction from '../features/introduction';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming';
@ -26,39 +24,6 @@ const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
store.dispatch(fetchCustomEmojis());
const mapStateToProps = state => ({
showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
});
@connect(mapStateToProps)
class MastodonMount extends React.PureComponent {
static propTypes = {
showIntroduction: PropTypes.bool,
};
shouldUpdateScroll (_, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState;
}
render () {
const { showIntroduction } = this.props;
if (showIntroduction) {
return <Introduction />;
}
return (
<BrowserRouter basename='/web'>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>
</BrowserRouter>
);
}
}
export default class Mastodon extends React.PureComponent {
static propTypes = {
@ -76,6 +41,10 @@ export default class Mastodon extends React.PureComponent {
}
}
shouldUpdateScroll (_, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState;
}
render () {
const { locale } = this.props;
@ -83,7 +52,11 @@ export default class Mastodon extends React.PureComponent {
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<ErrorBoundary>
<MastodonMount />
<BrowserRouter basename='/web'>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>
</BrowserRouter>
</ErrorBoundary>
</Provider>
</IntlProvider>

@ -51,12 +51,12 @@ class SearchResults extends ImmutablePureComponent {
<FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
</div>
{suggestions && suggestions.map(accountId => (
{suggestions && suggestions.map(suggestion => (
<AccountContainer
key={accountId}
id={accountId}
actionIcon='times'
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
key={suggestion.get('account')}
id={suggestion.get('account')}
actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
onActionClick={dismissSuggestion}
/>
))}

@ -11,7 +11,7 @@ const emojiFilenames = (emojis) => {
};
// Emoji requiring extra borders depending on theme
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼', '◾', '◼', '✒', '▪', '💣', '🎳', '📷', '📸', '♣', '🕶', '✴', '🔌', '💂', '📽', '🍳', '🦍', '💂', '🔪', '🕳', '🕹', '🕋', '🖊', '🖋', '💂', '🎤', '🎓', '🎥', '🎼', '♠', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲']);
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼', '◾', '◼', '✒', '▪', '💣', '🎳', '📷', '📸', '♣', '🕶', '✴', '🔌', '💂', '📽', '🍳', '🦍', '💂', '🔪', '🕳', '🕹', '🕋', '🖊', '🖋', '💂', '🎤', '🎓', '🎥', '🎼', '♠', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲']);
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁', '💨', '🕊', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸', '🌩', '🔊', '🔇', '📃', '🌧', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠', '🌨', '🔉', '🔈', '💬', '💭', '🏐', '🏳', '⚪', '⬜', '◽', '◻', '▫']);
const emojiFilename = (filename) => {

@ -0,0 +1,85 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import IconButton from 'mastodon/components/icon_button';
import { injectIntl, defineMessages } from 'react-intl';
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const getFirstSentence = str => {
const arr = str.split(/(([\.\?!]+\s)|[.。?!\n•])/);
return arr[0];
};
export default @connect(makeMapStateToProps)
@injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleFollow = () => {
const { account, dispatch } = this.props;
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
}
render () {
const { account, intl } = this.props;
let button;
if (account.getIn(['relationship', 'following'])) {
button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
}
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}

@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { changeSetting, saveSettings } from 'mastodon/actions/settings';
import { requestBrowserPermission } from 'mastodon/actions/notifications';
import Column from 'mastodon/features/ui/components/column';
import Account from './components/account';
import Logo from 'mastodon/components/logo';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Button from 'mastodon/components/button';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
export default @connect(mapStateToProps)
class FollowRecommendations extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
};
componentDidMount () {
const { dispatch, suggestions } = this.props;
// Don't re-fetch if we're e.g. navigating backwards to this page,
// since we don't want followed accounts to disappear from the list
if (suggestions.size === 0) {
dispatch(fetchSuggestions(true));
}
}
handleDone = () => {
const { dispatch } = this.props;
const { router } = this.context;
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
router.history.push('/timelines/home');
}
render () {
const { suggestions, isLoading } = this.props;
return (
<Column>
<div className='scrollable'>
<div className='column-title'>
<Logo />
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
</div>
{!isLoading && (
<React.Fragment>
<div>
{suggestions.map(suggestion => (
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
))}
</div>
<div className='column-actions'>
<img src={imageGreeting} alt='' className='column-actions__background' />
<Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
</div>
</React.Fragment>
)}
</div>
</Column>
);
}
}

@ -51,10 +51,12 @@ import {
Lists,
Search,
Directory,
FollowRecommendations,
} from './util/async-components';
import { me } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal';
import { previewState as previewVideoState } from './components/video_modal';
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
@ -71,6 +73,7 @@ const mapStateToProps = state => ({
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
});
const keyMap = {
@ -167,6 +170,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
<WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
@ -215,6 +219,7 @@ class UI extends React.PureComponent {
intl: PropTypes.object.isRequired,
dropdownMenuIsOpen: PropTypes.bool,
layout: PropTypes.string.isRequired,
firstLaunch: PropTypes.bool,
};
state = {
@ -350,6 +355,12 @@ class UI extends React.PureComponent {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
}
// On first launch, redirect to the follow recommendations page
if (this.props.firstLaunch) {
this.context.router.history.replace('/start');
this.props.dispatch(closeOnboarding());
}
this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());

@ -153,3 +153,7 @@ export function Audio () {
export function Directory () {
return import(/* webpackChunkName: "features/directory" */'../../directory');
}
export function FollowRecommendations () {
return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
}

@ -19,18 +19,18 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', true);
case SUGGESTIONS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', fromJS(action.accounts.map(x => x.id)));
map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
map.set('isLoading', false);
});
case SUGGESTIONS_FETCH_FAIL:
return state.set('isLoading', false);
case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(id => id === action.id));
return state.update('items', list => list.filterNot(x => x.account === action.id));
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return state.update('items', list => list.filterNot(id => id === action.relationship.id));
return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
case DOMAIN_BLOCK_SUCCESS:
return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
default:
return state;
}

@ -1307,6 +1307,29 @@
overflow: hidden;
text-decoration: none;
font-size: 14px;
&--with-note {
strong {
display: inline;
}
}
}
&__note {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $ui-secondary-color;
}
}
.follow-recommendations-account {
.icon-button {
color: $ui-primary-color;
&.active {
color: $valid-value-color;
}
}
}
@ -2459,6 +2482,49 @@ a.account__display-name {
border-color: darken($ui-base-color, 8%);
}
.column-title {
text-align: center;
padding: 40px;
.logo {
fill: $primary-text-color;
width: 50px;
margin: 0 auto;
margin-bottom: 40px;
}
h3 {
font-size: 24px;
line-height: 1.5;
font-weight: 700;
margin-bottom: 10px;
}
p {
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: $darker-text-color;
}
}
.column-actions {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
padding-top: 40px;
padding-bottom: 200px;
&__background {
position: absolute;
left: 0;
bottom: 0;
height: 220px;
width: auto;
}
}
.compose-panel {
width: 285px;
margin-top: 10px;

@ -0,0 +1,25 @@
# frozen_string_literal: true
class AccountReachFinder
def initialize(account)
@account = account
end
def inboxes
(followers_inboxes + reporters_inboxes + relay_inboxes).uniq
end
private
def followers_inboxes
@account.followers.inboxes
end
def reporters_inboxes
Account.where(id: @account.targeted_reports.select(:account_id)).inboxes
end
def relay_inboxes
Relay.enabled.pluck(:inbox_url)
end
end