diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 00000000..32d8f2d0 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,57 @@ +--- +name: Build container image + +on: + workflow_dispatch: + push: + branches: [ main ] + tags: [ '*' ] + pull_request: + paths: + - .github/workflows/build-image.yml + - Containerfile + +jobs: + build-image: + runs-on: ubuntu-latest + + concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v3.3.0 + + - name: Discover build-time variables + run: | + echo "RUBY_VERSION=$(cat .ruby-version)" >> $GITHUB_ENV + echo "BUNDLER_VERSION=$(egrep -A1 "^BUNDLED WITH" Gemfile.lock | tr -d '\n' | awk '{ print $3; }')" >> $GITHUB_ENV + case "${{ github.ref_name }}" in + */merge) + # use commit id as version for pull requests + echo "RETROSPRING_VERSION=${{ github.sha }}" >> $GITHUB_ENV + ;; + *) + # use tags and branches as version otherwise + echo "RETROSPRING_VERSION=${{ github.ref_name }}" >> $GITHUB_ENV + ;; + esac + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + if: github.event_name != 'pull_request' + + - name: Build and push + uses: docker/build-push-action@v4 + with: + build-args: | + BUNDLER_VERSION=${{ env.BUNDLER_VERSION }} + RETROSPRING_VERSION=${{ env.RETROSPRING_VERSION }} + RUBY_VERSION=${{ env.RUBY_VERSION }} + context: . + file: Containerfile + push: ${{ github.event_name != 'pull_request' }} + tags: retrospring/retrospring:${{ env.RETROSPRING_VERSION }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 321573da..4916381e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3.3.0 + - name: Install dependencies + run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -46,6 +48,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3.3.0 + - name: Install dependencies + run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.rubocop.yml b/.rubocop.yml index 6091bca9..acd3db74 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -131,3 +131,6 @@ Style/Encoding: Style/EndlessMethod: EnforcedStyle: allow_always + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma diff --git a/.stylelintrc.json b/.stylelintrc.json index b8f0eb23..2aa477b8 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -9,6 +9,7 @@ "scss/at-import-partial-extension": null, "scss/at-rule-no-unknown": true, "at-rule-no-unknown": null, + "color-function-notation": null, "color-hex-length": "long", "color-hex-case": "upper", "comment-whitespace-inside": null, diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..2e740498 --- /dev/null +++ b/Containerfile @@ -0,0 +1,79 @@ +# Container image for a production Retrospring setup + +FROM registry.opensuse.org/opensuse/leap:15.4 + +ARG RETROSPRING_VERSION=2023.0131.1 +ARG RUBY_VERSION=3.1.2 +ARG RUBY_INSTALL_VERSION=0.9.0 +ARG BUNDLER_VERSION=2.3.18 + +ENV RAILS_ENV=production + +# update and install dependencies +RUN zypper up -y \ + && zypper in -y \ + # build dependencies (ruby-install) + automake \ + gcc \ + gdbm-devel \ + gzip \ + libffi-devel \ + libopenssl-devel \ + libyaml-devel \ + make \ + ncurses-devel \ + readline-devel \ + tar \ + xz \ + zlib-devel \ + # build dependencies (app) + gcc-c++ \ + git \ + libidn-devel \ + nodejs14 \ + npm14 \ + postgresql-devel \ + # runtime dependencies + ImageMagick \ + # cleanup repos + && zypper clean -a \ + # install yarn as another build dependency + && npm install -g yarn + +# install Ruby via ruby-install +RUN curl -Lo ruby-install-${RUBY_INSTALL_VERSION}.tar.gz https://github.com/postmodern/ruby-install/archive/v${RUBY_INSTALL_VERSION}.tar.gz \ + && tar xvf ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \ + && (cd ruby-install-${RUBY_INSTALL_VERSION} && make install) \ + && rm -rf ruby-install-${RUBY_INSTALL_VERSION} ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \ + && ruby-install --no-install-deps --cleanup --system --jobs=$(nproc) ruby ${RUBY_VERSION} -- --disable-install-rdoc \ + && gem install bundler:${BUNDLER_VERSION} + +# create user and dirs to run retrospring in +RUN useradd -m justask \ + && install -o justask -g users -m 0755 -d /opt/retrospring/app \ + && install -o justask -g users -m 0755 -d /opt/retrospring/bundle + +WORKDIR /opt/retrospring/app +USER justask:users + +# install the app +RUN curl -L https://github.com/Retrospring/retrospring/archive/${RETROSPRING_VERSION}.tar.gz | tar xz --strip-components=1 + +RUN bundle config set without 'development test' \ + && bundle config set path '/opt/retrospring/bundle' \ + && bundle install --jobs=$(nproc) \ + && yarn install --frozen-lockfile + +# temporarily set a SECRET_KEY_BASE and copy config files so rake tasks can run +ARG SECRET_KEY_BASE=secret_for_build +RUN cp config/justask.yml.example config/justask.yml \ + && cp config/database.yml.postgres config/database.yml \ + && bundle exec rails locale:generate \ + && bundle exec i18n export \ + && bundle exec rails assets:precompile \ + && rm config/justask.yml config/database.yml + +# set some defaults +ENV RAILS_LOG_TO_STDOUT=true + +EXPOSE 3000 diff --git a/Gemfile b/Gemfile index b1387ede..48ecdf45 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,7 @@ gem "active_model_otp" gem "bootsnap", require: false gem "bootstrap_form", "~> 5.0" gem "carrierwave", "~> 2.0" -gem "carrierwave_backgrounder", git: "https://github.com/mltnhm/carrierwave_backgrounder.git" +gem "carrierwave_backgrounder", git: "https://github.com/raccube/carrierwave_backgrounder.git" gem "colorize" gem "devise", "~> 4.0" gem "devise-async" @@ -58,14 +58,9 @@ gem "httparty" gem "redcarpet" gem "sanitize" -# OmniAuth and providers -gem "omniauth" -gem "omniauth-twitter" - -# OAuth clients -gem "twitter" gem "twitter-text" +gem "connection_pool" gem "redis" gem "fake_email_validator" @@ -73,7 +68,7 @@ gem "fake_email_validator" # TLD validation gem "tldv", "~> 0.1.0" -gem "jwt", "~> 2.6" +gem "jwt", "~> 2.7" group :development do gem "binding_of_caller" @@ -97,7 +92,7 @@ group :development, :test do gem "rspec-mocks" gem "rspec-rails", "~> 6.0" gem "rspec-sidekiq", "~> 3.0", require: false - gem "rubocop", "~> 1.44" + gem "rubocop", "~> 1.45" gem "rubocop-rails", "~> 2.17" gem "shoulda-matchers", "~> 5.3" gem "simplecov", require: false @@ -109,8 +104,6 @@ group :production do gem "lograge" end -gem "omniauth-rails_csrf_protection", "~> 1.0" - gem "net-imap" gem "net-pop" gem "net-smtp" diff --git a/Gemfile.lock b/Gemfile.lock index 21bf8124..94439de4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT - remote: https://github.com/mltnhm/carrierwave_backgrounder.git - revision: 8fe468957f047ad7039f07679e5952a534d07b6d + remote: https://github.com/raccube/carrierwave_backgrounder.git + revision: 41b756f7514c0e410c561bc8b5ee321cd8cce1ee specs: carrierwave_backgrounder (0.4.2) carrierwave (>= 0.5, <= 2.1) @@ -96,7 +96,6 @@ GEM bootstrap_form (5.1.0) actionpack (>= 5.2) activemodel (>= 5.2) - buftok (0.2.0) builder (3.2.4) bullet (7.0.7) activesupport (>= 3.0.0) @@ -137,8 +136,6 @@ GEM devise (>= 4.8.0) diff-lcs (1.5.0) docile (1.4.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) dry-core (1.0.0) concurrent-ruby (~> 1.0) zeitwerk (~> 2.6) @@ -154,9 +151,8 @@ GEM dry-inflector (~> 1.0, < 2) dry-logic (>= 1.4, < 2) zeitwerk (~> 2.6) - equalizer (0.0.11) erubi (1.12.0) - excon (0.98.0) + excon (0.99.0) factory_bot (6.2.0) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) @@ -165,13 +161,10 @@ GEM fake_email_validator (1.0.11) activemodel mail - faker (3.1.0) + faker (3.1.1) i18n (>= 1.8.11, < 2) ffi (1.15.5) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) - rake - fog-aws (3.16.0) + fog-aws (3.17.0) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -202,22 +195,10 @@ GEM rainbow rubocop (>= 0.50.0) sysexits (~> 1.1) - hashie (5.0.0) hcaptcha (7.1.0) json hkdf (0.3.0) http-2 (0.11.0) - http (4.4.1) - addressable (~> 2.3) - http-cookie (~> 1.0) - http-form_data (~> 2.2) - http-parser (~> 1.2.0) - http-cookie (1.0.4) - domain_name (~> 0.5) - http-form_data (2.3.0) - http-parser (1.2.3) - ffi-compiler (>= 1.0, < 2.0) - http_parser.rb (0.6.0) httparty (0.21.0) mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) @@ -235,7 +216,7 @@ GEM json (2.6.3) json-schema (3.0.0) addressable (>= 2.8) - jwt (2.6.0) + jwt (2.7.0) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -263,8 +244,6 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.2) - memoizable (0.4.2) - thread_safe (~> 0.3, >= 0.3.1) method_source (1.0.0) mime-types (3.4.1) mime-types-data (~> 3.2015) @@ -279,8 +258,6 @@ GEM msgpack (1.6.0) multi_json (1.15.0) multi_xml (0.6.0) - multipart-post (2.1.1) - naught (1.1.0) nested_form (0.3.2) net-http-persistent (4.0.1) connection_pool (~> 2.2) @@ -296,41 +273,25 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.8) - nokogiri (1.14.0) + nokogiri (1.14.1) mini_portile2 (~> 2.8.0) racc (~> 1.4) - oauth (0.5.8) - oj (3.13.23) - omniauth (2.1.0) - hashie (>= 3.4.6) - rack (>= 2.2.3) - rack-protection - omniauth-oauth (1.2.0) - oauth - omniauth (>= 1.0, < 3) - omniauth-rails_csrf_protection (1.0.1) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth-twitter (1.4.0) - omniauth-oauth (~> 1.1) - rack + oj (3.14.2) openssl (3.1.0) orm_adapter (0.5.0) parallel (1.22.1) - parser (3.2.0.0) + parser (3.2.1.0) ast (~> 2.4.1) pg (1.4.5) pghero (3.1.0) activerecord (>= 6) public_suffix (4.0.7) - puma (6.0.2) + puma (6.1.0) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) racc (1.6.2) rack (2.2.6.2) - rack-protection (3.0.5) - rack rack-test (2.0.2) rack (>= 1.3) rails (6.1.7.2) @@ -376,14 +337,14 @@ GEM rake (13.0.6) redcarpet (3.6.0) redis (4.8.0) - regexp_parser (2.6.2) + regexp_parser (2.7.0) request_store (1.5.1) rack (>= 1.4) responders (3.0.1) actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.5) - rolify (6.0.0) + rolify (6.0.1) rotp (6.2.0) rpush (7.0.1) activesupport (>= 5.2) @@ -422,7 +383,7 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.12.0) - rubocop (1.44.1) + rubocop (1.45.1) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) @@ -432,8 +393,8 @@ GEM rubocop-ast (>= 1.24.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.24.1) - parser (>= 3.1.1.0) + rubocop-ast (1.26.0) + parser (>= 3.2.1.0) rubocop-rails (2.17.4) activesupport (>= 4.2.0) rack (>= 1.1) @@ -453,13 +414,13 @@ GEM sprockets (> 3.0) sprockets-rails tilt - sentry-rails (5.7.0) + sentry-rails (5.8.0) railties (>= 5.0) - sentry-ruby (~> 5.7.0) - sentry-ruby (5.7.0) + sentry-ruby (~> 5.8.0) + sentry-ruby (5.8.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.7.0) - sentry-ruby (~> 5.7.0) + sentry-sidekiq (5.8.0) + sentry-ruby (~> 5.8.0) sidekiq (>= 3.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) @@ -467,7 +428,6 @@ GEM connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) - simple_oauth (0.3.1) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -491,27 +451,15 @@ GEM sysexits (1.2.0) temple (0.10.0) thor (1.2.1) - thread_safe (0.3.6) tilt (2.0.11) timeout (0.3.1) tldv (0.1.0) tldv-data (~> 1.0) tldv-data (1.0.2022121701) - turbo-rails (1.3.2) + turbo-rails (1.3.3) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) - twitter (7.0.0) - addressable (~> 2.3) - buftok (~> 0.2.0) - equalizer (~> 0.0.11) - http (~> 4.0) - http-form_data (~> 2.0) - http_parser.rb (~> 0.6.0) - memoizable (~> 0.4.0) - multipart-post (~> 2.0) - naught (~> 1.0) - simple_oauth (~> 0.3.0) twitter-text (3.1.0) idn-ruby unf (~> 0.1.0) @@ -546,6 +494,7 @@ DEPENDENCIES carrierwave (~> 2.0) carrierwave_backgrounder! colorize + connection_pool cssbundling-rails (~> 1.1) database_cleaner devise (~> 4.0) @@ -566,7 +515,7 @@ DEPENDENCIES i18n-js (= 4.0) jsbundling-rails (~> 1.1) json-schema - jwt (~> 2.6) + jwt (~> 2.7) letter_opener lograge mail (~> 2.7.1) @@ -575,9 +524,6 @@ DEPENDENCIES net-pop net-smtp oj - omniauth - omniauth-rails_csrf_protection (~> 1.0) - omniauth-twitter openssl (~> 3.1) pg pghero @@ -598,7 +544,7 @@ DEPENDENCIES rspec-mocks rspec-rails (~> 6.0) rspec-sidekiq (~> 3.0) - rubocop (~> 1.44) + rubocop (~> 1.45) rubocop-rails (~> 2.17) ruby-progressbar rubyzip (~> 2.3) @@ -617,7 +563,6 @@ DEPENDENCIES sprockets-rails tldv (~> 0.1.0) turbo-rails - twitter twitter-text BUNDLED WITH diff --git a/app/assets/stylesheets/components/_answerbox.scss b/app/assets/stylesheets/components/_answerbox.scss index 2eae714c..adb1b732 100644 --- a/app/assets/stylesheets/components/_answerbox.scss +++ b/app/assets/stylesheets/components/_answerbox.scss @@ -82,11 +82,22 @@ } } + &__pinned { + display: none; + } + .card-body { padding-bottom: .6rem; } } +#pinned-answers { + + .answerbox__pinned { + display: inline; + } +} + body:not(.cap-web-share) { [name="ab-share"] { display: none; diff --git a/app/assets/stylesheets/components/_inbox-entry.scss b/app/assets/stylesheets/components/_inbox-entry.scss index 3fd728a1..a56875cd 100644 --- a/app/assets/stylesheets/components/_inbox-entry.scss +++ b/app/assets/stylesheets/components/_inbox-entry.scss @@ -1,6 +1,10 @@ +@use "sass:map"; + .inbox-entry { $this: &; + position: relative; + &--new { box-shadow: 0 0.125rem 0.25rem var(--primary); @@ -22,6 +26,24 @@ } } + &__close { + position: absolute; + top: map.get($spacers, 3); + right: map.get($spacers, 3); + } + + &__sharing { + position: absolute; + display: flex; + background-color: rgba(var(--raised-bg-rgb), 0.9); + backdrop-filter: blur(3px); + border-radius: var(--card-inner-border-radius); + top: 0; + bottom: 0; + left: 0; + right: 0; + } + .format-help { opacity: .3; } @@ -30,7 +52,7 @@ &:hover .format-help { opacity: 1; } - + .card-header { position: relative; } diff --git a/app/controllers/ajax/answer_controller.rb b/app/controllers/ajax/answer_controller.rb index de9c611f..859ae794 100644 --- a/app/controllers/ajax/answer_controller.rb +++ b/app/controllers/ajax/answer_controller.rb @@ -1,11 +1,17 @@ +# frozen_string_literal: true + +require "cgi" + class Ajax::AnswerController < AjaxController + include SocialHelper::TwitterMethods + include SocialHelper::TumblrMethods + def create params.require :id params.require :answer - params.require :share params.require :inbox - inbox = (params[:inbox] == 'true') + inbox = (params[:inbox] == "true") if inbox inbox_entry = Inbox.find(params[:id]) @@ -31,20 +37,23 @@ class Ajax::AnswerController < AjaxController current_user.answer question, params[:answer] end - services = JSON.parse params[:share] - services.each do |service| - ShareWorker.perform_async(current_user.id, answer.id, service) - end - - @response[:status] = :okay @response[:message] = t(".success") @response[:success] = true - unless inbox - # this assign is needed because shared/_answerbox relies on it, I think - @question = 1 - @response[:render] = render_to_string(partial: 'answerbox', locals: { a: answer, show_question: false }) + + if current_user.sharing_enabled + @response[:sharing] = { + twitter: twitter_share_url(answer), + tumblr: tumblr_share_url(answer), + custom: CGI.escape(prepare_tweet(answer)) + } end + + return if inbox + + # this assign is needed because shared/_answerbox relies on it, I think + @question = 1 + @response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false }) end def destroy @@ -52,15 +61,13 @@ class Ajax::AnswerController < AjaxController answer = Answer.find(params[:answer]) - unless (current_user == answer.user) or (privileged? answer.user) + unless (current_user == answer.user) || (privileged? answer.user) @response[:status] = :nopriv @response[:message] = t(".nopriv") return end - if answer.user == current_user - Inbox.create!(user: answer.user, question: answer.question, new: true, returning: true) - end + Inbox.create!(user: answer.user, question: answer.question, new: true, returning: true) if answer.user == current_user answer.destroy @response[:status] = :okay diff --git a/app/controllers/ajax/question_controller.rb b/app/controllers/ajax/question_controller.rb index e86e1572..f3bef810 100644 --- a/app/controllers/ajax/question_controller.rb +++ b/app/controllers/ajax/question_controller.rb @@ -31,7 +31,7 @@ class Ajax::QuestionController < AjaxController UseCase::Question::CreateFollowers.call( source_user_id: current_user.id, content: params[:question], - author_identifier: AnonymousBlock.get_identifier(request.ip) + author_identifier: AnonymousBlock.get_identifier(request.remote_ip) ) return end @@ -41,7 +41,7 @@ class Ajax::QuestionController < AjaxController target_user_id: params[:rcpt], content: params[:question], anonymous: params[:anonymousQuestion], - author_identifier: AnonymousBlock.get_identifier(request.ip) + author_identifier: AnonymousBlock.get_identifier(request.remote_ip) ) end end diff --git a/app/controllers/anonymous_block_controller.rb b/app/controllers/anonymous_block_controller.rb index 1daf9d8c..a8c9a32f 100644 --- a/app/controllers/anonymous_block_controller.rb +++ b/app/controllers/anonymous_block_controller.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class AnonymousBlockController < ApplicationController + include TurboStreamable + before_action :authenticate_user! + turbo_stream_actions :create, :destroy + def create params.require :question @@ -16,12 +20,15 @@ class AnonymousBlockController < ApplicationController target_user: question.user ) - inbox_id = question.inboxes.first.id + inbox_id = question.inboxes.first&.id question.inboxes.first&.destroy respond_to do |format| format.turbo_stream do - render turbo_stream: turbo_stream.remove("inbox_#{inbox_id}") + render turbo_stream: [ + inbox_id ? turbo_stream.remove("inbox_#{inbox_id}") : nil, + render_toast(t(".success")) + ].compact end format.html { redirect_back(fallback_location: inbox_path) } @@ -38,7 +45,10 @@ class AnonymousBlockController < ApplicationController respond_to do |format| format.turbo_stream do - render turbo_stream: turbo_stream.remove("block_#{params[:id]}") + render turbo_stream: [ + turbo_stream.remove("block_#{params[:id]}"), + render_toast(t(".success")) + ] end format.html { redirect_back(fallback_location: settings_blocks_path) } diff --git a/app/controllers/answer_controller.rb b/app/controllers/answer_controller.rb index b63be49e..d5e70881 100644 --- a/app/controllers/answer_controller.rb +++ b/app/controllers/answer_controller.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class AnswerController < ApplicationController + before_action :authenticate_user!, only: %i[pin unpin] + + include TurboStreamable + + turbo_stream_actions :pin, :unpin + def show @answer = Answer.includes(comments: %i[user smiles], question: [:user], smiles: [:user]).find(params[:id]) @display_all = true @@ -16,4 +22,34 @@ class AnswerController < ApplicationController notif.update_all(new: false) unless notif.empty? end end + + def pin + answer = Answer.includes(:user).find(params[:id]) + UseCase::Answer::Pin.call(user: current_user, answer:) + + respond_to do |format| + format.html { redirect_to(user_path(username: current_user.screen_name)) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }), + render_toast(t(".success")) + ] + end + end + end + + def unpin + answer = Answer.includes(:user).find(params[:id]) + UseCase::Answer::Unpin.call(user: current_user, answer:) + + respond_to do |format| + format.html { redirect_to(user_path(username: current_user.screen_name)) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }), + render_toast(t(".success")) + ] + end + end + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 05b7ad28..64ed88c7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -60,7 +60,7 @@ class ApplicationController < ActionController::Base if current_user.present? Sentry.set_user({ id: current_user.id }) else - Sentry.set_user({ ip_address: request.ip }) + Sentry.set_user({ ip_address: request.remote_ip }) end end end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/controllers/concerns/turbo_streamable.rb b/app/controllers/concerns/turbo_streamable.rb new file mode 100644 index 00000000..5066bc05 --- /dev/null +++ b/app/controllers/concerns/turbo_streamable.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module TurboStreamable + extend ActiveSupport::Concern + + class_methods do + def turbo_stream_actions(*actions) + around_action :handle_error, only: actions + end + end + + def render_toast(message, success = true) + turbo_stream.append("toasts", partial: "shared/toast", locals: { message:, success: }) + end + + private + + def handle_error + yield + rescue Errors::Base => e + render_error I18n.t(e.locale_tag) + rescue KeyError, ActionController::ParameterMissing => e + render_error t("errors.parameter_error", parameter: e.instance_of?(KeyError) ? e.key : e.param.capitalize) + rescue Dry::Types::CoercionError, Dry::Types::ConstraintError + render_error t("errors.invalid_parameter") + rescue ActiveRecord::RecordNotFound + render_error t("errors.record_not_found") + end + + def render_error(message) + respond_to do |format| + format.turbo_stream do + render turbo_stream: render_toast(message, false) + end + end + end +end diff --git a/app/controllers/inbox_controller.rb b/app/controllers/inbox_controller.rb index a31080e8..d849dafc 100644 --- a/app/controllers/inbox_controller.rb +++ b/app/controllers/inbox_controller.rb @@ -18,12 +18,11 @@ class InboxController < ApplicationController @delete_id = find_delete_id @disabled = true if @inbox.empty? - services = current_user.services.to_a respond_to do |format| - format.html { render "show", locals: { services: } } + format.html { render "show" } format.turbo_stream do - render "show", locals: { services: }, layout: false, status: :see_other + render "show", layout: false, status: :see_other # rubocop disabled as just flipping a flag doesn't need to have validations to be run @inbox.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations @@ -38,11 +37,10 @@ class InboxController < ApplicationController user: current_user) inbox = Inbox.create!(user: current_user, question_id: question.id, new: true) - services = current_user.services respond_to do |format| format.turbo_stream do - render turbo_stream: turbo_stream.prepend("entries", partial: "inbox/entry", locals: { i: inbox, services: }) + render turbo_stream: turbo_stream.prepend("entries", partial: "inbox/entry", locals: { i: inbox }) inbox.update(new: false) end diff --git a/app/controllers/services_controller.rb b/app/controllers/services_controller.rb deleted file mode 100644 index deaa39a0..00000000 --- a/app/controllers/services_controller.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -class ServicesController < ApplicationController - before_action :authenticate_user! - before_action :mark_notifications_as_read, only: %i[index] - - def index - @services = current_user.services - end - - def create - service = Service.initialize_from_omniauth(omniauth_hash) - service.user = current_user - service_name = service.type.split("::").last.titleize - - if service.save - flash[:success] = t(".success", service: service_name) - else - flash[:error] = if service.errors.details[:uid]&.any? { |err| err[:error] == :taken } - t(".duplicate", service: service_name, app: APP_CONFIG["site_name"]) - else - t(".error", service: service_name) - end - end - - redirect_to origin || services_path - end - - def update - service = current_user.services.find(params[:id]) - service.post_tag = params[:service][:post_tag].tr("@", "") - if service.save - flash[:success] = t(".success") - else - flash[:error] = t(".error") - end - redirect_to services_path - end - - def failure - Rails.logger.info "oauth error: #{params.inspect}" - flash[:error] = t(".error") - redirect_to services_path - end - - def destroy - @service = current_user.services.find(params[:id]) - service_name = @service.type.split("::").last.titleize - @service.destroy - flash[:success] = t(".success", service: service_name) - redirect_to services_path - end - - private - - def origin - request.env["omniauth.origin"] - end - - def omniauth_hash - request.env["omniauth.auth"] - end - - def mark_notifications_as_read - Notification::ServiceTokenExpired - .where(recipient: current_user, new: true) - .update_all(new: false) # rubocop:disable Rails/SkipsModelValidations - end -end diff --git a/app/controllers/settings/mutes_controller.rb b/app/controllers/settings/mutes_controller.rb index 6d312ee6..1000b862 100644 --- a/app/controllers/settings/mutes_controller.rb +++ b/app/controllers/settings/mutes_controller.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class Settings::MutesController < ApplicationController + include TurboStreamable + before_action :authenticate_user! + turbo_stream_actions :create, :destroy + def index @users = current_user.muted_users @rules = MuteRule.where(user: current_user) @@ -15,7 +19,8 @@ class Settings::MutesController < ApplicationController format.turbo_stream do render turbo_stream: [ turbo_stream.replace("form", partial: "settings/mutes/form"), - turbo_stream.append("rules", partial: "settings/mutes/rule", locals: { rule: result[:resource] }) + turbo_stream.append("rules", partial: "settings/mutes/rule", locals: { rule: result[:resource] }), + render_toast(t(".success")) ] end @@ -32,7 +37,10 @@ class Settings::MutesController < ApplicationController respond_to do |format| format.turbo_stream do - render turbo_stream: turbo_stream.remove("rule_#{params[:id]}") + render turbo_stream: [ + turbo_stream.remove("rule_#{params[:id]}"), + render_toast(t(".success")) + ] end format.html { redirect_to settings_muted_path } diff --git a/app/controllers/settings/sharing_controller.rb b/app/controllers/settings/sharing_controller.rb new file mode 100644 index 00000000..76a7ca76 --- /dev/null +++ b/app/controllers/settings/sharing_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Settings::SharingController < ApplicationController + before_action :authenticate_user! + + def edit; end + + def update + user_attributes = params.require(:user).permit(:sharing_enabled, + :sharing_autoclose, + :sharing_custom_url) + if current_user.update(user_attributes) + flash.now[:success] = t(".success") + else + flash.now[:error] = t(".error") + end + + render :edit + end +end diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index 17926429..1963b496 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -7,6 +7,7 @@ class UserController < ApplicationController def show @answers = @user.cursored_answers(last_id: params[:last_id]) + @pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10) @answers_last_id = @answers.map(&:id).min @more_data_available = !@user.cursored_answers(last_id: @answers_last_id, size: 1).count.zero? diff --git a/app/controllers/well_known/node_info_controller.rb b/app/controllers/well_known/node_info_controller.rb index 8d6771bb..8faad0eb 100644 --- a/app/controllers/well_known/node_info_controller.rb +++ b/app/controllers/well_known/node_info_controller.rb @@ -8,7 +8,7 @@ class WellKnown::NodeInfoController < ApplicationController links: [ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", href: node_info_url - ] + ], } end @@ -21,12 +21,12 @@ class WellKnown::NodeInfoController < ApplicationController protocols: %i[], services: { inbound: inbound_services, - outbound: outbound_services + outbound: outbound_services, }, usage: usage_stats, # We don't implement this so we can always return true for now openRegistrations: true, - metadata: {} + metadata: {}, } end @@ -43,16 +43,12 @@ class WellKnown::NodeInfoController < ApplicationController def usage_stats { users: { - total: User.count - } + total: User.count, + }, } end def inbound_services = [] - def outbound_services - { - "twitter" => APP_CONFIG.dig("sharing", "twitter", "enabled") - }.select { |_service, available| available }.keys - end + def outbound_services = [] end diff --git a/app/javascript/retrospring/controllers/inbox_sharing_controller.ts b/app/javascript/retrospring/controllers/inbox_sharing_controller.ts new file mode 100644 index 00000000..48f0a905 --- /dev/null +++ b/app/javascript/retrospring/controllers/inbox_sharing_controller.ts @@ -0,0 +1,48 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['twitter', 'tumblr', 'custom']; + + declare readonly twitterTarget: HTMLAnchorElement; + declare readonly tumblrTarget: HTMLAnchorElement; + declare readonly customTarget: HTMLAnchorElement; + declare readonly hasCustomTarget: boolean; + + static values = { + config: Object, + autoClose: Boolean + }; + + declare readonly configValue: Record; + declare readonly autoCloseValue: boolean; + + connect(): void { + if (this.autoCloseValue) { + this.twitterTarget.addEventListener('click', () => this.close()); + this.tumblrTarget.addEventListener('click', () => this.close()); + + if (this.hasCustomTarget) { + this.customTarget.addEventListener('click', () => this.close()); + } + } + } + + configValueChanged(value: Record): void { + if (Object.keys(value).length === 0) { + return; + } + + this.element.classList.remove('d-none'); + + this.twitterTarget.href = this.configValue['twitter']; + this.tumblrTarget.href = this.configValue['tumblr']; + + if (this.hasCustomTarget) { + this.customTarget.href = `${this.customTarget.href}${this.configValue['custom']}`; + } + } + + close(): void { + (this.element.closest(".inbox-entry")).remove(); + } +} diff --git a/app/javascript/retrospring/controllers/toast_controller.ts b/app/javascript/retrospring/controllers/toast_controller.ts new file mode 100644 index 00000000..76b91eed --- /dev/null +++ b/app/javascript/retrospring/controllers/toast_controller.ts @@ -0,0 +1,17 @@ +import { Controller } from '@hotwired/stimulus'; +import { showNotification } from "utilities/notifications"; + +export default class extends Controller { + static values = { + message: String, + success: Boolean + }; + + declare readonly messageValue: string; + declare readonly successValue: boolean; + + connect(): void { + showNotification(this.messageValue, this.successValue); + this.element.remove(); + } +} diff --git a/app/javascript/retrospring/features/inbox/entry/answer.ts b/app/javascript/retrospring/features/inbox/entry/answer.ts index 14b6b25a..93b0cefc 100644 --- a/app/javascript/retrospring/features/inbox/entry/answer.ts +++ b/app/javascript/retrospring/features/inbox/entry/answer.ts @@ -9,16 +9,9 @@ export function answerEntryHandler(event: Event): void { element.disabled = true; - const shareTo = []; - inboxEntry.querySelectorAll('input[type=checkbox][name=ib-share]:checked') - .forEach((element: HTMLInputElement) => { - shareTo.push(element.getAttribute('data-service')); - }); - const data = { id: element.getAttribute('data-ib-id'), answer: inboxEntry.querySelector('textarea[name=ib-answer]')?.value, - share: JSON.stringify(shareTo), inbox: 'true' }; @@ -36,7 +29,14 @@ export function answerEntryHandler(event: Event): void { } updateDeleteButton(false); showNotification(data.message); - (inboxEntry as HTMLElement).remove(); + + const sharing = inboxEntry.querySelector('.inbox-entry__sharing'); + if (sharing != null) { + sharing.dataset.inboxSharingConfigValue = JSON.stringify(data.sharing); + } + else { + (inboxEntry as HTMLElement).remove(); + } }) .catch(err => { console.log(err); @@ -51,4 +51,4 @@ export function answerEntryInputHandler(event: KeyboardEvent): void { if (event.keyCode == 13 && (event.ctrlKey || event.metaKey)) { document.querySelector(`button[name="ib-answer"][data-ib-id="${inboxId}"]`).click(); } -} \ No newline at end of file +} diff --git a/app/javascript/retrospring/features/inbox/entry/index.ts b/app/javascript/retrospring/features/inbox/entry/index.ts index 54022129..423a8191 100644 --- a/app/javascript/retrospring/features/inbox/entry/index.ts +++ b/app/javascript/retrospring/features/inbox/entry/index.ts @@ -1,7 +1,6 @@ import registerEvents from 'utilities/registerEvents'; import { answerEntryHandler, answerEntryInputHandler } from './answer'; import { deleteEntryHandler } from './delete'; -import optionsEntryHandler from './options'; import { reportEventHandler } from './report'; export default (): void => { @@ -9,7 +8,6 @@ export default (): void => { { type: 'click', target: 'button[name="ib-answer"]', handler: answerEntryHandler, global: true }, { type: 'click', target: '[name="ib-destroy"]', handler: deleteEntryHandler, global: true }, { type: 'click', target: '[name=ib-report]', handler: reportEventHandler, global: true }, - { type: 'click', target: 'button[name=ib-options]', handler: optionsEntryHandler, global: true }, { type: 'keydown', target: 'textarea[name=ib-answer]', handler: answerEntryInputHandler, global: true } ]); } diff --git a/app/javascript/retrospring/features/inbox/entry/options.ts b/app/javascript/retrospring/features/inbox/entry/options.ts deleted file mode 100644 index 00a50905..00000000 --- a/app/javascript/retrospring/features/inbox/entry/options.ts +++ /dev/null @@ -1,16 +0,0 @@ -export default function optionsEntryHandler(event: Event): void { - const button = event.target as HTMLElement; - const inboxId = button.dataset.ibId; - - const options = document.querySelector(`#ib-options-${inboxId}`); - options.classList.toggle('d-none'); - - const buttonIcon = button.getElementsByTagName('i')[0]; - if (buttonIcon.classList.contains('fa-chevron-down')) { - buttonIcon.classList.remove('fa-chevron-down'); - buttonIcon.classList.add('fa-chevron-up'); - } else { - buttonIcon.classList.remove('fa-chevron-up'); - buttonIcon.classList.add('fa-chevron-down'); - } -} diff --git a/app/javascript/retrospring/features/user/action.ts b/app/javascript/retrospring/features/user/action.ts index d9605c3e..91ad152e 100644 --- a/app/javascript/retrospring/features/user/action.ts +++ b/app/javascript/retrospring/features/user/action.ts @@ -7,6 +7,7 @@ export function userActionHandler(event: Event): void { const button: HTMLButtonElement = event.target as HTMLButtonElement; const target = button.dataset.target; const action = button.dataset.action; + button.disabled = true; let targetURL, relationshipType; @@ -56,6 +57,7 @@ export function userActionHandler(event: Event): void { showErrorNotification(I18n.translate('frontend.error.message')); }) .finally(() => { + button.disabled = false; if (!success) return; switch (action) { diff --git a/app/javascript/retrospring/initializers/stimulus.ts b/app/javascript/retrospring/initializers/stimulus.ts index 64ba1a50..6239a1f9 100644 --- a/app/javascript/retrospring/initializers/stimulus.ts +++ b/app/javascript/retrospring/initializers/stimulus.ts @@ -8,6 +8,8 @@ import CollapseController from "retrospring/controllers/collapse_controller"; import ThemeController from "retrospring/controllers/theme_controller"; import CapabilitiesController from "retrospring/controllers/capabilities_controller"; import CropperController from "retrospring/controllers/cropper_controller"; +import InboxSharingController from "retrospring/controllers/inbox_sharing_controller"; +import ToastController from "retrospring/controllers/toast_controller"; /** * This module sets up Stimulus and our controllers @@ -26,5 +28,7 @@ export default function (): void { window['Stimulus'].register('collapse', CollapseController); window['Stimulus'].register('cropper', CropperController); window['Stimulus'].register('format-popup', FormatPopupController); + window['Stimulus'].register('inbox-sharing', InboxSharingController); window['Stimulus'].register('theme', ThemeController); + window['Stimulus'].register('toast', ToastController); } diff --git a/app/models/answer.rb b/app/models/answer.rb index 91a6c956..d1e3e645 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -14,6 +14,8 @@ class Answer < ApplicationRecord validates :question_id, uniqueness: { scope: :user_id } # rubocop:enable Rails/UniqueValidationWithoutIndex + scope :pinned, -> { where.not(pinned_at: nil) } + SHORT_ANSWER_MAX_LENGTH = 640 # rubocop:disable Rails/SkipsModelValidations @@ -56,4 +58,6 @@ class Answer < ApplicationRecord end def long? = content.length > SHORT_ANSWER_MAX_LENGTH + + def pinned? = pinned_at.present? end diff --git a/app/models/notification/service_token_expired.rb b/app/models/notification/service_token_expired.rb deleted file mode 100644 index bc61a820..00000000 --- a/app/models/notification/service_token_expired.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class Notification::ServiceTokenExpired < Notification -end diff --git a/app/models/notification/twitter_token_expired.rb b/app/models/notification/twitter_token_expired.rb deleted file mode 100644 index 9ddb8447..00000000 --- a/app/models/notification/twitter_token_expired.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class Notification::TwitterTokenExpired < Notification::ServiceTokenExpired -end diff --git a/app/models/service.rb b/app/models/service.rb deleted file mode 100644 index 7cdf4524..00000000 --- a/app/models/service.rb +++ /dev/null @@ -1,41 +0,0 @@ -class Service < ApplicationRecord - attr_accessor :provider, :info - - belongs_to :user - validates_uniqueness_of :uid, scope: :type - validates_length_of :post_tag, maximum: 20 - - class << self - - def first_from_omniauth(auth_hash) - @@auth = auth_hash - where(type: service_type, uid: options[:uid]).first - end - - def initialize_from_omniauth(auth_hash) - @@auth = auth_hash - service_type.constantize.new(options) - end - - private - - def auth - @@auth - end - - def service_type - "Services::#{options[:provider].camelize}" - end - - def options - { - nickname: auth['info']['nickname'], - access_token: auth['credentials']['token'], - access_secret: auth['credentials']['secret'], - uid: auth['uid'], - provider: auth['provider'], - info: auth['info'] - } - end - end -end diff --git a/app/models/services/twitter.rb b/app/models/services/twitter.rb deleted file mode 100644 index 388c316d..00000000 --- a/app/models/services/twitter.rb +++ /dev/null @@ -1,28 +0,0 @@ -class Services::Twitter < Service - include Rails.application.routes.url_helpers - include SocialHelper::TwitterMethods - - def provider - "twitter" - end - - def post(answer) - Rails.logger.debug "posting to Twitter {'answer' => #{answer.id}, 'user' => #{self.user_id}}" - post_tweet answer - end - - private - - def client - @client ||= Twitter::REST::Client.new( - consumer_key: APP_CONFIG['sharing']['twitter']['consumer_key'], - consumer_secret: APP_CONFIG['sharing']['twitter']['consumer_secret'], - access_token: self.access_token, - access_token_secret: self.access_secret - ) - end - - def post_tweet(answer) - client.update! prepare_tweet(answer, self.post_tag) - end -end diff --git a/app/models/user.rb b/app/models/user.rb index fb32d232..6370ed01 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,6 +12,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include User::PushNotificationMethods include User::ReactionMethods include User::RelationshipMethods + include User::SharingMethods include User::TimelineMethods include ActiveModel::OneTimePassword @@ -33,7 +34,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :comments, dependent: :destroy_async has_many :inboxes, dependent: :destroy_async has_many :smiles, class_name: "Appendable::Reaction", dependent: :destroy_async - has_many :services, dependent: :destroy_async has_many :notifications, foreign_key: :recipient_id, dependent: :destroy_async has_many :reports, dependent: :destroy_async has_many :lists, dependent: :destroy_async @@ -61,6 +61,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end validates :email, fake_email: true, typoed_email: true + validates :sharing_custom_url, allow_blank: true, valid_url: true validates :screen_name, presence: true, format: { with: SCREEN_NAME_REGEX, message: I18n.t("activerecord.validation.user.screen_name.format") }, diff --git a/app/models/user/expired_service_connection.rb b/app/models/user/expired_service_connection.rb deleted file mode 100644 index 110dd896..00000000 --- a/app/models/user/expired_service_connection.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -# stub model for notifying about expired service connections -class User::ExpiredServiceConnection < User -end diff --git a/app/models/user/expired_twitter_service_connection.rb b/app/models/user/expired_twitter_service_connection.rb deleted file mode 100644 index bb39250e..00000000 --- a/app/models/user/expired_twitter_service_connection.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class User::ExpiredTwitterServiceConnection < User::ExpiredServiceConnection -end diff --git a/app/models/user/sharing_methods.rb b/app/models/user/sharing_methods.rb new file mode 100644 index 00000000..2c4e38b4 --- /dev/null +++ b/app/models/user/sharing_methods.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module User::SharingMethods + def display_sharing_custom_url + URI(sharing_custom_url).host + end +end diff --git a/app/policies/answer_policy.rb b/app/policies/answer_policy.rb new file mode 100644 index 00000000..c012c920 --- /dev/null +++ b/app/policies/answer_policy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AnswerPolicy + attr_reader :user, :answer + + def initialize(user, answer) + @user = user + @answer = answer + end + + def pin? = answer.user == user + + def unpin? = answer.user == user +end diff --git a/app/services/twittered_markdown.rb b/app/services/twittered_markdown.rb index 8787c1b4..ef71472b 100644 --- a/app/services/twittered_markdown.rb +++ b/app/services/twittered_markdown.rb @@ -7,17 +7,7 @@ class TwitteredMarkdown < Redcarpet::Render::StripDown def wrap_mentions(text) text.gsub(/(^|\s)@([a-zA-Z0-9_]{1,16})/) do - local_user = User.find_by(screen_name: $2) - if local_user.nil? - "#{$1}#{$2}" - else - service = local_user.services.where(type: "Services::Twitter").first - if service.nil? - "#{$1}#{$2}" - else - "#{$1}@#{service.nickname}" - end - end + "#{$1}#{$2}" end end end diff --git a/app/validators/valid_url_validator.rb b/app/validators/valid_url_validator.rb new file mode 100644 index 00000000..0d29426c --- /dev/null +++ b/app/validators/valid_url_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ValidUrlValidator < ActiveModel::EachValidator + URI_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]).freeze + + def validate_each(record, attribute, value) + return if valid?(value) + + record.errors.add(attribute, :invalid_url) + end + + def valid?(value) + return false unless URI_REGEXP.match?(value) + + URI.parse(value) # raises URI::InvalidURIError + + true + rescue URI::InvalidURIError + false + end +end diff --git a/app/views/actions/_answer.html.haml b/app/views/actions/_answer.html.haml index 1fcfdca8..de81c031 100644 --- a/app/views/actions/_answer.html.haml +++ b/app/views/actions/_answer.html.haml @@ -16,6 +16,8 @@ %a.dropdown-item{ href: "#", data: { a_id: answer.id, action: "ab-report" } } %i.fa.fa-fw.fa-exclamation-triangle = t("voc.report") + - else + = render "actions/pin", answer: - if current_user.admin? %a.dropdown-item{ href: rails_admin_path_for_resource(answer), target: "_blank" } %i.fa.fa-fw.fa-gears diff --git a/app/views/actions/_pin.html.haml b/app/views/actions/_pin.html.haml new file mode 100644 index 00000000..41a1fa59 --- /dev/null +++ b/app/views/actions/_pin.html.haml @@ -0,0 +1,14 @@ +- if answer.pinned? + = button_to unpin_answer_path(username: current_user.screen_name, id: answer.id), + class: "dropdown-item", + method: :delete, + form: { id: "ab-pin-#{answer.id}", data: { turbo_stream: true } } do + %i.fa.fa-fw.fa-thumbtack + = t(".unpin") +- else + = button_to pin_answer_path(username: current_user.screen_name, id: answer.id), + class: "dropdown-item", + method: :post, + form: { id: "ab-pin-#{answer.id}", data: { turbo_stream: true } } do + %i.fa.fa-fw.fa-thumbtack + = t(".pin") diff --git a/app/views/answer/pin.turbo_stream.haml b/app/views/answer/pin.turbo_stream.haml new file mode 100644 index 00000000..bc2c107a --- /dev/null +++ b/app/views/answer/pin.turbo_stream.haml @@ -0,0 +1,2 @@ += turbo_stream.update("ab-pin-#{answer.id}") do + = render "actions/pin", answer: diff --git a/app/views/application/_answerbox.html.haml b/app/views/application/_answerbox.html.haml index fe27f953..20b88963 100644 --- a/app/views/application/_answerbox.html.haml +++ b/app/views/application/_answerbox.html.haml @@ -27,6 +27,11 @@ .col-md-6.text-start.text-muted %i.fa.fa-clock-o = link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id), class: "answerbox__permalink") + - if a.pinned_at.present? + %span.answerbox__pinned + · + %i.fa.fa-thumbtack + = t(".pinned") .col-md-6.d-md-flex.answerbox__actions = render "answerbox/actions", a: a, display_all: display_all .card-footer{ id: "ab-comments-section-#{a.id}", class: display_all.nil? ? "d-none" : nil } diff --git a/app/views/inbox/_entry.html.haml b/app/views/inbox/_entry.html.haml index f630d80c..769d4f10 100644 --- a/app/views/inbox/_entry.html.haml +++ b/app/views/inbox/_entry.html.haml @@ -33,19 +33,23 @@ = t("voc.answer") %button.btn.btn-danger.me-sm-1{ name: "ib-destroy", data: { ib_id: i.id } } = t("voc.delete") - %button.btn.btn-default.px-1{ name: "ib-options", data: { ib_id: i.id, state: :hidden } } - %i.fa.fa-chevron-down - %span.pe-none= t(".options") %p.format-help.ms-auto.align-self-center.mt-2.mt-sm-0.text-center = render "shared/format_link" - .card-footer.d-none{ id: "ib-options-#{i.id}" } - %h4= t(".sharing.heading") - - if services.count.positive? - .row - - services.each do |service| - .col-md-3.col-sm-4.col-xs-6 - %label - %input{ type: "checkbox", name: "ib-share", checked: :checked, data: { ib_id: i.id, service: service.provider } } - = raw t(".sharing.post_to", service: service.provider.capitalize) - - else - %p= t(".sharing.none_html", settings: link_to(t(".sharing.settings"), services_path)) + - if current_user.sharing_enabled + .inbox-entry__sharing.text-center.p-2.justify-content-center.d-none{ + data: { controller: "inbox-sharing", inbox_sharing_config_value: "{}", inbox_sharing_auto_close_value: current_user.sharing_autoclose.to_s } } + %button.btn-close.inbox-entry__close{ data: { action: "inbox-sharing#close" } } + %span.visually-hidden= t("voc.close") + .align-self-center + %p.fs-3.fw-bold= t(".sharing.heading") + %p + %a.btn.btn-primary{ href: "https://twitter.com/intent/tweet?text=", data: { inbox_sharing_target: "twitter" }, target: "_blank" } + %i.fab.fa-twitter.fa-fw + Twitter + %a.btn.btn-primary{ href: "#", data: { inbox_sharing_target: "tumblr" }, target: "_blank" } + %i.fab.fa-tumblr.fa-fw + Tumblr + - if current_user.sharing_custom_url.present? + %a.btn.btn-primary{ href: current_user.sharing_custom_url, data: { inbox_sharing_target: "custom" }, target: "_blank" } + = current_user.display_sharing_custom_url + %p.text-muted= t(".sharing.hint_html", settings: link_to(t(".sharing.settings"), settings_sharing_path)) diff --git a/app/views/inbox/show.html.haml b/app/views/inbox/show.html.haml index 94d7d883..5c5de4e4 100644 --- a/app/views/inbox/show.html.haml +++ b/app/views/inbox/show.html.haml @@ -1,6 +1,6 @@ #entries - @inbox.each do |i| - = render "inbox/entry", services:, i: + = render "inbox/entry", i: - if @inbox.empty? %p.empty= t(".empty") diff --git a/app/views/inbox/show.turbo_stream.haml b/app/views/inbox/show.turbo_stream.haml index ce09c7da..fffc48fc 100644 --- a/app/views/inbox/show.turbo_stream.haml +++ b/app/views/inbox/show.turbo_stream.haml @@ -1,6 +1,6 @@ = turbo_stream.append "entries" do - @inbox.each do |i| - = render "inbox/entry", services:, i: + = render "inbox/entry", i: = turbo_stream.update "paginator" do - if @more_data_available diff --git a/app/views/layouts/base.html.haml b/app/views/layouts/base.html.haml index d1d7d54a..f8464ac6 100644 --- a/app/views/layouts/base.html.haml +++ b/app/views/layouts/base.html.haml @@ -31,6 +31,7 @@ = render 'shared/announcements' = yield = render "shared/formatting" + .d-none#toasts - if Rails.env.development? #debug %hr diff --git a/app/views/moderation/questions/show.html.haml b/app/views/moderation/questions/show.html.haml index 8f174ba0..48617bf2 100644 --- a/app/views/moderation/questions/show.html.haml +++ b/app/views/moderation/questions/show.html.haml @@ -5,4 +5,4 @@ .container-lg.container--main - @questions.each do |q| - = render 'shared/question', q: q, type: nil + = render "shared/question", q:, type: "moderation" diff --git a/app/views/notifications/type/_expiredtwitterserviceconnection.html.haml b/app/views/notifications/type/_expiredtwitterserviceconnection.html.haml deleted file mode 100644 index fd941e2f..00000000 --- a/app/views/notifications/type/_expiredtwitterserviceconnection.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -.d-flex.notification - .flex-shrink-0.notification__icon - %i.fa.fa-2x.fa-fw.fa-twitter - .flex-grow-1 - %h6.notification__user - = t(".heading") - .notification__text - = t(".text_html", settings_sharing: link_to(t(".settings_services"), services_path)) diff --git a/app/views/question/show.html.haml b/app/views/question/show.html.haml index 614a62e7..57137240 100644 --- a/app/views/question/show.html.haml +++ b/app/views/question/show.html.haml @@ -22,7 +22,3 @@ %br/ %button.btn.btn-success#q-answer-btn{ data: { q_id: @question.id } } = t("voc.answer") - - current_user.services.each do |service| - %label - %input{ type: "checkbox", name: "share", checked: :checked, data: { q_id: @question.id, service: service.provider } } - = t("inbox.entry.sharing.post_to", service: service.provider.capitalize) diff --git a/app/views/services/index.html.haml b/app/views/services/index.html.haml deleted file mode 100644 index 0863d21d..00000000 --- a/app/views/services/index.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -= render "settings/services", services: @services - -- provide(:title, generate_title(t(".title"))) -- parent_layout "user/settings" diff --git a/app/views/settings/_services.html.haml b/app/views/settings/_services.html.haml deleted file mode 100644 index d92b6a72..00000000 --- a/app/views/settings/_services.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -.card - .card-body - = t(".services", count: services.count) - - - APP_CONFIG["sharing"].each do |service, service_options| - - if service_options["enabled"] && services.none? { |x| x.provider == service.to_s } - %p= button_to t(".connect", service: service.capitalize), "/auth/#{service}", method: :post, class: "btn btn-info", - form: { data: { turbo: false } } - - - if services.count.positive? - %ul.list-group - - services.each do |service| - %li.list-group-item - %i{ class: "fa fa-#{service.provider}" } - %strong= service.provider.capitalize - (#{service.nickname}) - = button_to t(".disconnect"), - service_path(service), - data: { confirm: t(".confirm", service: service.provider.capitalize) }, - method: :delete, - class: "btn btn-link text-danger", - form: { class: "d-inline", data: { turbo: false } } - - .col-md-6.mt-2 - = bootstrap_form_for(service, as: "service", url: update_service_path(service), data: { turbo: false }) do |f| - = f.text_field :post_tag, label_as_placeholder: true, - append: f.submit(t("voc.update"), class: "btn btn-primary"), maxlength: 20, pattern: "^[^@]*$" diff --git a/app/views/settings/sharing/edit.html.haml b/app/views/settings/sharing/edit.html.haml new file mode 100644 index 00000000..002fc279 --- /dev/null +++ b/app/views/settings/sharing/edit.html.haml @@ -0,0 +1,20 @@ += bootstrap_form_for(current_user, url: settings_sharing_path, method: :patch, data: { turbo: false }) do |f| + .card + .card-body + = f.form_group :sharing, help: t("activerecord.help.user.sharing_enabled") do + = f.check_box :sharing_enabled + = f.form_group :sharing, help: t("activerecord.help.user.sharing_autoclose"), class: false do + = f.check_box :sharing_autoclose + + .card + .card-body + %h3= t(".advanced.title") + = t(".advanced.body_html") + = f.url_field :sharing_custom_url + + .card + .card-body + = f.primary + +- provide(:title, generate_title(t(".title"))) +- parent_layout "user/settings" diff --git a/app/views/shared/_question.html.haml b/app/views/shared/_question.html.haml index 354f8a6b..e1a4df80 100644 --- a/app/views/shared/_question.html.haml +++ b/app/views/shared/_question.html.haml @@ -2,18 +2,23 @@ .card.questionbox{ data: { id: q.id } } .card-body{ data: { controller: q.long? ? "collapse" : nil } } .d-flex - - if type == 'discover' + - if type == "discover" .flex-shrink-0 %a{ href: user_screen_name(q.user, link_only: true) } %img.avatar-md.me-2{ src: q.user&.profile_picture&.url(:small), loading: :lazy } .flex-grow-1 %h6.text-muted.answerbox__question-user + - if type.nil? && q.direct + - if user_signed_in? && q.user == current_user + %i.fa.fa-eye-slash{ data: { bs_toggle: "tooltip", bs_title: t(".visible_to_you") } } + - elsif moderation_view? + %i.fa.fa-eye-slash{ data: { bs_toggle: "tooltip", bs_title: t(".visible_mod_mode") } } = t("answerbox.header.asked_html", user: user_screen_name(q.user), time: time_tooltip(q)) - if q.answer_count > 1 · %a{ href: question_path(q.user.screen_name, q.id) } = pluralize(q.answer_count, t("voc.answer")) - .answerbox__question-text{ class: q.long? ? 'collapsed' : '', data: { collapse_target: "content" } } + .answerbox__question-text{ class: q.long? ? "collapsed" : "", data: { collapse_target: "content" } } = question_markdown q.content - if q.long? = render "shared/collapse", type: "question" diff --git a/app/views/shared/_toast.html.haml b/app/views/shared/_toast.html.haml new file mode 100644 index 00000000..de241ebd --- /dev/null +++ b/app/views/shared/_toast.html.haml @@ -0,0 +1 @@ +.d-none{ data: { controller: "toast", toast_message_value: message, toast_success_value: success } } diff --git a/app/views/tabs/_settings.html.haml b/app/views/tabs/_settings.html.haml index 366aafc1..f54ba115 100644 --- a/app/views/tabs/_settings.html.haml +++ b/app/views/tabs/_settings.html.haml @@ -4,7 +4,7 @@ = list_group_item t(".profile"), edit_settings_profile_path = list_group_item t(".privacy"), edit_settings_privacy_path = list_group_item t(".security"), settings_two_factor_authentication_otp_authentication_path - = list_group_item t(".sharing"), services_path + = list_group_item t(".sharing"), settings_sharing_path = list_group_item t(".mutes"), settings_muted_path = list_group_item t(".blocks"), settings_blocks_path = list_group_item t(".theme"), edit_settings_theme_path diff --git a/app/views/user/show.html.haml b/app/views/user/show.html.haml index ac6452a0..25240a33 100644 --- a/app/views/user/show.html.haml +++ b/app/views/user/show.html.haml @@ -1,7 +1,11 @@ - unless @user.banned? + #pinned-answers + - @pinned_answers.each do |a| + = render "answerbox", a: + #answers - @answers.each do |a| - = render 'answerbox', a: a + = render "answerbox", a: - if @more_data_available .d-flex.justify-content-center.justify-content-sm-start#paginator diff --git a/app/workers/share_worker.rb b/app/workers/share_worker.rb deleted file mode 100644 index 7edcf141..00000000 --- a/app/workers/share_worker.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class ShareWorker - include Sidekiq::Worker - - sidekiq_options queue: :share, retry: 5 - - # @param user_id [Integer] the user id - # @param answer_id [Integer] the user id - # @param service [String] the service to post to - def perform(user_id, answer_id, service) # rubocop:disable Metrics/AbcSize - @user_service = User.find(user_id).services.find_by!(type: "Services::#{service.camelize}") - - @user_service.post(Answer.find(answer_id)) - rescue ActiveRecord::RecordNotFound - logger.info "Tried to post answer ##{answer_id} for user ##{user_id} to #{service.titleize} but the user/answer/service did not exist (likely deleted), will not retry." - # The question to be posted was deleted - rescue Twitter::Error::DuplicateStatus - logger.info "Tried to post answer ##{answer_id} from user ##{user_id} to Twitter but the status was already posted." - rescue Twitter::Error::Forbidden - # User's Twitter account is suspended - logger.info "Tried to post answer ##{answer_id} from user ##{user_id} to Twitter but the account is suspended." - rescue Twitter::Error::Unauthorized - # User's Twitter token has expired or been revoked - logger.info "Tried to post answer ##{answer_id} from user ##{user_id} to Twitter but the token has expired or been revoked." - revoke_and_notify(user_id, service) - rescue => e - logger.info "failed to post answer #{answer_id} to #{service} for user #{user_id}: #{e.message}" - Sentry.capture_exception(e) - raise - end - - def revoke_and_notify(user_id, service) - @user_service.destroy - - Notification::ServiceTokenExpired.create( - target_id: user_id, - target_type: "User::Expired#{service.camelize}ServiceConnection", - recipient_id: user_id, - new: true - ) - end -end diff --git a/config/environments/development.rb b/config/environments/development.rb index 317e27e3..c2434c8c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -51,6 +51,10 @@ Rails.application.configure do config.action_mailer.perform_caching = false + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = ENV.fetch("LOG_LEVEL") { "debug" }.to_sym + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -73,6 +77,6 @@ Rails.application.configure do # config.file_watcher = ActiveSupport::EventedFileUpdateChecker end -# For better_errors to work inside Docker we need +# For better_errors to work inside Docker we need # to allow 0.0.0.0 as an IP in development context BetterErrors::Middleware.allow_ip! "0.0.0.0/0" diff --git a/config/environments/production.rb b/config/environments/production.rb index 5964fe5c..6fe7f142 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -45,7 +45,7 @@ Rails.application.configure do # Use the lowest log level to ensure availability of diagnostic information # when problems arise. - config.log_level = :debug + config.log_level = ENV.fetch("LOG_LEVEL") { "info" }.to_sym # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] @@ -54,7 +54,19 @@ Rails.application.configure do config.lograge.enabled = true # Use a different cache store in production. - # config.cache_store = :mem_cache_store + cache_redis_url = ENV.fetch("CACHE_REDIS_URL") { nil } + if cache_redis_url.present? + config.cache_store = :redis_cache_store, { + url: cache_redis_url, + pool_size: ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i, + pool_timeout: ENV.fetch("CACHE_REDIS_TIMEOUT") { 5 }, + error_handler: -> (method:, returning:, exception:) { + # Report errors to Sentry as warnings + Sentry.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + }, + } + end # Use a real queuing backend for Active Job (and separate queues per environment) # config.active_job.queue_adapter = :resque diff --git a/config/environments/test.rb b/config/environments/test.rb index 30587ef6..036f3f7a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -34,6 +34,10 @@ Rails.application.configure do # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = ENV.fetch("LOG_LEVEL") { "debug" }.to_sym + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/initializers/10_config.rb b/config/initializers/10_config.rb index 2e12b5f4..deba98c4 100644 --- a/config/initializers/10_config.rb +++ b/config/initializers/10_config.rb @@ -1,5 +1,23 @@ +# frozen_string_literal: true + # Auxiliary config -APP_CONFIG = YAML.load_file(Rails.root.join('config', 'justask.yml')).with_indifferent_access + +APP_CONFIG = {}.with_indifferent_access + +# load yml config if it's present +justask_yml_path = Rails.root.join("config/justask.yml") +APP_CONFIG.merge!(YAML.load_file(justask_yml_path)) if File.exist?(justask_yml_path) + +# load config from ENV where possible +env_config = { + # The site name, shown everywhere + site_name: ENV.fetch("SITE_NAME", nil), + + hostname: ENV.fetch("HOSTNAME", nil), +}.compact +APP_CONFIG.merge!(env_config) # Update rails config for mail -Rails.application.config.action_mailer.default_url_options = { host: APP_CONFIG['hostname'] } +Rails.application.config.action_mailer.default_url_options = { + host: APP_CONFIG["hostname"], +} diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb deleted file mode 100644 index c34c2b2f..00000000 --- a/config/initializers/omniauth.rb +++ /dev/null @@ -1,7 +0,0 @@ -Rails.application.config.middleware.use OmniAuth::Builder do - APP_CONFIG.fetch('sharing').each do |service, opts| - if opts['enabled'] - provider service.to_sym, opts['consumer_key'], opts['consumer_secret'] - end - end -end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 1124fc37..0001a1e0 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -11,7 +11,6 @@ Sentry.init do |config| exception_fingerprints = { Excon::Error::ServiceUnavailable => 'external-service', - Twitter::Error::InternalServerError => 'external-service', } config.before_send = lambda do |event, hint| # These are used for user-facing errors, not when something goes wrong diff --git a/config/justask.yml.example b/config/justask.yml.example index 65e109c6..97616e4c 100644 --- a/config/justask.yml.example +++ b/config/justask.yml.example @@ -53,14 +53,6 @@ features: public: enabled: true -# OAuth tokens -sharing: - twitter: - enabled: false - # Get the tokens from https://apps.twitter.com - consumer_key: '' - consumer_secret: '' - # Redis redis_url: "redis://localhost:6379" diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 2b1b178e..d7a879ae 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -30,8 +30,6 @@ en: location: "Location" motivation_header: "Motivation header" website: "Website" - service: - post_tag: "Tag" theme: background_color: "Background colour" body_text: "Body text colour" @@ -76,6 +74,9 @@ en: privacy_allow_stranger_answers: "Allow other people to answer your questions" privacy_noindex: "Prevent search engines from indexing your profile" privacy_hide_social_graph: "Hide your social graph from others" + sharing_enabled: "Enable sharing" + sharing_autoclose: "Automatically hide inbox entry after sharing once" + sharing_custom_url: "Custom Share Link" profile_picture: "Profile picture" profile_header: "Profile header" sign_in_count: "Sign in count" @@ -87,11 +88,12 @@ en: screen_name: "Alphanumerical and underscores allowed, max. 16 characters" email: "Don't forget to check your spam folder in case our email might have landed there!" current_password: "We need your current password to confirm your changes" + sharing_enabled: "Shows a sharing dialog after answering a question from your inbox" + sharing_autoclose: "Use this if you mainly post to a single service" + sharing_custom_url: "Example: https://mastodon.social/share?text=" profile: anon_display_name: "This name will be used for questions asked to you by anonymous users." motivation_header: "Shown in the header of the question box on your profile. Motivate users to ask you questions!" - services/twitter: - post_tag: "Automatically append a tag to your shared answers. A # symbol is not automatically prepended." theme: danger_color: "Colour used for errors or critical actions like deleting something." info_color: "Colour used for informational popups or messages." @@ -102,6 +104,9 @@ en: user: screen_name: format: "contains invalid characters" + errors: + messages: + invalid_url: "does not look like a valid URL" helpers: submit: user: diff --git a/config/locales/controllers.en.yml b/config/locales/controllers.en.yml index 1373b154..ff41c0c6 100644 --- a/config/locales/controllers.en.yml +++ b/config/locales/controllers.en.yml @@ -26,6 +26,8 @@ en: destroy: nopriv: "You cannot delete other people's answers." success: "Successfully deleted answer." + pin: + success: "Successfully pinned answer." comment: create: invalid: "Your comment is too long." @@ -144,22 +146,15 @@ en: zero: "You are not currently subscribed to push notifications on any devices." one: "You are currently receiving push notifications on one device." other: "You are currently receiving push notifications on %{count} devices." + anonymous_block: + create: + success: "Successfully blocked user." + destroy: + success: "Successfully unblocked user." inbox: author: info: "No questions from @%{author} found, showing entries from all users instead!" error: "No user with the name @%{author} found, showing entries from all users instead!" - services: - create: - success: "%{service} connected successfully." - duplicate: "The %{service} account you are trying to connect is already connected to another %{app} account. If you are unable to disconnect the account yourself, please send us a Direct Message on Twitter: @retrospring." - error: "Unable to connect to %{service}." - update: - success: "Service updated successfully." - error: "Unable to update service." - failure: - error: :errors.base - destroy: - success: "%{service} disconnected successfully." settings: export: index: @@ -167,6 +162,11 @@ en: create: success: "Your account is currently being exported. This will take a little while." error: "Exporting is currently not possible." + mutes: + create: + success: "Sucessfully created mute rule" + destroy: + success: "Successfully removed mute rule" privacy: update: success: :settings.profile.update.success @@ -182,6 +182,10 @@ en: notice: profile_picture: " It might take a few minutes until your new profile picture is shown everywhere." profile_header: " It might take a few minutes until your new profile header is shown everywhere." + sharing: + update: + success: "Sharing settings updated successfully." + error: "Unable to update sharing settings." theme: update: success: "Theme saved successfully." @@ -208,3 +212,8 @@ en: timeline: public: title: "Public Timeline" + answer: + pin: + success: "This answer will now appear at the top of your profile." + unpin: + success: "This answer will no longer be pinned to the top of your profile." diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index 295f8aab..351e2c03 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -27,10 +27,11 @@ en: header: "Share your answers" body_html: |

