Merge remote-tracking branch 'origin/main'

This commit is contained in:
Kay Faraday 2024-07-20 15:15:27 -07:00
commit 1aca0560f6
371 changed files with 4788 additions and 3184 deletions

View File

@ -1,14 +1,20 @@
FROM ruby:3.1 FROM ruby:3.2
USER root USER root
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
ARG NODE_MAJOR=16
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - RUN apt-get update -qq \
&& apt-get install -y ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends build-essential \ && apt-get install -y --no-install-recommends build-essential \

2
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,2 @@
71894d6c4987547533606258447b576ecb604c2b
1532741485af266f7ff04a1d6529abe9807a6815

View File

@ -24,7 +24,8 @@ using. We find screenshots (for front-end issues) very helpful.
We love pull requests! We are very happy to work with you to get your changes We love pull requests! We are very happy to work with you to get your changes
merged in, however please keep the following in mind. merged in, however please keep the following in mind.
* Please use the core team standard of `feature/*` or `fix/*` branch naming. * Please use the core team standard of `feature/*` or `bugfix/*` branch naming.
* Using these branch prefixes tags the Pull Requests with the appropriate labels for release categorization.
* Adhere to the coding conventions you see in the surrounding code. * Adhere to the coding conventions you see in the surrounding code.
* If you include a new feature also include tests, and make sure they'll pass. * If you include a new feature also include tests, and make sure they'll pass.
* Before submitting a pull-request, clean up the history by going over your * Before submitting a pull-request, clean up the history by going over your

View File

@ -18,6 +18,8 @@ updates:
schedule: schedule:
interval: weekly interval: weekly
open-pull-requests-limit: 99 open-pull-requests-limit: 99
ignore:
- dependency-name: 'carrierwave_backgrounder'
allow: allow:
- dependency-type: direct - dependency-type: direct

5
.github/labeler.yml vendored Normal file
View File

@ -0,0 +1,5 @@
feature:
- head-branch: ['^feature', 'feature']
bugfix:
- head-branch: ['^bugfix', 'bugfix']

17
.github/release.yml vendored Normal file
View File

@ -0,0 +1,17 @@
changelog:
categories:
- title: Added
labels:
- feature
- title: Fixed
labels:
- bugfix
- title: Changed
labels:
- '*'
- title: Developer experience
labels:
- developer experience
- title: Dependency updates
labels:
- dependencies

View File

@ -20,7 +20,7 @@ jobs:
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Discover build-time variables - name: Discover build-time variables
run: | run: |
@ -38,7 +38,7 @@ jobs:
esac esac
- name: Login to registry - name: Login to registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -46,7 +46,7 @@ jobs:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
build-args: | build-args: |
BUNDLER_VERSION=${{ env.BUNDLER_VERSION }} BUNDLER_VERSION=${{ env.BUNDLER_VERSION }}

View File

@ -33,11 +33,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.0.0 uses: actions/checkout@v4.1.7
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -48,7 +48,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -62,4 +62,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3

13
.github/workflows/labeler.yml vendored Normal file
View File

@ -0,0 +1,13 @@
name: "Pull Request Labeler"
on:
- pull_request_target
jobs:
triage:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5

View File

@ -11,10 +11,10 @@ jobs:
name: Rubocop name: Rubocop
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@v39 uses: tj-actions/changed-files@v44
with: with:
files: "**/*.rb" files: "**/*.rb"
- name: Install dependencies - name: Install dependencies
@ -37,16 +37,16 @@ jobs:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@v39 uses: tj-actions/changed-files@v44
with: with:
files: "**/*.ts" files: "**/*.ts"
- name: Set up Node 14 - name: Set up Node 16
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '14' node-version: '16'
cache: 'yarn' cache: 'yarn'
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
- name: Install node modules - name: Install node modules
@ -63,10 +63,10 @@ jobs:
haml-lint: haml-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@v39 uses: tj-actions/changed-files@v44
with: with:
files: "**/*.haml" files: "**/*.haml"
- name: Install dependencies - name: Install dependencies
@ -86,16 +86,16 @@ jobs:
stylelint: stylelint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@v39 uses: tj-actions/changed-files@v44
with: with:
files: "**/*.scss" files: "**/*.scss"
- name: Set up Node 14 - name: Set up Node 16
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '14' node-version: '16'
cache: 'yarn' cache: 'yarn'
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
- name: Install node modules - name: Install node modules
@ -104,7 +104,7 @@ jobs:
yarn install --frozen-lockfile yarn install --frozen-lockfile
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
- name: stylelint - name: stylelint
uses: reviewdog/action-stylelint@v1.18.1 uses: reviewdog/action-stylelint@v1.26.0
with: with:
github_token: ${{ secrets.github_token }} github_token: ${{ secrets.github_token }}
reporter: github-pr-check reporter: github-pr-check

View File

@ -41,17 +41,17 @@ jobs:
BUNDLE_WITHOUT: 'production' BUNDLE_WITHOUT: 'production'
steps: steps:
- uses: actions/checkout@v4.0.0 - uses: actions/checkout@v4.1.7
- name: Install dependencies - name: Install dependencies
run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- name: Set up Node 14 - name: Set up Node 16
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '14' node-version: '16'
cache: 'yarn' cache: 'yarn'
- name: Copy default configuration - name: Copy default configuration
run: | run: |
@ -75,7 +75,7 @@ jobs:
env: env:
POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
REDIS_URL: "redis://localhost:${{ job.services.redis.ports[6379] }}" REDIS_URL: "redis://localhost:${{ job.services.redis.ports[6379] }}"
- uses: codecov/codecov-action@v3 - uses: codecov/codecov-action@v4
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage/coverage.xml file: ./coverage/coverage.xml

View File

@ -27,43 +27,36 @@ Lint/NestedMethodDefinition:
Exclude: Exclude:
- api/sinatra/**/* - api/sinatra/**/*
Lint/MissingSuper:
Exclude:
- app/components/**/*
### Metrics ### Metrics
Metrics/AbcSize: Metrics/AbcSize:
Max: 20 Enabled: false
Exclude:
- 'db/**/*'
Layout/LineLength: Layout/LineLength:
Enabled: false Enabled: false
Metrics/MethodLength: Metrics/MethodLength:
Max: 15 Enabled: false
Exclude:
- 'db/migrate/*.rb'
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Enabled: false
- '*.gemspec'
- '**/*.rake'
- 'api/**/*'
- 'app/api/routes.rb'
- 'config/initialize/**/*'
- 'config/initializers/**/*'
- 'spec/**/*'
Metrics/ClassLength: Metrics/ClassLength:
Exclude: Enabled: false
- spec/**/*
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Severity: refactor Enabled: false
Metrics/PerceivedComplexity:
Enabled: false
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Enabled: false
- 'app/api/routes.rb'
- 'spec/requests/**/*'
### Style / Layout ### Style / Layout
@ -137,3 +130,7 @@ Style/TrailingCommaInHashLiteral:
Style/TrailingCommaInArguments: Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: consistent_comma EnforcedStyleForMultiline: consistent_comma
Style/RedundantSelf:
Exclude:
- app/models/**/*

View File

@ -1 +1 @@
3.1.2 3.2.3

View File

@ -1,6 +1,6 @@
# Container image for a production Retrospring setup # Container image for a production Retrospring setup
FROM registry.opensuse.org/opensuse/leap:15.4 FROM registry.opensuse.org/opensuse/leap:15.5
LABEL org.opencontainers.image.title="Retrospring (production)" LABEL org.opencontainers.image.title="Retrospring (production)"
LABEL org.opencontainers.image.description="Image containing everything to run Retrospring in production mode. Do not use this for development." LABEL org.opencontainers.image.description="Image containing everything to run Retrospring in production mode. Do not use this for development."
@ -8,14 +8,15 @@ LABEL org.opencontainers.image.vendor="The Retrospring team"
LABEL org.opencontainers.image.url="https://github.com/Retrospring/retrospring" LABEL org.opencontainers.image.url="https://github.com/Retrospring/retrospring"
ARG RETROSPRING_VERSION=2023.0131.1 ARG RETROSPRING_VERSION=2023.0131.1
ARG RUBY_VERSION=3.1.2 ARG RUBY_VERSION=3.2.3
ARG RUBY_INSTALL_VERSION=0.9.0 ARG RUBY_INSTALL_VERSION=0.9.3
ARG BUNDLER_VERSION=2.3.18 ARG BUNDLER_VERSION=2.5.5
ENV RAILS_ENV=production ENV RAILS_ENV=production
# update and install dependencies # update and install dependencies
RUN zypper up -y \ RUN zypper addrepo https://download.opensuse.org/repositories/devel:languages:nodejs/15.5/devel:languages:nodejs.repo \
&& zypper --gpg-auto-import-keys up -y \
&& zypper in -y \ && zypper in -y \
# build dependencies (ruby-install) # build dependencies (ruby-install)
automake \ automake \
@ -25,18 +26,20 @@ RUN zypper up -y \
libffi-devel \ libffi-devel \
libopenssl-devel \ libopenssl-devel \
libyaml-devel \ libyaml-devel \
jemalloc-devel \
make \ make \
ncurses-devel \ ncurses-devel \
readline-devel \ readline-devel \
tar \ tar \
xz \ xz \
zlib-devel \ zlib-devel \
curl \
# build dependencies (app) # build dependencies (app)
gcc-c++ \ gcc-c++ \
git \ git \
libidn-devel \ libidn-devel \
nodejs14 \ nodejs16 \
npm14 \ npm16 \
postgresql-devel \ postgresql-devel \
# runtime dependencies # runtime dependencies
ImageMagick \ ImageMagick \
@ -50,7 +53,7 @@ RUN curl -Lo ruby-install-${RUBY_INSTALL_VERSION}.tar.gz https://github.com/post
&& tar xvf ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \ && tar xvf ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \
&& (cd ruby-install-${RUBY_INSTALL_VERSION} && make install) \ && (cd ruby-install-${RUBY_INSTALL_VERSION} && make install) \
&& rm -rf ruby-install-${RUBY_INSTALL_VERSION} ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \ && 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 \ && ruby-install --no-install-deps --cleanup --system --jobs=$(nproc) ruby ${RUBY_VERSION} -- --disable-install-rdoc --with-jemalloc \
&& gem install bundler:${BUNDLER_VERSION} && gem install bundler:${BUNDLER_VERSION}
# create user and dirs to run retrospring in # create user and dirs to run retrospring in

37
Gemfile
View File

@ -3,11 +3,11 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "i18n-js", "4.0" gem "i18n-js", "4.0"
gem "rails", "~> 6.1" gem "rails", "~> 7.0.8"
gem "rails-i18n", "~> 7.0" gem "rails-i18n", "~> 7.0"
gem "cssbundling-rails", "~> 1.2" gem "cssbundling-rails", "~> 1.4"
gem "jsbundling-rails", "~> 1.1" gem "jsbundling-rails", "~> 1.3"
gem "sassc-rails" gem "sassc-rails"
gem "sprockets", "~> 4.2" gem "sprockets", "~> 4.2"
gem "sprockets-rails", require: "sprockets/railtie" gem "sprockets-rails", require: "sprockets/railtie"
@ -16,13 +16,13 @@ gem "pg"
gem "turbo-rails" gem "turbo-rails"
gem "bcrypt", "~> 3.1.19" gem "bcrypt", "~> 3.1.20"
gem "active_model_otp" gem "active_model_otp"
gem "bootsnap", require: false gem "bootsnap", require: false
gem "bootstrap_form", "~> 5.0" gem "bootstrap_form", "~> 5.0"
gem "carrierwave", "~> 2.0" gem "carrierwave", "~> 2.1"
gem "carrierwave_backgrounder", git: "https://github.com/raccube/carrierwave_backgrounder.git" gem "carrierwave_backgrounder", "~> 0.4.2"
gem "colorize" gem "colorize"
gem "devise", "~> 4.9" gem "devise", "~> 4.9"
gem "devise-async" gem "devise-async"
@ -30,12 +30,13 @@ gem "devise-i18n"
gem "fog-aws" gem "fog-aws"
gem "fog-core" gem "fog-core"
gem "fog-local" gem "fog-local"
gem "haml", "~> 6.1" gem "haml", "~> 6.3"
gem "hcaptcha", "~> 7.0" gem "hcaptcha", git: "https://github.com/retrospring/hcaptcha", ref: "fix/flash-in-turbo-streams"
gem "mini_magick" gem "mini_magick"
gem "oj" gem "oj"
gem "rpush" gem "rpush"
gem "rqrcode" gem "rqrcode"
gem "web-push"
gem "rolify", "~> 6.0" gem "rolify", "~> 6.0"
@ -49,6 +50,7 @@ gem "sentry-ruby"
gem "sentry-sidekiq" gem "sentry-sidekiq"
gem "sidekiq", "< 7" # remove version constraint once are ready to upgrade https://github.com/mperham/sidekiq/blob/main/docs/7.0-Upgrade.md gem "sidekiq", "< 7" # remove version constraint once are ready to upgrade https://github.com/mperham/sidekiq/blob/main/docs/7.0-Upgrade.md
gem "sidekiq-scheduler"
gem "questiongenerator", "~> 1.2", git: 'https://lab.freak.university/FreakU/questiongenerator' gem "questiongenerator", "~> 1.2", git: 'https://lab.freak.university/FreakU/questiongenerator'
@ -66,11 +68,12 @@ gem "fake_email_validator"
# TLD validation # TLD validation
gem "tldv", "~> 0.1.0" gem "tldv", "~> 0.1.0"
gem "jwt", "~> 2.7" gem "view_component"
gem "jwt", "~> 2.8"
group :development do group :development do
gem "binding_of_caller" gem "binding_of_caller"
gem "spring", "~> 4.1"
end end
gem "puma" gem "puma"
@ -79,7 +82,7 @@ group :development, :test do
gem "better_errors" gem "better_errors"
gem "bullet" gem "bullet"
gem "database_cleaner" gem "database_cleaner"
gem "dotenv-rails", "~> 2.8" gem "dotenv-rails", "~> 3.1"
gem "factory_bot_rails", require: false gem "factory_bot_rails", require: false
gem "faker" gem "faker"
gem "haml_lint", require: false gem "haml_lint", require: false
@ -89,11 +92,11 @@ group :development, :test do
gem "rake" gem "rake"
gem "rspec-its", "~> 1.3" gem "rspec-its", "~> 1.3"
gem "rspec-mocks" gem "rspec-mocks"
gem "rspec-rails", "~> 6.0" gem "rspec-rails", "~> 6.1"
gem "rspec-sidekiq", "~> 4.0", require: false gem "rspec-sidekiq", "~> 5.0", require: false
gem "rubocop", "~> 1.56" gem "rubocop", "~> 1.64"
gem "rubocop-rails", "~> 2.21" gem "rubocop-rails", "~> 2.25"
gem "shoulda-matchers", "~> 5.3" gem "shoulda-matchers", "~> 6.2"
gem "simplecov", require: false gem "simplecov", require: false
gem "simplecov-cobertura", require: false gem "simplecov-cobertura", require: false
gem "simplecov-json", require: false gem "simplecov-json", require: false
@ -112,7 +115,7 @@ gem "pundit", "~> 2.3"
gem "rubyzip", "~> 2.3" gem "rubyzip", "~> 2.3"
# to solve https://github.com/jwt/ruby-jwt/issues/526 # to solve https://github.com/jwt/ruby-jwt/issues/526
gem "openssl", "~> 3.1" gem "openssl", "~> 3.2"
# mail 2.8.0 breaks sendmail usage: https://github.com/mikel/mail/issues/1538 # mail 2.8.0 breaks sendmail usage: https://github.com/mikel/mail/issues/1538
gem "mail", "~> 2.7.1" gem "mail", "~> 2.7.1"

