Merge remote-tracking branch 'origin/main'

This commit is contained in:
Kay Faraday 2023-02-15 07:39:23 +00:00
commit a7b9c31c48
100 changed files with 1374 additions and 1003 deletions

57
.github/workflows/build-image.yml vendored Normal file
View File

@ -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 }}

View File

@ -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:

View File

@ -131,3 +131,6 @@ Style/Encoding:
Style/EndlessMethod:
EnforcedStyle: allow_always
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: consistent_comma

View File

@ -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,

79
Containerfile Normal file
View File

@ -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

15
Gemfile
View File

@ -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"

View File

@ -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

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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<string, string>;
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<string, string>): 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();
}
}

View File

@ -0,0 +1,17 @@
import { Controller } from '@hotwired/stimulus';
import { showNotification } from "utilities/notifications";
export default class extends Controller<HTMLElement> {
static values = {
message: String,
success: Boolean
};
declare readonly messageValue: string;
declare readonly successValue: boolean;
connect(): void {
showNotification(this.messageValue, this.successValue);
this.element.remove();
}
}

View File

@ -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<HTMLInputElement>('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<HTMLElement>('.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<HTMLButtonElement>(`button[name="ib-answer"][data-ib-id="${inboxId}"]`).click();
}
}
}

View File

@ -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 }
]);
}

View File

@ -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');
}
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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

View File

@ -1,4 +0,0 @@
# frozen_string_literal: true
class Notification::ServiceTokenExpired < Notification
end

View File

@ -1,4 +0,0 @@
# frozen_string_literal: true
class Notification::TwitterTokenExpired < Notification::ServiceTokenExpired
end

View File

@ -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

View File

@ -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

View File

@ -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") },

View File

@ -1,5 +0,0 @@
# frozen_string_literal: true
# stub model for notifying about expired service connections
class User::ExpiredServiceConnection < User
end

View File

@ -1,4 +0,0 @@
# frozen_string_literal: true
class User::ExpiredTwitterServiceConnection < User::ExpiredServiceConnection
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module User::SharingMethods
def display_sharing_custom_url
URI(sharing_custom_url).host
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1,2 @@
= turbo_stream.update("ab-pin-#{answer.id}") do
= render "actions/pin", answer:

View File

@ -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 }

View File

@ -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))

View File

@ -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")

View File

@ -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

View File

@ -31,6 +31,7 @@
= render 'shared/announcements'
= yield
= render "shared/formatting"
.d-none#toasts
- if Rails.env.development?
#debug
%hr

View File

@ -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"

View File

@ -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))

View File

@ -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)

View File

@ -1,4 +0,0 @@
= render "settings/services", services: @services
- provide(:title, generate_title(t(".title")))
- parent_layout "user/settings"

View File

@ -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: "^[^@]*$"

View File

@ -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"

View File

@ -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"

View File

@ -0,0 +1 @@
.d-none{ data: { controller: "toast", toast_message_value: message, toast_success_value: success } }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"],
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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."

View File

@ -27,10 +27,11 @@ en:
header: "Share your answers"
body_html: |
<p>Want your followers on another platform to see your %{app_name} answers?
You can configure automatic sharing to your favourite platforms easily.</p>
You can easily share them to your favourite platforms.</p>
<p class="text-muted">Not sure if it's a favourite, but at the moment only
<b>Twitter</b> is supported.</p>
<p class="text-muted">We support <strong>Tumblr</strong>, <strong>Twitter</strong>,
and many other services including <strong>Mastodon</strong> and
<strong>Misskey</strong>.</p>
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: |
<p>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.</p>
<p>We will prepend a URL-encoded version of the answer to the end of the given link.</p>
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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}
]
}

View File

@ -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",

View File

@ -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

View File

@ -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 <strong>oarger</strong> 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]

View File

@ -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

View File

@ -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

285
yarn.lock
View File

@ -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"