Want your followers on another platform to see your %{app_name} answers? - You can configure automatic sharing to your favourite platforms easily.

+ You can easily share them to your favourite platforms.

-

Not sure if it's a favourite, but at the moment only - Twitter is supported.

+

We support Tumblr, Twitter, + and many other services including Mastodon and + Misskey.

customize: header: "Customise your experience" body_html: | @@ -74,6 +75,9 @@ en: return: "Return to Inbox" comment: view_smiles: "View comment smiles" + pin: + pin: "Pin to Profile" + unpin: "Unpin from Profile" share: twitter: "Share on Twitter" tumblr: "Share on Tumblr" @@ -122,6 +126,7 @@ en: read: "Read the entire answer" answered: "%{hide} %{user}" # resolves into "Answered by %{user}" hide: "Answered by" + pinned: "Pinned" questionbox: title: "Ask something!" placeholder: "Type your question here…" @@ -214,10 +219,10 @@ en: options: "Sharing Options" placeholder: "Write your answer here…" sharing: - heading: "Sharing" + heading: "Share answer on..." post_to: "Post to %{service}" - none_html: "You have not connected any services yet. Visit your %{settings} to connect one." - settings: "service settings" + hint_html: "You can customise these options in your %{settings}" + settings: "sharing settings" show: empty: "Nothing to see here." actions: @@ -359,13 +364,6 @@ en: heading: "Push notifications are failing to send to one of your devices." text_html: "Please check the %{settings_push} if you still want to be notified." settings_push: "push notification settings" - expiredtwitterserviceconnection: - heading: "Twitter connection expired" - text_html: "If you would like to continue automatically sharing your answers to Twitter, head to %{settings_sharing} and re-connect your account." - settings_services: "Sharing Settings" - services: - index: - title: "Service Settings" settings: account: email_confirm: "Currently awaiting confirmation for %{resource}" @@ -453,6 +451,14 @@ en: profile_picture: "Adjust your new profile picture" profile_header: "Adjust your new profile header" submit_picture: "Save pictures" + sharing: + edit: + title: "Sharing Settings" + advanced: + title: "Advanced options" + body_html: | +