View File

@ -1,10 +1,10 @@
GIT GIT
remote: https://github.com/raccube/carrierwave_backgrounder.git remote: https://github.com/retrospring/hcaptcha
revision: 41b756f7514c0e410c561bc8b5ee321cd8cce1ee revision: f8de70ee2d629ac34395902dbee724c21297960c
ref: fix/flash-in-turbo-streams
specs: specs:
carrierwave_backgrounder (0.4.2) hcaptcha (7.1.0)
carrierwave (>= 0.5, <= 2.1) json
mime-types (>= 3.0.0)
GIT GIT
remote: https://lab.freak.university/FreakU/questiongenerator remote: https://lab.freak.university/FreakU/questiongenerator
@ -15,115 +15,127 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.7.6) actioncable (7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.6) actionmailbox (7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
activejob (= 6.1.7.6) activejob (= 7.0.8.4)
activerecord (= 6.1.7.6) activerecord (= 7.0.8.4)
activestorage (= 6.1.7.6) activestorage (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.7.6) net-imap
actionpack (= 6.1.7.6) net-pop
actionview (= 6.1.7.6) net-smtp
activejob (= 6.1.7.6) actionmailer (7.0.8.4)
activesupport (= 6.1.7.6) actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.7.6) actionpack (7.0.8.4)
actionview (= 6.1.7.6) actionview (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.6) actiontext (7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
activerecord (= 6.1.7.6) activerecord (= 7.0.8.4)
activestorage (= 6.1.7.6) activestorage (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.7.6) actionview (7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_otp (2.3.2) active_model_otp (2.3.4)
activemodel activemodel
rotp (~> 6.2.0) rotp (~> 6.3.0)
activejob (6.1.7.6) activejob (7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.7.6) activemodel (7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
activemodel-serializers-xml (1.0.2) activemodel-serializers-xml (1.0.2)
activemodel (> 5.x) activemodel (> 5.x)
activesupport (> 5.x) activesupport (> 5.x)
builder (~> 3.1) builder (~> 3.1)
activerecord (6.1.7.6) activerecord (7.0.8.4)
activemodel (= 6.1.7.6) activemodel (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
activestorage (6.1.7.6) activestorage (7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
activejob (= 6.1.7.6) activejob (= 7.0.8.4)
activerecord (= 6.1.7.6) activerecord (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.7.6) activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
zeitwerk (~> 2.3) addressable (2.8.6)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2) ast (2.4.2)
base64 (0.1.1) base64 (0.2.0)
bcrypt (3.1.19) bcrypt (3.1.20)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
rouge (>= 1.0.0) rouge (>= 1.0.0)
binding_of_caller (1.0.0) bigdecimal (3.1.8)
debug_inspector (>= 0.0.1) binding_of_caller (1.0.1)
bootsnap (1.16.0) debug_inspector (>= 1.2.0)
bootsnap (1.18.3)
msgpack (~> 1.2) msgpack (~> 1.2)
bootstrap_form (5.1.0) bootstrap_form (5.3.2)
actionpack (>= 5.2) actionpack (>= 6.1)
activemodel (>= 5.2) activemodel (>= 6.1)
builder (3.2.4) builder (3.3.0)
bullet (7.0.7) bullet (7.1.6)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
carrierwave (2.1.0) carrierwave (2.1.1)
activemodel (>= 5.0.0) activemodel (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
addressable (~> 2.6) addressable (~> 2.6)
image_processing (~> 1.1) image_processing (~> 1.1)
mimemagic (>= 0.3.0) mimemagic (>= 0.3.0)
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
ssrf_filter (~> 1.0)
carrierwave_backgrounder (0.4.3)
carrierwave (>= 0.5, < 2.2)
childprocess (5.0.0)
chunky_png (1.4.0) chunky_png (1.4.0)
colorize (1.1.0) colorize (1.1.0)
concurrent-ruby (1.2.2) concurrent-ruby (1.3.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
cssbundling-rails (1.2.0) cssbundling-rails (1.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
csv (3.3.0)
database_cleaner (2.0.2) database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0) database_cleaner-active_record (2.1.0)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.3) date (3.3.4)
debug_inspector (1.1.0) debug_inspector (1.2.0)
devise (4.9.2) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@ -132,15 +144,15 @@ GEM
devise-async (1.0.0) devise-async (1.0.0)
activejob (>= 5.0) activejob (>= 5.0)
devise (>= 4.0) devise (>= 4.0)
devise-i18n (1.10.3) devise-i18n (1.12.0)
devise (>= 4.8.0) devise (>= 4.9.0)
diff-lcs (1.5.0) diff-lcs (1.5.1)
docile (1.4.0) docile (1.4.0)
dotenv (2.8.1) dotenv (3.1.2)
dotenv-rails (2.8.1) dotenv-rails (3.1.2)
dotenv (= 2.8.1) dotenv (= 3.1.2)
railties (>= 3.2) railties (>= 6.1)
dry-core (1.0.0) dry-core (1.0.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
dry-inflector (1.0.0) dry-inflector (1.0.0)
@ -149,30 +161,33 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2) dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
dry-types (1.7.1) dry-types (1.7.2)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-core (~> 1.0) dry-core (~> 1.0)
dry-inflector (~> 1.0) dry-inflector (~> 1.0)
dry-logic (~> 1.4) dry-logic (~> 1.4)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
erubi (1.12.0) erubi (1.13.0)
excon (0.99.0) et-orbi (1.2.7)
factory_bot (6.2.0) tzinfo
excon (0.110.0)
factory_bot (6.4.5)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.2.0) factory_bot_rails (6.4.3)
factory_bot (~> 6.2.0) factory_bot (~> 6.4)
railties (>= 5.0.0) railties (>= 5.0.0)
fake_email_validator (1.0.11) fake_email_validator (1.0.11)
activemodel activemodel
mail mail
faker (3.1.1) faker (3.4.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
ffi (1.15.5) ffi (1.16.3)
fog-aws (3.19.0) fog-aws (3.23.0)
fog-core (~> 2.1) fog-core (~> 2.1)
fog-json (~> 1.1) fog-json (~> 1.1)
fog-xml (~> 0.1) fog-xml (~> 0.1)
fog-core (2.3.0) fog-core (2.4.0)
builder builder
excon (~> 0.71) excon (~> 0.71)
formatador (>= 0.2, < 2.0) formatador (>= 0.2, < 2.0)
@ -186,41 +201,44 @@ GEM
fog-core fog-core
nokogiri (>= 1.5.11, < 2.0.0) nokogiri (>= 1.5.11, < 2.0.0)
formatador (1.1.0) formatador (1.1.0)
glob (0.3.1) fugit (1.9.0)
globalid (1.1.0) et-orbi (~> 1, >= 1.2.7)
activesupport (>= 5.0) raabro (~> 1.4)
haml (6.1.2) glob (0.4.0)
globalid (1.2.1)
activesupport (>= 6.1)
haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
haml_lint (0.50.0) haml_lint (0.58.0)
haml (>= 4.0, < 6.2) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hcaptcha (7.1.0)
json
hkdf (0.3.0) hkdf (0.3.0)
http-2 (0.11.0) http-2 (0.11.0)
httparty (0.21.0) httparty (0.22.0)
csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
i18n (1.14.1) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-js (4.0.0) i18n-js (4.0.0)
glob glob
i18n i18n
idn-ruby (0.1.4) idn-ruby (0.1.5)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
jsbundling-rails (1.1.2) jsbundling-rails (1.3.0)
railties (>= 6.0.0) railties (>= 6.0.0)
json (2.6.3) json (2.7.2)
json-schema (4.0.0) json-schema (4.3.0)
addressable (>= 2.8) addressable (>= 2.8)
jwt (2.7.1) jwt (2.8.2)
base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@ -234,88 +252,91 @@ GEM
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (2.5.0) launchy (3.0.0)
addressable (~> 2.7) addressable (~> 2.8)
letter_opener (1.8.1) childprocess (~> 5.0)
launchy (>= 2.2, < 3) letter_opener (1.10.0)
lograge (0.13.0) launchy (>= 2.2, < 4)
lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.21.3) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.7.1) mail (2.7.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
marcel (1.0.2) marcel (1.0.4)
method_source (1.0.0) method_source (1.1.0)
mime-types (3.4.1) mime-types (3.5.2)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1) mime-types-data (3.2024.0604)
mimemagic (0.4.3) mimemagic (0.4.3)
nokogiri (~> 1) nokogiri (~> 1)
rake rake
mini_magick (4.12.0) mini_magick (4.13.1)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.4) mini_portile2 (2.8.7)
minitest (5.20.0) minitest (5.24.0)
msgpack (1.6.0) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.7.1)
bigdecimal (~> 3.1)
nested_form (0.3.2) nested_form (0.3.2)
net-http-persistent (4.0.1) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-http2 (0.18.4) net-http2 (0.18.5)
http-2 (~> 0.11) http-2 (~> 0.11)
net-imap (0.3.7) net-imap (0.4.14)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.1) net-protocol (0.2.2)
timeout timeout
net-smtp (0.3.3) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.7.0)
nokogiri (1.15.4) nokogiri (1.16.6)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.1) oj (3.16.4)
openssl (3.1.0) bigdecimal (>= 3.0)
openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
parallel (1.23.0) parallel (1.24.0)
parser (3.2.2.3) parser (3.3.2.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.4) pg (1.5.6)
pghero (3.3.4) pghero (3.5.0)
activerecord (>= 6) activerecord (>= 6)
prometheus-client (4.2.1) prometheus-client (4.2.2)
public_suffix (5.0.1) public_suffix (5.0.4)
puma (6.3.1) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.1) pundit (2.3.2)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
racc (1.7.1) raabro (1.4.0)
rack (2.2.8) racc (1.8.0)
rack (2.2.9)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (6.1.7.6) rails (7.0.8.4)
actioncable (= 6.1.7.6) actioncable (= 7.0.8.4)
actionmailbox (= 6.1.7.6) actionmailbox (= 7.0.8.4)
actionmailer (= 6.1.7.6) actionmailer (= 7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
actiontext (= 6.1.7.6) actiontext (= 7.0.8.4)
actionview (= 6.1.7.6) actionview (= 7.0.8.4)
activejob (= 6.1.7.6) activejob (= 7.0.8.4)
activemodel (= 6.1.7.6) activemodel (= 7.0.8.4)
activerecord (= 6.1.7.6) activerecord (= 7.0.8.4)
activestorage (= 6.1.7.6) activestorage (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.7.6) railties (= 7.0.8.4)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -327,7 +348,7 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
rails-i18n (7.0.8) rails-i18n (7.0.9)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
rails_admin (3.1.2) rails_admin (3.1.2)
@ -336,26 +357,28 @@ GEM
nested_form (~> 0.3) nested_form (~> 0.3)
rails (>= 6.0, < 8) rails (>= 6.0, < 8)
turbo-rails (~> 1.0) turbo-rails (~> 1.0)
railties (6.1.7.6) railties (7.0.8.4)
actionpack (= 6.1.7.6) actionpack (= 7.0.8.4)
activesupport (= 6.1.7.6) activesupport (= 7.0.8.4)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.2.1)
redcarpet (3.6.0) redcarpet (3.6.0)
redis (4.8.0) redis (4.8.1)
regexp_parser (2.8.1) regexp_parser (2.9.2)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.0) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.2.6) rexml (3.2.8)
strscan (>= 3.0.9)
rolify (6.0.1) rolify (6.0.1)
rotp (6.2.2) rotp (6.3.0)
rouge (4.1.2) rouge (4.1.3)
rpush (7.0.1) rpush (7.0.1)
activesupport (>= 5.2) activesupport (>= 5.2)
jwt (>= 1.5.6) jwt (>= 1.5.6)
@ -370,54 +393,56 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
rqrcode_core (1.2.0) rqrcode_core (1.2.0)
rspec-core (3.12.2) rspec-core (3.13.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-expectations (3.12.3) rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-its (1.3.0) rspec-its (1.3.0)
rspec-core (>= 3.0.0) rspec-core (>= 3.0.0)
rspec-expectations (>= 3.0.0) rspec-expectations (>= 3.0.0)
rspec-mocks (3.12.6) rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (6.0.3) rspec-rails (6.1.3)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
rspec-core (~> 3.12) rspec-core (~> 3.13)
rspec-expectations (~> 3.12) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.13)
rspec-support (~> 3.12) rspec-support (~> 3.13)
rspec-sidekiq (4.0.2) rspec-sidekiq (5.0.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.13.1)
rubocop (1.56.3) rubocop (1.64.1)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.3) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0) rubocop-ast (1.31.3)
parser (>= 3.2.1.0) parser (>= 3.3.1.0)
rubocop-rails (2.21.0) rubocop-rails (2.25.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.1.4) ruby-vips (2.2.1)
ffi (~> 1.12) ffi (~> 1.12)
rubyzip (2.3.2) rubyzip (2.3.2)
sanitize (6.0.2) rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6)
sanitize (6.1.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
sassc (2.4.0) sassc (2.4.0)
@ -428,20 +453,25 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
sentry-rails (5.10.0) sentry-rails (5.17.3)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.10.0) sentry-ruby (~> 5.17.3)
sentry-ruby (5.10.0) sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.10.0) sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.10.0) sentry-ruby (~> 5.17.3)
sidekiq (>= 3.0) sidekiq (>= 3.0)
shoulda-matchers (5.3.0) shoulda-matchers (6.2.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (6.5.8) sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3) connection_pool (>= 2.2.5, < 3)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.5.0, < 5) redis (>= 4.5.0, < 5)
sidekiq-scheduler (5.0.3)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0)
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
@ -454,23 +484,24 @@ GEM
json json
simplecov simplecov
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
spring (4.1.1)
sprockets (4.2.1) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2) sprockets-rails (3.5.1)
actionpack (>= 5.2) actionpack (>= 6.1)
activesupport (>= 5.2) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
ssrf_filter (1.1.2)
strscan (3.1.0)
sysexits (1.2.0) sysexits (1.2.0)
temple (0.10.2) temple (0.10.3)
thor (1.2.2) thor (1.3.1)
tilt (2.2.0) tilt (2.3.0)
timeout (0.4.0) timeout (0.4.1)
tldv (0.1.0) tldv (0.1.0)
tldv-data (~> 1.0) tldv-data (~> 1.0)
tldv-data (1.0.2023031000) tldv-data (1.0.2023080900)
turbo-rails (1.4.0) turbo-rails (1.5.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
@ -481,40 +512,47 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8) unf_ext (0.0.8.2)
unicode-display_width (2.4.2) unicode-display_width (2.5.0)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
view_component (3.12.1)
activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0)
method_source (~> 1.0)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
webpush (1.1.0) webpush (1.1.0)
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) jwt (~> 2.0)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.6.11) zeitwerk (2.6.16)
PLATFORMS PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
active_model_otp active_model_otp
bcrypt (~> 3.1.19) bcrypt (~> 3.1.20)
better_errors better_errors
binding_of_caller binding_of_caller
bootsnap bootsnap
bootstrap_form (~> 5.0) bootstrap_form (~> 5.0)
bullet bullet
carrierwave (~> 2.0) carrierwave (~> 2.1)
carrierwave_backgrounder! carrierwave_backgrounder (~> 0.4.2)
colorize colorize
connection_pool connection_pool
cssbundling-rails (~> 1.2) cssbundling-rails (~> 1.4)
database_cleaner database_cleaner
devise (~> 4.9) devise (~> 4.9)
devise-async devise-async
devise-i18n devise-i18n
dotenv-rails (~> 2.8) dotenv-rails (~> 3.1)
dry-initializer (~> 3.1) dry-initializer (~> 3.1)
dry-types (~> 1.7) dry-types (~> 1.7)
factory_bot_rails factory_bot_rails
@ -523,14 +561,14 @@ DEPENDENCIES
fog-aws fog-aws
fog-core fog-core
fog-local fog-local
haml (~> 6.1) haml (~> 6.3)
haml_lint haml_lint
hcaptcha (~> 7.0) hcaptcha!
httparty httparty
i18n-js (= 4.0) i18n-js (= 4.0)
jsbundling-rails (~> 1.1) jsbundling-rails (~> 1.3)
json-schema json-schema
jwt (~> 2.7) jwt (~> 2.8)
letter_opener letter_opener
lograge lograge
mail (~> 2.7.1) mail (~> 2.7.1)
@ -539,14 +577,13 @@ DEPENDENCIES
net-pop net-pop
net-smtp net-smtp
oj oj
openssl (~> 3.1) openssl (~> 3.2)
pg pg
pghero pghero
prometheus-client (~> 4.2) prometheus-client (~> 4.2)
puma puma
pundit (~> 2.3) pundit (~> 2.3)
questiongenerator (~> 1.2)! rails (~> 7.0.8)
rails (~> 6.1)
rails-controller-testing rails-controller-testing
rails-i18n (~> 7.0) rails-i18n (~> 7.0)
rails_admin rails_admin
@ -558,27 +595,29 @@ DEPENDENCIES
rqrcode rqrcode
rspec-its (~> 1.3) rspec-its (~> 1.3)
rspec-mocks rspec-mocks
rspec-rails (~> 6.0) rspec-rails (~> 6.1)
rspec-sidekiq (~> 4.0) rspec-sidekiq (~> 5.0)
rubocop (~> 1.56) rubocop (~> 1.64)
rubocop-rails (~> 2.21) rubocop-rails (~> 2.25)
rubyzip (~> 2.3) rubyzip (~> 2.3)
sanitize sanitize
sassc-rails sassc-rails
sentry-rails sentry-rails
sentry-ruby sentry-ruby
sentry-sidekiq sentry-sidekiq
shoulda-matchers (~> 5.3) shoulda-matchers (~> 6.2)
sidekiq (< 7) sidekiq (< 7)
sidekiq-scheduler
simplecov simplecov
simplecov-cobertura simplecov-cobertura
simplecov-json simplecov-json
spring (~> 4.1)
sprockets (~> 4.2) sprockets (~> 4.2)
sprockets-rails sprockets-rails
tldv (~> 0.1.0) tldv (~> 0.1.0)
turbo-rails turbo-rails
twitter-text twitter-text
view_component
web-push
BUNDLED WITH BUNDLED WITH
2.3.18 2.5.5

View File

@ -4,7 +4,7 @@ Currently the only change is to enable long questions by default.
## Licence ## Licence
Copyright (C) 2014-2022 The Retrospring team and contributors Copyright (C) 2014-2024 The Retrospring team and contributors
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

View File

@ -7,7 +7,7 @@ require File.expand_path("config/application", __dir__)
Rails.application.load_tasks Rails.application.load_tasks
namespace :justask do # rubocop:disable Metrics/BlockLength namespace :justask do
desc "Gives admin status to a user." desc "Gives admin status to a user."
task :admin, [:screen_name] => :environment do |_t, args| task :admin, [:screen_name] => :environment do |_t, args|
abort "screen name required" if args[:screen_name].nil? abort "screen name required" if args[:screen_name].nil?

View File

@ -0,0 +1 @@
// This is a stub so that we don't have to install actiontext

View File

@ -0,0 +1 @@
// This is a stub so that we don't have to install Trix

View File

@ -25,3 +25,12 @@
.pull-left { .pull-left {
float: left; float: left;
} }
// FIXME: Backport from Bootstrap 5.3, remove once updated
.z-n1 {
z-index: -1;
}
.grid-row-1 {
grid-row: 1;
}

View File

@ -1,8 +1,6 @@
@use "sass:map"; @use "sass:map";
.answerbox { .answerbox {
&__question-text,
&__question-user,
&__answer-user, &__answer-user,
&__answer-date { &__answer-date {
margin-bottom: 0; margin-bottom: 0;
@ -25,7 +23,6 @@
margin-bottom: map.get($spacers, 3); margin-bottom: map.get($spacers, 3);
} }
&__question-user-avatar,
&__answer-user-avatar { &__answer-user-avatar {
margin-right: map.get($spacers, 2); margin-right: map.get($spacers, 2);
border-radius: $avatar-border-radius; border-radius: $avatar-border-radius;
@ -38,8 +35,9 @@
} }
&__action { &__action {
padding-left: 0; color: RGBA(var(--raised-text), 0.75);
padding-right: map.get($spacers, 1); padding: var(--btn-padding-y);
margin-right: map.get($spacers, 1);
text-decoration: none; text-decoration: none;
& i { & i {
@ -50,24 +48,28 @@
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
color: RGBA(var(--raised-text), 1);
text-decoration: none; text-decoration: none;
} }
&[name="ab-smile"], &.smile {
&[name="ab-smile-comment"] {
color: var(--primary); color: var(--primary);
&:hover { &:hover {
color: var(--success); color: var(--success);
} }
}
&[data-action="unsmile"] { &.unsmile {
color: var(--success); color: var(--success);
&:hover { &:hover {
color: var(--danger); color: var(--danger);
} }
} }
&.dropdown-toggle::after {
margin-left: 0;
} }
} }
@ -97,9 +99,3 @@
display: inline; display: inline;
} }
} }
body:not(.cap-web-share) {
[name="ab-share"] {
display: none;
}
}

View File

@ -12,7 +12,7 @@
} }
} }
&.answerbox__question-text { &.question__text {
max-height: 15rem; max-height: 15rem;
@include media-breakpoint-up('sm') { @include media-breakpoint-up('sm') {

View File

@ -23,6 +23,10 @@
.text-muted { .text-muted {
color: RGBA(var(--primary-text), 0.8) !important; color: RGBA(var(--primary-text), 0.8) !important;
} }
.btn {
color: inherit;
}
} }
} }
@ -57,3 +61,10 @@
position: relative; position: relative;
} }
} }
body:not(.cap-web-share) {
[data-controller="share"] {
display: none;
}
}

View File

@ -1,18 +1,30 @@
@use "sass:map";
.question { .question {
&--fixed {
position: absolute; &__avatar {
margin-right: map.get($spacers, 2);
border-radius: $avatar-border-radius;
}
&__text,
&__user {
margin-bottom: 0;
word-break: break-word;
}
&__text {
overflow: hidden;
}
&--sticky {
border-radius: 0;
width: 100%; width: 100%;
z-index: 999;
@include media-breakpoint-up('sm') { @include media-breakpoint-up('sm') {
position: fixed; position: sticky;
top: $navbar-height;
z-index: 1;
} }
} }
&--hidden {
visibility: hidden;
position: relative;
box-shadow: none;
z-index: -1;
}
} }

View File