If you use a service other than Twitter or Tumblr, which has support for sharing from external services, you can enter their sharing URL here so it will be listed as an option for sharing after an answer has been sent.

+

We will prepend a URL-encoded version of the answer to the end of the given link.

two_factor_authentication: otp_authentication: index: @@ -573,6 +579,9 @@ en: anonymous_block: deleted_question: "Deleted question" blocked: "blocked %{time} ago" + question: + visible_to_you: "Only visible to you as it was asked directly" + visible_mod_mode: "You can see this because you are in moderation view" tabs: admin: announcements: "Announcements" diff --git a/config/routes.rb b/config/routes.rb index 7dca9c1a..744a8d83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,9 @@ Rails.application.routes.draw do get :privacy, to: redirect("/settings/privacy/edit") resource :privacy, controller: :privacy, only: %i[edit update] + get :sharing, to: redirect("/settings/sharing/edit") + resource :sharing, controller: :sharing, only: %i[edit update] + get :export, to: "export#index" post :export, to: "export#create" @@ -101,17 +104,6 @@ Rails.application.routes.draw do resolve("Theme") { [:settings_theme] } # to make link_to/form_for work nicely when passing a `Theme` object to it, see also: https://api.rubyonrails.org/v6.1.5.1/classes/ActionDispatch/Routing/Mapper/CustomUrls.html#method-i-resolve resolve("Profile") { [:settings_profile] } - # resources :services, only: [:index, :destroy] - get "/settings/services", to: "services#index", as: :services - patch "/settings/services/:id", to: "services#update", as: :update_service - delete "/settings/services/:id", to: "services#destroy", as: :service - controller :services do - scope "/auth", as: "auth" do - get ":provider/callback" => :create - get :failure - end - end - namespace :ajax do post "/ask", to: "question#create", as: :ask post "/destroy_question", to: "question#destroy", as: :destroy_question @@ -154,6 +146,8 @@ Rails.application.routes.draw do get "/user/:username", to: "user#show" get "/@:username", to: "user#show", as: :user get "/@:username/a/:id", to: "answer#show", as: :answer + post "/@:username/a/:id/pin", to: "answer#pin", as: :pin_answer + delete "/@:username/a/:id/pin", to: "answer#unpin", as: :unpin_answer get "/@:username/q/:id", to: "question#show", as: :question get "/@:username/followers", to: "user#followers", as: :show_user_followers get "/@:username/followings", to: "user#followings", as: :show_user_followings diff --git a/db/migrate/20230128233136_add_pinned_at_to_answers.rb b/db/migrate/20230128233136_add_pinned_at_to_answers.rb new file mode 100644 index 00000000..8501ba1e --- /dev/null +++ b/db/migrate/20230128233136_add_pinned_at_to_answers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddPinnedAtToAnswers < ActiveRecord::Migration[6.1] + def change + add_column :answers, :pinned_at, :timestamp + add_index :answers, %i[user_id pinned_at] + end +end diff --git a/db/migrate/20230205145011_add_sharing_fields_to_user.rb b/db/migrate/20230205145011_add_sharing_fields_to_user.rb new file mode 100644 index 00000000..cc2edc44 --- /dev/null +++ b/db/migrate/20230205145011_add_sharing_fields_to_user.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddSharingFieldsToUser < ActiveRecord::Migration[6.1] + def up + add_column :users, :sharing_enabled, :boolean, default: false + add_column :users, :sharing_autoclose, :boolean, default: false + add_column :users, :sharing_custom_url, :string + end + + def down + remove_column :users, :sharing_enabled + remove_column :users, :sharing_autoclose + remove_column :users, :sharing_custom_url + end +end diff --git a/db/migrate/20230205162103_enable_sharing_for_service_owners.rb b/db/migrate/20230205162103_enable_sharing_for_service_owners.rb new file mode 100644 index 00000000..f194722c --- /dev/null +++ b/db/migrate/20230205162103_enable_sharing_for_service_owners.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class EnableSharingForServiceOwners < ActiveRecord::Migration[6.1] + def up + execute <<~SQUIRREL + UPDATE users + SET sharing_enabled = true + WHERE id IN (SELECT user_id FROM services); + SQUIRREL + end + + def down; end +end diff --git a/db/migrate/20230205162800_drop_services_table.rb b/db/migrate/20230205162800_drop_services_table.rb new file mode 100644 index 00000000..6bb783df --- /dev/null +++ b/db/migrate/20230205162800_drop_services_table.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DropServicesTable < ActiveRecord::Migration[6.1] + def up + drop_table :services + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20230212181044_remove_expired_service_connection_notifications.rb b/db/migrate/20230212181044_remove_expired_service_connection_notifications.rb new file mode 100644 index 00000000..3f306f39 --- /dev/null +++ b/db/migrate/20230212181044_remove_expired_service_connection_notifications.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveExpiredServiceConnectionNotifications < ActiveRecord::Migration[6.1] + def up = Notification.where(type: "Notification::ServiceTokenExpired").delete_all + + def down = raise ActiveRecord::IrreversibleMigration +end diff --git a/db/schema.rb b/db/schema.rb index e02cfdcc..e85a2c7e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_02_03_054229) do +ActiveRecord::Schema.define(version: 2023_02_12_181044) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -48,8 +48,10 @@ ActiveRecord::Schema.define(version: 2023_02_03_054229) do t.datetime "created_at" t.datetime "updated_at" t.integer "smile_count", default: 0, null: false + t.datetime "pinned_at" t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id", "created_at"], name: "index_answers_on_user_id_and_created_at" + t.index ["user_id", "pinned_at"], name: "index_answers_on_user_id_and_pinned_at" end create_table "appendables", force: :cascade do |t| @@ -253,19 +255,6 @@ ActiveRecord::Schema.define(version: 2023_02_03_054229) do t.index ["delivered", "failed", "processing", "deliver_after", "created_at"], name: "index_rpush_notifications_multi", where: "((NOT delivered) AND (NOT failed))" end - create_table "services", id: :serial, force: :cascade do |t| - t.string "type", null: false - t.bigint "user_id", null: false - t.string "uid" - t.string "access_token" - t.string "access_secret" - t.string "nickname" - t.datetime "created_at" - t.datetime "updated_at" - t.string "post_tag", limit: 20 - t.index ["user_id"], name: "index_services_on_user_id" - end - create_table "subscriptions", id: :serial, force: :cascade do |t| t.bigint "user_id", null: false t.bigint "answer_id", null: false @@ -371,6 +360,9 @@ ActiveRecord::Schema.define(version: 2023_02_03_054229) do t.boolean "privacy_require_user", default: false t.boolean "privacy_hide_social_graph", default: false t.boolean "privacy_noindex", default: false + t.boolean "sharing_enabled", default: false + t.boolean "sharing_autoclose", default: false + t.string "sharing_custom_url" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/docker-compose.yml b/docker-compose.yml index 0bdc353d..ae852632 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - redis environment: - SPROCKETS_CACHE=/cache + - DATABASE_URL=postgres://postgres:justask@postgres/justask_development?pool=25 + - REDIS_URL=redis://redis:6379 volumes: - ./:/app - cache:/cache diff --git a/lib/use_case/answer/pin.rb b/lib/use_case/answer/pin.rb new file mode 100644 index 00000000..c790663c --- /dev/null +++ b/lib/use_case/answer/pin.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module UseCase + module Answer + class Pin < UseCase::Base + option :user, type: Types.Instance(::User) + option :answer, type: Types.Instance(::Answer) + + def call + authorize!(:pin, user, answer) + check_unpinned! + + answer.pinned_at = Time.now.utc + answer.save! + + { + status: 200, + resource: answer, + } + end + + private + + def check_unpinned! + raise ::Errors::BadRequest if answer.pinned_at.present? + end + end + end +end diff --git a/lib/use_case/answer/unpin.rb b/lib/use_case/answer/unpin.rb new file mode 100644 index 00000000..8f12742e --- /dev/null +++ b/lib/use_case/answer/unpin.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module UseCase + module Answer + class Unpin < UseCase::Base + option :user, type: Types.Instance(::User) + option :answer, type: Types.Instance(::Answer) + + def call + authorize!(:unpin, user, answer) + check_pinned! + + answer.pinned_at = nil + answer.save! + + { + status: 200, + resource: nil, + } + end + + private + + def check_pinned! + raise ::Errors::BadRequest if answer.pinned_at.nil? + end + end + end +end diff --git a/lib/use_case/base.rb b/lib/use_case/base.rb index e35585c2..7ff23bbf 100644 --- a/lib/use_case/base.rb +++ b/lib/use_case/base.rb @@ -9,5 +9,11 @@ module UseCase def self.call(...) = new(...).call def call = raise NotImplementedError + + private + + def authorize!(verb, user, record, error_class: Errors::NotAuthorized) + raise error_class unless Pundit.policy!(user, record).public_send("#{verb}?") + end end end diff --git a/package.json b/package.json index 2f4499f2..bca4b7f0 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ }, "dependencies": { "@fontsource/lexend": "^4.5.15", - "@fortawesome/fontawesome-free": "^6.2.1", + "@fortawesome/fontawesome-free": "^6.3.0", "@hotwired/stimulus": "^3.2.1", - "@hotwired/turbo-rails": "^7.2.4", + "@hotwired/turbo-rails": "^7.2.5", "@melloware/coloris": "^0.17.1", "@popperjs/core": "^2.11", "@rails/request.js": "^0.0.8", @@ -20,19 +20,19 @@ "croppr": "^2.3.1", "i18n-js": "^4.0", "js-cookie": "2.2.1", - "sass": "^1.57.1", + "sass": "^1.58.0", "sweetalert": "1.1.3", "toastify-js": "^1.12.0", - "typescript": "^4.9.4" + "typescript": "^4.9.5" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.11.0", "@typescript-eslint/parser": "^4.11.0", - "esbuild": "^0.17.5", + "esbuild": "^0.17.8", "eslint": "^7.16.0", "eslint-plugin-import": "^2.27.5", "stylelint": "^14.16.1", "stylelint-config-standard-scss": "^6.1.0", - "stylelint-scss": "^4.3.0" + "stylelint-scss": "^4.4.0" } } diff --git a/spec/controllers/ajax/answer_controller_spec.rb b/spec/controllers/ajax/answer_controller_spec.rb index 79c911dc..93374bb5 100644 --- a/spec/controllers/ajax/answer_controller_spec.rb +++ b/spec/controllers/ajax/answer_controller_spec.rb @@ -25,13 +25,6 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do expect { subject }.to(change { Answer.count }.by(1)) end - it "enqueues a job for sharing the answer to social networks" do - subject - shared_services.each do |service| - expect(ShareWorker).to have_enqueued_sidekiq_job(user.id, Answer.last.id, service) - end - end - include_examples "returns the expected response" end @@ -40,11 +33,6 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do expect { subject }.not_to(change { Answer.count }) end - it "does not enqueue a job for sharing the answer to social networks" do - subject - expect(ShareWorker).not_to have_enqueued_sidekiq_job(anything, anything, anything) - end - include_examples "returns the expected response" end diff --git a/spec/controllers/anonymous_block_controller_spec.rb b/spec/controllers/anonymous_block_controller_spec.rb index 15dfef01..a1619a51 100644 --- a/spec/controllers/anonymous_block_controller_spec.rb +++ b/spec/controllers/anonymous_block_controller_spec.rb @@ -4,7 +4,7 @@ require "rails_helper" describe AnonymousBlockController, type: :controller do describe "#create" do - subject { post(:create, params:) } + subject { post(:create, params:, format: :turbo_stream) } context "user signed in" do let(:user) { FactoryBot.create(:user) } @@ -23,6 +23,29 @@ describe AnonymousBlockController, type: :controller do it "creates an anonymous block" do expect { subject }.to(change { AnonymousBlock.count }.by(1)) end + + it "contains the inbox entry removal turbo stream action" do + subject + + expect(response.body).to include "turbo-stream action=\"remove\" target=\"inbox_#{inbox.id}" + end + end + + context "when all required parameters are given, but no inbox entry exists" do + let(:question) { FactoryBot.create(:question, author_identifier: "someidentifier") } + let(:params) do + { question: question.id } + end + + it "creates an anonymous block" do + expect { subject }.to(change { AnonymousBlock.count }.by(1)) + end + + it "doesn't contain the inbox entry removal turbo stream action" do + subject + + expect(response.body).not_to include "turbo-stream action=\"remove\" target=\"inbox_" + end end context "when blocking a user globally" do diff --git a/spec/controllers/answer_controller_spec.rb b/spec/controllers/answer_controller_spec.rb index b2c39fd5..108b3969 100644 --- a/spec/controllers/answer_controller_spec.rb +++ b/spec/controllers/answer_controller_spec.rb @@ -2,7 +2,9 @@ require "rails_helper" -describe AnswerController do +describe AnswerController, type: :controller do + include ActiveSupport::Testing::TimeHelpers + let(:user) do FactoryBot.create :user, otp_module: :disabled, @@ -39,4 +41,60 @@ describe AnswerController do end end end + + describe "#pin" do + subject { post :pin, params: { username: user.screen_name, id: answer.id }, format: :turbo_stream } + + context "user signed in" do + before(:each) { sign_in user } + + it "pins the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.to change { answer.reload.pinned_at }.from(nil).to(Time.at(1603290950).utc) + expect(response.body).to include("turbo-stream action=\"update\" target=\"ab-pin-#{answer.id}\"") + end + end + end + + context "other user signed in" do + let(:other_user) { FactoryBot.create(:user) } + + before(:each) { sign_in other_user } + + it "does not pin the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.not_to(change { answer.reload.pinned_at }) + end + end + end + end + + describe "#unpin" do + subject { delete :unpin, params: { username: user.screen_name, id: answer.id }, format: :turbo_stream } + + context "user signed in" do + before(:each) do + sign_in user + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "unpins the answer" do + expect { subject }.to change { answer.reload.pinned_at }.from(Time.at(1603290950).utc).to(nil) + expect(response.body).to include("turbo-stream action=\"update\" target=\"ab-pin-#{answer.id}\"") + end + end + + context "other user signed in" do + let(:other_user) { FactoryBot.create(:user) } + + before(:each) do + sign_in other_user + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "does not unpin the answer" do + expect { subject }.not_to(change { answer.reload.pinned_at }) + end + end + end end diff --git a/spec/controllers/concerns/turbo_streamable_spec.rb b/spec/controllers/concerns/turbo_streamable_spec.rb new file mode 100644 index 00000000..3a2c9c60 --- /dev/null +++ b/spec/controllers/concerns/turbo_streamable_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe TurboStreamable, type: :controller do + controller do + include TurboStreamable + + turbo_stream_actions :create, :blocked, :not_found + + def create + params.require :message + + respond_to do |format| + format.turbo_stream do + render turbo_stream: render_toast("success!") + end + end + end + + def blocked + raise Errors::Blocked + end + + def not_found + raise ActiveRecord::RecordNotFound + end + end + + before do + routes.draw do + get "create" => "anonymous#create" + get "blocked" => "anonymous#blocked" + get "not_found" => "anonymous#not_found" + end + end + + render_views + + shared_examples_for "it returns a toast as Turbo Stream response" do |action, message| + subject { get action, format: :turbo_stream } + + it "returns a toast as Turbo Stream response" do + subject + + expect(response.header["Content-Type"]).to include "text/vnd.turbo-stream.html" + expect(response.body).to include message + end + end + + describe "#create" do + context "gets called with the proper parameters" do + subject { get :create, format: :turbo_stream, params: { message: "test" } } + + it "returns a toast as Turbo Stream response" do + subject + + expect(response.header["Content-Type"]).to include "text/vnd.turbo-stream.html" + expect(response.body).to include "success!" + end + end + + context "gets called with the wrong parameters" do + it_behaves_like "it returns a toast as Turbo Stream response", :create, "Message is required" + end + end + + it_behaves_like "it returns a toast as Turbo Stream response", :create, "Message is required" + it_behaves_like "it returns a toast as Turbo Stream response", :blocked, "You have been blocked from performing this request" + it_behaves_like "it returns a toast as Turbo Stream response", :not_found, "Record not found" +end diff --git a/spec/controllers/services_controller_spec.rb b/spec/controllers/services_controller_spec.rb deleted file mode 100644 index 5e9582ab..00000000 --- a/spec/controllers/services_controller_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -describe ServicesController, type: :controller do - describe "#index" do - subject { get :index } - - context "user signed in" do - let(:user) { FactoryBot.create(:user) } - - before { sign_in user } - - it "renders the services settings page with no services" do - subject - expect(response).to render_template("index") - expect(controller.instance_variable_get(:@services)).to be_empty - end - - context "user has a service token expired notification" do - let(:notification) do - Notification::ServiceTokenExpired.create( - target_id: user.id, - target_type: "User::ExpiredTwitterServiceConnection", - recipient_id: user.id, - new: true - ) - end - - it "marks the notification as read" do - expect { subject }.to change { notification.reload.new }.from(true).to(false) - end - end - - context "user has Twitter connected" do - before do - Services::Twitter.create(user:, uid: 12) - end - - it "renders the services settings page" do - subject - expect(response).to render_template("index") - expect(controller.instance_variable_get(:@services)).not_to be_empty - end - end - end - end - - describe "#create" do - subject { get :create, params: { provider: "twitter" } } - - context "successful Twitter sign in" do - let(:user) { FactoryBot.create(:user) } - - before do - sign_in user - OmniAuth.config.test_mode = true - OmniAuth.config.mock_auth[:twitter] = OmniAuth::AuthHash.new({ - "provider" => "twitter", - "uid" => "12", - "info" => { "nickname" => "jack" }, - "credentials" => { "token" => "AAAA", "secret" => "BBBB" } - }) - request.env["omniauth.auth"] = OmniAuth.config.mock_auth[:twitter] - end - - after do - OmniAuth.config.mock_auth[:twitter] = nil - end - - context "no services connected" do - it "creates a service integration" do - expect { subject }.to change { Service.count }.by(1) - end - end - - context "a user has a service connected" do - let(:other_user) { FactoryBot.create(:user) } - let!(:service) { Services::Twitter.create(user: other_user, uid: 12) } - - it "shows an error when trying to attach a service account which is already connected" do - subject - expect(flash[:error]).to eq("The Twitter account you are trying to connect is already connected to another #{APP_CONFIG['site_name']} account. If you are unable to disconnect the account yourself, please send us a Direct Message on Twitter: @retrospring.") - end - end - end - end - - describe "#update" do - subject { patch :update, params: } - - context "not signed in" do - let(:params) { { id: 1 } } - - it "redirects to sign in page" do - subject - expect(response).to redirect_to(new_user_session_path) - end - end - - context "user with Twitter connection" do - before { sign_in user } - - let(:user) { FactoryBot.create(:user) } - let(:service) { Services::Twitter.create(user:, uid: 12) } - let(:params) { { id: service.id, service: { post_tag: } } } - - context "tag is valid" do - let(:post_tag) { "#askaraccoon" } - - it "updates a service connection" do - expect { subject }.to change { service.reload.post_tag }.to("#askaraccoon") - expect(response).to redirect_to(services_path) - expect(flash[:success]).to eq("Service updated successfully.") - end - end - - context "tag is too long" do - let(:post_tag) { "a" * 21 } # 1 character over the limit - - it "shows an error" do - subject - expect(response).to redirect_to(services_path) - expect(flash[:error]).to eq("Unable to update service.") - end - end - end - end -end diff --git a/spec/controllers/settings/mutes_controller_spec.rb b/spec/controllers/settings/mutes_controller_spec.rb index 95a25b78..3b7579c5 100644 --- a/spec/controllers/settings/mutes_controller_spec.rb +++ b/spec/controllers/settings/mutes_controller_spec.rb @@ -19,7 +19,7 @@ describe Settings::MutesController, type: :controller do end describe "#create" do - subject { post :create, params: { muted_phrase: "foo" } } + subject { post :create, params: { muted_phrase: "foo" }, format: :turbo_stream } context "user signed in" do let(:user) { FactoryBot.create(:user) } @@ -29,11 +29,17 @@ describe Settings::MutesController, type: :controller do it "creates a mute rule" do expect { subject }.to(change { MuteRule.count }.by(1)) end + + it "contains a turbo stream append tag" do + subject + + expect(response.body).to include "turbo-stream action=\"append\" target=\"rules\"" + end end end describe "#destroy" do - subject { delete :destroy, params: } + subject { delete :destroy, params:, format: :turbo_stream } context "user signed in" do let(:user) { FactoryBot.create(:user) } @@ -47,6 +53,12 @@ describe Settings::MutesController, type: :controller do expect(MuteRule.exists?(rule.id)).to eq(false) end + + it "contains a turbo stream remove tag" do + subject + + expect(response.body).to include "turbo-stream action=\"remove\" target=\"rule_#{rule.id}\"" + end end end end diff --git a/spec/controllers/settings/sharing_controller_spec.rb b/spec/controllers/settings/sharing_controller_spec.rb new file mode 100644 index 00000000..6755845a --- /dev/null +++ b/spec/controllers/settings/sharing_controller_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Settings::SharingController, type: :controller do + describe "#edit" do + subject { get :edit } + + it "redirects to the sign in page when not signed in" do + expect(subject).to redirect_to new_user_session_path + end + + context "user signed in" do + let(:user) { FactoryBot.create(:user) } + + before { sign_in user } + + it "renders the edit template" do + expect(subject).to render_template(:edit) + end + end + end + + describe "#update" do + subject { patch :update, params: { user: user_params } } + let(:user_params) do + { + sharing_enabled: "1", + sharing_autoclose: "1", + sharing_custom_url: "", + } + end + + it "redirects to the sign in page when not signed in" do + expect(subject).to redirect_to new_user_session_path + end + + context "user signed in" do + let(:user) { FactoryBot.create :user } + + before { sign_in user } + + it "renders the edit template" do + expect(subject).to render_template(:edit) + end + + it "updates the user's sharing preferences" do + expect { subject } + .to change { user.reload.slice(:sharing_enabled, :sharing_autoclose, :sharing_custom_url).values } + .from([false, false, nil]) + .to([true, true, ""]) + end + + it "sets the success flash" do + subject + expect(flash[:success]).to eq(I18n.t("settings.sharing.update.success")) + end + + context "when passed a valid url" do + let(:user_params) do + super().merge( + sharing_custom_url: "https://example.com/share?text=" + ) + end + + it "renders the edit template" do + expect(subject).to render_template(:edit) + end + + it "updates the user's sharing preferences" do + expect { subject } + .to change { user.reload.slice(:sharing_enabled, :sharing_autoclose, :sharing_custom_url).values } + .from([false, false, nil]) + .to([true, true, "https://example.com/share?text="]) + end + + it "sets the success flash" do + subject + expect(flash[:success]).to eq(I18n.t("settings.sharing.update.success")) + end + end + + context "when passed an invalid url" do + let(:user_params) do + super().merge( + sharing_custom_url: "nfs://example.com/data" + ) + end + + it "renders the edit template" do + expect(subject).to render_template(:edit) + end + + it "updates the user's sharing preferences" do + expect { subject } + .not_to(change { user.reload.slice(:sharing_enabled, :sharing_autoclose, :sharing_custom_url).values }) + end + + it "sets the error flash" do + subject + expect(flash[:error]).to eq(I18n.t("settings.sharing.update.error")) + end + end + + context "when unticking boolean settings" do + let(:user_params) do + super().merge( + sharing_enabled: "0", + sharing_autoclose: "0" + ) + end + + let(:user) { FactoryBot.create :user, sharing_enabled: true, sharing_autoclose: true, sharing_custom_url: "https://example.com/share?text=" } + + it "renders the edit template" do + expect(subject).to render_template(:edit) + end + + it "updates the user's sharing preferences" do + expect { subject } + .to change { user.reload.slice(:sharing_enabled, :sharing_autoclose, :sharing_custom_url).values } + .from([true, true, "https://example.com/share?text="]) + .to([false, false, ""]) + end + + it "sets the success flash" do + subject + expect(flash[:success]).to eq(I18n.t("settings.sharing.update.success")) + end + end + end + end +end diff --git a/spec/controllers/well_known/node_info_controller_spec.rb b/spec/controllers/well_known/node_info_controller_spec.rb index 19de310c..c4728d0a 100644 --- a/spec/controllers/well_known/node_info_controller_spec.rb +++ b/spec/controllers/well_known/node_info_controller_spec.rb @@ -13,9 +13,9 @@ describe WellKnown::NodeInfoController do "links" => [ { "rel" => "http://nodeinfo.diaspora.software/ns/schema/2.1", - "href" => "http://test.host/nodeinfo/2.1" + "href" => "http://test.host/nodeinfo/2.1", } - ] + ], }) end end @@ -49,42 +49,6 @@ describe WellKnown::NodeInfoController do end end - context "Twitter integration enabled" do - before do - stub_const("APP_CONFIG", { - "sharing" => { - "twitter" => { - "enabled" => true - } - } - }) - end - - it "includes Twitter in outbound services" do - subject - parsed = JSON.parse(response.body) - expect(parsed.dig("services", "outbound")).to include("twitter") - end - end - - context "Twitter integration disabled" do - before do - stub_const("APP_CONFIG", { - "sharing" => { - "twitter" => { - "enabled" => false - } - } - }) - end - - it "includes Twitter in outbound services" do - subject - parsed = JSON.parse(response.body) - expect(parsed.dig("services", "outbound")).to_not include("twitter") - end - end - context "site has users" do let(:num_users) { rand(10..50) } diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index b5ecf606..177b42c7 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -40,24 +40,8 @@ describe MarkdownHelper, type: :helper do end describe "#twitter_markdown" do - context "mentioned user has Twitter connected" do - let(:user) { FactoryBot.create(:user) } - let(:service) { Services::Twitter.create(user: user) } - - before do - service.nickname = "test" - service.save! - end - - it "should transform a internal mention to a Twitter mention" do - expect(twitter_markdown("@#{user.screen_name}")).to eq("@test") - end - end - - context "mentioned user doesnt have Twitter connected" do - it "should not transform the mention" do - expect(twitter_markdown("@test")).to eq("test") - end + it "should not transform the mention" do + expect(twitter_markdown("@test")).to eq("test") end it "should not strip weird hearts" do @@ -118,17 +102,9 @@ describe MarkdownHelper, type: :helper do describe "#twitter_markdown_io" do let(:user) { FactoryBot.create(:user) } - let(:service) { Services::Twitter.create(user: user) } - - before do - user.screen_name = "test" - user.save! - service.nickname = "ObamaGaming" - service.save! - end it "should return the expected text" do - expect(twitter_markdown_io("spec/fixtures/markdown/twitter.md")).to eq("@ObamaGaming") + expect(twitter_markdown_io("spec/fixtures/markdown/twitter.md")).to eq("test") end end diff --git a/spec/lib/use_case/answer/pin_spec.rb b/spec/lib/use_case/answer/pin_spec.rb new file mode 100644 index 00000000..26fa29d4 --- /dev/null +++ b/spec/lib/use_case/answer/pin_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UseCase::Answer::Pin do + include ActiveSupport::Testing::TimeHelpers + + subject { UseCase::Answer::Pin.call(user:, answer:) } + + context "answer exists" do + let(:answer) { FactoryBot.create(:answer, user: FactoryBot.create(:user)) } + + context "as answer owner" do + let(:user) { answer.user } + + it "pins the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.to change { answer.pinned_at }.from(nil).to(Time.at(1603290950).utc) + end + end + + context "answer is already pinned" do + before do + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "raises an error" do + expect { subject }.to raise_error(Errors::BadRequest) + expect(answer.reload.pinned_at).to eq(Time.at(1603290950).utc) + end + end + end + + context "as other user" do + let(:user) { FactoryBot.create(:user) } + + it "does not pin the answer" do + expect { subject }.to raise_error(Errors::NotAuthorized) + expect(answer.reload.pinned_at).to eq(nil) + end + end + end +end diff --git a/spec/lib/use_case/answer/unpin_spec.rb b/spec/lib/use_case/answer/unpin_spec.rb new file mode 100644 index 00000000..f4d7a3cb --- /dev/null +++ b/spec/lib/use_case/answer/unpin_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UseCase::Answer::Unpin do + include ActiveSupport::Testing::TimeHelpers + + subject { UseCase::Answer::Unpin.call(user:, answer:) } + + context "answer exists" do + let(:pinned_at) { Time.at(1603290950).utc } + let(:answer) { FactoryBot.create(:answer, user: FactoryBot.create(:user), pinned_at:) } + + context "as answer owner" do + let(:user) { answer.user } + + it "unpins the answer" do + expect { subject }.to change { answer.pinned_at }.from(pinned_at).to(nil) + end + + context "answer is already unpinned" do + let(:pinned_at) { nil } + + it "raises an error" do + expect { subject }.to raise_error(Errors::BadRequest) + expect(answer.reload.pinned_at).to eq(nil) + end + end + end + + context "as other user" do + let(:user) { FactoryBot.create(:user) } + + it "does not unpin the answer" do + expect { subject }.to raise_error(Errors::NotAuthorized) + expect(answer.reload.pinned_at).to eq(pinned_at) + end + end + end +end diff --git a/spec/lib/use_case/data_export/answers_spec.rb b/spec/lib/use_case/data_export/answers_spec.rb index 3fa24f8e..99335a91 100644 --- a/spec/lib/use_case/data_export/answers_spec.rb +++ b/spec/lib/use_case/data_export/answers_spec.rb @@ -32,7 +32,8 @@ describe UseCase::DataExport::Answers, :data_export do user_id: user.id, created_at: "2022-12-10T13:37:42.000Z", updated_at: "2022-12-10T13:37:42.000Z", - smile_count: 0 + smile_count: 0, + pinned_at: nil, } ] } diff --git a/spec/lib/use_case/data_export/user_spec.rb b/spec/lib/use_case/data_export/user_spec.rb index f9c70ff9..9f01e1ef 100644 --- a/spec/lib/use_case/data_export/user_spec.rb +++ b/spec/lib/use_case/data_export/user_spec.rb @@ -22,6 +22,9 @@ describe UseCase::DataExport::User, :data_export do privacy_allow_public_timeline: false, privacy_allow_stranger_answers: false, privacy_show_in_search: true, + sharing_enabled: false, + sharing_autoclose: false, + sharing_custom_url: nil, screen_name: "fizzyraccoon", show_foreign_themes: true, sign_in_count: 10, @@ -85,7 +88,10 @@ describe UseCase::DataExport::User, :data_export do privacy_lock_inbox: false, privacy_require_user: false, privacy_hide_social_graph: false, - privacy_noindex: false + privacy_noindex: false, + sharing_enabled: false, + sharing_autoclose: false, + sharing_custom_url: nil }, profile: { display_name: "Fizzy Raccoon", diff --git a/spec/models/services/twitter_spec.rb b/spec/models/services/twitter_spec.rb deleted file mode 100644 index c12870bb..00000000 --- a/spec/models/services/twitter_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Services::Twitter do - describe "#post" do - let(:user) { FactoryBot.create(:user) } - let(:service) { Services::Twitter.create(user: user) } - let(:answer) { FactoryBot.create(:answer, user: user, - content: 'a' * 255, - question_content: 'q' * 255) } - let(:twitter_client) { instance_double(Twitter::REST::Client) } - - before do - allow(Twitter::REST::Client).to receive(:new).and_return(twitter_client) - allow(twitter_client).to receive(:update!) - stub_const("APP_CONFIG", { - 'hostname' => 'example.com', - 'https' => true, - 'items_per_page' => 5, - 'sharing' => { - 'twitter' => { - 'consumer_key' => 'AAA', - } - } - }) - end - - it "posts a shortened tweet" do - service.post(answer) - - expect(twitter_client).to have_received(:update!).with("#{'q' * 123}… — #{'a' * 124}… https://example.com/@#{user.screen_name}/a/#{answer.id}") - end - - it "posts an un-shortened tweet" do - answer.question.content = 'Why are raccoons so good?' - answer.question.save! - answer.content = 'Because they are good cunes.' - answer.save! - - service.post(answer) - - expect(twitter_client).to have_received(:update!).with("#{answer.question.content} — #{answer.content} https://example.com/@#{user.screen_name}/a/#{answer.id}") - end - end -end \ No newline at end of file diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f1f2abdc..01d3fc47 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -33,6 +33,40 @@ RSpec.describe User, type: :model do end end + describe "custom sharing url validation" do + subject do + FactoryBot.build(:user, sharing_custom_url: url).tap(&:validate).errors[:sharing_custom_url] + end + + shared_examples_for "valid url" do |example_url| + context "when url is #{example_url}" do + let(:url) { example_url } + + it "does not have validation errors" do + expect(subject).to be_empty + end + end + end + + shared_examples_for "invalid url" do |example_url| + context "when url is #{example_url}" do + let(:url) { example_url } + + it "has validation errors" do + expect(subject).not_to be_empty + end + end + end + + include_examples "valid url", "https://myfunnywebsite.com/" + include_examples "valid url", "https://desu.social/share?text=" + include_examples "valid url", "http://insecurebutvalid.business/" + include_examples "invalid url", "ftp://fileprotocols.cool/" + include_examples "invalid url", "notevenanurl" + include_examples "invalid url", %(https://richtig oarger shice) # passes the regexp, but trips up URI.parse + include_examples "invalid url", %(https://österreich.gv.at) # needs to be ASCII + end + describe "email validation" do subject do FactoryBot.build(:user, email: email).tap(&:validate).errors[:email] diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 90ade2fe..43662e83 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -60,6 +60,10 @@ RSpec.configure do |config| # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + # anonymous controllers will infer ApplicationController instead of the + # described class + config.infer_base_class_for_anonymous_controllers = false + config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :helper end diff --git a/spec/workers/share_worker_spec.rb b/spec/workers/share_worker_spec.rb deleted file mode 100644 index c8003258..00000000 --- a/spec/workers/share_worker_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -describe ShareWorker do - let(:user) { FactoryBot.create(:user) } - let(:answer) { FactoryBot.create(:answer, user: user) } - let!(:service) do - Services::Twitter.create!(type: "Services::Twitter", - user: user) - end - - before do - stub_const("APP_CONFIG", { - "hostname" => "example.com", - "anonymous_name" => "Anonymous", - "https" => true, - "items_per_page" => 5, - "sharing" => { - "twitter" => { - "consumer_key" => "" - } - } - }) - end - - describe "#perform" do - before do - allow(Sidekiq.logger).to receive(:info) - end - - subject do - Sidekiq::Testing.fake! do - ShareWorker.perform_async(user.id, answer.id, "twitter") - end - end - - context "when answer doesn't exist" do - it "prevents the job from retrying and logs a message" do - answer.destroy! - expect { subject }.to change(ShareWorker.jobs, :size).by(1) - expect { ShareWorker.drain }.to change(ShareWorker.jobs, :size).by(-1) - expect(Sidekiq.logger).to have_received(:info).with("Tried to post answer ##{answer.id} for user ##{user.id} to Twitter but the user/answer/service did not exist (likely deleted), will not retry.") - end - end - - context "when answer exists" do - it "handles Twitter::Error::DuplicateStatus" do - allow_any_instance_of(Services::Twitter).to receive(:post).with(answer).and_raise(Twitter::Error::DuplicateStatus) - subject - ShareWorker.drain - expect(Sidekiq.logger).to have_received(:info).with("Tried to post answer ##{answer.id} from user ##{user.id} to Twitter but the status was already posted.") - end - - it "handles Twitter::Error::Unauthorized" do - allow_any_instance_of(Services::Twitter).to receive(:post).with(answer).and_raise(Twitter::Error::Unauthorized) - subject - ShareWorker.drain - expect(Sidekiq.logger).to have_received(:info).with("Tried to post answer ##{answer.id} from user ##{user.id} to Twitter but the token has expired or been revoked.") - end - - it "revokes the service connection when Twitter::Error::Unauthorized is raised" do - allow_any_instance_of(Services::Twitter).to receive(:post).with(answer).and_raise(Twitter::Error::Unauthorized) - subject - expect { ShareWorker.drain }.to change { Services::Twitter.count }.by(-1) - end - - it "sends the user a notification when Twitter::Error::Unauthorized is raised" do - allow_any_instance_of(Services::Twitter).to receive(:post).with(answer).and_raise(Twitter::Error::Unauthorized) - subject - expect { ShareWorker.drain }.to change { Notification::ServiceTokenExpired.count }.by(1) - end - - it "handles Twitter::Error::Forbidden" do - allow_any_instance_of(Services::Twitter).to receive(:post).with(answer).and_raise(Twitter::Error::Forbidden) - subject - ShareWorker.drain - expect(Sidekiq.logger).to have_received(:info).with("Tried to post answer ##{answer.id} from user ##{user.id} to Twitter but the account is suspended.") - end - - it "retries on unhandled exceptions" do - expect { subject }.to change(ShareWorker.jobs, :size).by(1) - expect { ShareWorker.drain }.to raise_error(Twitter::Error::BadRequest) - expect(Sidekiq.logger).to have_received(:info) - end - end - end -end diff --git a/yarn.lock b/yarn.lock index f38e6b8f..6324ee68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49,115 +49,115 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36" integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg== -"@esbuild/android-arm64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.5.tgz#a145f43018e639bed94ed637369e2dcdd6bf9ea2" - integrity sha512-KHWkDqYAMmKZjY4RAN1PR96q6UOtfkWlTS8uEwWxdLtkRt/0F/csUhXIrVfaSIFxnscIBMPynGfhsMwQDRIBQw== +"@esbuild/android-arm64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.8.tgz#b3d5b65a3b2e073a6c7ee36b1f3c30c8f000315b" + integrity sha512-oa/N5j6v1svZQs7EIRPqR8f+Bf8g6HBDjD/xHC02radE/NjKHK7oQmtmLxPs1iVwYyvE+Kolo6lbpfEQ9xnhxQ== -"@esbuild/android-arm@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.5.tgz#9fa2deff7fc5d180bb4ecff70beea3a95ac44251" - integrity sha512-crmPUzgCmF+qZXfl1YkiFoUta2XAfixR1tEnr/gXIixE+WL8Z0BGqfydP5oox0EUOgQMMRgtATtakyAcClQVqQ== +"@esbuild/android-arm@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.8.tgz#c41e496af541e175369d48164d0cf01a5f656cf6" + integrity sha512-0/rb91GYKhrtbeglJXOhAv9RuYimgI8h623TplY2X+vA4EXnk3Zj1fXZreJ0J3OJJu1bwmb0W7g+2cT/d8/l/w== -"@esbuild/android-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.5.tgz#145fc61f810400e65a56b275280d1422a102c2ef" - integrity sha512-8fI/AnIdmWz/+1iza2WrCw8kwXK9wZp/yZY/iS8ioC+U37yJCeppi9EHY05ewJKN64ASoBIseufZROtcFnX5GA== +"@esbuild/android-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.8.tgz#080fa67c29be77f5a3ca5ee4cc78d5bf927e3a3b" + integrity sha512-bTliMLqD7pTOoPg4zZkXqCDuzIUguEWLpeqkNfC41ODBHwoUgZ2w5JBeYimv4oP6TDVocoYmEhZrCLQTrH89bg== -"@esbuild/darwin-arm64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.5.tgz#61fb0546aa4bae0850817d6e0d008b1cb3f64b49" - integrity sha512-EAvaoyIySV6Iif3NQCglUNpnMfHSUgC5ugt2efl3+QDntucJe5spn0udNZjTgNi6tKVqSceOw9tQ32liNZc1Xw== +"@esbuild/darwin-arm64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.8.tgz#053622bf9a82f43d5c075b7818e02618f7b4a397" + integrity sha512-ghAbV3ia2zybEefXRRm7+lx8J/rnupZT0gp9CaGy/3iolEXkJ6LYRq4IpQVI9zR97ID80KJVoUlo3LSeA/sMAg== -"@esbuild/darwin-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.5.tgz#54b770f0c49f524ae9ba24c85d6dea8b521f610d" - integrity sha512-ha7QCJh1fuSwwCgoegfdaljowwWozwTDjBgjD3++WAy/qwee5uUi1gvOg2WENJC6EUyHBOkcd3YmLDYSZ2TPPA== +"@esbuild/darwin-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.8.tgz#8a1aadb358d537d8efad817bb1a5bff91b84734b" + integrity sha512-n5WOpyvZ9TIdv2V1K3/iIkkJeKmUpKaCTdun9buhGRWfH//osmUjlv4Z5mmWdPWind/VGcVxTHtLfLCOohsOXw== -"@esbuild/freebsd-arm64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.5.tgz#be1dd18b7b9411f10bdc362ba8bff16386175367" - integrity sha512-VbdXJkn2aI2pQ/wxNEjEcnEDwPpxt3CWWMFYmO7CcdFBoOsABRy2W8F3kjbF9F/pecEUDcI3b5i2w+By4VQFPg== +"@esbuild/freebsd-arm64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.8.tgz#e6738d0081ba0721a5c6c674e84c6e7fcea61989" + integrity sha512-a/SATTaOhPIPFWvHZDoZYgxaZRVHn0/LX1fHLGfZ6C13JqFUZ3K6SMD6/HCtwOQ8HnsNaEeokdiDSFLuizqv5A== -"@esbuild/freebsd-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.5.tgz#c9c1960fa3e1eada4e5d4be2a11a2f04ce14198f" - integrity sha512-olgGYND1/XnnWxwhjtY3/ryjOG/M4WfcA6XH8dBTH1cxMeBemMODXSFhkw71Kf4TeZFFTN25YOomaNh0vq2iXg== +"@esbuild/freebsd-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.8.tgz#1855e562f2b730f4483f6e94086e9e2597feb4c3" + integrity sha512-xpFJb08dfXr5+rZc4E+ooZmayBW6R3q59daCpKZ/cDU96/kvDM+vkYzNeTJCGd8rtO6fHWMq5Rcv/1cY6p6/0Q== -"@esbuild/linux-arm64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.5.tgz#34d96d11c6899017ecae42fb97de8e0c3282902f" - integrity sha512-8a0bqSwu3OlLCfu2FBbDNgQyBYdPJh1B9PvNX7jMaKGC9/KopgHs37t+pQqeMLzcyRqG6z55IGNQAMSlCpBuqg== +"@esbuild/linux-arm64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.8.tgz#481da38952721a3fdb77c17a36ceaacc4270b5c5" + integrity sha512-v3iwDQuDljLTxpsqQDl3fl/yihjPAyOguxuloON9kFHYwopeJEf1BkDXODzYyXEI19gisEsQlG1bM65YqKSIww== -"@esbuild/linux-arm@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.5.tgz#86332e6293fd713a54ab299a5e2ed7c60c9e1c07" - integrity sha512-YBdCyQwA3OQupi6W2/WO4FnI+NWFWe79cZEtlbqSESOHEg7a73htBIRiE6uHPQe7Yp5E4aALv+JxkRLGEUL7tw== +"@esbuild/linux-arm@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.8.tgz#18127072b270bb6321c6d11be20bfd30e0d6ad17" + integrity sha512-6Ij8gfuGszcEwZpi5jQIJCVIACLS8Tz2chnEBfYjlmMzVsfqBP1iGmHQPp7JSnZg5xxK9tjCc+pJ2WtAmPRFVA== -"@esbuild/linux-ia32@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.5.tgz#7bd9185c844e7dfce6a01dfdec584e115602a8c4" - integrity sha512-uCwm1r/+NdP7vndctgq3PoZrnmhmnecWAr114GWMRwg2QMFFX+kIWnp7IO220/JLgnXK/jP7VKAFBGmeOYBQYQ== +"@esbuild/linux-ia32@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.8.tgz#ee400af7b3bc69e8ca2e593ca35156ffb9abd54f" + integrity sha512-8svILYKhE5XetuFk/B6raFYIyIqydQi+GngEXJgdPdI7OMKUbSd7uzR02wSY4kb53xBrClLkhH4Xs8P61Q2BaA== -"@esbuild/linux-loong64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.5.tgz#2907d4120c7b3642b96be6014f77e7624c378eea" - integrity sha512-3YxhSBl5Sb6TtBjJu+HP93poBruFzgXmf3PVfIe4xOXMj1XpxboYZyw3W8BhoX/uwxzZz4K1I99jTE/5cgDT1g== +"@esbuild/linux-loong64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.8.tgz#8c509d8a454693d39824b83b3f66c400872fce82" + integrity sha512-B6FyMeRJeV0NpyEOYlm5qtQfxbdlgmiGdD+QsipzKfFky0K5HW5Td6dyK3L3ypu1eY4kOmo7wW0o94SBqlqBSA== -"@esbuild/linux-mips64el@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.5.tgz#fc98be741e8080ecd13b404d5fca5302d3835bf4" - integrity sha512-Hy5Z0YVWyYHdtQ5mfmfp8LdhVwGbwVuq8mHzLqrG16BaMgEmit2xKO+iDakHs+OetEx0EN/2mUzDdfdktI+Nmg== +"@esbuild/linux-mips64el@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.8.tgz#f2b0d36e63fb26bc3f95b203b6a80638292101ca" + integrity sha512-CCb67RKahNobjm/eeEqeD/oJfJlrWyw29fgiyB6vcgyq97YAf3gCOuP6qMShYSPXgnlZe/i4a8WFHBw6N8bYAA== -"@esbuild/linux-ppc64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.5.tgz#ea12e8f6b290a613ac4903c9e00835c69ced065c" - integrity sha512-5dbQvBLbU/Y3Q4ABc9gi23hww1mQcM7KZ9KBqabB7qhJswYMf8WrDDOSw3gdf3p+ffmijMd28mfVMvFucuECyg== +"@esbuild/linux-ppc64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.8.tgz#1e628be003e036e90423716028cc884fe5ba25bd" + integrity sha512-bytLJOi55y55+mGSdgwZ5qBm0K9WOCh0rx+vavVPx+gqLLhxtSFU0XbeYy/dsAAD6xECGEv4IQeFILaSS2auXw== -"@esbuild/linux-riscv64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.5.tgz#ce47b15fd4227eeb0590826e41bdc430c5bfd06c" - integrity sha512-fp/KUB/ZPzEWGTEUgz9wIAKCqu7CjH1GqXUO2WJdik1UNBQ7Xzw7myIajpxztE4Csb9504ERiFMxZg5KZ6HlZQ== +"@esbuild/linux-riscv64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.8.tgz#419a815cb4c3fb9f1b78ef5295f5b48b8bf6427a" + integrity sha512-2YpRyQJmKVBEHSBLa8kBAtbhucaclb6ex4wchfY0Tj3Kg39kpjeJ9vhRU7x4mUpq8ISLXRXH1L0dBYjAeqzZAw== -"@esbuild/linux-s390x@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.5.tgz#962fa540d7498967270eb1d4b9ac6c4a4f339735" - integrity sha512-kRV3yw19YDqHTp8SfHXfObUFXlaiiw4o2lvT1XjsPZ++22GqZwSsYWJLjMi1Sl7j9qDlDUduWDze/nQx0d6Lzw== +"@esbuild/linux-s390x@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.8.tgz#291c49ae5c3d11d226352755c0835911fe1a9e5c" + integrity sha512-QgbNY/V3IFXvNf11SS6exkpVcX0LJcob+0RWCgV9OiDAmVElnxciHIisoSix9uzYzScPmS6dJFbZULdSAEkQVw== -"@esbuild/linux-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.5.tgz#9fa52884c3d876593a522aa1d4df43b717907050" - integrity sha512-vnxuhh9e4pbtABNLbT2ANW4uwQ/zvcHRCm1JxaYkzSehugoFd5iXyC4ci1nhXU13mxEwCnrnTIiiSGwa/uAF1g== +"@esbuild/linux-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.8.tgz#03199d91c76faf80bd54104f5cbf0a489bc39f6a" + integrity sha512-mM/9S0SbAFDBc4OPoyP6SEOo5324LpUxdpeIUUSrSTOfhHU9hEfqRngmKgqILqwx/0DVJBzeNW7HmLEWp9vcOA== -"@esbuild/netbsd-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.5.tgz#47bb187b86aad9622051cb80c27e439b7d9e3a9a" - integrity sha512-cigBpdiSx/vPy7doUyImsQQBnBjV5f1M99ZUlaJckDAJjgXWl6y9W17FIfJTy8TxosEF6MXq+fpLsitMGts2nA== +"@esbuild/netbsd-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.8.tgz#b436d767e1b21852f9ed212e2bb57f77203b0ae2" + integrity sha512-eKUYcWaWTaYr9zbj8GertdVtlt1DTS1gNBWov+iQfWuWyuu59YN6gSEJvFzC5ESJ4kMcKR0uqWThKUn5o8We6Q== -"@esbuild/openbsd-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.5.tgz#abc55c35a1ed2bc3c5ede2ef50a3b2f87395009a" - integrity sha512-VdqRqPVIjjZfkf40LrqOaVuhw9EQiAZ/GNCSM2UplDkaIzYVsSnycxcFfAnHdWI8Gyt6dO15KHikbpxwx+xHbw== +"@esbuild/openbsd-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.8.tgz#d1481d8539e21d4729cd04a0450a26c2c8789e89" + integrity sha512-Vc9J4dXOboDyMXKD0eCeW0SIeEzr8K9oTHJU+Ci1mZc5njPfhKAqkRt3B/fUNU7dP+mRyralPu8QUkiaQn7iIg== -"@esbuild/sunos-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.5.tgz#b83c080a2147662599a5d18b2ff47f07c93e03a0" - integrity sha512-ItxPaJ3MBLtI4nK+mALLEoUs6amxsx+J1ibnfcYMkqaCqHST1AkF4aENpBehty3czqw64r/XqL+W9WqU6kc2Qw== +"@esbuild/sunos-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.8.tgz#2cfb8126e079b2c00fd1bf095541e9f5c47877e4" + integrity sha512-0xvOTNuPXI7ft1LYUgiaXtpCEjp90RuBBYovdd2lqAFxje4sEucurg30M1WIm03+3jxByd3mfo+VUmPtRSVuOw== -"@esbuild/win32-arm64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.5.tgz#2a4c41f427d9cf25b75f9d61493711a482106850" - integrity sha512-4u2Q6qsJTYNFdS9zHoAi80spzf78C16m2wla4eJPh4kSbRv+BpXIfl6TmBSWupD8e47B1NrTfrOlEuco7mYQtg== +"@esbuild/win32-arm64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.8.tgz#7c6ecfd097ca23b82119753bf7072bbaefe51e3a" + integrity sha512-G0JQwUI5WdEFEnYNKzklxtBheCPkuDdu1YrtRrjuQv30WsYbkkoixKxLLv8qhJmNI+ATEWquZe/N0d0rpr55Mg== -"@esbuild/win32-ia32@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.5.tgz#7c14e3250725d0e2c21f89c98eb6abb520cba0e0" - integrity sha512-KYlm+Xu9TXsfTWAcocLuISRtqxKp/Y9ZBVg6CEEj0O5J9mn7YvBKzAszo2j1ndyzUPk+op+Tie2PJeN+BnXGqQ== +"@esbuild/win32-ia32@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.8.tgz#cffec63c3cb0ef8563a04df4e09fa71056171d00" + integrity sha512-Fqy63515xl20OHGFykjJsMnoIWS+38fqfg88ClvPXyDbLtgXal2DTlhb1TfTX34qWi3u4I7Cq563QcHpqgLx8w== -"@esbuild/win32-x64@0.17.5": - version "0.17.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.5.tgz#a8f3d26d8afc5186eccda265ceb1820b8e8830be" - integrity sha512-XgA9qWRqby7xdYXuF6KALsn37QGBMHsdhmnpjfZtYxKxbTOwfnDM6MYi2WuUku5poNaX2n9XGVr20zgT/2QwCw== +"@esbuild/win32-x64@0.17.8": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.8.tgz#200a0965cf654ac28b971358ecdca9cc5b44c335" + integrity sha512-1iuezdyDNngPnz8rLRDO2C/ZZ/emJLb72OsZeqQ6gL6Avko/XCXZw+NuxBSNhBAP13Hie418V7VMt9et1FMvpg== "@eslint/eslintrc@^0.4.3": version "0.4.3" @@ -179,28 +179,28 @@ resolved "https://registry.yarnpkg.com/@fontsource/lexend/-/lexend-4.5.15.tgz#ee033b850224d05b665d6661bf8d32c950f166bb" integrity sha512-6edLmDmte8pJWtQ1NIahcJq0iWlf6b0/JLwkd7WbDQ3C5tZWnxjvbP4RSklMv4MPzGjpuYuYnc+QANbjUws1oA== -"@fortawesome/fontawesome-free@^6.2.1": - version "6.2.1" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz#344baf6ff9eaad7a73cff067d8c56bfc11ae5304" - integrity sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A== +"@fortawesome/fontawesome-free@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.3.0.tgz#b5877182692a6f7a39d1108837bec24247ba4bd7" + integrity sha512-qVtd5i1Cc7cdrqnTWqTObKQHjPWAiRwjUPaXObaeNPcy7+WKxJumGBx66rfSFgK6LNpIasVKkEgW8oyf0tmPLA== "@hotwired/stimulus@^3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.1.tgz#e3de23623b0c52c247aba4cd5d530d257008676b" integrity sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ== -"@hotwired/turbo-rails@^7.2.4": - version "7.2.4" - resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.2.4.tgz#d155533e79c4ebdac23e8fe12697d821d5c06307" - integrity sha512-givDUQqaccd19BvErz1Cf2j6MXF74m0G6I75oqFJGeXAa7vwkz9nDplefVNrALCR9Xi9j9gy32xmSI6wD0tZyA== +"@hotwired/turbo-rails@^7.2.5": + version "7.2.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.2.5.tgz#74fc3395a29a76df2bb8835aa88c86885cffde4c" + integrity sha512-F8ztmARxd/XBdevRa//HoJGZ7u+Unb0J7cQUeUP+pBvt9Ta2TJJ7a2TORAOhjC8Zgxx+LKwm/1UUHqN3ojjiGw== dependencies: - "@hotwired/turbo" "^7.2.4" + "@hotwired/turbo" "^7.2.5" "@rails/actioncable" "^7.0" -"@hotwired/turbo@^7.2.4": - version "7.2.4" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.2.4.tgz#0d35541be32cfae3b4f78c6ab9138f5b21f28a21" - integrity sha512-c3xlOroHp/cCZHDOuLp6uzQYEbvXBUVaal0puXoGJ9M8L/KHwZ3hQozD4dVeSN9msHWLxxtmPT1TlCN7gFhj4w== +"@hotwired/turbo@^7.2.5": + version "7.2.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.2.5.tgz#2d9d6bde8a9549c3aea8970445ade16ffd56719a" + integrity sha512-o5PByC/mWkmTe4pWnKrixhPECJUxIT/NHtxKqjq7n9Fj6JlNza1pgxdTCJVIq+PI0j95U+7mA3N4n4A/QYZtZQ== "@humanwhocodes/config-array@^0.5.0": version "0.5.0" @@ -835,33 +835,33 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild@^0.17.5: - version "0.17.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.5.tgz#cd76d75700d49ac050ad9eedfbed777bd6a9d930" - integrity sha512-Bu6WLCc9NMsNoMJUjGl3yBzTjVLXdysMltxQWiLAypP+/vQrf+3L1Xe8fCXzxaECus2cEJ9M7pk4yKatEwQMqQ== +esbuild@^0.17.8: + version "0.17.8" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.8.tgz#f7f799abc7cdce3f0f2e3e0c01f120d4d55193b4" + integrity sha512-g24ybC3fWhZddZK6R3uD2iF/RIPnRpwJAqLov6ouX3hMbY4+tKolP0VMF3zuIYCaXun+yHwS5IPQ91N2BT191g== optionalDependencies: - "@esbuild/android-arm" "0.17.5" - "@esbuild/android-arm64" "0.17.5" - "@esbuild/android-x64" "0.17.5" - "@esbuild/darwin-arm64" "0.17.5" - "@esbuild/darwin-x64" "0.17.5" - "@esbuild/freebsd-arm64" "0.17.5" - "@esbuild/freebsd-x64" "0.17.5" - "@esbuild/linux-arm" "0.17.5" - "@esbuild/linux-arm64" "0.17.5" - "@esbuild/linux-ia32" "0.17.5" - "@esbuild/linux-loong64" "0.17.5" - "@esbuild/linux-mips64el" "0.17.5" - "@esbuild/linux-ppc64" "0.17.5" - "@esbuild/linux-riscv64" "0.17.5" - "@esbuild/linux-s390x" "0.17.5" - "@esbuild/linux-x64" "0.17.5" - "@esbuild/netbsd-x64" "0.17.5" - "@esbuild/openbsd-x64" "0.17.5" - "@esbuild/sunos-x64" "0.17.5" - "@esbuild/win32-arm64" "0.17.5" - "@esbuild/win32-ia32" "0.17.5" - "@esbuild/win32-x64" "0.17.5" + "@esbuild/android-arm" "0.17.8" + "@esbuild/android-arm64" "0.17.8" + "@esbuild/android-x64" "0.17.8" + "@esbuild/darwin-arm64" "0.17.8" + "@esbuild/darwin-x64" "0.17.8" + "@esbuild/freebsd-arm64" "0.17.8" + "@esbuild/freebsd-x64" "0.17.8" + "@esbuild/linux-arm" "0.17.8" + "@esbuild/linux-arm64" "0.17.8" + "@esbuild/linux-ia32" "0.17.8" + "@esbuild/linux-loong64" "0.17.8" + "@esbuild/linux-mips64el" "0.17.8" + "@esbuild/linux-ppc64" "0.17.8" + "@esbuild/linux-riscv64" "0.17.8" + "@esbuild/linux-s390x" "0.17.8" + "@esbuild/linux-x64" "0.17.8" + "@esbuild/netbsd-x64" "0.17.8" + "@esbuild/openbsd-x64" "0.17.8" + "@esbuild/sunos-x64" "0.17.8" + "@esbuild/win32-arm64" "0.17.8" + "@esbuild/win32-ia32" "0.17.8" + "@esbuild/win32-x64" "0.17.8" escape-string-regexp@^1.0.5: version "1.0.5" @@ -2110,10 +2110,10 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -sass@^1.57.1: - version "1.57.1" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.57.1.tgz#dfafd46eb3ab94817145e8825208ecf7281119b5" - integrity sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw== +sass@^1.58.0: + version "1.58.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.58.0.tgz#ee8aea3ad5ea5c485c26b3096e2df6087d0bb1cc" + integrity sha512-PiMJcP33DdKtZ/1jSjjqVIKihoDc6yWmYr9K/4r3fVVIEDAluD0q7XZiRKrNJcPK3qkLRF/79DND1H5q1LBjgg== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -2176,12 +2176,7 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" -"source-map-js@>=0.6.2 <2.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.1.tgz#a1741c131e3c77d048252adfa24e23b908670caf" - integrity sha512-4+TN2b3tqOCd/kaGRJ/sTYA0tR0mdXx26ipdolxcwtJVqEnqNYvlCAt1q3ypy4QMlYus+Zh34RNtYLoq2oQ4IA== - -source-map-js@^1.0.2: +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== @@ -2318,10 +2313,10 @@ stylelint-config-standard@^29.0.0: dependencies: stylelint-config-recommended "^9.0.0" -stylelint-scss@^4.0.0, stylelint-scss@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-4.3.0.tgz#638800faf823db11fff60d537c81051fe74c90fa" - integrity sha512-GvSaKCA3tipzZHoz+nNO7S02ZqOsdBzMiCx9poSmLlb3tdJlGddEX/8QzCOD8O7GQan9bjsvLMsO5xiw6IhhIQ== +stylelint-scss@^4.0.0, stylelint-scss@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-4.4.0.tgz#87ce9d049eff1ce67cce788780fbfda63099017e" + integrity sha512-Qy66a+/30aylFhPmUArHhVsHOun1qrO93LGT15uzLuLjWS7hKDfpFm34mYo1ndR4MCo8W4bEZM1+AlJRJORaaw== dependencies: lodash "^4.17.21" postcss-media-query-parser "^0.2.3" @@ -2512,10 +2507,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@^4.9.4: - version "4.9.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" - integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +typescript@^4.9.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== unbox-primitive@^1.0.1: version "1.0.1"