@ -1,5 +1,8 @@
.btn { .btn {
--btn-padding-x: 1rem;
--btn-border-radius: 2rem;
color: RGB(var(--body-text)); color: RGB(var(--body-text));
font-weight: bold;
} }
.btn-link:hover { .btn-link:hover {

View File

@ -20,3 +20,18 @@
background-color: var(--raised-accent); background-color: var(--raised-accent);
border: 0; border: 0;
} }
.form-check-input:checked {
background-color: var(--primary);
border-color: var(--primary);
}
.form-check-input:focus {
box-shadow: rgba(var(--primary-rgb), 0.25) 0 0 0 0.25rem;
}
.input-group .button_to .btn {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View File

@ -0,0 +1 @@
// This is a stub so that we don't have to install Trix

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ApplicationComponent < ViewComponent::Base
include ApplicationHelper
delegate :current_user, to: :helpers
delegate :user_signed_in?, to: :helpers
end

View File

@ -0,0 +1,4 @@
%img{ class: avatar_classes,
alt: alt_text,
src: avatar_image,
loading: :lazy }

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class AvatarComponent < ViewComponent::Base
ALLOWED_SIZES = %w[xs sm md lg xl xxl].freeze
def initialize(user:, size:, classes: [])
@user = user
@size = size if ALLOWED_SIZES.include? size
@classes = classes
end
private
def size_to_version(size)
case size
when "xs", "sm"
:small
when "md", "lg"
:medium
when "xl", "xxl"
:large
end
end
def alt_text = "@#{@user.screen_name}"
def avatar_classes = @classes.unshift("avatar-#{@size}")
def avatar_image = @user.profile_picture.url(size_to_version(@size))
end

View File

@ -0,0 +1,22 @@
%li.comment{ data: { comment_id: @comment.id } }
.d-flex
.flex-shrink-0
%a{ href: user_path(@comment.user), target: :_top }
= render AvatarComponent.new(user: @comment.user, size: "sm", classes: ["comment__user-avatar"])
.flex-grow-1
%h6.comment__user
= user_screen_name @comment.user
%span.text-muted
·
= time_tooltip @comment
.comment__content
= markdown @comment.content
.flex-shrink-0.ms-auto
- if current_user&.smiled?(@comment)
= render "reactions/destroy", type: "Comment", target: @comment
- else
= render "reactions/create", type: "Comment", target: @comment
.dropdown.d-inline
%button.btn.btn-link.answerbox__action{ data: { bs_toggle: :dropdown }, aria: { expanded: false } }
%i.fa.fa-fw.fa-ellipsis
= render "actions/comment", comment: @comment, answer: @answer

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CommentComponent < ApplicationComponent
include ApplicationHelper
include BootstrapHelper
include UserHelper
def initialize(comment:, answer:)
@comment = comment
@answer = answer
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class QuestionComponent < ApplicationComponent
include ApplicationHelper
include BootstrapHelper
include UserHelper
def initialize(question:, context_user: nil, collapse: true, hide_avatar: false, profile_question: false)
@question = question
@context_user = context_user
@collapse = collapse
@hide_avatar = hide_avatar
@profile_question = profile_question
end
private
def author_identifier = @question.author_is_anonymous ? @question.author_identifier : nil
def follower_question? = !@question.author_is_anonymous && !@question.direct && @question.answer_count.positive?
def hide_avatar? = @hide_avatar || @question.author_is_anonymous
end

View File

@ -0,0 +1,9 @@
en:
anon_hint: "This question was asked anonymously"
answers:
zero: "0 answers"
one: "1 answer"
other: "%{count} answers"
asked_html: "%{user} asked %{time} ago"
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"

View File

@ -0,0 +1,34 @@
.d-flex
- unless hide_avatar?
.flex-shrink-0
%a{ href: user_path(@question.user) }
= render AvatarComponent.new(user: @question.user, size: "md", classes: ["question__avatar"])
.flex-grow-1
%h6.text-muted.question__user
- if @question.author_is_anonymous
%span{ title: t(".anon_hint"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fas.fa-user-secret
- if @profile_question && @question.direct
- if user_signed_in? && @question.user == current_user
%span.d-inline-block{ title: t(".visible_to_you"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fa.fa-eye-slash
- elsif moderation_view?
%span{ title: t(".visible_mod_mode"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fa.fa-eye-slash
= user_screen_name(@question.user, context_user: @context_user, author_identifier: author_identifier)
- if follower_question?
·
%a{ href: question_path(@question.user.screen_name, @question.id), data: { selection_hotkey: "a" } }
= t(".answers", count: @question.answer_count)
·
= time_tooltip(@question)
- if user_signed_in?
.dropdown.d-inline
%button.btn.btn-link.btn-sm.p-0{ data: { bs_toggle: :dropdown }, aria: { expanded: false } }
%i.fa.fa-fw.fa-ellipsis
= render "actions/question", question: @question
.question__body{ data: { controller: @question.long? ? "collapse" : nil } }
.question__text{ class: @question.long? && @collapse ? "collapsed" : "", data: { collapse_target: "content" } }
= question_markdown @question.content
- if @question.long? && @collapse
= render "shared/collapse", type: "question"

View File

@ -3,9 +3,7 @@
require "cgi" require "cgi"
class Ajax::AnswerController < AjaxController class Ajax::AnswerController < AjaxController
include SocialHelper::TwitterMethods include SocialHelper
include SocialHelper::TumblrMethods
include SocialHelper::TelegramMethods
def create def create
params.require :id params.require :id
@ -15,7 +13,7 @@ class Ajax::AnswerController < AjaxController
inbox = (params[:inbox] == "true") inbox = (params[:inbox] == "true")
if inbox if inbox
inbox_entry = Inbox.find(params[:id]) inbox_entry = InboxEntry.find(params[:id])
unless current_user == inbox_entry.user unless current_user == inbox_entry.user
@response[:status] = :fail @response[:status] = :fail
@ -46,9 +44,8 @@ class Ajax::AnswerController < AjaxController
return if inbox return if inbox
# this assign is needed because shared/_answerbox relies on it, I think
@question = 1 @question = 1
@response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false, subscribed_answer_ids: [answer.id] }) @response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false })
end end
def destroy def destroy
@ -62,7 +59,7 @@ class Ajax::AnswerController < AjaxController
return return
end end
Inbox.create!(user: answer.user, question: answer.question, new: true, returning: true) if answer.user == current_user InboxEntry.create!(user: answer.user, question: answer.question, new: true, returning: true) if answer.user == current_user
answer.destroy answer.destroy
@response[:status] = :okay @response[:status] = :okay
@ -73,7 +70,10 @@ class Ajax::AnswerController < AjaxController
private private
def sharing_hash(answer) = { def sharing_hash(answer) = {
url: answer_share_url(answer),
text: prepare_tweet(answer, nil, true),
twitter: twitter_share_url(answer), twitter: twitter_share_url(answer),
bluesky: bluesky_share_url(answer),
tumblr: tumblr_share_url(answer), tumblr: tumblr_share_url(answer),
telegram: telegram_share_url(answer), telegram: telegram_share_url(answer),
custom: CGI.escape(prepare_tweet(answer)), custom: CGI.escape(prepare_tweet(answer)),

View File

@ -14,10 +14,12 @@ class Ajax::CommentController < AjaxController
return return
end end
comments = Comment.where(answer:).includes([{ user: :profile }, :smiles])
@response[:status] = :okay @response[:status] = :okay
@response[:message] = t(".success") @response[:message] = t(".success")
@response[:success] = true @response[:success] = true
@response[:render] = render_to_string(partial: 'answerbox/comments', locals: { a: answer }) @response[:render] = render_to_string(partial: "answerbox/comments", locals: { a: answer, comments: })
@response[:count] = answer.comment_count @response[:count] = answer.comment_count
end end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
class Ajax::InboxController < AjaxController class Ajax::InboxController < AjaxController
def remove def remove
params.require :id params.require :id
inbox = Inbox.find(params[:id]) inbox = InboxEntry.find(params[:id])
unless current_user == inbox.user unless current_user == inbox.user
@response[:status] = :fail @response[:status] = :fail
@ -28,7 +30,7 @@ class Ajax::InboxController < AjaxController
raise unless user_signed_in? raise unless user_signed_in?
begin begin
Inbox.where(user: current_user).each { |i| i.remove } InboxEntry.where(user: current_user).find_each(&:remove)
rescue => e rescue => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
@response[:status] = :err @response[:status] = :err
@ -43,10 +45,10 @@ class Ajax::InboxController < AjaxController
def remove_all_author def remove_all_author
begin begin
@target_user = User.where('LOWER(screen_name) = ?', params[:author].downcase).first! @target_user = User.where("LOWER(screen_name) = ?", params[:author].downcase).first!
@inbox = current_user.inboxes.joins(:question) @inbox = current_user.inbox_entries.joins(:question)
.where(questions: { user_id: @target_user.id, author_is_anonymous: false }) .where(questions: { user_id: @target_user.id, author_is_anonymous: false })
@inbox.each { |i| i.remove } @inbox.each(&:remove)
rescue => e rescue => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
@response[:status] = :err @response[:status] = :err

View File

@ -84,7 +84,7 @@ class Ajax::ModerationController < AjaxController
target_user = User.find_by_screen_name!(params[:user]) target_user = User.find_by_screen_name!(params[:user])
@response[:message] = t(".error") @response[:message] = t(".error")
return unless %w(moderator admin).include? params[:type].downcase return unless %w[moderator administrator].include? params[:type].downcase
unless current_user.has_cached_role?(:administrator) unless current_user.has_cached_role?(:administrator)
@response[:status] = :nopriv @response[:status] = :nopriv
@ -94,7 +94,7 @@ class Ajax::ModerationController < AjaxController
@response[:checked] = status @response[:checked] = status
type = params[:type].downcase type = params[:type].downcase
target_role = {'admin' => 'administrator'}.fetch(type, type).to_sym target_role = type.to_sym
if status if status
target_user.add_role target_role target_user.add_role target_role

View File

@ -1,19 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Ajax::QuestionController < AjaxController class Ajax::QuestionController < AjaxController
def destroy
params.require :question
UseCase::Question::Destroy.call(
question_id: params[:question],
current_user: current_user
)
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def create def create
params.require :question params.require :question
params.require :anonymousQuestion params.require :anonymousQuestion
@ -24,14 +11,15 @@ class Ajax::QuestionController < AjaxController
@response = { @response = {
success: true, success: true,
message: t(".success"), message: t(".success"),
status: :okay status: :okay,
} }
if user_signed_in? && params[:rcpt] == "followers" if user_signed_in? && params[:rcpt] == "followers"
UseCase::Question::CreateFollowers.call( UseCase::Question::CreateFollowers.call(
source_user_id: current_user.id, source_user_id: current_user.id,
content: params[:question], content: params[:question],
author_identifier: AnonymousBlock.get_identifier(request.remote_ip) author_identifier: AnonymousBlock.get_identifier(request.remote_ip),
send_to_own_inbox: params[:sendToOwnInbox],
) )
return return
end end
@ -41,7 +29,20 @@ class Ajax::QuestionController < AjaxController
target_user_id: params[:rcpt], target_user_id: params[:rcpt],
content: params[:question], content: params[:question],
anonymous: params[:anonymousQuestion], anonymous: params[:anonymousQuestion],
author_identifier: AnonymousBlock.get_identifier(request.remote_ip) author_identifier: AnonymousBlock.get_identifier(request.remote_ip),
) )
end end
def destroy
params.require :question
UseCase::Question::Destroy.call(
question_id: params[:question],
current_user:,
)
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
end end

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
class Ajax::RelationshipController < AjaxController
before_action :authenticate_user!
def create
params.require :screen_name
UseCase::Relationship::Create.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type]
)
@response[:success] = true
@response[:message] = t(".#{params[:type]}.success")
rescue Errors::Base => e
@response[:message] = t(e.locale_tag)
ensure
return_response
end
def destroy
UseCase::Relationship::Destroy.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type]
)
@response[:success] = true
@response[:message] = t(".#{params[:type]}.success")
rescue Errors::Base => e
@response[:message] = t(e.locale_tag)
ensure
return_response
end
end

View File

@ -1,85 +0,0 @@
class Ajax::SmileController < AjaxController
def create
params.require :id
answer = Answer.find(params[:id])
begin
current_user.smile answer
rescue Errors::Base => e
@response[:status] = e.code
@response[:message] = I18n.t(e.locale_tag)
return
rescue => e
Sentry.capture_exception(e)
@response[:status] = :fail
@response[:message] = t(".error")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def destroy
params.require :id
answer = Answer.find(params[:id])
begin
current_user.unsmile answer
rescue => e
Sentry.capture_exception(e)
@response[:status] = :fail
@response[:message] = t(".error")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def create_comment
params.require :id
comment = Comment.find(params[:id])
begin
current_user.smile comment
rescue Errors::Base => e
@response[:status] = e.code
@response[:message] = I18n.t(e.locale_tag)
return
rescue => e
Sentry.capture_exception(e)
@response[:status] = :fail
@response[:message] = t(".error")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def destroy_comment
params.require :id
comment = Comment.find(params[:id])
begin
current_user.unsmile comment
rescue => e
Sentry.capture_exception(e)
@response[:status] = :fail
@response[:message] = t(".error")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
end

View File

@ -1,17 +0,0 @@
class Ajax::SubscriptionController < AjaxController
before_action :authenticate_user!
def subscribe
params.require :answer
@response[:status] = :okay
result = Subscription.subscribe(current_user, Answer.find(params[:answer]))
@response[:success] = result.present?
end
def unsubscribe
params.require :answer
@response[:status] = :okay
result = Subscription.unsubscribe(current_user, Answer.find(params[:answer]))
@response[:success] = result&.destroyed? || false
end
end

View File

@ -41,7 +41,7 @@ class Ajax::WebPushController < AjaxController
@response[:message] = t(".subscription_count", count: current_user.web_push_subscriptions.count) @response[:message] = t(".subscription_count", count: current_user.web_push_subscriptions.count)
end end
def unsubscribe # rubocop:disable Metrics/AbcSize def unsubscribe
removed = if params.key?(:endpoint) removed = if params.key?(:endpoint)
current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).destroy_all current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).destroy_all
else else

View File

@ -20,8 +20,8 @@ class AnonymousBlockController < ApplicationController
target_user: question.user target_user: question.user
) )
inbox_id = question.inboxes.first&.id inbox_id = question.inbox_entries.first&.id
question.inboxes.first&.destroy question.inbox_entries.first&.destroy
respond_to do |format| respond_to do |format|
format.turbo_stream do format.turbo_stream do

View File

@ -8,13 +8,11 @@ class AnswerController < ApplicationController
turbo_stream_actions :pin, :unpin turbo_stream_actions :pin, :unpin
def show def show
@answer = Answer.includes(comments: %i[user smiles], question: [:user], smiles: [:user]).find(params[:id]) @answer = Answer.for_user(current_user).includes(question: [:user], smiles: [:user]).find(params[:id])
@display_all = true @display_all = true
@subscribed_answer_ids = []
return unless user_signed_in? return unless user_signed_in?
@subscribed_answer_ids = Subscription.where(user: current_user, answer: @answer).pluck(:answer_id)
mark_notifications_as_read mark_notifications_as_read
end end
@ -51,11 +49,12 @@ class AnswerController < ApplicationController
private private
def mark_notifications_as_read def mark_notifications_as_read
Notification.where(recipient_id: current_user.id, new: true) updated = Notification.where(recipient_id: current_user.id, new: true)
.and(Notification.where(type: "Notification::QuestionAnswered", target_id: @answer.id) .and(Notification.where(type: "Notification::QuestionAnswered", target_id: @answer.id)
.or(Notification.where(type: "Notification::Commented", target_id: @answer.comments.pluck(:id))) .or(Notification.where(type: "Notification::Commented", target_id: @answer.comments.pluck(:id)))
.or(Notification.where(type: "Notification::Smiled", target_id: @answer.smiles.pluck(:id))) .or(Notification.where(type: "Notification::Smiled", target_id: @answer.smiles.pluck(:id)))
.or(Notification.where(type: "Notification::CommentSmiled", target_id: @answer.comment_smiles.pluck(:id)))) .or(Notification.where(type: "Notification::CommentSmiled", target_id: @answer.comment_smiles.pluck(:id))))
.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations .update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
current_user.touch(:notifications_updated_at) if updated.positive?
end end
end end

View File

@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
around_action :switch_locale around_action :switch_locale
before_action :banned? before_action :banned?
before_action :find_active_announcements before_action :find_active_announcements
before_action :set_has_new_reports
# check if user wants to read # check if user wants to read
def switch_locale(&) def switch_locale(&)
@ -30,13 +31,15 @@ class ApplicationController < ActionController::Base
# obligatory '2001: A Space Odyssey' reference # obligatory '2001: A Space Odyssey' reference
flash[:notice] = t("user.sessions.create.banned", name:) flash[:notice] = t("user.sessions.create.banned", name:)
current_ban = current_user.bans.current.first current_ban = current_user.bans.current.first
unless current_ban&.reason.nil? flash[:notice] += "\n#{t('user.sessions.create.reason', reason: current_ban.reason)}" unless current_ban&.reason&.empty?
flash[:notice] += "\n#{t('user.sessions.create.reason', reason: current_ban.reason)}"
end flash[:notice] += if current_ban&.permanent?
unless current_ban&.permanent? "\n#{t('user.sessions.create.permanent')}"
# TODO format banned_until else
flash[:notice] += "\n#{t('user.sessions.create.until', time: current_ban.expires_at)}" # TODO: format banned_until
"\n#{t('user.sessions.create.until', time: current_ban.expires_at)}"
end end
sign_out current_user sign_out current_user
redirect_to new_user_session_path redirect_to new_user_session_path
end end
@ -46,6 +49,18 @@ class ApplicationController < ActionController::Base
@active_announcements ||= Announcement.find_active @active_announcements ||= Announcement.find_active
end end
def set_has_new_reports
return unless current_user&.mod?
@has_new_reports = if current_user.last_reports_visit.nil?
true
else
Report.where(deleted: false)
.where("created_at > ?", current_user.last_reports_visit)
.count.positive?
end
end
include ApplicationHelper include ApplicationHelper
protected protected

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class Comments::ReactionsController < ApplicationController
def index
comment = Comment.find(params[:id])
@reactions = Reaction.where(parent_type: "Comment", parent: comment.id).includes([{ user: :profile }])
redirect_to answer_path(username: comment.answer.user.screen_name, id: comment.answer.id) unless turbo_frame_request?
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class CommentsController < ApplicationController
def index
answer = Answer.find(params[:id])
@comments = Comment.where(answer:).includes([{ user: :profile }, :smiles])
render "index", locals: { a: answer }
end
end

View File

@ -5,8 +5,6 @@ module PaginatesAnswers
@answers = yield(last_id: params[:last_id]) @answers = yield(last_id: params[:last_id])
answer_ids = @answers.map(&:id) answer_ids = @answers.map(&:id)
@answers_last_id = answer_ids.min @answers_last_id = answer_ids.min
answer_ids += @pinned_answers.pluck(:id) if @pinned_answers.present? @more_data_available = !yield(last_id: @answers_last_id, size: 1).select("answers.id").count.zero?
@more_data_available = !yield(last_id: @answers_last_id, size: 1).count.zero?
@subscribed_answer_ids = Subscription.where(user: current_user, answer_id: answer_ids).pluck(:answer_id) if user_signed_in?
end end
end end

View File

@ -23,6 +23,8 @@ module TurboStreamable
render_error t("errors.parameter_error", parameter: e.instance_of?(KeyError) ? e.key : e.param.capitalize) render_error t("errors.parameter_error", parameter: e.instance_of?(KeyError) ? e.key : e.param.capitalize)
rescue Dry::Types::CoercionError, Dry::Types::ConstraintError rescue Dry::Types::CoercionError, Dry::Types::ConstraintError
render_error t("errors.invalid_parameter") render_error t("errors.invalid_parameter")
rescue ActiveRecord::RecordInvalid => e
render_error e.record.errors.full_messages.flatten.join(" ")
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render_error t("errors.record_not_found") render_error t("errors.record_not_found")
end end

View File

@ -1,36 +1,34 @@
# frozen_string_literal: true
class DiscoverController < ApplicationController class DiscoverController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
def index def index
unless APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod? return redirect_to root_path unless APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod?
return redirect_to root_path
end
top_x = 10 # only display the top X items top_x = 10 # only display the top X items
week_ago = Time.now.utc.ago(1.week)
@popular_answers = Answer.where("created_at > ?", Time.now.ago(1.week)).order(:smile_count).reverse_order.limit(top_x).includes(:question, :user, :comments) @popular_answers = Answer.for_user(current_user).where("created_at > ?", week_ago).order(:smile_count).reverse_order.limit(top_x).includes(:question, :user, :comments)
@most_discussed = Answer.where("created_at > ?", Time.now.ago(1.week)).order(:comment_count).reverse_order.limit(top_x).includes(:question, :user, :comments) @most_discussed = Answer.for_user(current_user).where("created_at > ?", week_ago).order(:comment_count).reverse_order.limit(top_x).includes(:question, :user, :comments)
@popular_questions = Question.where("created_at > ?", Time.now.ago(1.week)).order(:answer_count).reverse_order.limit(top_x).includes(:user) @popular_questions = Question.where("created_at > ?", week_ago).order(:answer_count).reverse_order.limit(top_x).includes(:user)
@new_users = User.where("asked_count > 0").order(:id).reverse_order.limit(top_x).includes(:profile) @new_users = User.where("asked_count > 0").order(:id).reverse_order.limit(top_x).includes(:profile)
answer_ids = @popular_answers.map(&:id) + @most_discussed.map(&:id)
@subscribed_answer_ids = Subscription.where(user: current_user, answer_id: answer_ids).pluck(:answer_id)
# .user = the user # .user = the user
# .question_count = how many questions did the user ask # .question_count = how many questions did the user ask
@users_with_most_questions = Question.select('user_id, COUNT(*) AS question_count'). @users_with_most_questions = Question.select("user_id, COUNT(*) AS question_count")
where("created_at > ?", Time.now.ago(1.week)). .where("created_at > ?", week_ago)
where(author_is_anonymous: false). .where(author_is_anonymous: false)
group(:user_id). .group(:user_id)
order('question_count'). .order("question_count")
reverse_order.limit(top_x) .reverse_order.limit(top_x)
# .user = the user # .user = the user
# .answer_count = how many questions did the user answer # .answer_count = how many questions did the user answer
@users_with_most_answers = Answer.select('user_id, COUNT(*) AS answer_count'). @users_with_most_answers = Answer.select("user_id, COUNT(*) AS answer_count")
where("created_at > ?", Time.now.ago(1.week)). .where("created_at > ?", week_ago)
group(:user_id). .group(:user_id)
order('answer_count'). .order("answer_count")
reverse_order.limit(top_x) .reverse_order.limit(top_x)
end end
end end

View File

@ -3,30 +3,17 @@
class InboxController < ApplicationController class InboxController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
after_action :mark_inbox_entries_as_read, only: %i[show] def show
def show # rubocop:disable Metrics/MethodLength
find_author
find_inbox_entries find_inbox_entries
if @author_user && @inbox_count.zero?
# rubocop disabled because of a false positive
flash[:info] = t(".author.info", author: @author) # rubocop:disable Rails/ActionControllerFlashBeforeRender
redirect_to inbox_path(last_id: params[:last_id])
return
end
@delete_id = find_delete_id @delete_id = find_delete_id
@disabled = true if @inbox.empty? @disabled = true if @inbox.empty?
respond_to do |format| mark_inbox_entries_as_read
format.html { render "show" }
format.turbo_stream do
render "show", layout: false, status: :see_other
# rubocop disabled as just flipping a flag doesn't need to have validations to be run respond_to do |format|
@inbox.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations format.html
end format.turbo_stream
end end
end end
@ -36,7 +23,7 @@ class InboxController < ApplicationController
author_identifier: "justask", author_identifier: "justask",
user: current_user) user: current_user)
inbox = Inbox.create!(user: current_user, question_id: question.id, new: true) inbox = InboxEntry.create!(user: current_user, question_id: question.id, new: true)
increment_metric increment_metric
respond_to do |format| respond_to do |format|
@ -52,41 +39,29 @@ class InboxController < ApplicationController
private private
def find_author def filter_params
return if params[:author].blank? params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS)
@author = params[:author]
@author_user = User.where("LOWER(screen_name) = ?", @author.downcase).first
flash.now[:error] = t(".author.error", author: @author) unless @author_user
end end
def find_inbox_entries def find_inbox_entries
@inbox = current_user.cursored_inbox(last_id: params[:last_id]).then(&method(:filter_author_chain)) filter = InboxFilter.new(current_user, filter_params)
@inbox = filter.cursored_results(last_id: params[:last_id])
@inbox_last_id = @inbox.map(&:id).min @inbox_last_id = @inbox.map(&:id).min
@more_data_available = current_user.cursored_inbox(last_id: @inbox_last_id, size: 1).then(&method(:filter_author_chain)).count.positive? @more_data_available = filter.cursored_results(last_id: @inbox_last_id, size: 1).count.positive?
@inbox_count = current_user.inboxes.then(&method(:filter_author_chain)).count @inbox_count = filter.results.count
end end
def find_delete_id def find_delete_id
return "ib-delete-all-author" if @author_user && @inbox_count.positive? return "ib-delete-all-author" if params[:author].present? && @inbox_count.positive?
"ib-delete-all" "ib-delete-all"
end end
def filter_author_chain(query)
return query unless @author_user
query
.joins(:question)
.where(questions: { user: @author_user, author_is_anonymous: false })
end
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
def mark_inbox_entries_as_read def mark_inbox_entries_as_read
# using .dup to not modify @inbox -- useful in tests # using .dup to not modify @inbox -- useful in tests
@inbox&.dup&.update_all(new: false) updated = @inbox&.dup&.update_all(new: false)
current_user.touch(:inbox_updated_at) current_user.touch(:inbox_updated_at) if updated.positive?
end end
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class ModalController < ApplicationController
include ActionView::Helpers::TagHelper
include Turbo::FramesHelper
skip_before_action :find_active_announcements, :banned?
def close
return redirect_to root_path unless turbo_frame_request?
render inline: turbo_frame_tag("modal") # rubocop:disable Rails/RenderInline
end
end

View File

@ -5,14 +5,22 @@ class Moderation::InboxController < ApplicationController
def index def index
@user = User.find_by(screen_name: params[:user]) @user = User.find_by(screen_name: params[:user])
@inboxes = @user.cursored_inbox(last_id: params[:last_id]) filter = InboxFilter.new(@user, filter_params)
@inboxes = filter.cursored_results(last_id: params[:last_id])
@inbox_last_id = @inboxes.map(&:id).min @inbox_last_id = @inboxes.map(&:id).min
@more_data_available = !@user.cursored_inbox(last_id: @inbox_last_id, size: 1).count.zero? @more_data_available = !filter.cursored_results(last_id: @inbox_last_id, size: 1).count.zero?
@inbox_count = @user.inboxes.count @inbox_count = @user.inbox_entries.count
respond_to do |format| respond_to do |format|
format.html format.html
format.turbo_stream { render "index", layout: false, status: :see_other } format.turbo_stream { render "index", layout: false, status: :see_other }
end end
end end
private
def filter_params
params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS)
end
end end

View File

@ -2,12 +2,15 @@
class Moderation::ReportsController < ApplicationController class Moderation::ReportsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_filter_enabled
before_action :set_type_options
before_action :set_last_reports_visit
def index def index
@type = params[:type] filter = ReportFilter.new(filter_params)
@reports = list_reports(type: @type, last_id: params[:last_id]) @reports = filter.cursored_results(last_id: params[:last_id])
@reports_last_id = @reports.map(&:id).min @reports_last_id = @reports.map(&:id).min
@more_data_available = !list_reports(type: @type, last_id: @reports_last_id, size: 1).count.zero? @more_data_available = filter.cursored_results(last_id: @reports_last_id, size: 1).count.positive?
respond_to do |format| respond_to do |format|
format.html format.html
@ -17,13 +20,29 @@ class Moderation::ReportsController < ApplicationController
private private
def list_reports(type:, last_id:, size: nil) def filter_params
cursor_params = { last_id:, size: }.compact params.slice(*ReportFilter::KEYS).permit(*ReportFilter::KEYS)
if type == "all"
Report.cursored_reports(**cursor_params)
else
Report.cursored_reports_of_type(type, **cursor_params)
end end
def set_filter_enabled
@filter_enabled = params.slice(*ReportFilter::KEYS)
.reject! { |_, value| value.empty? || value.nil? }
.values
.any?
end
def set_type_options
@type_options = [
[t("voc.all"), ""],
[t("activerecord.models.answer.one"), :answer],
[t("activerecord.models.comment.one"), :comment],
[t("activerecord.models.question.one"), :question],
[t("activerecord.models.user.one"), :user]
]
end
def set_last_reports_visit
current_user.last_reports_visit = DateTime.now
current_user.save
end end
end end

View File

@ -3,8 +3,6 @@
class NotificationsController < ApplicationController class NotificationsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
after_action :mark_notifications_as_read, only: %i[index]
TYPE_MAPPINGS = { TYPE_MAPPINGS = {
"answer" => Notification::QuestionAnswered.name, "answer" => Notification::QuestionAnswered.name,
"comment" => Notification::Commented.name, "comment" => Notification::Commented.name,
@ -18,6 +16,7 @@ class NotificationsController < ApplicationController
@notifications = cursored_notifications_for(type: @type, last_id: params[:last_id]) @notifications = cursored_notifications_for(type: @type, last_id: params[:last_id])
paginate_notifications paginate_notifications
@counters = count_unread_by_type @counters = count_unread_by_type
mark_notifications_as_read
respond_to do |format| respond_to do |format|
format.html format.html
@ -52,8 +51,8 @@ class NotificationsController < ApplicationController
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
def mark_notifications_as_read def mark_notifications_as_read
# using .dup to not modify @notifications -- useful in tests # using .dup to not modify @notifications -- useful in tests
@notifications&.dup&.update_all(new: false) updated = @notifications&.dup&.update_all(new: false)
current_user.touch(:notifications_updated_at) current_user.touch(:notifications_updated_at) if updated.positive?
end end
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations

View File

@ -8,8 +8,7 @@ class QuestionController < ApplicationController
@answers = @question.cursored_answers(last_id: params[:last_id], current_user:) @answers = @question.cursored_answers(last_id: params[:last_id], current_user:)
answer_ids = @answers.map(&:id) answer_ids = @answers.map(&:id)
@answers_last_id = answer_ids.min @answers_last_id = answer_ids.min
@more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1, current_user:).count.zero? @more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1, current_user:).select("answers.id").count.zero?
@subscribed = Subscription.where(user: current_user, answer_id: answer_ids).pluck(:answer_id) if user_signed_in?
respond_to do |format| respond_to do |format|
format.html format.html

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
class ReactionsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!, only: %w[create destroy]
turbo_stream_actions :create, :destroy
def index
answer = Answer.includes([smiles: { user: :profile }]).find(params[:id])
render "index", locals: { a: answer }
end
def create
params.require :id
target = target_class.find(params[:id])
UseCase::Reaction::Create.call(
source_user_id: current_user.id,
target:,
)
target.reload
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("reaction-#{params[:type]}-#{params[:id]}", partial: "reactions/destroy", locals: { type: params[:type], target: }),
render_toast(t(".#{params[:type].downcase}.success"))
]
end
format.html { redirect_back(fallback_location: root_path) }
end
end
def destroy
params.require :id
target = target_class.find(params[:id])
UseCase::Reaction::Destroy.call(
source_user_id: current_user.id,
target:,
)
target.reload
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("reaction-#{params[:type]}-#{params[:id]}", partial: "reactions/create", locals: { type: params[:type], target: }),
render_toast(t(".#{params[:type].downcase}.success"))
]
end
format.html { redirect_back(fallback_location: root_path) }
end
end
private
ALLOWED_TYPES = %w[Answer Comment].freeze
private_constant :ALLOWED_TYPES
def target_class
params.require :type
raise NameError unless ALLOWED_TYPES.include?(params[:type])
params[:type].constantize
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
class RelationshipsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def create
params.require :screen_name
UseCase::Relationship::Create.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type],
)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("#{params[:type]}-#{params[:screen_name]}", partial: "relationships/destroy", locals: { type: params[:type], screen_name: params[:screen_name] }),
render_toast(t(".#{params[:type]}.success"))
]
end
format.html { redirect_back(fallback_location: user_path(username: params[:screen_name])) }
end
end
def destroy
UseCase::Relationship::Destroy.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type],
)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("#{params[:type]}-#{params[:screen_name]}", partial: "relationships/create", locals: { type: params[:type], screen_name: params[:screen_name] }),
render_toast(t(".#{params[:type]}.success"))
]
end
format.html { redirect_back(fallback_location: user_path(username: params[:screen_name])) }
end
end
end

View File

@ -21,9 +21,12 @@ class Settings::ExportController < ApplicationController
private private
# rubocop:disable Rails/SkipsModelValidations
def mark_notifications_as_read def mark_notifications_as_read
Notification::DataExported updated = Notification::DataExported
.where(recipient: current_user, new: true) .where(recipient: current_user, new: true)
.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations .update_all(new: false)
current_user.touch(:notifications_updated_at) if updated.positive?
end end
# rubocop:enable Rails/SkipsModelValidations
end end

View File

@ -19,6 +19,6 @@ class Settings::PrivacyController < ApplicationController
else else
flash[:error] = t(".error") flash[:error] = t(".error")
end end
redirect_to settings_privacy_path render :edit
end end
end end

View File

@ -14,6 +14,6 @@ class Settings::ProfileController < ApplicationController
flash[:error] = t(".error") flash[:error] = t(".error")
end end
redirect_to settings_profile_path render :edit
end end
end end

View File

@ -12,9 +12,10 @@ class Settings::ProfilePictureController < ApplicationController
text += t(".notice.profile_header") if user_attributes[:profile_header] text += t(".notice.profile_header") if user_attributes[:profile_header]
flash[:success] = text flash[:success] = text
else else
flash[:error] = t(".error") # CarrierWave resets the image to the default upon an error
current_user.reload
end end
redirect_to settings_profile_path render "settings/profile/edit"
end end
end end

View File

@ -22,7 +22,7 @@ class Settings::ThemeController < ApplicationController
else else
flash[:error] = t(".error", errors: current_user.theme.errors.messages.flatten.join(" ")) flash[:error] = t(".error", errors: current_user.theme.errors.messages.flatten.join(" "))
end end
redirect_to settings_theme_path render :edit
end end
def destroy def destroy

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class SubscriptionsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def create
answer = Answer.find(params[:answer])
result = Subscription.subscribe(current_user, answer)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("subscription-#{answer.id}", partial: "subscriptions/destroy", locals: { answer: }),
render_toast(t(result.present? ? ".success" : ".error"), result.present?)
]
end
format.html { redirect_to answer_path(username: answer.user.screen_name, id: answer.id) }
end
end
def destroy
answer = Answer.find(params[:answer])
result = Subscription.unsubscribe(current_user, answer)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("subscription-#{answer.id}", partial: "subscriptions/create", locals: { answer: }),
render_toast(t(result.present? ? ".success" : ".error"), result.present?)
]
end
format.html { redirect_to answer_path(username: answer.user.screen_name, id: answer.id) }
end
end
end

View File

@ -32,10 +32,9 @@ class TimelineController < ApplicationController
def paginate_timeline def paginate_timeline
@timeline = yield(last_id: params[:last_id]) @timeline = yield(last_id: params[:last_id])
timeline_ids = @timeline.map(&:id) timeline_ids = @timeline.select("answers.id").map(&:id)
@timeline_last_id = timeline_ids.min @timeline_last_id = timeline_ids.min
@more_data_available = !yield(last_id: @timeline_last_id, size: 1).count.zero? @more_data_available = !yield(last_id: @timeline_last_id, size: 1).select("answers.id").count.zero?
@subscribed_answer_ids = Subscription.where(user: current_user, answer_id: timeline_ids).pluck(:answer_id)
respond_to do |format| respond_to do |format|
format.html { render "timeline/timeline" } format.html { render "timeline/timeline" }

View File

@ -8,8 +8,8 @@ class UserController < ApplicationController
after_action :mark_notification_as_read, only: %i[show] after_action :mark_notification_as_read, only: %i[show]
def show def show
@pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10) @pinned_answers = @user.answers.for_user(current_user).pinned.includes([{ user: :profile }, :question]).order(pinned_at: :desc).limit(10).load_async
paginate_answers { |args| @user.cursored_answers(**args) } paginate_answers { |args| @user.cursored_answers(current_user_id: current_user, **args) }
respond_to do |format| respond_to do |format|
format.html format.html

View File

@ -1,2 +1,4 @@
# frozen_string_literal: true
module AjaxHelper module AjaxHelper
end end

View File

@ -4,13 +4,13 @@ module ApplicationHelper::GraphMethods
# Creates <meta> tags for OpenGraph properties from a hash # Creates <meta> tags for OpenGraph properties from a hash
# @param values [Hash] # @param values [Hash]
def opengraph_meta_tags(values) def opengraph_meta_tags(values)
safe_join(values.map { |name, content| tag.meta(property: name, content: content) }, "\n") safe_join(values.map { |name, content| tag.meta(property: name, content:) }, "\n")
end end
# Creates <meta> tags from a hash # Creates <meta> tags from a hash
# @param values [Hash] # @param values [Hash]
def meta_tags(values) def meta_tags(values)
safe_join(values.map { |name, content| tag.meta(name: name, content: content) }, "\n") safe_join(values.map { |name, content| tag.meta(name:, content:) }, "\n")
end end
# @param user [User] # @param user [User]
@ -22,7 +22,7 @@ module ApplicationHelper::GraphMethods
"og:url": user_url(user), "og:url": user_url(user),
"og:description": user.profile.description, "og:description": user.profile.description,
"og:site_name": APP_CONFIG["site_name"], "og:site_name": APP_CONFIG["site_name"],
"profile:username": user.screen_name "profile:username": user.screen_name,
}) })
end end
@ -33,7 +33,7 @@ module ApplicationHelper::GraphMethods
"twitter:site": "@retrospring", "twitter:site": "@retrospring",
"twitter:title": user.profile.motivation_header.presence || "Ask me anything!", "twitter:title": user.profile.motivation_header.presence || "Ask me anything!",
"twitter:description": "Ask #{user.profile.safe_name} anything on Retrospring", "twitter:description": "Ask #{user.profile.safe_name} anything on Retrospring",
"twitter:image": full_profile_picture_url(user) "twitter:image": full_profile_picture_url(user),
}) })
end end
@ -45,7 +45,7 @@ module ApplicationHelper::GraphMethods
"og:image": full_profile_picture_url(answer.user), "og:image": full_profile_picture_url(answer.user),
"og:url": answer_url(answer.user.screen_name, answer.id), "og:url": answer_url(answer.user.screen_name, answer.id),
"og:description": answer.content, "og:description": answer.content,
"og:site_name": APP_CONFIG["site_name"] "og:site_name": APP_CONFIG["site_name"],
}) })
end end

View File

@ -28,7 +28,7 @@ module ApplicationHelper::TitleMethods
def question_title(question) def question_title(question)
context_user = question.answers&.first&.user if question.direct context_user = question.answers&.first&.user if question.direct
name = user_screen_name question.user, name = user_screen_name question.user,
context_user: context_user, context_user:,
author_identifier: question.author_is_anonymous ? question.author_identifier : nil, author_identifier: question.author_is_anonymous ? question.author_identifier : nil,
url: false url: false
generate_title name, "asked", question.content generate_title name, "asked", question.content

View File

@ -8,6 +8,7 @@ module BootstrapHelper
badge_attr: {}, badge_attr: {},
icon: nil, icon: nil,
class: "", class: "",
id: nil,
hotkey: nil, hotkey: nil,
}.merge(options) }.merge(options)
@ -24,24 +25,24 @@ module BootstrapHelper
"#{content_tag(:i, '', class: "fa fa-#{options[:icon]}")} #{body}" "#{content_tag(:i, '', class: "fa fa-#{options[:icon]}")} #{body}"
end end
end end
if options[:badge].present? || options.dig(:badge_attr, :data)&.has_key?(:controller) if options[:badge].present? || options.dig(:badge_attr, :data)&.key?(:controller)
badge_class = [ badge_class = [
"badge", "badge",
("badge-#{options[:badge_color]}" unless options[:badge_color].nil?), ("badge-#{options[:badge_color]}" unless options[:badge_color].nil?),
("badge-pill" if options[:badge_pill]) ("badge-pill" if options[:badge_pill])
].compact.join(" ") ].compact.join(" ")
body += " #{content_tag(:span, options[:badge], class: badge_class, **options[:badge_attr])}".html_safe body += " #{content_tag(:span, options[:badge], class: badge_class, **options[:badge_attr])}".html_safe # rubocop:disable Rails/OutputSafety
end end
content_tag(:li, link_to(body.html_safe, path, class: "nav-link", data: { hotkey: options[:hotkey] }), class: classes) content_tag(:li, link_to(body.html_safe, path, class: "nav-link", data: { hotkey: options[:hotkey] }), class: classes, id: options[:id]) # rubocop:disable Rails/OutputSafety
end end
def list_group_item(body, path, options = {}) def list_group_item(body, path, options = {})
options = { options = {
badge: nil, badge: nil,
badge_color: nil, badge_color: nil,
class: "" class: "",
}.merge(options) }.merge(options)
classes = [ classes = [
@ -53,25 +54,26 @@ module BootstrapHelper
unless options[:badge].nil? || (options[:badge]).zero? unless options[:badge].nil? || (options[:badge]).zero?
# TODO: make this prettier? # TODO: make this prettier?
body << " #{ badge = content_tag(:span, options[:badge], class: "badge#{
content_tag(:span, options[:badge], class: "badge#{
" badge-#{options[:badge_color]}" unless options[:badge_color].nil? " badge-#{options[:badge_color]}" unless options[:badge_color].nil?
}")}" }",)
end end
content_tag(:a, body.html_safe, href: path, class: classes) html = if badge
"#{body} #{badge}"
else
body
end
content_tag(:a, html.html_safe, href: path, class: classes) # rubocop:disable Rails/OutputSafety
end end
def tooltip(body, tooltip_content, placement = "bottom") def tooltip(body, tooltip_content, placement = "bottom")
content_tag(:span, body, { :title => tooltip_content, "data-bs-toggle" => "tooltip", "data-bs-placement" => placement }) content_tag(:span, body, { :title => tooltip_content, "data-controller" => "tooltip", "data-bs-placement" => placement })
end end
def time_tooltip(subject, placement = "bottom") def time_tooltip(subject, placement = "bottom")
tooltip time_ago_in_words(subject.created_at), localize(subject.created_at), placement tooltip time_ago_in_words(subject.created_at, scope: "datetime.distance_in_words.short"), localize(subject.created_at, format: :long), placement
end
def hidespan(body, hide)
content_tag(:span, body, class: hide)
end end
## ##

View File

@ -8,7 +8,7 @@ module FeedbackHelper
avatarURL: current_user.profile_picture.url(:large), avatarURL: current_user.profile_picture.url(:large),
name: current_user.screen_name, name: current_user.screen_name,
id: current_user.id, id: current_user.id,
email: current_user.email email: current_user.email,
} }
JWT.encode(user_data, APP_CONFIG.dig("canny", "sso")) JWT.encode(user_data, APP_CONFIG.dig("canny", "sso"))

View File

@ -30,7 +30,7 @@ module MarkdownHelper
def raw_markdown(content) def raw_markdown(content)
renderer = Redcarpet::Render::HTML.new(**MARKDOWN_RENDERER_OPTS) renderer = Redcarpet::Render::HTML.new(**MARKDOWN_RENDERER_OPTS)
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS) md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
raw md.render content raw md.render content # rubocop:disable Rails/OutputSafety
end end
def get_markdown(path, relative_to = Rails.root) def get_markdown(path, relative_to = Rails.root)

View File

@ -1,7 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
module SocialHelper module SocialHelper
include SocialHelper::BlueskyMethods
include SocialHelper::TwitterMethods include SocialHelper::TwitterMethods
include SocialHelper::TumblrMethods include SocialHelper::TumblrMethods
include SocialHelper::TelegramMethods include SocialHelper::TelegramMethods
def answer_share_url(answer)
answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
end
end end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::BlueskyMethods
def bluesky_share_url(answer)
"https://bsky.app/intent/compose?text=#{CGI.escape(prepare_tweet(answer))}"
end
end

View File

@ -15,7 +15,7 @@ module SocialHelper::TelegramMethods
id: answer.id, id: answer.id,
username: answer.user.screen_name, username: answer.user.screen_name,
host: APP_CONFIG["hostname"], host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http) protocol: (APP_CONFIG["https"] ? :https : :http),
) )
%(https://t.me/share/url?url=#{CGI.escape(url)}&text=#{CGI.escape(telegram_text(answer))}) %(https://t.me/share/url?url=#{CGI.escape(url)}&text=#{CGI.escape(telegram_text(answer))})

View File

@ -1,4 +1,6 @@
require 'cgi' # frozen_string_literal: true
require "cgi"
module SocialHelper::TumblrMethods module SocialHelper::TumblrMethods
def tumblr_title(answer) def tumblr_title(answer)
@ -15,8 +17,8 @@ module SocialHelper::TumblrMethods
answer_url = answer_url( answer_url = answer_url(
id: answer.id, id: answer.id,
username: answer.user.screen_name, username: answer.user.screen_name,
host: APP_CONFIG['hostname'], host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG['https'] ? :https : :http) protocol: (APP_CONFIG["https"] ? :https : :http),
) )
"#{answer.content}\n\n[Smile or comment on the answer here](#{answer_url})" "#{answer.content}\n\n[Smile or comment on the answer here](#{answer_url})"
@ -26,8 +28,8 @@ module SocialHelper::TumblrMethods
answer_url = answer_url( answer_url = answer_url(
id: answer.id, id: answer.id,
username: answer.user.screen_name, username: answer.user.screen_name,
host: APP_CONFIG['hostname'], host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG['https'] ? :https : :http) protocol: (APP_CONFIG["https"] ? :https : :http),
) )
"https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title=#{CGI.escape(tumblr_title(answer))}&url=#{CGI.escape(answer_url)}&caption=&content=#{CGI.escape(tumblr_body(answer))}" "https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title=#{CGI.escape(tumblr_title(answer))}&url=#{CGI.escape(answer_url)}&caption=&content=#{CGI.escape(tumblr_body(answer))}"

View File

@ -1,21 +1,26 @@
require 'cgi' # frozen_string_literal: true
require "cgi"
module SocialHelper::TwitterMethods module SocialHelper::TwitterMethods
include MarkdownHelper include MarkdownHelper
def prepare_tweet(answer, post_tag = nil) def prepare_tweet(answer, post_tag = nil, omit_url = false)
question_content = twitter_markdown answer.question.content.gsub(/\@(\w+)/, '\1') question_content = twitter_markdown answer.question.content.gsub(/@(\w+)/, '\1')
original_question_length = question_content.length original_question_length = question_content.length
answer_content = twitter_markdown answer.content answer_content = twitter_markdown answer.content
original_answer_length = answer_content.length original_answer_length = answer_content.length
unless omit_url
answer_url = answer_url( answer_url = answer_url(
id: answer.id, id: answer.id,
username: answer.user.screen_name, username: answer.user.screen_name,
host: APP_CONFIG['hostname'], host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG['https'] ? :https : :http) protocol: (APP_CONFIG["https"] ? :https : :http),
) )
end
parsed_tweet = { :valid => false } parsed_tweet = { valid: false }
tweet_text = "" tweet_text = ""
until parsed_tweet[:valid] until parsed_tweet[:valid]
@ -23,14 +28,14 @@ module SocialHelper::TwitterMethods
shortened_answer = "#{answer_content[0..123]}#{'…' if original_answer_length > [124, answer_content.length].min}" shortened_answer = "#{answer_content[0..123]}#{'…' if original_answer_length > [124, answer_content.length].min}"
components = [ components = [
shortened_question, shortened_question,
'—', "",
shortened_answer, shortened_answer,
post_tag, post_tag,
answer_url answer_url
] ]
tweet_text = components.compact.join(' ') tweet_text = components.compact.join(" ")
parsed_tweet = Twitter::TwitterText::Validation::parse_tweet(tweet_text) parsed_tweet = Twitter::TwitterText::Validation.parse_tweet(tweet_text)
question_content = question_content[0..-2] question_content = question_content[0..-2]
answer_content = answer_content[0..-2] answer_content = answer_content[0..-2]

View File

@ -25,7 +25,7 @@ module ThemeHelper
"input_color" => "input-bg", "input_color" => "input-bg",
"input_text" => "input-text", "input_text" => "input-text",
"input_placeholder" => "input-placeholder", "input_placeholder" => "input-placeholder",
"muted_text" => "muted-text" "muted_text" => "muted-text",
}.freeze }.freeze
def render_theme def render_theme

View File

@ -0,0 +1,23 @@
import { Controller } from "@hotwired/stimulus";
import I18n from 'retrospring/i18n';
import { showErrorNotification, showNotification } from "retrospring/utilities/notifications";
export default class extends Controller {
static values = {
copy: String
};
declare readonly copyValue: string;
async copy(){
try {
await navigator.clipboard.writeText(this.copyValue);
showNotification(I18n.translate("frontend.clipboard_copy.success"));
this.element.dispatchEvent(new CustomEvent('retrospring:copied'));
} catch (error) {
console.log(error);
showErrorNotification(I18n.translate("frontend.clipboard_copy.error"));
}
}
}

View File

@ -1,12 +1,15 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
export default class extends Controller { export default class extends Controller {
static targets = ['twitter', 'tumblr', 'telegram', 'custom']; static targets = ['twitter', 'bluesky', 'tumblr', 'telegram', 'other', 'custom', 'clipboard'];
declare readonly twitterTarget: HTMLAnchorElement; declare readonly twitterTarget: HTMLAnchorElement;
declare readonly blueskyTarget: HTMLAnchorElement;
declare readonly tumblrTarget: HTMLAnchorElement; declare readonly tumblrTarget: HTMLAnchorElement;
declare readonly telegramTarget: HTMLAnchorElement; declare readonly telegramTarget: HTMLAnchorElement;
declare readonly customTarget: HTMLAnchorElement; declare readonly customTarget: HTMLAnchorElement;
declare readonly otherTarget: HTMLButtonElement;
declare readonly clipboardTarget: HTMLButtonElement;
declare readonly hasCustomTarget: boolean; declare readonly hasCustomTarget: boolean;
static values = { static values = {
@ -20,8 +23,11 @@ export default class extends Controller {
connect(): void { connect(): void {
if (this.autoCloseValue) { if (this.autoCloseValue) {
this.twitterTarget.addEventListener('click', () => this.close()); this.twitterTarget.addEventListener('click', () => this.close());
this.blueskyTarget.addEventListener('click', () => this.close());
this.tumblrTarget.addEventListener('click', () => this.close()); this.tumblrTarget.addEventListener('click', () => this.close());
this.telegramTarget.addEventListener('click', () => this.close()); this.telegramTarget.addEventListener('click', () => this.close());
this.otherTarget.addEventListener('click', () => this.closeAfterShare());
this.clipboardTarget.addEventListener('click', () => this.closeAfterCopyToClipboard());
if (this.hasCustomTarget) { if (this.hasCustomTarget) {
this.customTarget.addEventListener('click', () => this.close()); this.customTarget.addEventListener('click', () => this.close());
@ -37,6 +43,7 @@ export default class extends Controller {
this.element.classList.remove('d-none'); this.element.classList.remove('d-none');
this.twitterTarget.href = this.configValue['twitter']; this.twitterTarget.href = this.configValue['twitter'];
this.blueskyTarget.href = this.configValue['bluesky'];
this.tumblrTarget.href = this.configValue['tumblr']; this.tumblrTarget.href = this.configValue['tumblr'];
this.telegramTarget.href = this.configValue['telegram']; this.telegramTarget.href = this.configValue['telegram'];
@ -48,4 +55,12 @@ export default class extends Controller {
close(): void { close(): void {
(this.element.closest(".inbox-entry")).remove(); (this.element.closest(".inbox-entry")).remove();
} }
closeAfterShare(): void {
this.otherTarget.addEventListener('retrospring:shared', () => this.close());
}
closeAfterCopyToClipboard(): void {
this.clipboardTarget.addEventListener('retrospring:copied', () => this.close());
}
} }

View File

@ -0,0 +1,12 @@
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
export default class extends Controller {
click(): void {
const modal = Modal.getInstance(this.element.closest('.modal'));
const questionbox = document.querySelector((this.element as HTMLAnchorElement).href);
modal.hide();
questionbox.scrollIntoView();
}
}

View File

@ -0,0 +1,15 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['button'];
declare readonly buttonTarget: HTMLButtonElement;
enable(): void {
this.buttonTarget.disabled = false;
}
disable(): void {
this.buttonTarget.disabled = true;
}
}

View File

@ -0,0 +1,45 @@
import { Controller } from '@hotwired/stimulus';
import noop from 'utilities/noop';
export default class extends Controller {
static values = {
url: String,
text: String,
title: String
};
declare readonly urlValue: string;
declare readonly textValue: string;
declare readonly titleValue: string;
share() {
let shareConfiguration = {};
if (this.urlValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ url: this.urlValue }
};
}
if (this.textValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ text: this.textValue }
};
}
if (this.titleValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ title: this.titleValue }
};
}
navigator.share(shareConfiguration)
.then(() => {
this.element.dispatchEvent(new CustomEvent('retrospring:shared'));
})
.catch(noop);
}
}

View File

@ -0,0 +1,8 @@
import { Controller } from '@hotwired/stimulus';
import { Tooltip } from 'bootstrap';
export default class extends Controller {
connect(): void {
new Tooltip(this.element);
}
}

View File

@ -1,7 +1,10 @@
export function commentHotkeyHandler(event: Event): void { export function commentHotkeyHandler(event: Event): void {
const button = event.target as HTMLButtonElement; const button = event.target as HTMLButtonElement;
const id = button.dataset.aId; const id = button.dataset.aId;
const answerbox = button.closest('.answerbox');
document.querySelector(`#ab-comments-section-${id}`).classList.remove('d-none'); if (answerbox !== null) {
document.querySelector<HTMLElement>(`[name="ab-comment-new"][data-a-id="${id}"]`).focus(); answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none');
answerbox.querySelector<HTMLElement>(`[name="ab-comment-new"][data-a-id="${id}"]`).focus();
}
} }

View File

@ -2,7 +2,6 @@ import registerEvents from "retrospring/utilities/registerEvents";
import { commentDestroyHandler } from "./destroy"; import { commentDestroyHandler } from "./destroy";
import { commentComposeEnd, commentComposeStart, commentCreateClickHandler, commentCreateKeyboardHandler } from "./new"; import { commentComposeEnd, commentComposeStart, commentCreateClickHandler, commentCreateKeyboardHandler } from "./new";
import { commentReportHandler } from "./report"; import { commentReportHandler } from "./report";
import { commentSmileHandler } from "./smile";
import { commentToggleHandler } from "./toggle"; import { commentToggleHandler } from "./toggle";
import { commentHotkeyHandler } from "retrospring/features/answerbox/comment/hotkey"; import { commentHotkeyHandler } from "retrospring/features/answerbox/comment/hotkey";
@ -10,7 +9,6 @@ export default (): void => {
registerEvents([ registerEvents([
{ type: 'click', target: '[name=ab-comments]', handler: commentToggleHandler, global: true }, { type: 'click', target: '[name=ab-comments]', handler: commentToggleHandler, global: true },
{ type: 'click', target: '[name=ab-open-and-comment]', handler: commentHotkeyHandler, global: true }, { type: 'click', target: '[name=ab-open-and-comment]', handler: commentHotkeyHandler, global: true },
{ type: 'click', target: '[name=ab-smile-comment]', handler: commentSmileHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-report]', handler: commentReportHandler, global: true }, { type: 'click', target: '[data-action=ab-comment-report]', handler: commentReportHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-destroy]', handler: commentDestroyHandler, global: true }, { type: 'click', target: '[data-action=ab-comment-destroy]', handler: commentDestroyHandler, global: true },
{ type: 'compositionstart', target: '[name=ab-comment-new]', handler: commentComposeStart, global: true }, { type: 'compositionstart', target: '[name=ab-comment-new]', handler: commentComposeStart, global: true },

View File

@ -31,10 +31,6 @@ function createComment(input: HTMLInputElement, id: string, counter: Element, gr
} }
input.value = ''; input.value = '';
counter.innerHTML = String(512); counter.innerHTML = String(512);
const sub = document.querySelector<HTMLElement>(`[data-action=ab-submarine][data-a-id="${id}"]`);
sub.dataset.torpedo = "no"
sub.children[0].nextSibling.textContent = ' ' + I18n.translate('voc.unsubscribe');
} }
showNotification(data.message, data.success); showNotification(data.message, data.success);

View File

@ -1,59 +0,0 @@
import { post } from '@rails/request.js';
import I18n from 'retrospring/i18n';
import { showNotification, showErrorNotification } from 'utilities/notifications';
export function commentSmileHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.cId;
const action = button.dataset.action;
let count = Number(document.querySelector(`#ab-comment-smile-count-${id}`).innerHTML);
let success = false;
let targetUrl;
if (action === 'smile') {
count++;
targetUrl = '/ajax/create_comment_smile';
}
else if (action === 'unsmile') {
count--;
targetUrl = '/ajax/destroy_comment_smile';
}
button.disabled = true;
post(targetUrl, {
body: {
id: id
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
success = data.success;
if (success) {
document.querySelector(`#ab-comment-smile-count-${id}`).innerHTML = String(count);
}
showNotification(data.message, data.success);
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
})
.finally(() => {
button.disabled = false;
if (success) {
switch(action) {
case 'smile':
button.dataset.action = 'unsmile';
break;
case 'unsmile':
button.dataset.action = 'smile';
break;
}
}
});
}

View File

@ -1,6 +1,9 @@
export function commentToggleHandler(event: Event): void { export function commentToggleHandler(event: Event): void {
const button = event.target as HTMLButtonElement; const button = event.target as HTMLButtonElement;
const id = button.dataset.aId; const id = button.dataset.aId;
const answerbox = button.closest('.answerbox');
document.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none'); if (answerbox !== null) {
answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none');
}
} }

View File

@ -2,17 +2,11 @@ import registerEvents from 'utilities/registerEvents';
import registerAnswerboxCommentEvents from './comment'; import registerAnswerboxCommentEvents from './comment';
import { answerboxDestroyHandler } from './destroy'; import { answerboxDestroyHandler } from './destroy';
import { answerboxReportHandler } from './report'; import { answerboxReportHandler } from './report';
import { shareEventHandler } from './share';
import { answerboxSmileHandler } from './smile';
import { answerboxSubscribeHandler } from './subscribe';
export default (): void => { export default (): void => {
registerEvents([ registerEvents([
{ type: 'click', target: '[name=ab-share]', handler: shareEventHandler, global: true },
{ type: 'click', target: '[data-action=ab-submarine]', handler: answerboxSubscribeHandler, global: true },
{ type: 'click', target: '[data-action=ab-report]', handler: answerboxReportHandler, global: true }, { type: 'click', target: '[data-action=ab-report]', handler: answerboxReportHandler, global: true },
{ type: 'click', target: '[data-action=ab-destroy]', handler: answerboxDestroyHandler, global: true }, { type: 'click', target: '[data-action=ab-destroy]', handler: answerboxDestroyHandler, global: true },
{ type: 'click', target: '[name=ab-smile]', handler: answerboxSmileHandler, global: true }
]); ]);
registerAnswerboxCommentEvents(); registerAnswerboxCommentEvents();

View File

@ -1,12 +0,0 @@
import noop from 'utilities/noop';
export function shareEventHandler(event: Event): void {
event.preventDefault();
const answerbox = (event.target as HTMLElement).closest('.answerbox');
navigator.share({
url: answerbox.querySelector<HTMLAnchorElement>('.answerbox__answer-date > a, a.answerbox__permalink').href
})
.then(noop)
.catch(noop)
}

View File

@ -1,59 +0,0 @@
import { post } from '@rails/request.js';
import I18n from 'retrospring/i18n';
import { showNotification, showErrorNotification } from 'utilities/notifications';
export function answerboxSmileHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
const action = button.dataset.action;
let count = Number(document.querySelector(`#ab-smile-count-${id}`).innerHTML);
let success = false;
let targetUrl;
if (action === 'smile') {
count++;
targetUrl = '/ajax/create_smile';
}
else if (action === 'unsmile') {
count--;
targetUrl = '/ajax/destroy_smile';
}
button.disabled = true;
post(targetUrl, {
body: {
id: id
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
success = data.success;
if (success) {
document.querySelector(`#ab-smile-count-${id}`).innerHTML = String(count);
}
showNotification(data.message, data.success);
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
})
.finally(() => {
button.disabled = false;
if (success) {
switch(action) {
case 'smile':
button.dataset.action = 'unsmile';
break;
case 'unsmile':
button.dataset.action = 'smile';
break;
}
}
});
}

View File

@ -1,44 +0,0 @@
import { post } from '@rails/request.js';
import I18n from 'retrospring/i18n';
import { showNotification, showErrorNotification } from 'utilities/notifications';
export function answerboxSubscribeHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
let torpedo = 0;
let targetUrl;
event.preventDefault();
if (button.dataset.torpedo === 'yes') {
torpedo = 1;
}
if (torpedo) {
targetUrl = '/ajax/subscribe';
} else {
targetUrl = '/ajax/unsubscribe';
}
post(targetUrl, {
body: {
answer: id
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
if (data.success) {
button.dataset.torpedo = ["yes", "no"][torpedo];
button.children[0].nextSibling.textContent = ' ' + (torpedo ? I18n.translate('voc.unsubscribe') : I18n.translate('voc.subscribe'));
showNotification(I18n.translate(`frontend.subscription.${torpedo ? 'subscribe' : 'unsubscribe'}`));
} else {
showErrorNotification(I18n.translate(`frontend.subscription.fail.${torpedo ? 'subscribe' : 'unsubscribe'}`));
}
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
});
}

View File

@ -30,6 +30,17 @@ export function answerEntryHandler(event: Event): void {
updateDeleteButton(false); updateDeleteButton(false);
showNotification(data.message); showNotification(data.message);
const shareButton = inboxEntry.querySelector<HTMLButtonElement>('[data-controller="share"]');
const clipboardCopyButton = inboxEntry.querySelector<HTMLButtonElement>('[data-action="clipboard#copy"]')
if (shareButton != null) {
shareButton.dataset.shareUrlValue = data.sharing.url;
shareButton.dataset.shareTextValue = data.sharing.text;
}
if (clipboardCopyButton != null){
clipboardCopyButton.dataset.clipboardCopyValue = `${data.sharing.text} ${data.sharing.url}`;
}
const sharing = inboxEntry.querySelector<HTMLElement>('.inbox-entry__sharing'); const sharing = inboxEntry.querySelector<HTMLElement>('.inbox-entry__sharing');
if (sharing != null) { if (sharing != null) {
sharing.dataset.inboxSharingConfigValue = JSON.stringify(data.sharing); sharing.dataset.inboxSharingConfigValue = JSON.stringify(data.sharing);

View File

@ -7,7 +7,6 @@ import { showNotification, showErrorNotification } from 'utilities/notifications
export function deleteEntryHandler(event: Event): void { export function deleteEntryHandler(event: Event): void {
const element: HTMLButtonElement = event.target as HTMLButtonElement; const element: HTMLButtonElement = event.target as HTMLButtonElement;
element.disabled = true;
const data = { const data = {
id: element.getAttribute('data-ib-id') id: element.getAttribute('data-ib-id')
@ -22,11 +21,8 @@ export function deleteEntryHandler(event: Event): void {
confirmButtonText: I18n.translate('voc.delete'), confirmButtonText: I18n.translate('voc.delete'),
cancelButtonText: I18n.translate('voc.cancel'), cancelButtonText: I18n.translate('voc.cancel'),
closeOnConfirm: true closeOnConfirm: true
}, (returnValue) => { }, () => {
if (returnValue === false) { element.disabled = true;
element.disabled = false;
return;
}
post('/ajax/delete_inbox', { post('/ajax/delete_inbox', {
body: data, body: data,
@ -44,6 +40,7 @@ export function deleteEntryHandler(event: Event): void {
(inboxEntry as HTMLElement).remove(); (inboxEntry as HTMLElement).remove();
}) })
.catch(err => { .catch(err => {
element.disabled = false;
console.log(err); console.log(err);
showErrorNotification(I18n.translate('frontend.error.message')); showErrorNotification(I18n.translate('frontend.error.message'));
}); });

View File

@ -4,7 +4,7 @@ export default (): void => {
cheet('up up down down left right left right b a', () => { cheet('up up down down left right left right b a', () => {
document.body.classList.add('fa-spin'); document.body.classList.add('fa-spin');
Array.from(document.querySelectorAll('.answerbox__question-text')).forEach((element: HTMLElement) => { Array.from(document.querySelectorAll('.question__text')).forEach((element: HTMLElement) => {
element.innerText = ':^)'; element.innerText = ':^)';
}); });
}); });

View File

@ -13,7 +13,8 @@ export function questionboxAllHandler(event: Event): void {
body: { body: {
rcpt: 'followers', rcpt: 'followers',
question: document.querySelector<HTMLInputElement>('textarea[name=qb-all-question]').value, question: document.querySelector<HTMLInputElement>('textarea[name=qb-all-question]').value,
anonymousQuestion: 'false' anonymousQuestion: 'false',
sendToOwnInbox: (document.getElementById('qb-send-to-own-inbox') as HTMLInputElement).checked,
}, },
contentType: 'application/json' contentType: 'application/json'
}) })

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