diff --git a/.docker/ruby/Dockerfile b/.docker/ruby/Dockerfile index 205ec80a..0b652131 100644 --- a/.docker/ruby/Dockerfile +++ b/.docker/ruby/Dockerfile @@ -1,14 +1,20 @@ -FROM ruby:3.1 +FROM ruby:3.2 USER root ARG UID=1000 ARG GID=1000 +ARG NODE_MAJOR=16 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 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 \ && apt-get install -y --no-install-recommends build-essential \ diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..2fb6d1c3 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +71894d6c4987547533606258447b576ecb604c2b +1532741485af266f7ff04a1d6529abe9807a6815 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 45d4f725..71d8ab05 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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 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. * 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0d514c8e..809c1178 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,6 +18,8 @@ updates: schedule: interval: weekly open-pull-requests-limit: 99 + ignore: + - dependency-name: 'carrierwave_backgrounder' allow: - dependency-type: direct @@ -27,4 +29,4 @@ updates: interval: weekly open-pull-requests-limit: 99 allow: - - dependency-type: direct \ No newline at end of file + - dependency-type: direct diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..6712e91a --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,5 @@ +feature: +- head-branch: ['^feature', 'feature'] + +bugfix: +- head-branch: ['^bugfix', 'bugfix'] diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..76cf7f65 --- /dev/null +++ b/.github/release.yml @@ -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 diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 36b98fa1..63fc5d49 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -20,7 +20,7 @@ jobs: cancel-in-progress: true steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Discover build-time variables run: | @@ -38,7 +38,7 @@ jobs: esac - name: Login to registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -46,7 +46,7 @@ jobs: if: github.event_name != 'pull_request' - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: build-args: | BUNDLER_VERSION=${{ env.BUNDLER_VERSION }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index da1c33c7..c5d6be7d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,11 +33,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # 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). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -62,4 +62,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..2dc354dd --- /dev/null +++ b/.github/workflows/labeler.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 27e57015..0a57bc09 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,10 +11,10 @@ jobs: name: Rubocop runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v39 + uses: tj-actions/changed-files@v44 with: files: "**/*.rb" - name: Install dependencies @@ -37,16 +37,16 @@ jobs: name: ESLint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v39 + uses: tj-actions/changed-files@v44 with: files: "**/*.ts" - - name: Set up Node 14 - uses: actions/setup-node@v3 + - name: Set up Node 16 + uses: actions/setup-node@v4 with: - node-version: '14' + node-version: '16' cache: 'yarn' if: steps.changed-files.outputs.any_changed == 'true' - name: Install node modules @@ -63,10 +63,10 @@ jobs: haml-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v39 + uses: tj-actions/changed-files@v44 with: files: "**/*.haml" - name: Install dependencies @@ -86,16 +86,16 @@ jobs: stylelint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v39 + uses: tj-actions/changed-files@v44 with: files: "**/*.scss" - - name: Set up Node 14 - uses: actions/setup-node@v3 + - name: Set up Node 16 + uses: actions/setup-node@v4 with: - node-version: '14' + node-version: '16' cache: 'yarn' if: steps.changed-files.outputs.any_changed == 'true' - name: Install node modules @@ -104,7 +104,7 @@ jobs: yarn install --frozen-lockfile if: steps.changed-files.outputs.any_changed == 'true' - name: stylelint - uses: reviewdog/action-stylelint@v1.18.1 + uses: reviewdog/action-stylelint@v1.26.0 with: github_token: ${{ secrets.github_token }} reporter: github-pr-check diff --git a/.github/workflows/retrospring.yml b/.github/workflows/retrospring.yml index fefbfc04..ac710bf1 100644 --- a/.github/workflows/retrospring.yml +++ b/.github/workflows/retrospring.yml @@ -41,17 +41,17 @@ jobs: BUNDLE_WITHOUT: 'production' steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.7 - name: Install dependencies run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev - name: Set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - - name: Set up Node 14 - uses: actions/setup-node@v3 + - name: Set up Node 16 + uses: actions/setup-node@v4 with: - node-version: '14' + node-version: '16' cache: 'yarn' - name: Copy default configuration run: | @@ -75,7 +75,7 @@ jobs: env: POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} REDIS_URL: "redis://localhost:${{ job.services.redis.ports[6379] }}" - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage/coverage.xml diff --git a/.rubocop.yml b/.rubocop.yml index ed74d1c9..7aca81c7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,43 +27,36 @@ Lint/NestedMethodDefinition: Exclude: - api/sinatra/**/* +Lint/MissingSuper: + Exclude: + - app/components/**/* + ### Metrics Metrics/AbcSize: - Max: 20 - Exclude: - - 'db/**/*' + Enabled: false Layout/LineLength: Enabled: false Metrics/MethodLength: - Max: 15 - Exclude: - - 'db/migrate/*.rb' + Enabled: false Metrics/BlockLength: - Exclude: - - '*.gemspec' - - '**/*.rake' - - 'api/**/*' - - 'app/api/routes.rb' - - 'config/initialize/**/*' - - 'config/initializers/**/*' - - 'spec/**/*' + Enabled: false Metrics/ClassLength: - Exclude: - - spec/**/* + Enabled: false Metrics/CyclomaticComplexity: - Severity: refactor + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false Metrics/ModuleLength: - Exclude: - - 'app/api/routes.rb' - - 'spec/requests/**/*' + Enabled: false ### Style / Layout @@ -137,3 +130,7 @@ Style/TrailingCommaInHashLiteral: Style/TrailingCommaInArguments: EnforcedStyleForMultiline: consistent_comma + +Style/RedundantSelf: + Exclude: + - app/models/**/* diff --git a/.ruby-version b/.ruby-version index ef538c28..b347b11e 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.2 +3.2.3 diff --git a/Containerfile b/Containerfile index 700b458e..ca5903b3 100644 --- a/Containerfile +++ b/Containerfile @@ -1,6 +1,6 @@ # 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.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" ARG RETROSPRING_VERSION=2023.0131.1 -ARG RUBY_VERSION=3.1.2 -ARG RUBY_INSTALL_VERSION=0.9.0 -ARG BUNDLER_VERSION=2.3.18 +ARG RUBY_VERSION=3.2.3 +ARG RUBY_INSTALL_VERSION=0.9.3 +ARG BUNDLER_VERSION=2.5.5 ENV RAILS_ENV=production # 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 \ # build dependencies (ruby-install) automake \ @@ -25,18 +26,20 @@ RUN zypper up -y \ libffi-devel \ libopenssl-devel \ libyaml-devel \ + jemalloc-devel \ make \ ncurses-devel \ readline-devel \ tar \ xz \ zlib-devel \ + curl \ # build dependencies (app) gcc-c++ \ git \ libidn-devel \ - nodejs14 \ - npm14 \ + nodejs16 \ + npm16 \ postgresql-devel \ # runtime dependencies 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 \ && (cd ruby-install-${RUBY_INSTALL_VERSION} && make install) \ && rm -rf ruby-install-${RUBY_INSTALL_VERSION} ruby-install-${RUBY_INSTALL_VERSION}.tar.gz \ - && ruby-install --no-install-deps --cleanup --system --jobs=$(nproc) ruby ${RUBY_VERSION} -- --disable-install-rdoc \ + && ruby-install --no-install-deps --cleanup --system --jobs=$(nproc) ruby ${RUBY_VERSION} -- --disable-install-rdoc --with-jemalloc \ && gem install bundler:${BUNDLER_VERSION} # create user and dirs to run retrospring in diff --git a/Gemfile b/Gemfile index 54a8c3a1..8730c524 100644 --- a/Gemfile +++ b/Gemfile @@ -3,11 +3,11 @@ source "https://rubygems.org" gem "i18n-js", "4.0" -gem "rails", "~> 6.1" +gem "rails", "~> 7.0.8" gem "rails-i18n", "~> 7.0" -gem "cssbundling-rails", "~> 1.2" -gem "jsbundling-rails", "~> 1.1" +gem "cssbundling-rails", "~> 1.4" +gem "jsbundling-rails", "~> 1.3" gem "sassc-rails" gem "sprockets", "~> 4.2" gem "sprockets-rails", require: "sprockets/railtie" @@ -16,13 +16,13 @@ gem "pg" gem "turbo-rails" -gem "bcrypt", "~> 3.1.19" +gem "bcrypt", "~> 3.1.20" gem "active_model_otp" gem "bootsnap", require: false gem "bootstrap_form", "~> 5.0" -gem "carrierwave", "~> 2.0" -gem "carrierwave_backgrounder", git: "https://github.com/raccube/carrierwave_backgrounder.git" +gem "carrierwave", "~> 2.1" +gem "carrierwave_backgrounder", "~> 0.4.2" gem "colorize" gem "devise", "~> 4.9" gem "devise-async" @@ -30,12 +30,13 @@ gem "devise-i18n" gem "fog-aws" gem "fog-core" gem "fog-local" -gem "haml", "~> 6.1" -gem "hcaptcha", "~> 7.0" +gem "haml", "~> 6.3" +gem "hcaptcha", git: "https://github.com/retrospring/hcaptcha", ref: "fix/flash-in-turbo-streams" gem "mini_magick" gem "oj" gem "rpush" gem "rqrcode" +gem "web-push" gem "rolify", "~> 6.0" @@ -49,6 +50,7 @@ gem "sentry-ruby" 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-scheduler" gem "questiongenerator", "~> 1.2", git: 'https://lab.freak.university/FreakU/questiongenerator' @@ -66,11 +68,12 @@ gem "fake_email_validator" # TLD validation gem "tldv", "~> 0.1.0" -gem "jwt", "~> 2.7" +gem "view_component" + +gem "jwt", "~> 2.8" group :development do gem "binding_of_caller" - gem "spring", "~> 4.1" end gem "puma" @@ -79,7 +82,7 @@ group :development, :test do gem "better_errors" gem "bullet" gem "database_cleaner" - gem "dotenv-rails", "~> 2.8" + gem "dotenv-rails", "~> 3.1" gem "factory_bot_rails", require: false gem "faker" gem "haml_lint", require: false @@ -89,11 +92,11 @@ group :development, :test do gem "rake" gem "rspec-its", "~> 1.3" gem "rspec-mocks" - gem "rspec-rails", "~> 6.0" - gem "rspec-sidekiq", "~> 4.0", require: false - gem "rubocop", "~> 1.56" - gem "rubocop-rails", "~> 2.21" - gem "shoulda-matchers", "~> 5.3" + gem "rspec-rails", "~> 6.1" + gem "rspec-sidekiq", "~> 5.0", require: false + gem "rubocop", "~> 1.64" + gem "rubocop-rails", "~> 2.25" + gem "shoulda-matchers", "~> 6.2" gem "simplecov", require: false gem "simplecov-cobertura", require: false gem "simplecov-json", require: false @@ -112,7 +115,7 @@ gem "pundit", "~> 2.3" gem "rubyzip", "~> 2.3" # 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 gem "mail", "~> 2.7.1" diff --git a/Gemfile.lock b/Gemfile.lock index 7abeada8..861e7b6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,10 @@ GIT - remote: https://github.com/raccube/carrierwave_backgrounder.git - revision: 41b756f7514c0e410c561bc8b5ee321cd8cce1ee + remote: https://github.com/retrospring/hcaptcha + revision: f8de70ee2d629ac34395902dbee724c21297960c + ref: fix/flash-in-turbo-streams specs: - carrierwave_backgrounder (0.4.2) - carrierwave (>= 0.5, <= 2.1) - mime-types (>= 3.0.0) + hcaptcha (7.1.0) + json GIT remote: https://lab.freak.university/FreakU/questiongenerator @@ -15,115 +15,127 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.6) - actionpack (= 6.1.7.6) - activesupport (= 6.1.7.6) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.6) - actionpack (= 6.1.7.6) - activejob (= 6.1.7.6) - activerecord (= 6.1.7.6) - activestorage (= 6.1.7.6) - activesupport (= 6.1.7.6) + actionmailbox (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (>= 2.7.1) - actionmailer (6.1.7.6) - actionpack (= 6.1.7.6) - actionview (= 6.1.7.6) - activejob (= 6.1.7.6) - activesupport (= 6.1.7.6) + net-imap + net-pop + net-smtp + actionmailer (7.0.8.4) + 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) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.0) - actionpack (6.1.7.6) - actionview (= 6.1.7.6) - activesupport (= 6.1.7.6) - rack (~> 2.0, >= 2.0.9) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) + rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.6) - actionpack (= 6.1.7.6) - activerecord (= 6.1.7.6) - activestorage (= 6.1.7.6) - activesupport (= 6.1.7.6) + actiontext (7.0.8.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.7.6) - activesupport (= 6.1.7.6) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_otp (2.3.2) + active_model_otp (2.3.4) activemodel - rotp (~> 6.2.0) - activejob (6.1.7.6) - activesupport (= 6.1.7.6) + rotp (~> 6.3.0) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (6.1.7.6) - activesupport (= 6.1.7.6) + activemodel (7.0.8.4) + activesupport (= 7.0.8.4) activemodel-serializers-xml (1.0.2) activemodel (> 5.x) activesupport (> 5.x) builder (~> 3.1) - activerecord (6.1.7.6) - activemodel (= 6.1.7.6) - activesupport (= 6.1.7.6) - activestorage (6.1.7.6) - actionpack (= 6.1.7.6) - activejob (= 6.1.7.6) - activerecord (= 6.1.7.6) - activesupport (= 6.1.7.6) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) + activestorage (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.6) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) - base64 (0.1.1) - bcrypt (3.1.19) + base64 (0.2.0) + bcrypt (3.1.20) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - binding_of_caller (1.0.0) - debug_inspector (>= 0.0.1) - bootsnap (1.16.0) + bigdecimal (3.1.8) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + bootsnap (1.18.3) msgpack (~> 1.2) - bootstrap_form (5.1.0) - actionpack (>= 5.2) - activemodel (>= 5.2) - builder (3.2.4) - bullet (7.0.7) + bootstrap_form (5.3.2) + actionpack (>= 6.1) + activemodel (>= 6.1) + builder (3.3.0) + bullet (7.1.6) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) - carrierwave (2.1.0) + carrierwave (2.1.1) activemodel (>= 5.0.0) activesupport (>= 5.0.0) addressable (~> 2.6) image_processing (~> 1.1) mimemagic (>= 0.3.0) 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) colorize (1.1.0) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.3) connection_pool (2.4.1) crass (1.0.6) - cssbundling-rails (1.2.0) + cssbundling-rails (1.4.0) railties (>= 6.0.0) + csv (3.3.0) database_cleaner (2.0.2) database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (2.1.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.3) - debug_inspector (1.1.0) - devise (4.9.2) + date (3.3.4) + debug_inspector (1.2.0) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -132,15 +144,15 @@ GEM devise-async (1.0.0) activejob (>= 5.0) devise (>= 4.0) - devise-i18n (1.10.3) - devise (>= 4.8.0) - diff-lcs (1.5.0) + devise-i18n (1.12.0) + devise (>= 4.9.0) + diff-lcs (1.5.1) docile (1.4.0) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) - dry-core (1.0.0) + dotenv (3.1.2) + dotenv-rails (3.1.2) + dotenv (= 3.1.2) + railties (>= 6.1) + dry-core (1.0.1) concurrent-ruby (~> 1.0) zeitwerk (~> 2.6) dry-inflector (1.0.0) @@ -149,30 +161,33 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) - dry-types (1.7.1) + dry-types (1.7.2) + bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - erubi (1.12.0) - excon (0.99.0) - factory_bot (6.2.0) + erubi (1.13.0) + et-orbi (1.2.7) + tzinfo + excon (0.110.0) + factory_bot (6.4.5) activesupport (>= 5.0.0) - factory_bot_rails (6.2.0) - factory_bot (~> 6.2.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) railties (>= 5.0.0) fake_email_validator (1.0.11) activemodel mail - faker (3.1.1) + faker (3.4.1) i18n (>= 1.8.11, < 2) - ffi (1.15.5) - fog-aws (3.19.0) + ffi (1.16.3) + fog-aws (3.23.0) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) - fog-core (2.3.0) + fog-core (2.4.0) builder excon (~> 0.71) formatador (>= 0.2, < 2.0) @@ -186,41 +201,44 @@ GEM fog-core nokogiri (>= 1.5.11, < 2.0.0) formatador (1.1.0) - glob (0.3.1) - globalid (1.1.0) - activesupport (>= 5.0) - haml (6.1.2) + fugit (1.9.0) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) + glob (0.4.0) + globalid (1.2.1) + activesupport (>= 6.1) + haml (6.3.0) temple (>= 0.8.2) thor tilt - haml_lint (0.50.0) - haml (>= 4.0, < 6.2) + haml_lint (0.58.0) + haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hcaptcha (7.1.0) - json hkdf (0.3.0) http-2 (0.11.0) - httparty (0.21.0) + httparty (0.22.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) i18n-js (4.0.0) glob i18n - idn-ruby (0.1.4) + idn-ruby (0.1.5) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - jsbundling-rails (1.1.2) + jsbundling-rails (1.3.0) railties (>= 6.0.0) - json (2.6.3) - json-schema (4.0.0) + json (2.7.2) + json-schema (4.3.0) addressable (>= 2.8) - jwt (2.7.1) + jwt (2.8.2) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -234,88 +252,91 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.3) - launchy (2.5.0) - addressable (~> 2.7) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) - lograge (0.13.0) + launchy (3.0.0) + addressable (~> 2.8) + childprocess (~> 5.0) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.3) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (1.0.2) - method_source (1.0.0) - mime-types (3.4.1) + marcel (1.0.4) + method_source (1.1.0) + mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.0218.1) + mime-types-data (3.2024.0604) mimemagic (0.4.3) nokogiri (~> 1) rake - mini_magick (4.12.0) + mini_magick (4.13.1) mini_mime (1.1.5) - mini_portile2 (2.8.4) - minitest (5.20.0) - msgpack (1.6.0) + mini_portile2 (2.8.7) + minitest (5.24.0) + msgpack (1.7.2) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) nested_form (0.3.2) - net-http-persistent (4.0.1) + net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-http2 (0.18.4) + net-http2 (0.18.5) http-2 (~> 0.11) - net-imap (0.3.7) + net-imap (0.4.14) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.5.0) net-protocol - nio4r (2.5.9) - nokogiri (1.15.4) + nio4r (2.7.0) + nokogiri (1.16.6) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.1) - openssl (3.1.0) + oj (3.16.4) + bigdecimal (>= 3.0) + openssl (3.2.0) orm_adapter (0.5.0) - parallel (1.23.0) - parser (3.2.2.3) + parallel (1.24.0) + parser (3.3.2.0) ast (~> 2.4.1) racc - pg (1.5.4) - pghero (3.3.4) + pg (1.5.6) + pghero (3.5.0) activerecord (>= 6) - prometheus-client (4.2.1) - public_suffix (5.0.1) - puma (6.3.1) + prometheus-client (4.2.2) + public_suffix (5.0.4) + puma (6.4.2) nio4r (~> 2.0) - pundit (2.3.1) + pundit (2.3.2) activesupport (>= 3.0.0) - racc (1.7.1) - rack (2.2.8) + raabro (1.4.0) + racc (1.8.0) + rack (2.2.9) rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.6) - actioncable (= 6.1.7.6) - actionmailbox (= 6.1.7.6) - actionmailer (= 6.1.7.6) - actionpack (= 6.1.7.6) - actiontext (= 6.1.7.6) - actionview (= 6.1.7.6) - activejob (= 6.1.7.6) - activemodel (= 6.1.7.6) - activerecord (= 6.1.7.6) - activestorage (= 6.1.7.6) - activesupport (= 6.1.7.6) + rails (7.0.8.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 6.1.7.6) - sprockets-rails (>= 2.0.0) + railties (= 7.0.8.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -327,7 +348,7 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.8) + rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails_admin (3.1.2) @@ -336,26 +357,28 @@ GEM nested_form (~> 0.3) rails (>= 6.0, < 8) turbo-rails (~> 1.0) - railties (6.1.7.6) - actionpack (= 6.1.7.6) - activesupport (= 6.1.7.6) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) + zeitwerk (~> 2.5) rainbow (3.1.1) - rake (13.0.6) + rake (13.2.1) redcarpet (3.6.0) - redis (4.8.0) - regexp_parser (2.8.1) + redis (4.8.1) + regexp_parser (2.9.2) request_store (1.5.1) rack (>= 1.4) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) + rexml (3.2.8) + strscan (>= 3.0.9) rolify (6.0.1) - rotp (6.2.2) - rouge (4.1.2) + rotp (6.3.0) + rouge (4.1.3) rpush (7.0.1) activesupport (>= 5.2) jwt (>= 1.5.6) @@ -370,54 +393,56 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) + rspec-support (~> 3.13.0) rspec-its (1.3.0) rspec-core (>= 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) - rspec-support (~> 3.12.0) - rspec-rails (6.0.3) + rspec-support (~> 3.13.0) + rspec-rails (6.1.3) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-sidekiq (4.0.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-sidekiq (5.0.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) - rspec-support (3.12.1) - rubocop (1.56.3) - base64 (~> 0.1.1) + rspec-support (3.13.1) + rubocop (1.64.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.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) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) - rubocop-rails (2.21.0) + rubocop-ast (1.31.3) + parser (>= 3.3.1.0) + rubocop-rails (2.25.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) - ruby-vips (2.1.4) + ruby-vips (2.2.1) ffi (~> 1.12) 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) nokogiri (>= 1.12.0) sassc (2.4.0) @@ -428,20 +453,25 @@ GEM sprockets (> 3.0) sprockets-rails tilt - sentry-rails (5.10.0) + sentry-rails (5.17.3) railties (>= 5.0) - sentry-ruby (~> 5.10.0) - sentry-ruby (5.10.0) + sentry-ruby (~> 5.17.3) + sentry-ruby (5.17.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.10.0) - sentry-ruby (~> 5.10.0) + sentry-sidekiq (5.17.3) + sentry-ruby (~> 5.17.3) sidekiq (>= 3.0) - shoulda-matchers (5.3.0) + shoulda-matchers (6.2.0) activesupport (>= 5.2.0) - sidekiq (6.5.8) + sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) 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) docile (~> 1.1) simplecov-html (~> 0.11) @@ -454,23 +484,24 @@ GEM json simplecov simplecov_json_formatter (0.1.4) - spring (4.1.1) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.1) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) + ssrf_filter (1.1.2) + strscan (3.1.0) sysexits (1.2.0) - temple (0.10.2) - thor (1.2.2) - tilt (2.2.0) - timeout (0.4.0) + temple (0.10.3) + thor (1.3.1) + tilt (2.3.0) + timeout (0.4.1) tldv (0.1.0) tldv-data (~> 1.0) - tldv-data (1.0.2023031000) - turbo-rails (1.4.0) + tldv-data (1.0.2023080900) + turbo-rails (1.5.0) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) @@ -481,40 +512,47 @@ GEM concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8) - unicode-display_width (2.4.2) + unf_ext (0.0.8.2) + unicode-display_width (2.5.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) rack (>= 2.0.9) + web-push (3.0.1) + jwt (~> 2.0) + openssl (~> 3.0) webpush (1.1.0) hkdf (~> 0.2) jwt (~> 2.0) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.11) + zeitwerk (2.6.16) PLATFORMS ruby DEPENDENCIES active_model_otp - bcrypt (~> 3.1.19) + bcrypt (~> 3.1.20) better_errors binding_of_caller bootsnap bootstrap_form (~> 5.0) bullet - carrierwave (~> 2.0) - carrierwave_backgrounder! + carrierwave (~> 2.1) + carrierwave_backgrounder (~> 0.4.2) colorize connection_pool - cssbundling-rails (~> 1.2) + cssbundling-rails (~> 1.4) database_cleaner devise (~> 4.9) devise-async devise-i18n - dotenv-rails (~> 2.8) + dotenv-rails (~> 3.1) dry-initializer (~> 3.1) dry-types (~> 1.7) factory_bot_rails @@ -523,14 +561,14 @@ DEPENDENCIES fog-aws fog-core fog-local - haml (~> 6.1) + haml (~> 6.3) haml_lint - hcaptcha (~> 7.0) + hcaptcha! httparty i18n-js (= 4.0) - jsbundling-rails (~> 1.1) + jsbundling-rails (~> 1.3) json-schema - jwt (~> 2.7) + jwt (~> 2.8) letter_opener lograge mail (~> 2.7.1) @@ -539,14 +577,13 @@ DEPENDENCIES net-pop net-smtp oj - openssl (~> 3.1) + openssl (~> 3.2) pg pghero prometheus-client (~> 4.2) puma pundit (~> 2.3) - questiongenerator (~> 1.2)! - rails (~> 6.1) + rails (~> 7.0.8) rails-controller-testing rails-i18n (~> 7.0) rails_admin @@ -558,27 +595,29 @@ DEPENDENCIES rqrcode rspec-its (~> 1.3) rspec-mocks - rspec-rails (~> 6.0) - rspec-sidekiq (~> 4.0) - rubocop (~> 1.56) - rubocop-rails (~> 2.21) + rspec-rails (~> 6.1) + rspec-sidekiq (~> 5.0) + rubocop (~> 1.64) + rubocop-rails (~> 2.25) rubyzip (~> 2.3) sanitize sassc-rails sentry-rails sentry-ruby sentry-sidekiq - shoulda-matchers (~> 5.3) + shoulda-matchers (~> 6.2) sidekiq (< 7) + sidekiq-scheduler simplecov simplecov-cobertura simplecov-json - spring (~> 4.1) sprockets (~> 4.2) sprockets-rails tldv (~> 0.1.0) turbo-rails twitter-text + view_component + web-push BUNDLED WITH - 2.3.18 + 2.5.5 diff --git a/README.md b/README.md index 11d51d50..aa53eb9c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Currently the only change is to enable long questions by default. ## 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 it under the terms of the GNU Affero General Public License as published by diff --git a/Rakefile b/Rakefile index 17ba7dcf..dfd42972 100644 --- a/Rakefile +++ b/Rakefile @@ -7,7 +7,7 @@ require File.expand_path("config/application", __dir__) Rails.application.load_tasks -namespace :justask do # rubocop:disable Metrics/BlockLength +namespace :justask do desc "Gives admin status to a user." task :admin, [:screen_name] => :environment do |_t, args| abort "screen name required" if args[:screen_name].nil? diff --git a/app/assets/config/actiontext.js b/app/assets/config/actiontext.js new file mode 100644 index 00000000..9da9b2bc --- /dev/null +++ b/app/assets/config/actiontext.js @@ -0,0 +1 @@ +// This is a stub so that we don't have to install actiontext diff --git a/app/assets/config/trix.js b/app/assets/config/trix.js new file mode 100644 index 00000000..915e526b --- /dev/null +++ b/app/assets/config/trix.js @@ -0,0 +1 @@ +// This is a stub so that we don't have to install Trix diff --git a/app/assets/stylesheets/_utilities.scss b/app/assets/stylesheets/_utilities.scss index 5fe1bbae..5f9df7b1 100644 --- a/app/assets/stylesheets/_utilities.scss +++ b/app/assets/stylesheets/_utilities.scss @@ -25,3 +25,12 @@ .pull-left { float: left; } + +// FIXME: Backport from Bootstrap 5.3, remove once updated +.z-n1 { + z-index: -1; +} + +.grid-row-1 { + grid-row: 1; +} diff --git a/app/assets/stylesheets/components/_answerbox.scss b/app/assets/stylesheets/components/_answerbox.scss index adb1b732..851693a9 100644 --- a/app/assets/stylesheets/components/_answerbox.scss +++ b/app/assets/stylesheets/components/_answerbox.scss @@ -1,8 +1,6 @@ @use "sass:map"; .answerbox { - &__question-text, - &__question-user, &__answer-user, &__answer-date { margin-bottom: 0; @@ -25,7 +23,6 @@ margin-bottom: map.get($spacers, 3); } - &__question-user-avatar, &__answer-user-avatar { margin-right: map.get($spacers, 2); border-radius: $avatar-border-radius; @@ -38,8 +35,9 @@ } &__action { - padding-left: 0; - padding-right: map.get($spacers, 1); + color: RGBA(var(--raised-text), 0.75); + padding: var(--btn-padding-y); + margin-right: map.get($spacers, 1); text-decoration: none; & i { @@ -50,25 +48,29 @@ &:hover, &:focus, &:active { + color: RGBA(var(--raised-text), 1); text-decoration: none; } - &[name="ab-smile"], - &[name="ab-smile-comment"] { + &.smile { color: var(--primary); &:hover { color: var(--success); } + } - &[data-action="unsmile"] { - color: var(--success); + &.unsmile { + color: var(--success); - &:hover { - color: var(--danger); - } + &:hover { + color: var(--danger); } } + + &.dropdown-toggle::after { + margin-left: 0; + } } &__actions { @@ -97,9 +99,3 @@ display: inline; } } - -body:not(.cap-web-share) { - [name="ab-share"] { - display: none; - } -} diff --git a/app/assets/stylesheets/components/_collapse.scss b/app/assets/stylesheets/components/_collapse.scss index 0f8afd60..a54dfca0 100644 --- a/app/assets/stylesheets/components/_collapse.scss +++ b/app/assets/stylesheets/components/_collapse.scss @@ -12,7 +12,7 @@ } } - &.answerbox__question-text { + &.question__text { max-height: 15rem; @include media-breakpoint-up('sm') { diff --git a/app/assets/stylesheets/components/_inbox-entry.scss b/app/assets/stylesheets/components/_inbox-entry.scss index a56875cd..549ee55c 100644 --- a/app/assets/stylesheets/components/_inbox-entry.scss +++ b/app/assets/stylesheets/components/_inbox-entry.scss @@ -23,6 +23,10 @@ .text-muted { color: RGBA(var(--primary-text), 0.8) !important; } + + .btn { + color: inherit; + } } } @@ -57,3 +61,10 @@ position: relative; } } + +body:not(.cap-web-share) { + + [data-controller="share"] { + display: none; + } +} diff --git a/app/assets/stylesheets/components/_question.scss b/app/assets/stylesheets/components/_question.scss index 7d03e06b..e3425bc0 100644 --- a/app/assets/stylesheets/components/_question.scss +++ b/app/assets/stylesheets/components/_question.scss @@ -1,18 +1,30 @@ +@use "sass:map"; + .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%; - z-index: 999; @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; - } } diff --git a/app/assets/stylesheets/overrides/_buttons.scss b/app/assets/stylesheets/overrides/_buttons.scss index bb33b8d0..576356b7 100644 --- a/app/assets/stylesheets/overrides/_buttons.scss +++ b/app/assets/stylesheets/overrides/_buttons.scss @@ -1,5 +1,8 @@ .btn { + --btn-padding-x: 1rem; + --btn-border-radius: 2rem; color: RGB(var(--body-text)); + font-weight: bold; } .btn-link:hover { @@ -61,4 +64,4 @@ .fa { pointer-events: none; } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/overrides/_inputs.scss b/app/assets/stylesheets/overrides/_inputs.scss index a1ef2007..650d7542 100644 --- a/app/assets/stylesheets/overrides/_inputs.scss +++ b/app/assets/stylesheets/overrides/_inputs.scss @@ -20,3 +20,18 @@ background-color: var(--raised-accent); 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; +} diff --git a/app/assets/stylesheets/trix.scss b/app/assets/stylesheets/trix.scss new file mode 100644 index 00000000..915e526b --- /dev/null +++ b/app/assets/stylesheets/trix.scss @@ -0,0 +1 @@ +// This is a stub so that we don't have to install Trix diff --git a/app/components/application_component.rb b/app/components/application_component.rb new file mode 100644 index 00000000..0be055fa --- /dev/null +++ b/app/components/application_component.rb @@ -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 diff --git a/app/components/avatar_component.html.haml b/app/components/avatar_component.html.haml new file mode 100644 index 00000000..379979c6 --- /dev/null +++ b/app/components/avatar_component.html.haml @@ -0,0 +1,4 @@ +%img{ class: avatar_classes, + alt: alt_text, + src: avatar_image, + loading: :lazy } diff --git a/app/components/avatar_component.rb b/app/components/avatar_component.rb new file mode 100644 index 00000000..c970576a --- /dev/null +++ b/app/components/avatar_component.rb @@ -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 diff --git a/app/components/comment_component.html.haml b/app/components/comment_component.html.haml new file mode 100644 index 00000000..c7a1543c --- /dev/null +++ b/app/components/comment_component.html.haml @@ -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 diff --git a/app/components/comment_component.rb b/app/components/comment_component.rb new file mode 100644 index 00000000..1fe41b9a --- /dev/null +++ b/app/components/comment_component.rb @@ -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 diff --git a/app/components/question_component.rb b/app/components/question_component.rb new file mode 100644 index 00000000..f14a1de3 --- /dev/null +++ b/app/components/question_component.rb @@ -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 diff --git a/app/components/question_component/question_component.en.yml b/app/components/question_component/question_component.en.yml new file mode 100644 index 00000000..0d215401 --- /dev/null +++ b/app/components/question_component/question_component.en.yml @@ -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" diff --git a/app/components/question_component/question_component.html.haml b/app/components/question_component/question_component.html.haml new file mode 100644 index 00000000..ea139a1c --- /dev/null +++ b/app/components/question_component/question_component.html.haml @@ -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" diff --git a/app/controllers/ajax/answer_controller.rb b/app/controllers/ajax/answer_controller.rb index 0c401638..3674e887 100644 --- a/app/controllers/ajax/answer_controller.rb +++ b/app/controllers/ajax/answer_controller.rb @@ -3,9 +3,7 @@ require "cgi" class Ajax::AnswerController < AjaxController - include SocialHelper::TwitterMethods - include SocialHelper::TumblrMethods - include SocialHelper::TelegramMethods + include SocialHelper def create params.require :id @@ -15,7 +13,7 @@ class Ajax::AnswerController < AjaxController inbox = (params[:inbox] == "true") if inbox - inbox_entry = Inbox.find(params[:id]) + inbox_entry = InboxEntry.find(params[:id]) unless current_user == inbox_entry.user @response[:status] = :fail @@ -46,9 +44,8 @@ class Ajax::AnswerController < AjaxController return if inbox - # this assign is needed because shared/_answerbox relies on it, I think @question = 1 - @response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false, subscribed_answer_ids: [answer.id] }) + @response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false }) end def destroy @@ -62,7 +59,7 @@ class Ajax::AnswerController < AjaxController return 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 @response[:status] = :okay @@ -73,7 +70,10 @@ class Ajax::AnswerController < AjaxController private def sharing_hash(answer) = { + url: answer_share_url(answer), + text: prepare_tweet(answer, nil, true), twitter: twitter_share_url(answer), + bluesky: bluesky_share_url(answer), tumblr: tumblr_share_url(answer), telegram: telegram_share_url(answer), custom: CGI.escape(prepare_tweet(answer)), diff --git a/app/controllers/ajax/comment_controller.rb b/app/controllers/ajax/comment_controller.rb index 72dd305e..c7264464 100644 --- a/app/controllers/ajax/comment_controller.rb +++ b/app/controllers/ajax/comment_controller.rb @@ -14,10 +14,12 @@ class Ajax::CommentController < AjaxController return end + comments = Comment.where(answer:).includes([{ user: :profile }, :smiles]) + @response[:status] = :okay @response[:message] = t(".success") @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 end diff --git a/app/controllers/ajax/inbox_controller.rb b/app/controllers/ajax/inbox_controller.rb index 66ed1766..77042d62 100644 --- a/app/controllers/ajax/inbox_controller.rb +++ b/app/controllers/ajax/inbox_controller.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + class Ajax::InboxController < AjaxController def remove params.require :id - inbox = Inbox.find(params[:id]) + inbox = InboxEntry.find(params[:id]) unless current_user == inbox.user @response[:status] = :fail @@ -28,7 +30,7 @@ class Ajax::InboxController < AjaxController raise unless user_signed_in? begin - Inbox.where(user: current_user).each { |i| i.remove } + InboxEntry.where(user: current_user).find_each(&:remove) rescue => e Sentry.capture_exception(e) @response[:status] = :err @@ -43,10 +45,10 @@ class Ajax::InboxController < AjaxController def remove_all_author begin - @target_user = User.where('LOWER(screen_name) = ?', params[:author].downcase).first! - @inbox = current_user.inboxes.joins(:question) - .where(questions: { user_id: @target_user.id, author_is_anonymous: false }) - @inbox.each { |i| i.remove } + @target_user = User.where("LOWER(screen_name) = ?", params[:author].downcase).first! + @inbox = current_user.inbox_entries.joins(:question) + .where(questions: { user_id: @target_user.id, author_is_anonymous: false }) + @inbox.each(&:remove) rescue => e Sentry.capture_exception(e) @response[:status] = :err diff --git a/app/controllers/ajax/moderation_controller.rb b/app/controllers/ajax/moderation_controller.rb index 4fda3cc8..61cb79c6 100644 --- a/app/controllers/ajax/moderation_controller.rb +++ b/app/controllers/ajax/moderation_controller.rb @@ -84,7 +84,7 @@ class Ajax::ModerationController < AjaxController target_user = User.find_by_screen_name!(params[:user]) @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) @response[:status] = :nopriv @@ -94,7 +94,7 @@ class Ajax::ModerationController < AjaxController @response[:checked] = status type = params[:type].downcase - target_role = {'admin' => 'administrator'}.fetch(type, type).to_sym + target_role = type.to_sym if status target_user.add_role target_role diff --git a/app/controllers/ajax/question_controller.rb b/app/controllers/ajax/question_controller.rb index f3bef810..e2094fda 100644 --- a/app/controllers/ajax/question_controller.rb +++ b/app/controllers/ajax/question_controller.rb @@ -1,19 +1,6 @@ # frozen_string_literal: true 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 params.require :question params.require :anonymousQuestion @@ -24,14 +11,15 @@ class Ajax::QuestionController < AjaxController @response = { success: true, message: t(".success"), - status: :okay + status: :okay, } if user_signed_in? && params[:rcpt] == "followers" UseCase::Question::CreateFollowers.call( source_user_id: current_user.id, 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 end @@ -41,7 +29,20 @@ class Ajax::QuestionController < AjaxController target_user_id: params[:rcpt], content: params[:question], anonymous: params[:anonymousQuestion], - author_identifier: AnonymousBlock.get_identifier(request.remote_ip) + author_identifier: AnonymousBlock.get_identifier(request.remote_ip), ) 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 diff --git a/app/controllers/ajax/relationship_controller.rb b/app/controllers/ajax/relationship_controller.rb deleted file mode 100644 index ba9f4546..00000000 --- a/app/controllers/ajax/relationship_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/ajax/smile_controller.rb b/app/controllers/ajax/smile_controller.rb deleted file mode 100644 index 227b1d9c..00000000 --- a/app/controllers/ajax/smile_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/ajax/subscription_controller.rb b/app/controllers/ajax/subscription_controller.rb deleted file mode 100644 index fd574e78..00000000 --- a/app/controllers/ajax/subscription_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/ajax/web_push_controller.rb b/app/controllers/ajax/web_push_controller.rb index 4dd9ecdd..04d7fb40 100644 --- a/app/controllers/ajax/web_push_controller.rb +++ b/app/controllers/ajax/web_push_controller.rb @@ -41,7 +41,7 @@ class Ajax::WebPushController < AjaxController @response[:message] = t(".subscription_count", count: current_user.web_push_subscriptions.count) end - def unsubscribe # rubocop:disable Metrics/AbcSize + def unsubscribe removed = if params.key?(:endpoint) current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).destroy_all else diff --git a/app/controllers/anonymous_block_controller.rb b/app/controllers/anonymous_block_controller.rb index a8c9a32f..2d0ce4fc 100644 --- a/app/controllers/anonymous_block_controller.rb +++ b/app/controllers/anonymous_block_controller.rb @@ -20,8 +20,8 @@ class AnonymousBlockController < ApplicationController target_user: question.user ) - inbox_id = question.inboxes.first&.id - question.inboxes.first&.destroy + inbox_id = question.inbox_entries.first&.id + question.inbox_entries.first&.destroy respond_to do |format| format.turbo_stream do diff --git a/app/controllers/answer_controller.rb b/app/controllers/answer_controller.rb index ac52ab40..917501e5 100644 --- a/app/controllers/answer_controller.rb +++ b/app/controllers/answer_controller.rb @@ -8,13 +8,11 @@ class AnswerController < ApplicationController turbo_stream_actions :pin, :unpin 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 - @subscribed_answer_ids = [] return unless user_signed_in? - @subscribed_answer_ids = Subscription.where(user: current_user, answer: @answer).pluck(:answer_id) mark_notifications_as_read end @@ -51,11 +49,12 @@ class AnswerController < ApplicationController private 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) .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::CommentSmiled", target_id: @answer.comment_smiles.pluck(:id)))) .update_all(new: false) # rubocop:disable Rails/SkipsModelValidations + current_user.touch(:notifications_updated_at) if updated.positive? end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 64ed88c7..cfd2fcc6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base around_action :switch_locale before_action :banned? before_action :find_active_announcements + before_action :set_has_new_reports # check if user wants to read def switch_locale(&) @@ -30,13 +31,15 @@ class ApplicationController < ActionController::Base # obligatory '2001: A Space Odyssey' reference flash[:notice] = t("user.sessions.create.banned", name:) current_ban = current_user.bans.current.first - unless current_ban&.reason.nil? - flash[:notice] += "\n#{t('user.sessions.create.reason', reason: current_ban.reason)}" - end - unless current_ban&.permanent? - # TODO format banned_until - flash[:notice] += "\n#{t('user.sessions.create.until', time: current_ban.expires_at)}" - end + flash[:notice] += "\n#{t('user.sessions.create.reason', reason: current_ban.reason)}" unless current_ban&.reason&.empty? + + flash[:notice] += if current_ban&.permanent? + "\n#{t('user.sessions.create.permanent')}" + else + # TODO: format banned_until + "\n#{t('user.sessions.create.until', time: current_ban.expires_at)}" + end + sign_out current_user redirect_to new_user_session_path end @@ -46,6 +49,18 @@ class ApplicationController < ActionController::Base @active_announcements ||= Announcement.find_active 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 protected diff --git a/app/controllers/comments/reactions_controller.rb b/app/controllers/comments/reactions_controller.rb new file mode 100644 index 00000000..0efe2c2a --- /dev/null +++ b/app/controllers/comments/reactions_controller.rb @@ -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 diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb new file mode 100644 index 00000000..25f4b435 --- /dev/null +++ b/app/controllers/comments_controller.rb @@ -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 diff --git a/app/controllers/concerns/paginates_answers.rb b/app/controllers/concerns/paginates_answers.rb index 2d5081c5..fe0b0c0b 100644 --- a/app/controllers/concerns/paginates_answers.rb +++ b/app/controllers/concerns/paginates_answers.rb @@ -5,8 +5,6 @@ module PaginatesAnswers @answers = yield(last_id: params[:last_id]) answer_ids = @answers.map(&:id) @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).count.zero? - @subscribed_answer_ids = Subscription.where(user: current_user, answer_id: answer_ids).pluck(:answer_id) if user_signed_in? + @more_data_available = !yield(last_id: @answers_last_id, size: 1).select("answers.id").count.zero? end end diff --git a/app/controllers/concerns/turbo_streamable.rb b/app/controllers/concerns/turbo_streamable.rb index 5066bc05..da9a3a95 100644 --- a/app/controllers/concerns/turbo_streamable.rb +++ b/app/controllers/concerns/turbo_streamable.rb @@ -23,6 +23,8 @@ module TurboStreamable render_error t("errors.parameter_error", parameter: e.instance_of?(KeyError) ? e.key : e.param.capitalize) rescue Dry::Types::CoercionError, Dry::Types::ConstraintError render_error t("errors.invalid_parameter") + rescue ActiveRecord::RecordInvalid => e + render_error e.record.errors.full_messages.flatten.join(" ") rescue ActiveRecord::RecordNotFound render_error t("errors.record_not_found") end diff --git a/app/controllers/discover_controller.rb b/app/controllers/discover_controller.rb index 2e1be8b4..5898875d 100644 --- a/app/controllers/discover_controller.rb +++ b/app/controllers/discover_controller.rb @@ -1,36 +1,34 @@ +# frozen_string_literal: true + class DiscoverController < ApplicationController before_action :authenticate_user! def index - unless APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod? - return redirect_to root_path - end + return redirect_to root_path unless APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod? - 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) - @most_discussed = Answer.where("created_at > ?", Time.now.ago(1.week)).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_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.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 > ?", 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) - 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 # .question_count = how many questions did the user ask - @users_with_most_questions = Question.select('user_id, COUNT(*) AS question_count'). - where("created_at > ?", Time.now.ago(1.week)). - where(author_is_anonymous: false). - group(:user_id). - order('question_count'). - reverse_order.limit(top_x) + @users_with_most_questions = Question.select("user_id, COUNT(*) AS question_count") + .where("created_at > ?", week_ago) + .where(author_is_anonymous: false) + .group(:user_id) + .order("question_count") + .reverse_order.limit(top_x) # .user = the user # .answer_count = how many questions did the user answer - @users_with_most_answers = Answer.select('user_id, COUNT(*) AS answer_count'). - where("created_at > ?", Time.now.ago(1.week)). - group(:user_id). - order('answer_count'). - reverse_order.limit(top_x) + @users_with_most_answers = Answer.select("user_id, COUNT(*) AS answer_count") + .where("created_at > ?", week_ago) + .group(:user_id) + .order("answer_count") + .reverse_order.limit(top_x) end end diff --git a/app/controllers/inbox_controller.rb b/app/controllers/inbox_controller.rb index 661d49f4..26d4113c 100644 --- a/app/controllers/inbox_controller.rb +++ b/app/controllers/inbox_controller.rb @@ -3,30 +3,17 @@ class InboxController < ApplicationController before_action :authenticate_user! - after_action :mark_inbox_entries_as_read, only: %i[show] - - def show # rubocop:disable Metrics/MethodLength - find_author + def show 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 @disabled = true if @inbox.empty? - respond_to do |format| - format.html { render "show" } - format.turbo_stream do - render "show", layout: false, status: :see_other + mark_inbox_entries_as_read - # rubocop disabled as just flipping a flag doesn't need to have validations to be run - @inbox.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations - end + respond_to do |format| + format.html + format.turbo_stream end end @@ -36,7 +23,7 @@ class InboxController < ApplicationController author_identifier: "justask", 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 respond_to do |format| @@ -52,41 +39,29 @@ class InboxController < ApplicationController private - def find_author - return if params[:author].blank? - - @author = params[:author] - - @author_user = User.where("LOWER(screen_name) = ?", @author.downcase).first - flash.now[:error] = t(".author.error", author: @author) unless @author_user + def filter_params + params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS) end 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 - @more_data_available = current_user.cursored_inbox(last_id: @inbox_last_id, size: 1).then(&method(:filter_author_chain)).count.positive? - @inbox_count = current_user.inboxes.then(&method(:filter_author_chain)).count + @more_data_available = filter.cursored_results(last_id: @inbox_last_id, size: 1).count.positive? + @inbox_count = filter.results.count end 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" 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 def mark_inbox_entries_as_read # using .dup to not modify @inbox -- useful in tests - @inbox&.dup&.update_all(new: false) - current_user.touch(:inbox_updated_at) + updated = @inbox&.dup&.update_all(new: false) + current_user.touch(:inbox_updated_at) if updated.positive? end # rubocop:enable Rails/SkipsModelValidations diff --git a/app/controllers/modal_controller.rb b/app/controllers/modal_controller.rb new file mode 100644 index 00000000..0a45285b --- /dev/null +++ b/app/controllers/modal_controller.rb @@ -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 diff --git a/app/controllers/moderation/inbox_controller.rb b/app/controllers/moderation/inbox_controller.rb index 5cbb8a80..b906a11c 100644 --- a/app/controllers/moderation/inbox_controller.rb +++ b/app/controllers/moderation/inbox_controller.rb @@ -5,14 +5,22 @@ class Moderation::InboxController < ApplicationController def index @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 - @more_data_available = !@user.cursored_inbox(last_id: @inbox_last_id, size: 1).count.zero? - @inbox_count = @user.inboxes.count + @more_data_available = !filter.cursored_results(last_id: @inbox_last_id, size: 1).count.zero? + @inbox_count = @user.inbox_entries.count respond_to do |format| format.html format.turbo_stream { render "index", layout: false, status: :see_other } end end + + private + + def filter_params + params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS) + end end diff --git a/app/controllers/moderation/reports_controller.rb b/app/controllers/moderation/reports_controller.rb index a52977b2..7d29d1b2 100644 --- a/app/controllers/moderation/reports_controller.rb +++ b/app/controllers/moderation/reports_controller.rb @@ -2,12 +2,15 @@ class Moderation::ReportsController < ApplicationController before_action :authenticate_user! + before_action :set_filter_enabled + before_action :set_type_options + before_action :set_last_reports_visit def index - @type = params[:type] - @reports = list_reports(type: @type, last_id: params[:last_id]) + filter = ReportFilter.new(filter_params) + @reports = filter.cursored_results(last_id: params[:last_id]) @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| format.html @@ -17,13 +20,29 @@ class Moderation::ReportsController < ApplicationController private - def list_reports(type:, last_id:, size: nil) - cursor_params = { last_id:, size: }.compact + def filter_params + params.slice(*ReportFilter::KEYS).permit(*ReportFilter::KEYS) + end - if type == "all" - Report.cursored_reports(**cursor_params) - else - Report.cursored_reports_of_type(type, **cursor_params) - 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 diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index b0088074..a240d3f7 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -3,8 +3,6 @@ class NotificationsController < ApplicationController before_action :authenticate_user! - after_action :mark_notifications_as_read, only: %i[index] - TYPE_MAPPINGS = { "answer" => Notification::QuestionAnswered.name, "comment" => Notification::Commented.name, @@ -18,6 +16,7 @@ class NotificationsController < ApplicationController @notifications = cursored_notifications_for(type: @type, last_id: params[:last_id]) paginate_notifications @counters = count_unread_by_type + mark_notifications_as_read respond_to do |format| format.html @@ -52,8 +51,8 @@ class NotificationsController < ApplicationController # rubocop:disable Rails/SkipsModelValidations def mark_notifications_as_read # using .dup to not modify @notifications -- useful in tests - @notifications&.dup&.update_all(new: false) - current_user.touch(:notifications_updated_at) + updated = @notifications&.dup&.update_all(new: false) + current_user.touch(:notifications_updated_at) if updated.positive? end # rubocop:enable Rails/SkipsModelValidations diff --git a/app/controllers/question_controller.rb b/app/controllers/question_controller.rb index 14dbe80e..6d334ea3 100644 --- a/app/controllers/question_controller.rb +++ b/app/controllers/question_controller.rb @@ -8,8 +8,7 @@ class QuestionController < ApplicationController @answers = @question.cursored_answers(last_id: params[:last_id], current_user:) answer_ids = @answers.map(&:id) @answers_last_id = answer_ids.min - @more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1, current_user:).count.zero? - @subscribed = Subscription.where(user: current_user, answer_id: answer_ids).pluck(:answer_id) if user_signed_in? + @more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1, current_user:).select("answers.id").count.zero? respond_to do |format| format.html diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb new file mode 100644 index 00000000..b1914e46 --- /dev/null +++ b/app/controllers/reactions_controller.rb @@ -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 diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb new file mode 100644 index 00000000..e9965f79 --- /dev/null +++ b/app/controllers/relationships_controller.rb @@ -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 diff --git a/app/controllers/settings/export_controller.rb b/app/controllers/settings/export_controller.rb index 1a8bc0a2..3957e427 100644 --- a/app/controllers/settings/export_controller.rb +++ b/app/controllers/settings/export_controller.rb @@ -21,9 +21,12 @@ class Settings::ExportController < ApplicationController private + # rubocop:disable Rails/SkipsModelValidations def mark_notifications_as_read - Notification::DataExported - .where(recipient: current_user, new: true) - .update_all(new: false) # rubocop:disable Rails/SkipsModelValidations + updated = Notification::DataExported + .where(recipient: current_user, new: true) + .update_all(new: false) + current_user.touch(:notifications_updated_at) if updated.positive? end + # rubocop:enable Rails/SkipsModelValidations end diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index a4980d25..99d63515 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -19,6 +19,6 @@ class Settings::PrivacyController < ApplicationController else flash[:error] = t(".error") end - redirect_to settings_privacy_path + render :edit end end diff --git a/app/controllers/settings/profile_controller.rb b/app/controllers/settings/profile_controller.rb index 75091ed0..81344ca3 100644 --- a/app/controllers/settings/profile_controller.rb +++ b/app/controllers/settings/profile_controller.rb @@ -14,6 +14,6 @@ class Settings::ProfileController < ApplicationController flash[:error] = t(".error") end - redirect_to settings_profile_path + render :edit end end diff --git a/app/controllers/settings/profile_picture_controller.rb b/app/controllers/settings/profile_picture_controller.rb index c44e1200..68359fe7 100644 --- a/app/controllers/settings/profile_picture_controller.rb +++ b/app/controllers/settings/profile_picture_controller.rb @@ -12,9 +12,10 @@ class Settings::ProfilePictureController < ApplicationController text += t(".notice.profile_header") if user_attributes[:profile_header] flash[:success] = text else - flash[:error] = t(".error") + # CarrierWave resets the image to the default upon an error + current_user.reload end - redirect_to settings_profile_path + render "settings/profile/edit" end end diff --git a/app/controllers/settings/theme_controller.rb b/app/controllers/settings/theme_controller.rb index 58829a67..94c87192 100644 --- a/app/controllers/settings/theme_controller.rb +++ b/app/controllers/settings/theme_controller.rb @@ -22,7 +22,7 @@ class Settings::ThemeController < ApplicationController else flash[:error] = t(".error", errors: current_user.theme.errors.messages.flatten.join(" ")) end - redirect_to settings_theme_path + render :edit end def destroy diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 00000000..87049981 --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -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 diff --git a/app/controllers/timeline_controller.rb b/app/controllers/timeline_controller.rb index 6b34fdb4..e5c7fd33 100644 --- a/app/controllers/timeline_controller.rb +++ b/app/controllers/timeline_controller.rb @@ -32,10 +32,9 @@ class TimelineController < ApplicationController def paginate_timeline @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 - @more_data_available = !yield(last_id: @timeline_last_id, size: 1).count.zero? - @subscribed_answer_ids = Subscription.where(user: current_user, answer_id: timeline_ids).pluck(:answer_id) + @more_data_available = !yield(last_id: @timeline_last_id, size: 1).select("answers.id").count.zero? respond_to do |format| format.html { render "timeline/timeline" } diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index 7b9084b9..6f03231b 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -8,8 +8,8 @@ class UserController < ApplicationController after_action :mark_notification_as_read, only: %i[show] def show - @pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10) - paginate_answers { |args| @user.cursored_answers(**args) } + @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(current_user_id: current_user, **args) } respond_to do |format| format.html diff --git a/app/helpers/ajax_helper.rb b/app/helpers/ajax_helper.rb index 3f1a4867..6a7aa743 100644 --- a/app/helpers/ajax_helper.rb +++ b/app/helpers/ajax_helper.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + module AjaxHelper end diff --git a/app/helpers/application_helper/graph_methods.rb b/app/helpers/application_helper/graph_methods.rb index cde48656..a9487db9 100644 --- a/app/helpers/application_helper/graph_methods.rb +++ b/app/helpers/application_helper/graph_methods.rb @@ -4,13 +4,13 @@ module ApplicationHelper::GraphMethods # Creates tags for OpenGraph properties from a hash # @param values [Hash] 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 # Creates tags from a hash # @param values [Hash] 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 # @param user [User] @@ -22,7 +22,7 @@ module ApplicationHelper::GraphMethods "og:url": user_url(user), "og:description": user.profile.description, "og:site_name": APP_CONFIG["site_name"], - "profile:username": user.screen_name + "profile:username": user.screen_name, }) end @@ -33,7 +33,7 @@ module ApplicationHelper::GraphMethods "twitter:site": "@retrospring", "twitter:title": user.profile.motivation_header.presence || "Ask me anything!", "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 @@ -45,7 +45,7 @@ module ApplicationHelper::GraphMethods "og:image": full_profile_picture_url(answer.user), "og:url": answer_url(answer.user.screen_name, answer.id), "og:description": answer.content, - "og:site_name": APP_CONFIG["site_name"] + "og:site_name": APP_CONFIG["site_name"], }) end diff --git a/app/helpers/application_helper/title_methods.rb b/app/helpers/application_helper/title_methods.rb index b80deee3..9784e646 100644 --- a/app/helpers/application_helper/title_methods.rb +++ b/app/helpers/application_helper/title_methods.rb @@ -28,7 +28,7 @@ module ApplicationHelper::TitleMethods def question_title(question) context_user = question.answers&.first&.user if question.direct name = user_screen_name question.user, - context_user: context_user, + context_user:, author_identifier: question.author_is_anonymous ? question.author_identifier : nil, url: false generate_title name, "asked", question.content diff --git a/app/helpers/bootstrap_helper.rb b/app/helpers/bootstrap_helper.rb index 4d14e3dc..124c4ca8 100644 --- a/app/helpers/bootstrap_helper.rb +++ b/app/helpers/bootstrap_helper.rb @@ -8,6 +8,7 @@ module BootstrapHelper badge_attr: {}, icon: nil, class: "", + id: nil, hotkey: nil, }.merge(options) @@ -24,24 +25,24 @@ module BootstrapHelper "#{content_tag(:i, '', class: "fa fa-#{options[:icon]}")} #{body}" 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", ("badge-#{options[:badge_color]}" unless options[:badge_color].nil?), ("badge-pill" if options[:badge_pill]) ].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 - 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 def list_group_item(body, path, options = {}) options = { badge: nil, badge_color: nil, - class: "" + class: "", }.merge(options) classes = [ @@ -53,25 +54,26 @@ module BootstrapHelper unless options[:badge].nil? || (options[:badge]).zero? # TODO: make this prettier? - body << " #{ - content_tag(:span, options[:badge], class: "badge#{ + badge = content_tag(:span, options[:badge], class: "badge#{ " badge-#{options[:badge_color]}" unless options[:badge_color].nil? - }")}" + }",) 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 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 def time_tooltip(subject, placement = "bottom") - tooltip time_ago_in_words(subject.created_at), localize(subject.created_at), placement - end - - def hidespan(body, hide) - content_tag(:span, body, class: hide) + tooltip time_ago_in_words(subject.created_at, scope: "datetime.distance_in_words.short"), localize(subject.created_at, format: :long), placement end ## diff --git a/app/helpers/feedback_helper.rb b/app/helpers/feedback_helper.rb index ff51d91b..ac47c27a 100644 --- a/app/helpers/feedback_helper.rb +++ b/app/helpers/feedback_helper.rb @@ -8,7 +8,7 @@ module FeedbackHelper avatarURL: current_user.profile_picture.url(:large), name: current_user.screen_name, id: current_user.id, - email: current_user.email + email: current_user.email, } JWT.encode(user_data, APP_CONFIG.dig("canny", "sso")) diff --git a/app/helpers/markdown_helper.rb b/app/helpers/markdown_helper.rb index ef634209..6ae71c17 100644 --- a/app/helpers/markdown_helper.rb +++ b/app/helpers/markdown_helper.rb @@ -30,7 +30,7 @@ module MarkdownHelper def raw_markdown(content) renderer = Redcarpet::Render::HTML.new(**MARKDOWN_RENDERER_OPTS) md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS) - raw md.render content + raw md.render content # rubocop:disable Rails/OutputSafety end def get_markdown(path, relative_to = Rails.root) diff --git a/app/helpers/social_helper.rb b/app/helpers/social_helper.rb index e1ffe35f..e248da85 100644 --- a/app/helpers/social_helper.rb +++ b/app/helpers/social_helper.rb @@ -1,7 +1,17 @@ # frozen_string_literal: true module SocialHelper + include SocialHelper::BlueskyMethods include SocialHelper::TwitterMethods include SocialHelper::TumblrMethods 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 diff --git a/app/helpers/social_helper/bluesky_methods.rb b/app/helpers/social_helper/bluesky_methods.rb new file mode 100644 index 00000000..1565ae59 --- /dev/null +++ b/app/helpers/social_helper/bluesky_methods.rb @@ -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 diff --git a/app/helpers/social_helper/telegram_methods.rb b/app/helpers/social_helper/telegram_methods.rb index ec13f9ea..046d2392 100644 --- a/app/helpers/social_helper/telegram_methods.rb +++ b/app/helpers/social_helper/telegram_methods.rb @@ -15,7 +15,7 @@ module SocialHelper::TelegramMethods id: answer.id, username: answer.user.screen_name, 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))}) diff --git a/app/helpers/social_helper/tumblr_methods.rb b/app/helpers/social_helper/tumblr_methods.rb index 868f31d3..683199b9 100644 --- a/app/helpers/social_helper/tumblr_methods.rb +++ b/app/helpers/social_helper/tumblr_methods.rb @@ -1,4 +1,6 @@ -require 'cgi' +# frozen_string_literal: true + +require "cgi" module SocialHelper::TumblrMethods def tumblr_title(answer) @@ -13,10 +15,10 @@ module SocialHelper::TumblrMethods def tumblr_body(answer) answer_url = answer_url( - id: answer.id, - username: answer.user.screen_name, - host: APP_CONFIG['hostname'], - protocol: (APP_CONFIG['https'] ? :https : :http) + id: answer.id, + username: answer.user.screen_name, + host: APP_CONFIG["hostname"], + protocol: (APP_CONFIG["https"] ? :https : :http), ) "#{answer.content}\n\n[Smile or comment on the answer here](#{answer_url})" @@ -24,10 +26,10 @@ module SocialHelper::TumblrMethods def tumblr_share_url(answer) answer_url = answer_url( - id: answer.id, + id: answer.id, username: answer.user.screen_name, - host: APP_CONFIG['hostname'], - protocol: (APP_CONFIG['https'] ? :https : :http) + host: APP_CONFIG["hostname"], + 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))}" diff --git a/app/helpers/social_helper/twitter_methods.rb b/app/helpers/social_helper/twitter_methods.rb index 7f025f71..61ce366a 100644 --- a/app/helpers/social_helper/twitter_methods.rb +++ b/app/helpers/social_helper/twitter_methods.rb @@ -1,21 +1,26 @@ -require 'cgi' +# frozen_string_literal: true + +require "cgi" module SocialHelper::TwitterMethods include MarkdownHelper - def prepare_tweet(answer, post_tag = nil) - question_content = twitter_markdown answer.question.content.gsub(/\@(\w+)/, '\1') + def prepare_tweet(answer, post_tag = nil, omit_url = false) + question_content = twitter_markdown answer.question.content.gsub(/@(\w+)/, '\1') original_question_length = question_content.length answer_content = twitter_markdown answer.content original_answer_length = answer_content.length - answer_url = answer_url( - id: answer.id, - username: answer.user.screen_name, - host: APP_CONFIG['hostname'], - protocol: (APP_CONFIG['https'] ? :https : :http) - ) - parsed_tweet = { :valid => false } + unless omit_url + answer_url = answer_url( + id: answer.id, + username: answer.user.screen_name, + host: APP_CONFIG["hostname"], + protocol: (APP_CONFIG["https"] ? :https : :http), + ) + end + + parsed_tweet = { valid: false } tweet_text = "" 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}" components = [ shortened_question, - '—', + "—", shortened_answer, post_tag, 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] answer_content = answer_content[0..-2] diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 4c1a735f..9cd04910 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -25,7 +25,7 @@ module ThemeHelper "input_color" => "input-bg", "input_text" => "input-text", "input_placeholder" => "input-placeholder", - "muted_text" => "muted-text" + "muted_text" => "muted-text", }.freeze def render_theme diff --git a/app/javascript/retrospring/controllers/clipboard_controller.ts b/app/javascript/retrospring/controllers/clipboard_controller.ts new file mode 100644 index 00000000..59afaf46 --- /dev/null +++ b/app/javascript/retrospring/controllers/clipboard_controller.ts @@ -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")); + } + } +} diff --git a/app/javascript/retrospring/controllers/inbox_sharing_controller.ts b/app/javascript/retrospring/controllers/inbox_sharing_controller.ts index 6d0218b1..24783da3 100644 --- a/app/javascript/retrospring/controllers/inbox_sharing_controller.ts +++ b/app/javascript/retrospring/controllers/inbox_sharing_controller.ts @@ -1,12 +1,15 @@ import { Controller } from '@hotwired/stimulus'; 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 blueskyTarget: HTMLAnchorElement; declare readonly tumblrTarget: HTMLAnchorElement; declare readonly telegramTarget: HTMLAnchorElement; declare readonly customTarget: HTMLAnchorElement; + declare readonly otherTarget: HTMLButtonElement; + declare readonly clipboardTarget: HTMLButtonElement; declare readonly hasCustomTarget: boolean; static values = { @@ -20,8 +23,11 @@ export default class extends Controller { connect(): void { if (this.autoCloseValue) { this.twitterTarget.addEventListener('click', () => this.close()); + this.blueskyTarget.addEventListener('click', () => this.close()); this.tumblrTarget.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) { this.customTarget.addEventListener('click', () => this.close()); @@ -37,6 +43,7 @@ export default class extends Controller { this.element.classList.remove('d-none'); this.twitterTarget.href = this.configValue['twitter']; + this.blueskyTarget.href = this.configValue['bluesky']; this.tumblrTarget.href = this.configValue['tumblr']; this.telegramTarget.href = this.configValue['telegram']; @@ -48,4 +55,12 @@ export default class extends Controller { close(): void { (this.element.closest(".inbox-entry")).remove(); } + + closeAfterShare(): void { + this.otherTarget.addEventListener('retrospring:shared', () => this.close()); + } + + closeAfterCopyToClipboard(): void { + this.clipboardTarget.addEventListener('retrospring:copied', () => this.close()); + } } diff --git a/app/javascript/retrospring/controllers/questionbox_focus_controller.ts b/app/javascript/retrospring/controllers/questionbox_focus_controller.ts new file mode 100644 index 00000000..0453c2bb --- /dev/null +++ b/app/javascript/retrospring/controllers/questionbox_focus_controller.ts @@ -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(); + } +} diff --git a/app/javascript/retrospring/controllers/reaction_controller.ts b/app/javascript/retrospring/controllers/reaction_controller.ts new file mode 100644 index 00000000..f35fd8ae --- /dev/null +++ b/app/javascript/retrospring/controllers/reaction_controller.ts @@ -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; + } +} diff --git a/app/javascript/retrospring/controllers/share_controller.ts b/app/javascript/retrospring/controllers/share_controller.ts new file mode 100644 index 00000000..c1b5d350 --- /dev/null +++ b/app/javascript/retrospring/controllers/share_controller.ts @@ -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); + } +} diff --git a/app/javascript/retrospring/controllers/tooltip_controller.ts b/app/javascript/retrospring/controllers/tooltip_controller.ts new file mode 100644 index 00000000..a6c61fa0 --- /dev/null +++ b/app/javascript/retrospring/controllers/tooltip_controller.ts @@ -0,0 +1,8 @@ +import { Controller } from '@hotwired/stimulus'; +import { Tooltip } from 'bootstrap'; + +export default class extends Controller { + connect(): void { + new Tooltip(this.element); + } +} diff --git a/app/javascript/retrospring/features/answerbox/comment/hotkey.ts b/app/javascript/retrospring/features/answerbox/comment/hotkey.ts index 0e15dd7f..e56cb0e9 100644 --- a/app/javascript/retrospring/features/answerbox/comment/hotkey.ts +++ b/app/javascript/retrospring/features/answerbox/comment/hotkey.ts @@ -1,7 +1,10 @@ export function commentHotkeyHandler(event: Event): void { const button = event.target as HTMLButtonElement; const id = button.dataset.aId; + const answerbox = button.closest('.answerbox'); - document.querySelector(`#ab-comments-section-${id}`).classList.remove('d-none'); - document.querySelector(`[name="ab-comment-new"][data-a-id="${id}"]`).focus(); + if (answerbox !== null) { + answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none'); + answerbox.querySelector(`[name="ab-comment-new"][data-a-id="${id}"]`).focus(); + } } diff --git a/app/javascript/retrospring/features/answerbox/comment/index.ts b/app/javascript/retrospring/features/answerbox/comment/index.ts index 3e5a0f81..649ec286 100644 --- a/app/javascript/retrospring/features/answerbox/comment/index.ts +++ b/app/javascript/retrospring/features/answerbox/comment/index.ts @@ -2,7 +2,6 @@ import registerEvents from "retrospring/utilities/registerEvents"; import { commentDestroyHandler } from "./destroy"; import { commentComposeEnd, commentComposeStart, commentCreateClickHandler, commentCreateKeyboardHandler } from "./new"; import { commentReportHandler } from "./report"; -import { commentSmileHandler } from "./smile"; import { commentToggleHandler } from "./toggle"; import { commentHotkeyHandler } from "retrospring/features/answerbox/comment/hotkey"; @@ -10,7 +9,6 @@ export default (): void => { registerEvents([ { 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-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-destroy]', handler: commentDestroyHandler, global: true }, { type: 'compositionstart', target: '[name=ab-comment-new]', handler: commentComposeStart, global: true }, diff --git a/app/javascript/retrospring/features/answerbox/comment/new.ts b/app/javascript/retrospring/features/answerbox/comment/new.ts index 154448e7..8c60b9cf 100644 --- a/app/javascript/retrospring/features/answerbox/comment/new.ts +++ b/app/javascript/retrospring/features/answerbox/comment/new.ts @@ -31,10 +31,6 @@ function createComment(input: HTMLInputElement, id: string, counter: Element, gr } input.value = ''; counter.innerHTML = String(512); - - const sub = document.querySelector(`[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); diff --git a/app/javascript/retrospring/features/answerbox/comment/smile.ts b/app/javascript/retrospring/features/answerbox/comment/smile.ts deleted file mode 100644 index b80d5bd5..00000000 --- a/app/javascript/retrospring/features/answerbox/comment/smile.ts +++ /dev/null @@ -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; - } - } - }); -} \ No newline at end of file diff --git a/app/javascript/retrospring/features/answerbox/comment/toggle.ts b/app/javascript/retrospring/features/answerbox/comment/toggle.ts index 578351f8..c53b9cfc 100644 --- a/app/javascript/retrospring/features/answerbox/comment/toggle.ts +++ b/app/javascript/retrospring/features/answerbox/comment/toggle.ts @@ -1,6 +1,9 @@ export function commentToggleHandler(event: Event): void { const button = event.target as HTMLButtonElement; const id = button.dataset.aId; + const answerbox = button.closest('.answerbox'); - document.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none'); -} \ No newline at end of file + if (answerbox !== null) { + answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none'); + } +} diff --git a/app/javascript/retrospring/features/answerbox/index.ts b/app/javascript/retrospring/features/answerbox/index.ts index b0a3a897..423d9ef4 100644 --- a/app/javascript/retrospring/features/answerbox/index.ts +++ b/app/javascript/retrospring/features/answerbox/index.ts @@ -2,17 +2,11 @@ import registerEvents from 'utilities/registerEvents'; import registerAnswerboxCommentEvents from './comment'; import { answerboxDestroyHandler } from './destroy'; import { answerboxReportHandler } from './report'; -import { shareEventHandler } from './share'; -import { answerboxSmileHandler } from './smile'; -import { answerboxSubscribeHandler } from './subscribe'; export default (): void => { 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-destroy]', handler: answerboxDestroyHandler, global: true }, - { type: 'click', target: '[name=ab-smile]', handler: answerboxSmileHandler, global: true } ]); registerAnswerboxCommentEvents(); diff --git a/app/javascript/retrospring/features/answerbox/share.ts b/app/javascript/retrospring/features/answerbox/share.ts deleted file mode 100644 index 9018550e..00000000 --- a/app/javascript/retrospring/features/answerbox/share.ts +++ /dev/null @@ -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('.answerbox__answer-date > a, a.answerbox__permalink').href - }) - .then(noop) - .catch(noop) -} diff --git a/app/javascript/retrospring/features/answerbox/smile.ts b/app/javascript/retrospring/features/answerbox/smile.ts deleted file mode 100644 index fc154b0d..00000000 --- a/app/javascript/retrospring/features/answerbox/smile.ts +++ /dev/null @@ -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; - } - } - }); -} \ No newline at end of file diff --git a/app/javascript/retrospring/features/answerbox/subscribe.ts b/app/javascript/retrospring/features/answerbox/subscribe.ts deleted file mode 100644 index 977d410e..00000000 --- a/app/javascript/retrospring/features/answerbox/subscribe.ts +++ /dev/null @@ -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')); - }); -} \ No newline at end of file diff --git a/app/javascript/retrospring/features/inbox/entry/answer.ts b/app/javascript/retrospring/features/inbox/entry/answer.ts index 93b0cefc..312527c8 100644 --- a/app/javascript/retrospring/features/inbox/entry/answer.ts +++ b/app/javascript/retrospring/features/inbox/entry/answer.ts @@ -30,6 +30,17 @@ export function answerEntryHandler(event: Event): void { updateDeleteButton(false); showNotification(data.message); + const shareButton = inboxEntry.querySelector('[data-controller="share"]'); + const clipboardCopyButton = inboxEntry.querySelector('[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('.inbox-entry__sharing'); if (sharing != null) { sharing.dataset.inboxSharingConfigValue = JSON.stringify(data.sharing); diff --git a/app/javascript/retrospring/features/inbox/entry/delete.ts b/app/javascript/retrospring/features/inbox/entry/delete.ts index a03b3914..0036685f 100644 --- a/app/javascript/retrospring/features/inbox/entry/delete.ts +++ b/app/javascript/retrospring/features/inbox/entry/delete.ts @@ -7,7 +7,6 @@ import { showNotification, showErrorNotification } from 'utilities/notifications export function deleteEntryHandler(event: Event): void { const element: HTMLButtonElement = event.target as HTMLButtonElement; - element.disabled = true; const data = { id: element.getAttribute('data-ib-id') @@ -22,11 +21,8 @@ export function deleteEntryHandler(event: Event): void { confirmButtonText: I18n.translate('voc.delete'), cancelButtonText: I18n.translate('voc.cancel'), closeOnConfirm: true - }, (returnValue) => { - if (returnValue === false) { - element.disabled = false; - return; - } + }, () => { + element.disabled = true; post('/ajax/delete_inbox', { body: data, @@ -44,6 +40,7 @@ export function deleteEntryHandler(event: Event): void { (inboxEntry as HTMLElement).remove(); }) .catch(err => { + element.disabled = false; console.log(err); showErrorNotification(I18n.translate('frontend.error.message')); }); diff --git a/app/javascript/retrospring/features/memes/index.ts b/app/javascript/retrospring/features/memes/index.ts index fe509ce7..9f74598f 100644 --- a/app/javascript/retrospring/features/memes/index.ts +++ b/app/javascript/retrospring/features/memes/index.ts @@ -4,8 +4,8 @@ export default (): void => { cheet('up up down down left right left right b a', () => { 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 = ':^)'; }); }); -} \ No newline at end of file +} diff --git a/app/javascript/retrospring/features/questionbox/all.ts b/app/javascript/retrospring/features/questionbox/all.ts index 4a1373f4..5f38684a 100644 --- a/app/javascript/retrospring/features/questionbox/all.ts +++ b/app/javascript/retrospring/features/questionbox/all.ts @@ -13,7 +13,8 @@ export function questionboxAllHandler(event: Event): void { body: { rcpt: 'followers', question: document.querySelector('textarea[name=qb-all-question]').value, - anonymousQuestion: 'false' + anonymousQuestion: 'false', + sendToOwnInbox: (document.getElementById('qb-send-to-own-inbox') as HTMLInputElement).checked, }, contentType: 'application/json' }) diff --git a/app/javascript/retrospring/features/user/action.ts b/app/javascript/retrospring/features/user/action.ts deleted file mode 100644 index 91ad152e..00000000 --- a/app/javascript/retrospring/features/user/action.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { post } from '@rails/request.js'; -import { showNotification, showErrorNotification } from 'utilities/notifications'; -import I18n from 'retrospring/i18n'; - -export function userActionHandler(event: Event): void { - event.preventDefault(); - const button: HTMLButtonElement = event.target as HTMLButtonElement; - const target = button.dataset.target; - const action = button.dataset.action; - button.disabled = true; - - let targetURL, relationshipType; - - switch (action) { - case 'follow': - targetURL = '/ajax/create_relationship'; - relationshipType = 'follow'; - break; - case 'unfollow': - targetURL = '/ajax/destroy_relationship'; - relationshipType = 'follow'; - break; - case 'block': - targetURL = '/ajax/create_relationship'; - relationshipType = 'block'; - break; - case 'unblock': - targetURL = '/ajax/destroy_relationship'; - relationshipType = 'block'; - break; - case 'mute': - targetURL = '/ajax/create_relationship'; - relationshipType = 'mute'; - break; - case 'unmute': - targetURL = '/ajax/destroy_relationship'; - relationshipType = 'mute'; - break; - } - let success = false; - - post(targetURL, { - body: { - screen_name: target, - type: relationshipType, - }, - contentType: 'application/json' - }) - .then(async response => { - const data = await response.json; - - success = data.success; - showNotification(data.message, data.success); - }) - .catch(err => { - console.log(err); - showErrorNotification(I18n.translate('frontend.error.message')); - }) - .finally(() => { - button.disabled = false; - if (!success) return; - - switch (action) { - case 'follow': - button.dataset.action = 'unfollow'; - button.innerText = I18n.translate('voc.unfollow'); - button.classList.remove('btn-primary'); - button.classList.add('btn-default'); - break; - case 'unfollow': - resetFollowButton(button); - break; - case 'block': - button.dataset.action = 'unblock'; - button.querySelector('span').innerText = I18n.translate('voc.unblock'); - if (button.classList.contains('btn')) { - button.classList.remove('btn-primary'); - button.classList.add('btn-default'); - } - resetFollowButton(document.querySelector('button[data-action="unfollow"]')); - break; - case 'unblock': - button.dataset.action = 'block'; - button.querySelector('span').innerText = I18n.translate('voc.block'); - if (button.classList.contains('btn')) { - button.classList.remove('btn-default'); - button.classList.add('btn-primary'); - } - break; - case 'mute': - button.dataset.action = 'unmute'; - button.querySelector('span').innerText = I18n.translate('voc.unmute'); - if (button.classList.contains('btn')) { - button.classList.remove('btn-primary'); - button.classList.add('btn-default'); - } - break; - case 'unmute': - button.dataset.action = 'mute'; - button.querySelector('span').innerText = I18n.translate('voc.mute'); - if (button.classList.contains('btn')) { - button.classList.remove('btn-default'); - button.classList.add('btn-primary'); - } - break; - } - }); -} - -function resetFollowButton(button: HTMLButtonElement) { - button.dataset.action = 'follow'; - button.innerText = I18n.translate('voc.follow'); - button.classList.remove('btn-default'); - button.classList.add('btn-primary'); -} diff --git a/app/javascript/retrospring/features/user/index.ts b/app/javascript/retrospring/features/user/index.ts index 00256485..9d56c74f 100644 --- a/app/javascript/retrospring/features/user/index.ts +++ b/app/javascript/retrospring/features/user/index.ts @@ -1,12 +1,8 @@ -import { userActionHandler } from './action'; import { userReportHandler } from './report'; import registerEvents from 'retrospring/utilities/registerEvents'; export default (): void => { registerEvents([ - { type: 'click', target: 'button[name=user-action]', handler: userActionHandler, global: true }, - { type: 'click', target: '[data-action=block], [data-action=unblock]', handler: userActionHandler, global: true }, - { type: 'click', target: '[data-action=mute], [data-action=unmute]', handler: userActionHandler, global: true }, { type: 'click', target: 'a[data-action=report-user]', handler: userReportHandler, global: true } ]); } diff --git a/app/javascript/retrospring/features/webpush/enable.ts b/app/javascript/retrospring/features/webpush/enable.ts index c92aed64..3a77485a 100644 --- a/app/javascript/retrospring/features/webpush/enable.ts +++ b/app/javascript/retrospring/features/webpush/enable.ts @@ -3,52 +3,53 @@ import I18n from "retrospring/i18n"; import { showNotification } from "utilities/notifications"; import { Buffer } from "buffer"; -export function enableHandler (event: Event): void { +export async function enableHandler (event: Event): Promise { event.preventDefault(); const sender = event.target as HTMLButtonElement; try { - getServiceWorker() - .then(subscribe) - .then(async subscription => { - return Notification.requestPermission().then(permission => { - if (permission != "granted") { - return; - } + const registration = await getServiceWorker(); + const subscription = await subscribe(registration); + const permission = await Notification.requestPermission(); - post('/ajax/webpush', { - body: { - subscription - }, - contentType: 'application/json' - }).then(async response => { - const data = await response.json; - - if (data.success) { - new Notification(I18n.translate("frontend.push_notifications.subscribe.success.title"), { - body: I18n.translate("frontend.push_notifications.subscribe.success.body") - }); - - document.querySelectorAll('button[data-action="push-disable"], button[data-action="push-remove-all"]') - .forEach(button => button.classList.remove('d-none')); - - sender.classList.add('d-none'); - document.querySelector('.push-settings')?.classList.add('d-none'); - localStorage.setItem('dismiss-push-settings-prompt', 'true'); - - document.getElementById('subscription-count').textContent = data.message; - } else { - new Notification(I18n.translate("frontend.push_notifications.fail.title"), { - body: I18n.translate("frontend.push_notifications.fail.body") - }); - } - }); - }); - }); - } catch (error) { - console.error("Failed to set up push notifications", error); - showNotification(I18n.translate("frontend.push_notifications.setup_fail")); + if (permission != "granted") { + return; } + + const response = await post('/ajax/webpush', { + body: { + subscription + }, + contentType: 'application/json' + }); + + const data = await response.json; + + if (data.success) { + new Notification(I18n.translate("frontend.push_notifications.subscribe.success.title"), { + body: I18n.translate("frontend.push_notifications.subscribe.success.body") + }); + + document.querySelectorAll('button[data-action="push-disable"], button[data-action="push-remove-all"]') + .forEach(button => button.classList.remove('d-none')); + + sender.classList.add('d-none'); + document.querySelector('.push-settings')?.classList.add('d-none'); + localStorage.setItem('dismiss-push-settings-prompt', 'true'); + + const subscriptionCountElement = document.getElementById('subscription-count'); + if (subscriptionCountElement != null) { + subscriptionCountElement.textContent = data.message; + } + } else { + new Notification(I18n.translate("frontend.push_notifications.fail.title"), { + body: I18n.translate("frontend.push_notifications.fail.body") + }); + } + } catch (error) { + console.error("Failed to set up push notifications", error); + showNotification(I18n.translate("frontend.push_notifications.setup_fail"), false); + } } async function getServiceWorker(): Promise { diff --git a/app/javascript/retrospring/initializers/bootstrap.ts b/app/javascript/retrospring/initializers/bootstrap.ts index 277f8c15..a2ff7dfd 100644 --- a/app/javascript/retrospring/initializers/bootstrap.ts +++ b/app/javascript/retrospring/initializers/bootstrap.ts @@ -8,10 +8,7 @@ import * as bootstrap from 'bootstrap'; */ export default function (): void { document.addEventListener('turbo:load', () => { - const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); - [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); - - const dropdownElementList = document.querySelectorAll('.dropdown-toggle'); + const dropdownElementList = document.querySelectorAll('[data-bs-toggle="dropdown"]'); [...dropdownElementList].map(dropdownToggleEl => new bootstrap.Dropdown(dropdownToggleEl)); // HACK/BUG?: Bootstrap disables dropdowns in navbars, here we re-enable and "kinda" fix it diff --git a/app/javascript/retrospring/initializers/serviceWorker.ts b/app/javascript/retrospring/initializers/serviceWorker.ts index fdf5f1ff..e5dfb081 100644 --- a/app/javascript/retrospring/initializers/serviceWorker.ts +++ b/app/javascript/retrospring/initializers/serviceWorker.ts @@ -1,3 +1,5 @@ export default function (): void { - navigator.serviceWorker.register("/service_worker.js", { scope: "/" }); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register("/service_worker.js", { scope: "/" }); + } } diff --git a/app/javascript/retrospring/initializers/stimulus.ts b/app/javascript/retrospring/initializers/stimulus.ts index aee44e36..0c775324 100644 --- a/app/javascript/retrospring/initializers/stimulus.ts +++ b/app/javascript/retrospring/initializers/stimulus.ts @@ -13,6 +13,11 @@ import InboxSharingController from "retrospring/controllers/inbox_sharing_contro import ToastController from "retrospring/controllers/toast_controller"; import PwaBadgeController from "retrospring/controllers/pwa_badge_controller"; import NavigationController from "retrospring/controllers/navigation_controller"; +import ShareController from "retrospring/controllers/share_controller"; +import ClipboardController from "retrospring/controllers/clipboard_controller"; +import QuestionboxFocusController from "retrospring/controllers/questionbox_focus_controller"; +import ReactionController from "retrospring/controllers/reaction_controller"; +import TooltipController from "retrospring/controllers/tooltip_controller"; /** * This module sets up Stimulus and our controllers @@ -37,4 +42,9 @@ export default function (): void { window['Stimulus'].register('navigation', NavigationController); window['Stimulus'].register('theme', ThemeController); window['Stimulus'].register('toast', ToastController); + window['Stimulus'].register('share', ShareController); + window['Stimulus'].register('clipboard', ClipboardController); + window['Stimulus'].register('questionbox-focus', QuestionboxFocusController); + window['Stimulus'].register('reaction', ReactionController); + window['Stimulus'].register('tooltip', TooltipController); } diff --git a/app/models/answer.rb b/app/models/answer.rb index 5e43464b..750009cf 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,10 +1,12 @@ class Answer < ApplicationRecord extend Answer::TimelineMethods + attr_accessor :has_reacted, :is_subscribed + belongs_to :user, counter_cache: :answered_count belongs_to :question, counter_cache: :answer_count has_many :comments, dependent: :destroy - has_many :smiles, class_name: "Appendable::Reaction", foreign_key: :parent_id, dependent: :destroy + has_many :smiles, class_name: "Reaction", foreign_key: :parent_id, dependent: :destroy has_many :subscriptions, dependent: :destroy has_many :comment_smiles, through: :comments, source: :smiles @@ -15,11 +17,27 @@ class Answer < ApplicationRecord # rubocop:enable Rails/UniqueValidationWithoutIndex scope :pinned, -> { where.not(pinned_at: nil) } + scope :for_user, lambda { |current_user| + next select("answers.*", "false as is_subscribed", "false as has_reacted") if current_user.nil? + + select("answers.*", + "EXISTS(SELECT 1 + FROM subscriptions + WHERE answer_id = answers.id + AND user_id = #{current_user.id}) as is_subscribed", + "EXISTS(SELECT 1 + FROM reactions + WHERE parent_id = answers.id + AND parent_type = 'Answer' + AND user_id = #{current_user.id}) as has_reacted", + ) + } SHORT_ANSWER_MAX_LENGTH = 640 after_create do - Inbox.where(user: self.user, question: self.question).destroy_all + InboxEntry.where(user: self.user, question: self.question).destroy_all + user.touch :inbox_updated_at # rubocop:disable Rails/SkipsModelValidations Notification.notify self.question.user, self unless self.question.user == self.user or self.question.user.nil? Subscription.subscribe self.user, self @@ -53,4 +71,8 @@ class Answer < ApplicationRecord def long? = content.length > SHORT_ANSWER_MAX_LENGTH def pinned? = pinned_at.present? + + def has_reacted = self.attributes["has_reacted"] || false + + def is_subscribed = self.attributes["is_subscribed"] || false end diff --git a/app/models/answer/timeline_methods.rb b/app/models/answer/timeline_methods.rb index 8c6085bf..465869da 100644 --- a/app/models/answer/timeline_methods.rb +++ b/app/models/answer/timeline_methods.rb @@ -6,7 +6,8 @@ module Answer::TimelineMethods define_cursor_paginator :cursored_public_timeline, :public_timeline def public_timeline(current_user: nil) - joins(:user) + for_user(current_user) + .includes([{ user: :profile }, :question]) .then do |query| next query unless current_user diff --git a/app/models/appendable.rb b/app/models/appendable.rb deleted file mode 100644 index f736acfa..00000000 --- a/app/models/appendable.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class Appendable < ApplicationRecord - belongs_to :parent, polymorphic: true - belongs_to :user -end diff --git a/app/models/comment.rb b/app/models/comment.rb index 00472037..9c052206 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -3,7 +3,7 @@ class Comment < ApplicationRecord belongs_to :answer, counter_cache: :comment_count validates :user_id, presence: true validates :answer_id, presence: true - has_many :smiles, class_name: "Appendable::Reaction", foreign_key: :parent_id, dependent: :destroy + has_many :smiles, class_name: "Reaction", foreign_key: :parent_id, dependent: :destroy validates :content, length: { maximum: 512 } diff --git a/app/models/inbox.rb b/app/models/inbox_entry.rb similarity index 74% rename from app/models/inbox.rb rename to app/models/inbox_entry.rb index 6a74687f..d03fa6e7 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox_entry.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Inbox < ApplicationRecord +class InboxEntry < ApplicationRecord belongs_to :user, touch: :inbox_updated_at belongs_to :question @@ -13,6 +13,19 @@ class Inbox < ApplicationRecord !user.privacy_allow_anonymous_questions? end + after_create do + user.touch(:inbox_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + + after_update do + user.touch(:inbox_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + + after_destroy do + # user might not exist at this point (account deleted, records are cleaned up async) + user&.touch(:inbox_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + def answer(answer_content, user) raise Errors::AnsweringOtherBlockedSelf if question.user&.blocking?(user) raise Errors::AnsweringSelfBlockedOther if user.blocking?(question.user) @@ -37,7 +50,7 @@ class Inbox < ApplicationRecord user.profile.anon_display_name || APP_CONFIG["anonymous_name"] else question.user.profile.safe_name - end + end, ), icon: notification_icon, body: question.content.truncate(Question::SHORT_QUESTION_MAX_LENGTH), diff --git a/app/models/inbox_filter.rb b/app/models/inbox_filter.rb new file mode 100644 index 00000000..f649bb3e --- /dev/null +++ b/app/models/inbox_filter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class InboxFilter + include CursorPaginatable + + define_cursor_paginator :cursored_results, :results + + KEYS = %i[ + author + anonymous + ].freeze + + FORBIDDEN_PARAM_GROUPS = [ + %i[author anonymous] + ].freeze + + attr_reader :params, :user + + def initialize(user, params) + @user = user + @params = params + end + + def results + return InboxEntry.none unless valid_params? + + scope = @user.inbox_entries + .includes(:question, user: :profile) + .order(:created_at) + .reverse_order + + params.each do |key, value| + scope.merge!(scope_for(key, value)) if value.present? + end + + scope + end + + private + + def valid_params? + FORBIDDEN_PARAM_GROUPS.none? { |combination| combination.all? { |key| params.key?(key) } } + end + + def scope_for(key, value) + case key.to_s + when "author" + @user.inbox_entries.joins(question: [:user]) + .where(questions: { users: { screen_name: value }, author_is_anonymous: false }) + when "anonymous" + @user.inbox_entries.joins(:question) + .where(questions: { author_is_anonymous: true }) + end + end +end diff --git a/app/models/list/timeline_methods.rb b/app/models/list/timeline_methods.rb index 7909d544..c55c9471 100644 --- a/app/models/list/timeline_methods.rb +++ b/app/models/list/timeline_methods.rb @@ -5,9 +5,11 @@ module List::TimelineMethods define_cursor_paginator :cursored_timeline, :timeline - # @return [Array] the lists' timeline + # @return [ActiveRecord::Relation] the lists' timeline def timeline(current_user: nil) Answer + .for_user(current_user) + .includes([{ user: :profile }, :question]) .then do |query| next query unless current_user diff --git a/app/models/mute_rule.rb b/app/models/mute_rule.rb index 3e2a66af..4ca5e15d 100644 --- a/app/models/mute_rule.rb +++ b/app/models/mute_rule.rb @@ -3,7 +3,7 @@ class MuteRule < ApplicationRecord belongs_to :user - validates :muted_phrase, presence: true + validates :muted_phrase, length: { minimum: 1 } def applies_to?(post) !!(post.content =~ /\b#{Regexp.escape(muted_phrase)}\b/i) diff --git a/app/models/notification.rb b/app/models/notification.rb index 3e15eae4..76233c67 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,9 +1,22 @@ # frozen_string_literal: true class Notification < ApplicationRecord - belongs_to :recipient, class_name: "User", touch: :notifications_updated_at + belongs_to :recipient, class_name: "User" belongs_to :target, polymorphic: true + after_create do + recipient.touch(:notifications_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + + after_update do + recipient.touch(:notifications_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + + after_destroy do + # recipient might not exist at this point (account deleted, records are cleaned up async) + recipient&.touch(:notifications_updated_at) # rubocop:disable Rails/SkipsModelValidations + end + class << self include CursorPaginatable @@ -45,7 +58,7 @@ class Notification < ApplicationRecord n = notification_type.new(target:, recipient:, - new: true) + new: true,) n.save! n end diff --git a/app/models/question.rb b/app/models/question.rb index 0a6adec0..6426c640 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -1,16 +1,20 @@ +# frozen_string_literal: true + class Question < ApplicationRecord include Question::AnswerMethods belongs_to :user, optional: true + has_many :anonymous_blocks, dependent: :nullify has_many :answers, dependent: :destroy - has_many :inboxes, dependent: :destroy + has_many :inbox_entries, dependent: :destroy validates :content, length: { minimum: 1 } SHORT_QUESTION_MAX_LENGTH = 512 + LONG_QUESTION_MAX_LENGTH = 32768 before_destroy do - rep = Report.where(target_id: self.id, type: 'Reports::Question') + rep = Report.where(target_id: self.id, type: "Reports::Question") rep.each do |r| unless r.nil? r.deleted = true @@ -24,8 +28,9 @@ class Question < ApplicationRecord end def can_be_removed? - return false if self.answers.count > 0 - return false if Inbox.where(question: self).count > 1 + return false if self.answers.count.positive? + return false if InboxEntry.where(question: self).count > 1 + true end diff --git a/app/models/question/answer_methods.rb b/app/models/question/answer_methods.rb index efb94a7b..63301904 100644 --- a/app/models/question/answer_methods.rb +++ b/app/models/question/answer_methods.rb @@ -7,6 +7,8 @@ module Question::AnswerMethods def ordered_answers(current_user: nil) answers + .for_user(current_user) + .includes([{ user: :profile }, :question]) .then do |query| next query unless current_user diff --git a/app/models/appendable/reaction.rb b/app/models/reaction.rb similarity index 78% rename from app/models/appendable/reaction.rb rename to app/models/reaction.rb index 4486fb0f..e1b54ce3 100644 --- a/app/models/appendable/reaction.rb +++ b/app/models/reaction.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true -class Appendable::Reaction < Appendable +class Reaction < ApplicationRecord + belongs_to :parent, polymorphic: true + belongs_to :user + + validates :parent_id, uniqueness: { scope: :user_id } + # rubocop:disable Rails/SkipsModelValidations after_create do Notification.notify parent.user, self unless parent.user == user diff --git a/app/models/report.rb b/app/models/report.rb index bf61c377..b437b5c9 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -1,5 +1,6 @@ class Report < ApplicationRecord belongs_to :user + belongs_to :target_user, class_name: "User", optional: true validates :type, presence: true validates :target_id, presence: true validates :user_id, presence: true @@ -15,19 +16,4 @@ class Report < ApplicationRecord update(reason: [reason || "", new_reason].join("\n")) end end - - class << self - include CursorPaginatable - - define_cursor_paginator :cursored_reports, :list_reports - define_cursor_paginator :cursored_reports_of_type, :list_reports_of_type - - def list_reports(options = {}) - self.where(options.merge!(deleted: false)).reverse_order - end - - def list_reports_of_type(type, options = {}) - self.where(options.merge!(deleted: false)).where('LOWER(type) = ?', "reports::#{type}").reverse_order - end - end end diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb new file mode 100644 index 00000000..403d17df --- /dev/null +++ b/app/models/report_filter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class ReportFilter + include CursorPaginatable + + define_cursor_paginator :cursored_results, :results + + KEYS = %i[ + user + target_user + type + ].freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = Report.where(deleted: false) + .order(:created_at) + .reverse_order + + params.each do |key, value| + scope.merge!(scope_for(key, value)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when "user" + Report.joins(:user) + .where(users: { screen_name: value }) + when "target_user" + Report.joins(:target_user) + .where(users: { screen_name: value }) + when "type" + Report.where("LOWER(type) = ?", "reports::#{value}") + end + end +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 0b05a1b9..2274fcac 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -28,18 +28,26 @@ class Subscription < ApplicationRecord def notify(source, target) return nil if source.nil? || target.nil? - muted_by = Relationships::Mute.where(target: source.user).pluck(&:source_id) - # As we will need to notify for each person subscribed, # it's much faster to bulk insert than to use +Notification.notify+ - notifications = Subscription.where(answer: target) - .where.not(user: source.user) - .where.not(user_id: muted_by) - .map do |s| - { target_id: source.id, target_type: Comment, recipient_id: s.user_id, new: true, type: Notification::Commented, created_at: source.created_at, updated_at: source.created_at } + notifications = Subscription.for(source, target).pluck(:user_id).map do |recipient_id| + { + target_id: source.id, + target_type: Comment, + recipient_id:, + new: true, + type: Notification::Commented, + created_at: source.created_at, + updated_at: source.created_at, + } end - Notification.insert_all!(notifications) unless notifications.empty? # rubocop:disable Rails/SkipsModelValidations + return if notifications.empty? + + # rubocop:disable Rails/SkipsModelValidations + Notification.insert_all!(notifications) + User.where(id: notifications.pluck(:recipient_id)).touch_all(:notifications_updated_at) + # rubocop:enable Rails/SkipsModelValidations end def denotify(source, target) @@ -48,5 +56,13 @@ class Subscription < ApplicationRecord subs = Subscription.where(answer: target) Notification.where(target:, recipient: subs.map(&:user)).delete_all end + + def for(source, target) + muted_by = Relationships::Mute.where(target: source.user).pluck(&:source_id) + + Subscription.where(answer: target) + .where.not(user: source.user) + .where.not(user_id: muted_by) + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 2b2241e0..50a88d2b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class User < ApplicationRecord # rubocop:disable Metrics/ClassLength +class User < ApplicationRecord include User::Relationship include User::Relationship::Follow include User::Relationship::Block @@ -33,14 +33,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :questions, dependent: :destroy_async has_many :answers, dependent: :destroy_async has_many :comments, dependent: :destroy_async - has_many :inboxes, dependent: :destroy_async - has_many :smiles, class_name: "Appendable::Reaction", dependent: :destroy_async + has_many :inbox_entries, dependent: :destroy_async + has_many :smiles, class_name: "Reaction", dependent: :destroy_async has_many :notifications, foreign_key: :recipient_id, dependent: :destroy_async has_many :reports, dependent: :destroy_async has_many :lists, dependent: :destroy_async has_many :list_memberships, class_name: "ListMember", dependent: :destroy_async has_many :mute_rules, dependent: :destroy_async - has_many :anonymous_blocks, dependent: :destroy_async has_many :subscriptions, dependent: :destroy_async has_many :totp_recovery_codes, dependent: :destroy_async @@ -54,6 +53,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength foreign_key: "banned_by_id", dependent: :nullify + has_many :anonymous_blocks, dependent: :destroy_async + has_many :passive_anonymous_blocks, class_name: "AnonymousBlock", + foreign_key: "target_user_id", + dependent: :nullify + SCREEN_NAME_REGEX = /\A[a-zA-Z0-9_]+\z/ WEBSITE_REGEX = /https?:\/\/([A-Za-z.-]+)\/?(?:.*)/i @@ -117,14 +121,12 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength # @param question [Question] the question to answer # @param content [String] the answer content def answer(question, content) - # rubocop:disable Style/RedundantSelf raise Errors::AnsweringOtherBlockedSelf if question.user&.blocking?(self) raise Errors::AnsweringSelfBlockedOther if self.blocking?(question.user) - # rubocop:enable Style/RedundantSelf Retrospring::Metrics::QUESTIONS_ANSWERED.increment - Answer.create!(content:, user: self, question:) + Answer.create!(content:, user: self, question:, has_reacted: false, is_subscribed: true) end # has the user answered +question+ yet? @@ -132,10 +134,8 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength def answered?(question) = question.answers.pluck(:user_id).include? id def comment(answer, content) - # rubocop:disable Style/RedundantSelf raise Errors::CommentingSelfBlockedOther if self.blocking?(answer.user) raise Errors::CommentingOtherBlockedSelf if answer.user.blocking?(self) - # rubocop:enable Style/RedundantSelf Retrospring::Metrics::COMMENTS_CREATED.increment @@ -149,9 +149,15 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength # region stuff used for reporting/moderation def report(object, reason = nil) - existing = Report.find_by(type: "Reports::#{object.class}", target_id: object.id, user_id: id, deleted: false) + target_user = if object.instance_of?(::User) + object + elsif object.respond_to? :user + object.user + end + + existing = Report.find_by(type: "Reports::#{object.class}", target_id: object.id, user_id: id, target_user_id: target_user&.id, deleted: false) if existing.nil? - Report.create(type: "Reports::#{object.class}", target_id: object.id, user_id: id, reason:) + Report.create(type: "Reports::#{object.class}", target_id: object.id, user_id: id, target_user_id: target_user&.id, reason:) elsif !reason.nil? && reason.length.positive? existing.append_reason(reason) end diff --git a/app/models/user/answer_methods.rb b/app/models/user/answer_methods.rb index e40eb82b..211e4d93 100644 --- a/app/models/user/answer_methods.rb +++ b/app/models/user/answer_methods.rb @@ -6,10 +6,11 @@ module User::AnswerMethods define_cursor_paginator :cursored_answers, :ordered_answers # @return [ActiveRecord::Relation] List of a user's answers - def ordered_answers + def ordered_answers(current_user_id:) answers + .for_user(current_user_id) .order(:created_at) .reverse_order - .includes(comments: %i[user smiles], question: { user: :profile }, smiles: [:user]) + .includes(question: { user: [:profile] }) end end diff --git a/app/models/user/ban_methods.rb b/app/models/user/ban_methods.rb index 07826b1b..90cb62d6 100644 --- a/app/models/user/ban_methods.rb +++ b/app/models/user/ban_methods.rb @@ -6,14 +6,17 @@ module User::BanMethods end def banned? - bans.current.count.positive? + Rails.cache.fetch("#{cache_key}/banned", expires_in: 6.hours) do + bans.current.count.positive? + end end def unban bans.current.update( # -1s to account for flakyness with timings in tests - expires_at: DateTime.now.utc - 1.second + expires_at: DateTime.now.utc - 1.second, ) + Rails.cache.delete("#{cache_key}/banned") end # Bans a user. @@ -24,8 +27,9 @@ module User::BanMethods ::UserBan.create!( user: self, expires_at: expiry, - banned_by: banned_by, - reason: reason + banned_by:, + reason:, ) + Rails.cache.delete("#{cache_key}/banned") end end diff --git a/app/models/user/inbox_methods.rb b/app/models/user/inbox_methods.rb index 8e3b00fe..50a81039 100644 --- a/app/models/user/inbox_methods.rb +++ b/app/models/user/inbox_methods.rb @@ -1,21 +1,9 @@ # frozen_string_literal: true module User::InboxMethods - include CursorPaginatable - - define_cursor_paginator :cursored_inbox, :ordered_inbox - - # @return [ActiveRecord::Relation] the user's inbox entries - def ordered_inbox - inboxes - .includes(:question, user: :profile) - .order(:created_at) - .reverse_order - end - def unread_inbox_count - Rails.cache.fetch(inbox_cache_key) do - count = Inbox.where(new: true, user_id: id).count(:id) + Rails.cache.fetch(inbox_cache_key, expires_in: 12.hours) do + count = InboxEntry.where(new: true, user_id: id).count(:id) # Returning +nil+ here in order to not display a counter # at all when there isn't anything in the user's inbox diff --git a/app/models/user/notification_methods.rb b/app/models/user/notification_methods.rb index 41ad8a2e..10738516 100644 --- a/app/models/user/notification_methods.rb +++ b/app/models/user/notification_methods.rb @@ -2,7 +2,7 @@ module User::NotificationMethods def unread_notification_count - Rails.cache.fetch(notification_cache_key) do + Rails.cache.fetch(notification_cache_key, expires_in: 12.hours) do count = Notification.for(self).where(new: true).count(:id) # Returning +nil+ here in order to not display a counter diff --git a/app/models/user/reaction_methods.rb b/app/models/user/reaction_methods.rb index 18ecac25..381dc000 100644 --- a/app/models/user/reaction_methods.rb +++ b/app/models/user/reaction_methods.rb @@ -4,18 +4,16 @@ module User::ReactionMethods # smiles an answer or comment # @param item [ApplicationRecord] the answer/comment to smile def smile(item) - # rubocop:disable Style/RedundantSelf raise Errors::ReactingSelfBlockedOther if self.blocking?(item.user) raise Errors::ReactingOtherBlockedSelf if item.user.blocking?(self) - # rubocop:enable Style/RedundantSelf - ::Appendable::Reaction.create!(user: self, parent: item, content: "🙂") + Reaction.create!(user: self, parent: item, content: "🙂") end # unsmile an answer or comment # @param item [ApplicationRecord] the answer/comment to unsmile def unsmile(item) - ::Appendable::Reaction.find_by(user: self, parent: item).destroy + Reaction.find_by!(user: self, parent: item).destroy end def smiled?(item) diff --git a/app/models/user/relationship/block.rb b/app/models/user/relationship/block.rb index 01cd25ed..03639cf2 100644 --- a/app/models/user/relationship/block.rb +++ b/app/models/user/relationship/block.rb @@ -53,8 +53,8 @@ class User def unfollow_and_remove(target_user) unfollow(target_user) if following?(target_user) target_user.unfollow(self) if target_user.following?(self) - target_user.inboxes.joins(:question).where(question: { user_id: id }).destroy_all - inboxes.joins(:question).where(questions: { user_id: target_user.id, author_is_anonymous: false }).destroy_all + target_user.inbox_entries.joins(:question).where(question: { user_id: id }).destroy_all + inbox_entries.joins(:question).where(questions: { user_id: target_user.id, author_is_anonymous: false }).destroy_all ListMember.joins(:list).where(list: { user_id: target_user.id }, user_id: id).destroy_all end diff --git a/app/models/user/relationship/follow.rb b/app/models/user/relationship/follow.rb index 4e034821..50dc321e 100644 --- a/app/models/user/relationship/follow.rb +++ b/app/models/user/relationship/follow.rb @@ -19,10 +19,8 @@ class User # Follow an user def follow(target_user) raise Errors::FollowingSelf if target_user == self - # rubocop:disable Style/RedundantSelf raise Errors::FollowingOtherBlockedSelf if target_user.blocking?(self) raise Errors::FollowingSelfBlockedOther if self.blocking?(target_user) - # rubocop:enable Style/RedundantSelf create_relationship(active_follow_relationships, target_user) end diff --git a/app/models/user/timeline_methods.rb b/app/models/user/timeline_methods.rb index 822f9d8f..24796954 100644 --- a/app/models/user/timeline_methods.rb +++ b/app/models/user/timeline_methods.rb @@ -8,6 +8,7 @@ module User::TimelineMethods # @return [ActiveRecord::Relation] the user's timeline def timeline Answer + .for_user(self) .then do |query| blocked_and_muted_user_ids = blocked_user_ids_cached + muted_user_ids_cached next query if blocked_and_muted_user_ids.empty? @@ -21,6 +22,6 @@ module User::TimelineMethods .where("answers.user_id in (?) OR answers.user_id = ?", following_ids, id) .order(:created_at) .reverse_order - .includes(comments: %i[user smiles], question: { user: :profile }, user: [:profile], smiles: [:user]) + .includes(question: { user: [:profile] }, user: [:profile]) end end diff --git a/app/uploaders/base_uploader.rb b/app/uploaders/base_uploader.rb index fea89216..ddf5152c 100644 --- a/app/uploaders/base_uploader.rb +++ b/app/uploaders/base_uploader.rb @@ -8,12 +8,14 @@ class BaseUploader < CarrierWave::Uploader::Base # Store original size version :original - # Process cropping on upload + process :remove_animation process :cropping - def store_dir - "/uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" - end + def content_type_whitelist = %w[image/jpeg image/gif image/png] + + def store_dir = "/uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" + + def size_range = (1.byte)..(5.megabytes) def paperclip_path return "/users/:attachment/:id_partition/:style/:basename.:extension" if APP_CONFIG["fog"].blank? @@ -31,4 +33,10 @@ class BaseUploader < CarrierWave::Uploader::Base image.crop "#{w}x#{h}+#{x}+#{y}" end end + + def remove_animation + return unless content_type == "image/gif" + + manipulate!(&:collapse!) + end end diff --git a/app/uploaders/profile_header_uploader.rb b/app/uploaders/profile_header_uploader.rb index 23a47588..649ab10e 100644 --- a/app/uploaders/profile_header_uploader.rb +++ b/app/uploaders/profile_header_uploader.rb @@ -1,7 +1,7 @@ class ProfileHeaderUploader < BaseUploader - def default_url(*args) - "/images/header/#{[version_name || args.first, 'no_header.jpg'].compact.join('/')}" - end + def default_url(*args) = "/images/header/#{[version_name || args.first, 'no_header.jpg'].compact.join('/')}" + + def size_range = (1.byte)..(10.megabytes) version :web do process resize_to_fit: [1500, 350] diff --git a/app/validators/typoed_email_validator.rb b/app/validators/typoed_email_validator.rb index 717fae97..73dbc446 100644 --- a/app/validators/typoed_email_validator.rb +++ b/app/validators/typoed_email_validator.rb @@ -56,7 +56,7 @@ class TypoedEmailValidator < ActiveModel::EachValidator # check if the TLD is valid tld = domain_parts.last - return false unless TLDv.valid?(tld) + return false unless TLDv.valid?(tld) || (Rails.env.test? && %w[example test].include?(tld)) # finally, common typos return false if INVALID_ENDINGS.any? { value.end_with?(_1) } diff --git a/app/views/actions/_answer.html.haml b/app/views/actions/_answer.html.haml index e63e452b..e029fe19 100644 --- a/app/views/actions/_answer.html.haml +++ b/app/views/actions/_answer.html.haml @@ -1,13 +1,8 @@ .dropdown-menu.dropdown-menu-end{ role: :menu } - - if subscribed_answer_ids&.include?(answer.id) - -# fun joke should subscribe? - %a.dropdown-item{ href: "#", data: { a_id: answer.id, action: "ab-submarine", torpedo: "no" } } - %i.fa.fa-fw.fa-anchor - = t("voc.unsubscribe") + - if answer.is_subscribed + = render "subscriptions/destroy", answer: answer - else - %a.dropdown-item{ href: "#", data: { a_id: answer.id, action: "ab-submarine", torpedo: "yes" } } - %i.fa.fa-fw.fa-anchor - = t("voc.subscribe") + = render "subscriptions/create", answer: answer - if privileged? answer.user %a.dropdown-item.text-danger{ href: "#", data: { a_id: answer.id, action: "ab-destroy" } } %i.fa.fa-fw.fa-trash-o diff --git a/app/views/actions/_comment.html.haml b/app/views/actions/_comment.html.haml index 23b8e408..a88d7a4a 100644 --- a/app/views/actions/_comment.html.haml +++ b/app/views/actions/_comment.html.haml @@ -1,5 +1,5 @@ .dropdown-menu.dropdown-menu-end{ role: :menu } - %a.dropdown-item{ href: "#", data: { bs_target: "#modal-view-comment#{comment.id}-smiles", bs_toggle: :modal } } + = link_to comment_reactions_path(username: comment.user.screen_name, id: comment.id), class: "dropdown-item", data: { turbo_frame: "modal" } do %i.fa.fa-fw.fa-smile-o = t(".view_smiles") - if privileged?(comment.user) || privileged?(answer.user) diff --git a/app/views/actions/_question.html.haml b/app/views/actions/_question.html.haml index c96e99a9..504e3e2c 100644 --- a/app/views/actions/_question.html.haml +++ b/app/views/actions/_question.html.haml @@ -8,9 +8,10 @@ %i.fa.fa-fw.fa-exclamation-triangle = t("voc.report") - if question.anonymous? && !question.generated? - = button_to anonymous_block_path, method: :post, params: { question: question.id }, class: "dropdown-item" do - %i.fa.fa-fw.fa-minus-circle - = t("voc.block") + - unless question.user == current_user + = button_to anonymous_block_path, method: :post, params: { question: question.id }, class: "dropdown-item" do + %i.fa.fa-fw.fa-minus-circle + = t("voc.block") - if current_user.mod? = button_to anonymous_block_path, method: :post, params: { question: question.id, global: true }, class: "dropdown-item" do %i.fas.fa-fw.fa-user-slash diff --git a/app/views/actions/_share.html.haml b/app/views/actions/_share.html.haml index 65daacc2..039ef411 100644 --- a/app/views/actions/_share.html.haml +++ b/app/views/actions/_share.html.haml @@ -2,11 +2,17 @@ %a.dropdown-item{ href: twitter_share_url(answer), target: "_blank" } %i.fa.fa-fw.fa-twitter = t(".twitter") + %a.dropdown-item{ href: bluesky_share_url(answer), target: "_blank" } + %i.fa.fa-fw.fa-cloud + = t(".bluesky") %a.dropdown-item{ href: tumblr_share_url(answer), target: "_blank" } %i.fa.fa-fw.fa-tumblr = t(".tumblr") %a.dropdown-item{ href: telegram_share_url(answer), target: "_blank" } %i.fa.fa-fw.fa-telegram = t(".telegram") - %a.dropdown-item{ href: "#", name: "ab-share" } + %a.dropdown-item{ href: "#", data: { controller: :clipboard, action: "clipboard#copy", clipboard_copy_value: prepare_tweet(answer) } } + %i.fa.fa-fw.fa-solid.fa-copy + = t(".copy") + %a.dropdown-item{ href: "#", data: { controller: :share, action: "share#share", share_url_value: answer_share_url(answer) } } = t(".other") diff --git a/app/views/answer/show.html.haml b/app/views/answer/show.html.haml index 55f2a90b..4cc12576 100644 --- a/app/views/answer/show.html.haml +++ b/app/views/answer/show.html.haml @@ -1,4 +1,4 @@ - provide(:title, answer_title(@answer)) - provide(:og, answer_opengraph(@answer)) .container-lg.container--main - = render "answerbox", a: @answer, display_all: @display_all, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a: @answer, display_all: @display_all diff --git a/app/views/answerbox/_actions.html.haml b/app/views/answerbox/_actions.html.haml index 5c152abe..961507c2 100644 --- a/app/views/answerbox/_actions.html.haml +++ b/app/views/answerbox/_actions.html.haml @@ -1,16 +1,17 @@ -%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-smile", data: { a_id: a.id, action: current_user&.smiled?(a) ? :unsmile : :smile, selection_hotkey: "s" }, disabled: !user_signed_in? } - %i.fa.fa-fw.fa-smile-o - %span{ id: "ab-smile-count-#{a.id}" }= a.smiles.count +- if a.has_reacted + = render "reactions/destroy", type: "Answer", target: a +- else + = render "reactions/create", type: "Answer", target: a - unless display_all %button.btn.btn-link.answerbox__action{ type: :button, name: "ab-comments", data: { a_id: a.id, state: :hidden, selection_hotkey: "x" } } %i.fa.fa-fw.fa-comments %span{ id: "ab-comment-count-#{a.id}" }= a.comment_count -.btn-group +.dropdown.d-inline %button.btn.btn-link.answerbox__action{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } %i.fa.fa-fw.fa-share-alt{ title: t(".share.title") } = render "actions/share", answer: a - if user_signed_in? - .btn-group - %button.btn.btn-default.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - = render "actions/answer", answer: a, subscribed_answer_ids: + .dropdown.d-inline + %button.btn.btn-link.answerbox__action{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } + %i.fa.fa-fw.fa-ellipsis + = render "actions/answer", answer: a diff --git a/app/views/answerbox/_comments.html.haml b/app/views/answerbox/_comments.html.haml index 023990e9..e7d61654 100644 --- a/app/views/answerbox/_comments.html.haml +++ b/app/views/answerbox/_comments.html.haml @@ -1,43 +1,6 @@ -- if a.comments.all.count.zero? +- if comments.all.count.zero? = t(".none") - else %ul.comment__container - - a.comments.order(:created_at).each do |comment| - %li.comment{ data: { comment_id: comment.id } } - %div{ style: "height: 0; width: 0" }= render "modal/comment_smiles", comment: comment - .d-flex - .flex-shrink-0 - %a{ href: user_path(comment.user) } - %img.comment__user-avatar.avatar-sm{ src: comment.user.profile_picture.url(:small), loading: :lazy } - .flex-grow-1 - %h6.comment__user - = user_screen_name comment.user - %span.text-muted{ title: comment.created_at, data: { bs_toggle: :tooltip, bs_placement: :right } } - = t("time.distance_ago", time: time_ago_in_words(comment.created_at)) - .comment__content - = markdown comment.content - .flex-shrink-0.ms-auto - %button.btn.btn-link.answerbox__action{ type: :button, name: "ab-smile-comment", data: { c_id: comment.id, action: current_user&.smiled?(comment) ? :unsmile : :smile }, disabled: !user_signed_in? } - %i.fa.fa-fw.fa-smile-o - %span{ id: "ab-comment-smile-count-#{comment.id}" }= comment.smile_count - .btn-group - %button.btn.btn-link.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - = render "actions/comment", comment: comment, answer: a -- if user_signed_in? - %button.d-none{ name: "ab-open-and-comment", data: { a_id: a.id, selection_hotkey: "c" } } - .comment__compose-wrapper{ - name: "ab-comment-new-group", - data: { a_id: a.id, controller: "character-count", character_count_max_value: 512 } - } - .form-group.has-feedback.comment__input-group.input-group - %textarea.form-control.comment__input{ type: :text, placeholder: t(".placeholder"), name: "ab-comment-new", data: { a_id: a.id, "character-count-target": "input" } } - .comment__submit-wrapper - %button.btn.btn-primary{ - type: :button, - name: "ab-comment-new-submit", - title: t(".action"), - data: { a_id: a.id, "character-count-target": "action" } - } - %i.fa.fa-paper-plane-o - %span.text-muted.form-control-feedback.comment__character-count{ id: "ab-comment-charcount-#{a.id}", data: { "character-count-target": "counter" } } 512 + - comments.order(:created_at).each do |comment| + = render CommentComponent.new(comment:, answer: a) diff --git a/app/views/answerbox/_header.html.haml b/app/views/answerbox/_header.html.haml deleted file mode 100644 index 30dd9738..00000000 --- a/app/views/answerbox/_header.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -.card-header - .d-flex - - unless a.question.author_is_anonymous - .flex-shrink-0 - %a{ href: user_path(a.question.user) } - %img.answerbox__question-user-avatar.avatar-md{ src: a.question.user.profile_picture.url(:small), loading: :lazy } - .flex-grow-1 - %h6.text-muted.answerbox__question-user - - if a.question.author_is_anonymous - %i.fas.fa-user-secret{ title: t(".anon_hint") } - = t(".asked_html", user: user_screen_name(a.question.user, context_user: a.user, author_identifier: a.question.author_is_anonymous ? a.question.author_identifier: nil), time: time_tooltip(a.question)) - - if !a.question.author_is_anonymous && !a.question.direct - · - %a{ href: question_path(a.question.user.screen_name, a.question.id), data: { selection_hotkey: "a" } } - = t(".answers", count: a.question.answer_count) - .answerbox__question-body{ data: { controller: a.question.long? ? "collapse" : nil } } - .answerbox__question-text{ class: a.question.long? && !display_all ? "collapsed" : "", data: { collapse_target: "content" } } - = question_markdown a.question.content - - if a.question.long? && !display_all - = render "shared/collapse", type: "question" - - if user_signed_in? - .flex-shrink-0.ms-auto - .btn-group - %button.btn.btn-link.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - = render "actions/question", question: a.question diff --git a/app/views/answerbox/_smiles.html.haml b/app/views/answerbox/_smiles.html.haml index 518c72ee..68f02e63 100644 --- a/app/views/answerbox/_smiles.html.haml +++ b/app/views/answerbox/_smiles.html.haml @@ -8,5 +8,5 @@ - a.smiles.all.each do |smile| %a{ href: user_path(smile.user), title: user_screen_name(smile.user, url: false), - data: { bs_toggle: :tooltip, bs_placement: :top, smile_id: smile.id } } - %img.avatar-xs{ src: smile.user.profile_picture.url(:small), loading: :lazy } + data: { controller: :tooltip, bs_placement: :top, smile_id: smile.id, turbo: :false } } + = render AvatarComponent.new(user: smile.user, size: "xs") diff --git a/app/views/application/_answerbox.html.haml b/app/views/application/_answerbox.html.haml index b84a5f17..565447d1 100644 --- a/app/views/application/_answerbox.html.haml +++ b/app/views/application/_answerbox.html.haml @@ -1,39 +1,55 @@ - display_all ||= nil .card.answerbox{ data: { id: a.id, q_id: a.question.id, navigation_target: "traversable" } } - if @question.nil? - = render "answerbox/header", a: a, display_all: display_all + .card-header + = render QuestionComponent.new(question: a.question, context_user: a.user, collapse: !display_all) .card-body .answerbox__answer-body{ data: { controller: a.long? ? "collapse" : nil } } .answerbox__answer-text{ class: a.long? && !display_all ? "collapsed" : "", data: { collapse_target: "content" } } = markdown a.content - if a.long? && !display_all = render "shared/collapse", type: "answer" - - if @user.nil? - .row - .col-sm-6.text-start.text-muted - .d-flex - .flex-shrink-0 - %a{ href: user_path(a.user) } - %img.answerbox__answer-user-avatar.avatar-sm{ src: a.user.profile_picture.url(:small), loading: :lazy } - .flex-grow-1 - %h6.answerbox__answer-user - = raw t(".answered", hide: hidespan(t(".hide"), "d-none d-sm-inline"), user: user_screen_name(a.user)) - .answerbox__answer-date - = link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id), data: { selection_hotkey: "l" }) - .col-md-6.d-flex.d-md-block.answerbox__actions - = render "answerbox/actions", a:, display_all:, subscribed_answer_ids: - - else - .row - .col-md-6.text-start.text-muted - %i.fa.fa-clock-o - = link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id), class: "answerbox__permalink") - - if a.pinned_at.present? - %span.answerbox__pinned + .d-md-flex + .text-muted + .d-flex.align-items-center + .flex-shrink-0 + %a{ href: user_path(a.user) } + = render AvatarComponent.new(user: a.user, size: "sm", classes: ["answerbox__answer-user-avatar"]) + .flex-grow-1 + %h6.answerbox__answer-user + = user_screen_name(a.user) · - %i.fa.fa-thumbtack - = t(".pinned") - .col-md-6.d-md-flex.answerbox__actions - = render "answerbox/actions", a:, display_all:, subscribed_answer_ids: + = link_to(time_tooltip(a), answer_path(a.user.screen_name, a.id), data: { selection_hotkey: "l" }) + - if a.pinned_at.present? + %span.answerbox__pinned + · + %i.fa.fa-thumbtack + = t(".pinned") + .d-flex.d-md-block.answerbox__actions.ms-auto + = render "answerbox/actions", a:, display_all: .card-footer{ id: "ab-comments-section-#{a.id}", class: display_all.nil? ? "d-none" : nil } - %div{ id: "ab-smiles-#{a.id}" }= render "answerbox/smiles", a: a - %div{ id: "ab-comments-#{a.id}" }= render "answerbox/comments", a: a + = turbo_frame_tag("ab-reactions-list-#{a.id}", src: reactions_path(a.question, a), loading: :lazy) do + .d-flex.smiles + .flex-shrink-0.me-1 + %i.fa.fa-smile-o + = turbo_frame_tag("ab-comments-list-#{a.id}", src: comments_path(a.question, a), loading: :lazy) do + .d-flex.justify-content-center + .spinner-border{ role: :status } + .visually-hidden= t("voc.loading") + - if user_signed_in? + %button.d-none{ name: "ab-open-and-comment", data: { a_id: a.id, selection_hotkey: "c" } } + .comment__compose-wrapper{ + name: "ab-comment-new-group", + data: { a_id: a.id, controller: "character-count", character_count_max_value: 512 } + } + .form-group.has-feedback.comment__input-group.input-group + %textarea.form-control.comment__input{ type: :text, placeholder: t(".comments.placeholder"), name: "ab-comment-new", data: { a_id: a.id, "character-count-target": "input" } } + .comment__submit-wrapper + %button.btn.btn-primary{ + type: :button, + name: "ab-comment-new-submit", + title: t(".comments.action"), + data: { a_id: a.id, "character-count-target": "action" } + } + %i.fa.fa-paper-plane-o + %span.text-muted.form-control-feedback.comment__character-count{ id: "ab-comment-charcount-#{a.id}", data: { "character-count-target": "counter" } } 512 diff --git a/app/views/application/_questionbox.html.haml b/app/views/application/_questionbox.html.haml index d56fefda..c6bfee72 100644 --- a/app/views/application/_questionbox.html.haml +++ b/app/views/application/_questionbox.html.haml @@ -1,4 +1,4 @@ -.card +.card#question-card .card-header - if user.profile.motivation_header.blank? = t(".title") diff --git a/app/views/comments/index.html.haml b/app/views/comments/index.html.haml new file mode 100644 index 00000000..e5334203 --- /dev/null +++ b/app/views/comments/index.html.haml @@ -0,0 +1,2 @@ += turbo_frame_tag "ab-comments-list-#{a.id}" do + %div{ id: "ab-comments-#{a.id}" }= render "answerbox/comments", a:, comments: @comments diff --git a/app/views/comments/reactions/index.html.haml b/app/views/comments/reactions/index.html.haml new file mode 100644 index 00000000..690943f8 --- /dev/null +++ b/app/views/comments/reactions/index.html.haml @@ -0,0 +1,21 @@ += turbo_frame_tag "modal" do + .modal.fade.show.d-block{ id: "modal-view-comment-smiles", aria: { hidden: false, labelledby: "modal-commentsmile-label" }, role: :dialog, tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %h5.modal-title#modal-commentsmile-label= t(".title") + = button_to modal_close_path, method: :get, class: "btn-close" do + %span.visually-hidden Close + .modal-body + - if @reactions.count.zero? + = t(".none") + - else + %ul.smiles__user-list + - @reactions.each do |smile| + %li.smiles__user-list-entry + %a{ href: user_path(smile.user) } + %img{ src: smile.user.profile_picture.url(:small), alt: user_screen_name(smile.user, url: false) } + %span= user_screen_name(smile.user, url: false) + .modal-footer + = button_to t("voc.close"), modal_close_path, method: :get, class: "btn btn-default" + = link_to "", modal_close_path, method: :get, class: "modal-backdrop fade show z-n1" diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 698e63b9..d0fa29d6 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -5,7 +5,7 @@ .card.mt-3 .card-body %h1= t(".title") - = bootstrap_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| + = bootstrap_form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }) do |f| = render "devise/shared/error_messages", resource: resource = render "layouts/messages" diff --git a/app/views/discover/_userbox.html.haml b/app/views/discover/_userbox.html.haml index 8c322b50..8293104b 100644 --- a/app/views/discover/_userbox.html.haml +++ b/app/views/discover/_userbox.html.haml @@ -3,9 +3,9 @@ .d-flex .flex-shrink-0 %a{ href: user_path(u) } - %img.avatar-md.me-2{ src: u.profile_picture.url(:medium) } + = render AvatarComponent.new(user: u, size: "md", classes: ["me-2"]) .flex-grow-1 - %h6.answerbox__question-user + %h6.question__user - if u.profile.display_name.blank? %a{ href: user_path(u) } = u.screen_name @@ -13,4 +13,4 @@ %a{ href: user_path(u) } = u.profile.display_name %span.text-muted= u.screen_name - %p.answerbox__question-text= t(".#{type}", value: value, count: value) + %p.question__text= t(".#{type}", value: value, count: value) diff --git a/app/views/discover/tab/_answers.html.haml b/app/views/discover/tab/_answers.html.haml index 4438edec..cef9a25a 100644 --- a/app/views/discover/tab/_answers.html.haml +++ b/app/views/discover/tab/_answers.html.haml @@ -1,3 +1,3 @@ .tab-pane.active.fade.show{ role: :tabpanel, id: "answers" } - answers.each do |a| - = render "answerbox", a:, subscribed_answer_ids: + = render "answerbox", a: diff --git a/app/views/discover/tab/_discussed.html.haml b/app/views/discover/tab/_discussed.html.haml index 7b3b7375..ed288036 100644 --- a/app/views/discover/tab/_discussed.html.haml +++ b/app/views/discover/tab/_discussed.html.haml @@ -1,3 +1,3 @@ .tab-pane.fade{ role: :tabpanel, id: "comments" } - comments.each do |a| - = render "answerbox", a:, subscribed_answer_ids: + = render "answerbox", a: diff --git a/app/views/inbox/_entry.html.haml b/app/views/inbox/_entry.html.haml index eb92ed7d..411ce337 100644 --- a/app/views/inbox/_entry.html.haml +++ b/app/views/inbox/_entry.html.haml @@ -1,40 +1,18 @@ .card.inbox-entry{ id: "inbox_#{i.id}", class: i.new? ? "inbox-entry--new" : "", data: { id: i.id } } .card-header - .d-flex - - unless i.question.author_is_anonymous - .flex-shrink-0 - %a.pull-left{ href: user_path(i.question.user) } - %img.answerbox__question-user-avatar.avatar-md{ src: i.question.user.profile_picture.url(:small), loading: :lazy } - .flex-grow-1 - %h6.text-muted.answerbox__question-user - - if i.question.author_is_anonymous - %i.fas.fa-user-secret{ title: t('.anon_hint') } - = t(".asked_html", user: user_screen_name(i.question.user, context_user: i.user, author_identifier: i.question.author_is_anonymous ? i.question.author_identifier : nil), time: time_tooltip(i.question)) - - if !i.question.author_is_anonymous && i.question.answer_count.positive? - · - %a{ href: question_path(i.question.user.screen_name, i.question.id) } - = t(".answers", count: i.question.answer_count) - .answerbox__question-body{ data: { controller: i.question.long? ? "collapse" : nil } } - .answerbox__question-text{ class: i.question.long? ? "collapsed" : "", data: { collapse_target: "content" } } - = question_markdown i.question.content - - if i.question.long? - = render "shared/collapse", type: "question" - - if i.question.user_id != current_user.id || current_user.has_cached_role?(:administrator) - .flex-shrink-0.ms-auto - .btn-group - %button.btn.btn-default.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - = render "actions/question", question: i.question + = render QuestionComponent.new(question: i.question, context_user: i.user) - if current_user == i.user .card-body %textarea.form-control.mb-3{ name: "ib-answer", placeholder: t(".placeholder"), data: { id: i.id } } - .d-sm-flex - %button.btn.btn-success.me-sm-1{ name: "ib-answer", data: { ib_id: i.id } } - = t("voc.answer") - %button.btn.btn-danger.me-sm-1{ name: "ib-destroy", data: { ib_id: i.id } } - = t("voc.delete") - %p.format-help.ms-auto.align-self-center.mt-2.mt-sm-0.text-center + .d-flex.flex-column-reverse.flex-sm-row + %p.format-help.me-sm-auto.align-self-center.mb-0.mt-2.mt-sm-0.text-center = render "shared/format_link" + .d-grid.gap-2.d-sm-block + %button.btn.btn-default.text-muted.me-sm-1.mb-sm-0{ name: "ib-destroy", data: { ib_id: i.id } } + %i.fa.fa-trash.fa-fw + = t("voc.delete") + %button.btn.btn-primary.grid-row-1{ name: "ib-answer", data: { ib_id: i.id } } + = t("voc.answer") - if current_user.sharing_enabled .inbox-entry__sharing.text-center.p-2.justify-content-center.d-none{ data: { controller: "inbox-sharing", inbox_sharing_config_value: "{}", inbox_sharing_auto_close_value: current_user.sharing_autoclose.to_s } } @@ -43,16 +21,25 @@ .align-self-center %p.fs-3.fw-bold= t(".sharing.heading") %p - %a.btn.btn-primary{ href: "https://twitter.com/intent/tweet?text=", data: { inbox_sharing_target: "twitter" }, target: "_blank" } + %a.btn.btn-primary.mb-1{ href: "#", data: { inbox_sharing_target: "twitter" }, target: "_blank" } %i.fab.fa-twitter.fa-fw Twitter - %a.btn.btn-primary{ href: "#", data: { inbox_sharing_target: "tumblr" }, target: "_blank" } + %a.btn.btn-primary.mb-1{ href: "#", data: { inbox_sharing_target: "bluesky" }, target: "_blank" } + %i.fas.fa-cloud.fa-fw + Bluesky + %a.btn.btn-primary.mb-1{ href: "#", data: { inbox_sharing_target: "tumblr" }, target: "_blank" } %i.fab.fa-tumblr.fa-fw Tumblr - %a.btn.btn-primary{ href: "#", data: { inbox_sharing_target: "telegram" }, target: "_blank" } + %a.btn.btn-primary.mb-1{ href: "#", data: { inbox_sharing_target: "telegram" }, target: "_blank" } %i.fab.fa-telegram.fa-fw Telegram + %button.btn.btn-primary.mb-1{ data: { controller: :clipboard, action: "clipboard#copy", inbox_sharing_target: "clipboard" } } + %i.fa.fa-fw.fa-solid.fa-copy + = t("actions.share.copy") + %button.btn.btn-primary.mb-1{ data: { controller: "share", action: "share#share", inbox_sharing_target: "other" } } + %i.fa.fa-fw.fa-share-alt + = t("actions.share.other") - if current_user.sharing_custom_url.present? - %a.btn.btn-primary{ href: current_user.sharing_custom_url, data: { inbox_sharing_target: "custom" }, target: "_blank" } + %a.btn.btn-primary.mb-1{ href: current_user.sharing_custom_url, data: { inbox_sharing_target: "custom" }, target: "_blank" } = current_user.display_sharing_custom_url %p.text-muted= t(".sharing.hint_html", settings: link_to(t(".sharing.settings"), settings_sharing_path)) diff --git a/app/views/inbox/show.html.haml b/app/views/inbox/show.html.haml index 802127ee..8299b6ce 100644 --- a/app/views/inbox/show.html.haml +++ b/app/views/inbox/show.html.haml @@ -3,7 +3,7 @@ = render "inbox/entry", i: - if @inbox.empty? - %p.empty= t(".empty") + = render "shared/empty", type: "inbox" - if @more_data_available .d-flex.justify-content-center#paginator diff --git a/app/views/inbox/show.turbo_stream.haml b/app/views/inbox/show.turbo_stream.haml index bbd233bd..e88cfc8d 100644 --- a/app/views/inbox/show.turbo_stream.haml +++ b/app/views/inbox/show.turbo_stream.haml @@ -1,3 +1,5 @@ +- inbox_count = current_user.unread_inbox_count + = turbo_stream.append "entries" do - @inbox.each do |i| = render "inbox/entry", i: @@ -10,3 +12,13 @@ params: { last_id: @inbox_last_id, author: @author }.compact, data: { controller: :hotkey, hotkey: "." }, form: { data: { turbo_stream: true } } + += turbo_stream.update "nav-inbox-desktop" do + = nav_entry t("navigation.inbox"), "/inbox", + badge: inbox_count, badge_attr: { data: { controller: "pwa-badge" } }, + icon: "inbox", hotkey: "g i" + += turbo_stream.update "nav-inbox-mobile" do + = nav_entry t("navigation.inbox"), "/inbox", + badge: inbox_count, badge_color: "primary", badge_pill: true, + icon: "inbox", icon_only: true diff --git a/app/views/layouts/base.html.haml b/app/views/layouts/base.html.haml index 0607c377..cf829de8 100644 --- a/app/views/layouts/base.html.haml +++ b/app/views/layouts/base.html.haml @@ -32,6 +32,7 @@ = yield = render "shared/formatting" = render "shared/hotkeys" + = turbo_frame_tag "modal" .d-none#toasts - if Rails.env.development? #debug diff --git a/app/views/layouts/user/profile.html.haml b/app/views/layouts/user/profile.html.haml index 1af75a29..ef497627 100644 --- a/app/views/layouts/user/profile.html.haml +++ b/app/views/layouts/user/profile.html.haml @@ -8,8 +8,9 @@ .d-none.d-sm-block= render 'shared/links' .col-lg-9.col-md-8.col-xs-12.col-sm-8 = render 'questionbox', user: @user - = render 'tabs/profile', user: @user - = yield + - unless @user.banned? + = render 'tabs/profile', user: @user + = yield - if user_signed_in? = render 'modal/list', user: @user - if current_user.mod? && @user != current_user diff --git a/app/views/modal/_ask.html.haml b/app/views/modal/_ask.html.haml index 00192b61..326a3172 100644 --- a/app/views/modal/_ask.html.haml +++ b/app/views/modal/_ask.html.haml @@ -6,9 +6,17 @@ %button.btn-close{ data: { bs_dismiss: :modal }, type: :button } %span.visually-hidden= t("voc.close") .modal-body + - if @user + .alert.alert-info.d-sm-none= t(".user_note_html", user: @user.profile.safe_name) + - if current_user.followers.count.zero? + .alert.alert-warning= t(".follower_note_html") .form-group.has-feedback %textarea.form-control{ name: "qb-all-question", placeholder: t(".placeholder"), data: { "character-count-warning-target": "input" } } .alert.alert-warning.mt-3.d-none{ data: { "character-count-warning-target": "warning" } }= t('.long_question_warning') .modal-footer - %button.btn.btn-default{ type: :button, data: { bs_dismiss: :modal } }= t("voc.cancel") - %button.btn.btn-primary{ name: "qb-all-ask", type: :button, data: { loading_text: t(".loading") } }= t(".action") + .flex-grow-1 + %input.form-check-input#qb-send-to-own-inbox{ type: :checkbox } + %label.form-check-label{ for: 'qb-send-to-own-inbox' }= t('.send_to_own_inbox') + .flex-grow-1.d-flex + %button.btn.btn-default.ms-auto{ type: :button, data: { bs_dismiss: :modal } }= t("voc.cancel") + %button.btn.btn-primary{ name: "qb-all-ask", type: :button, data: { loading_text: t(".loading") } }= t(".action") diff --git a/app/views/modal/_comment_smiles.html.haml b/app/views/modal/_comment_smiles.html.haml deleted file mode 100644 index 70cf7772..00000000 --- a/app/views/modal/_comment_smiles.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -.modal.fade{ id: "modal-view-comment#{comment.id}-smiles", aria: { hidden: true, labelledby: "modal-commentsmile-label" }, role: :dialog, tabindex: -1 } - .modal-dialog - .modal-content - .modal-header - %h5.modal-title#modal-commentsmile-label= t(".title") - %button.btn-close{ data: { bs_dismiss: :modal }, type: :button } - %span.visually-hidden Close - .modal-body - - if comment.smiles.all.count.zero? - = t(".none") - - else - %ul.smiles__user-list - - comment.smiles.all.each do |smile| - %li.smiles__user-list-entry - %a{ href: user_path(smile.user) } - %img{ src: smile.user.profile_picture.url(:small), alt: user_screen_name(smile.user, url: false) } - %span= user_screen_name(smile.user, url: false) diff --git a/app/views/modal/_privileges.html.haml b/app/views/modal/_privileges.html.haml index 7444e664..45ad7eaf 100644 --- a/app/views/modal/_privileges.html.haml +++ b/app/views/modal/_privileges.html.haml @@ -9,6 +9,6 @@ %ul.list-group - if current_user.has_cached_role?(:administrator) = render "modal/privileges/item", privilege: "moderator", description: t(".role.moderator"), user: user - = render "modal/privileges/item", privilege: "admin", description: t(".role.admin"), user: user + = render "modal/privileges/item", privilege: "administrator", description: t(".role.admin"), user: user .modal-footer %button.btn.btn-primary{ name: "checked-privileges", type: :button, data: { bs_dismiss: :modal } }= t("voc.close") diff --git a/app/views/modal/privileges/_item.html.haml b/app/views/modal/privileges/_item.html.haml index f87b4d3b..c9be9a30 100644 --- a/app/views/modal/privileges/_item.html.haml +++ b/app/views/modal/privileges/_item.html.haml @@ -1,12 +1,13 @@ :ruby description ||= "" - role_mapping = { admin: "administrator" } - requires_role = %w[admin moderator].include?(privilege) - checked = requires_role ? user.has_cached_role?(role_mapping.fetch(privilege, privilege).to_sym) : user.public_send("#{privilege}?") %li.list-group-item{ id: "privilege-#{privilege}" } .d-flex .flex-shrink-0 - %input{ type: :checkbox, name: "check-your-privileges", data: { type: privilege, user: user.screen_name }, checked: checked, autocomplete: :off } + %input{ type: :checkbox, + name: "check-your-privileges", + data: { type: privilege, user: user.screen_name }, + checked: user.has_cached_role?(privilege.to_sym), + autocomplete: :off } .flex-grow-1 .list-group-item-heading= privilege.capitalize - unless description.blank? diff --git a/app/views/moderation/_moderationbox.html.haml b/app/views/moderation/_moderationbox.html.haml index 721177af..ef0c16fb 100644 --- a/app/views/moderation/_moderationbox.html.haml +++ b/app/views/moderation/_moderationbox.html.haml @@ -1,6 +1,6 @@ .card.moderationbox{ data: { id: report.id } } .card-header - %img.avatar-sm{ src: report.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: report.user, size: "sm") = t(".reported_html", user: user_screen_name(report.user), content: report.type.sub("Reports::", ""), diff --git a/app/views/moderation/inbox/_header.html.haml b/app/views/moderation/inbox/_header.html.haml index eae01b4b..830583cb 100644 --- a/app/views/moderation/inbox/_header.html.haml +++ b/app/views/moderation/inbox/_header.html.haml @@ -1,9 +1,9 @@ -.card.question--fixed{ class: hidden ? 'question--hidden' : '', tabindex: hidden ? -1 : '', aria: { hidden: hidden } } +.card.question--sticky .container .card-body .d-flex .flex-shrink-0 %a{ href: user_path(user) } - %img.answerbox__question-user-avatar.avatar-md{ src: user.profile_picture.url(:medium) } + = render AvatarComponent.new(user:, size: "md", classes: ["question__avatar"]) .flex-grow-1 = t(".title_html", screen_name: user.screen_name, user_id: user.id) diff --git a/app/views/moderation/inbox/index.html.haml b/app/views/moderation/inbox/index.html.haml index 7e400437..3cae722c 100644 --- a/app/views/moderation/inbox/index.html.haml +++ b/app/views/moderation/inbox/index.html.haml @@ -1,10 +1,12 @@ - provide(:title, generate_title(t(".title", user: @user.screen_name))) -= render "header", user: @user, hidden: false -= render "header", user: @user, hidden: true += render "header", user: @user -.container-lg.container--main +.container-lg.question-page #entries + - if @inboxes.empty? + = render "shared/empty", icon: "fa fa-inbox", translation_key: ".moderation.inbox" + - @inboxes.each do |i| = render "inbox/entry", i: i diff --git a/app/views/moderation/questions/_header.html.haml b/app/views/moderation/questions/_header.html.haml index c8984458..e42f6a4e 100644 --- a/app/views/moderation/questions/_header.html.haml +++ b/app/views/moderation/questions/_header.html.haml @@ -1,4 +1,4 @@ -.card.question--fixed{ class: hidden ? 'question--hidden' : '', tabindex: hidden ? -1 : '', aria: { hidden: hidden } } +.card.question--sticky .container .card-body .d-flex diff --git a/app/views/moderation/questions/show.html.haml b/app/views/moderation/questions/show.html.haml index 48617bf2..934ada11 100644 --- a/app/views/moderation/questions/show.html.haml +++ b/app/views/moderation/questions/show.html.haml @@ -1,8 +1,7 @@ - provide(:title, generate_title(t(".title", author_identifier: params[:author_identifier].truncate(32)))) -= render "header", author_identifier: params[:author_identifier], hidden: false -= render "header", author_identifier: params[:author_identifier], hidden: true += render "header", author_identifier: params[:author_identifier] -.container-lg.container--main +.container-lg.question-page - @questions.each do |q| = render "shared/question", q:, type: "moderation" diff --git a/app/views/moderation/reports/index.html.haml b/app/views/moderation/reports/index.html.haml index bf92cdf7..c7924a3e 100644 --- a/app/views/moderation/reports/index.html.haml +++ b/app/views/moderation/reports/index.html.haml @@ -1,4 +1,24 @@ +.card + .card-body + .dropdown + %button.btn.dropdown-toggle{ class: @filter_enabled ? "btn-primary" : "btn-light", + type: :button, + data: { bs_toggle: :dropdown }, + aria: { expanded: :false } } + %i.fa.fa-filter + = t("voc.filter") + .dropdown-menu{ style: "min-width: 300px;" } + = bootstrap_form_tag url: moderation_reports_path, method: :get, html: { class: "px-3 py-2" } do |f| + = f.select :type, options_for_select(@type_options, params[:type]), {}, { class: "form-control" } + = f.text_field :user, value: params[:user] + = f.text_field :target_user, value: params[:target_user] + .d-flex.flex-row-reverse + = f.primary t("voc.filter") + #reports + - if @reports.empty? + = render "shared/empty", icon: "fa-regular fa-smile-beam", translation_key: ".moderation.reports" + - @reports.each do |r| = render "moderation/moderationbox", report: r diff --git a/app/views/navigation/_desktop.html.haml b/app/views/navigation/_desktop.html.haml index 05e9a6a3..26172183 100644 --- a/app/views/navigation/_desktop.html.haml +++ b/app/views/navigation/_desktop.html.haml @@ -10,12 +10,12 @@ DEV %ul.nav.navbar-nav.me-auto = nav_entry t("navigation.timeline"), root_path, icon: "home", hotkey: "g t" - = nav_entry t("navigation.inbox"), "/inbox", icon: "inbox", badge: inbox_count, badge_attr: { data: { controller: "pwa-badge" } }, hotkey: "g i" + = nav_entry t("navigation.inbox"), "/inbox", icon: "inbox", badge: inbox_count, badge_attr: { data: { controller: "pwa-badge" } }, hotkey: "g i", id: "nav-inbox-desktop" - if APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod? = nav_entry t("navigation.discover"), discover_path, icon: "compass", hotkey: "g d" %ul.nav.navbar-nav - if @user.present? && @user != current_user - %li.nav-item.d-none.d-sm-block{ data: { bs_toggle: 'tooltip', bs_placement: 'bottom' }, title: t(".list") } + %li.nav-item.d-none.d-sm-block{ data: { controller: 'tooltip', bs_placement: 'bottom' }, title: t(".list") } %a.nav-link{ href: '#', data: { bs_target: '#modal-list-memberships', bs_toggle: :modal } } %i.fa.fa-list.hidden-xs %span.d-none.d-sm-inline.d-md-none= t(".list") @@ -23,23 +23,18 @@ %li.nav-item.dropdown.d-none.d-sm-block %a.nav-link.dropdown-toggle{ href: '#', data: { bs_toggle: :dropdown } } %turbo-frame#notification-desktop-icon - - if notification_count.nil? - %i.fa.fa-bell-o - - else - %i.fa.fa-bell - %span.visually-hidden= t("navigation.notifications") - %span.badge= notification_count + = render "navigation/icons/notifications", notification_count: .dropdown-menu.dropdown-menu-end.notification-dropdown %turbo-frame#notifications-dropdown-list - - cache current_user.notification_dropdown_cache_key do + - cache current_user.notification_dropdown_cache_key, expires_in: 12.hours do - notifications = Notification.for(current_user).where(new: true).includes([:target]).limit(4) = render "navigation/dropdown/notifications", notifications:, size: "desktop" - %li.nav-item.d-none.d-sm-block{ data: { bs_toggle: 'tooltip', bs_placement: 'bottom' }, title: t('.ask_question') } + %li.nav-item.d-none.d-sm-block{ data: { controller: :tooltip, bs_placement: 'bottom' }, title: t('.ask_question') } %a.nav-link{ href: "#", name: "toggle-all-ask", data: { bs_target: "#modal-ask-followers", bs_toggle: :modal, hotkey: "n" } } %i.fa.fa-pencil-square-o %li.nav-item.dropdown.profile--image-dropdown %a.nav-link.dropdown-toggle.p-sm-0{ href: "#", data: { bs_toggle: :dropdown } } - %img.avatar-md.d-none.d-sm-inline{ src: current_user.profile_picture.url(:small) } + = render AvatarComponent.new(user: current_user, size: "md", classes: ["d-none", "d-sm-inline"]) %span.d-inline.d-sm-none = current_user.screen_name %b.caret diff --git a/app/views/navigation/_mobile.html.haml b/app/views/navigation/_mobile.html.haml index 97e37ccc..010d592c 100644 --- a/app/views/navigation/_mobile.html.haml +++ b/app/views/navigation/_mobile.html.haml @@ -5,12 +5,12 @@ = nav_entry t("navigation.timeline"), root_path, icon: 'home', icon_only: true = nav_entry t("navigation.inbox"), '/inbox', badge: inbox_count, badge_color: 'primary', badge_pill: true, - icon: 'inbox', icon_only: true + icon: 'inbox', icon_only: true, id: "nav-inbox-mobile" - if APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod? = nav_entry t("navigation.discover"), discover_path, icon: 'compass', icon_only: true = nav_entry t("navigation.notifications"), notifications_path("all"), icon: notifications_icon, badge: notification_count, badge_color: "primary", badge_attr: { id: "notification-mobile-count" }, icon_only: true %li.nav-item.profile--image-dropdown %a.nav-link{ href: '#', data: { bs_toggle: 'dropdown', bs_target: '#rs-mobile-nav-profile' }, aria: { controls: 'rs-mobile-nav-profile', expanded: 'false' } } - %img.avatar-md.d-inline{ src: current_user.profile_picture.url(:small) } + = render AvatarComponent.new(user: current_user, size: "md", classes: ["d-inline"]) = render 'navigation/dropdown/profile', size: "mobile" diff --git a/app/views/navigation/dropdown/_profile.html.haml b/app/views/navigation/dropdown/_profile.html.haml index aef649a9..1e25574a 100644 --- a/app/views/navigation/dropdown/_profile.html.haml +++ b/app/views/navigation/dropdown/_profile.html.haml @@ -16,8 +16,8 @@ = link_to moderation_toggle_unmask_path, class: "dropdown-item", data: { turbo_method: :post } do %i.fa.fa-toggle-off = t(".unmask.enable") - %a.dropdown-item{ href: moderation_reports_path } - %i.fa.fa-fw.fa-gavel + %a.dropdown-item{ class: @has_new_reports ? "text-primary" : nil, href: moderation_reports_path } + %i.fa.fa-fw.fa-gavel{ class: @has_new_reports ? "fa-fade" : nil } = t(".moderation") - if current_user.has_cached_role?(:administrator) %a.dropdown-item{ href: admin_dashboard_path } diff --git a/app/views/navigation/icons/_notifications.html.haml b/app/views/navigation/icons/_notifications.html.haml new file mode 100644 index 00000000..767ee980 --- /dev/null +++ b/app/views/navigation/icons/_notifications.html.haml @@ -0,0 +1,6 @@ +- if notification_count.nil? + %i.fa.fa-bell-o +- else + %i.fa.fa-bell +%span.visually-hidden= t("navigation.notifications") +%span.badge= notification_count diff --git a/app/views/notifications/index.turbo_stream.haml b/app/views/notifications/index.turbo_stream.haml index 4339faae..378a9a44 100644 --- a/app/views/notifications/index.turbo_stream.haml +++ b/app/views/notifications/index.turbo_stream.haml @@ -13,3 +13,6 @@ params: { last_id: @notifications_last_id }, data: { controller: :hotkey, hotkey: "." }, form: { data: { turbo_stream: true } } + += turbo_stream.update "notification-desktop-icon" do + = render "navigation/icons/notifications", notification_count: current_user.unread_notification_count diff --git a/app/views/notifications/type/_answer.html.haml b/app/views/notifications/type/_answer.html.haml index 890fb394..184b58a6 100644 --- a/app/views/notifications/type/_answer.html.haml +++ b/app/views/notifications/type/_answer.html.haml @@ -3,15 +3,16 @@ %i.fa.fa-2x.fa-fw.fa-exclamation .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") = t(".heading_html", user: user_screen_name(notification.target.user), - question: link_to(t(".link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.id), target: "_top"), - time: time_tooltip(notification.target)) + question: link_to(t(".link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.id), target: "_top")) + · + = time_tooltip(notification.target) .list-group .list-group-item %h6.notification__list-heading= t("activerecord.models.question.one") - = markdown notification.target.question.content[0..60] + (notification.target.question.content.length > 60 ? "[…]" : "") + = question_markdown notification.target.question.content[0..60] + (notification.target.question.content.length > 60 ? "[…]" : "") .list-group-item %h6.notification__list-heading= t("activerecord.models.answer.one") = markdown notification.target.content[0..60] + (notification.target.content.length > 60 ? "[…]" : "") diff --git a/app/views/notifications/type/_comment.html.haml b/app/views/notifications/type/_comment.html.haml index 56c3797c..f591250b 100644 --- a/app/views/notifications/type/_comment.html.haml +++ b/app/views/notifications/type/_comment.html.haml @@ -3,23 +3,21 @@ %i.fa.fa-2x.fa-fw.fa-comments .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") - if notification.target.answer.user == current_user = t(".heading_html", user: user_screen_name(notification.target.user), answer: link_to(t(".active.link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.answer.id), - target: "_top"), - time: time_tooltip(notification.target)) + target: "_top")) - elsif notification.target.user == notification.target.answer.user = t(".heading_html", user: user_screen_name(notification.target.user), answer: link_to(t(".passive.link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.answer.id), - target: "_top"), - time: time_tooltip(notification.target)) + target: "_top")) - else = t(".heading_html", user: user_screen_name(notification.target.user), @@ -27,8 +25,9 @@ user: user_screen_name(notification.target.answer.user, url: false)), answer_path(username: notification.target.user.screen_name, id: notification.target.answer.id), - target: "_top"), - time: time_tooltip(notification.target)) + target: "_top")) + · + = time_tooltip(notification.target) .list-group .list-group-item %h6.notification__list-heading= t("activerecord.models.answer.one") diff --git a/app/views/notifications/type/_follow.html.haml b/app/views/notifications/type/_follow.html.haml index 0994b6de..d142943f 100644 --- a/app/views/notifications/type/_follow.html.haml +++ b/app/views/notifications/type/_follow.html.haml @@ -1,6 +1,6 @@ .d-flex.notification .flex-shrink-0.notification__icon - %img.avatar-sm{ src: notification.target.source.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.source, size: "sm") .flex-grow-1 %h6.notification__user = user_screen_name notification.target.source diff --git a/app/views/notifications/type/_reaction.html.haml b/app/views/notifications/type/_reaction.html.haml index ae9259c1..4238df29 100644 --- a/app/views/notifications/type/_reaction.html.haml +++ b/app/views/notifications/type/_reaction.html.haml @@ -3,23 +3,23 @@ %i.fa.fa-2x.fa-fw.fa-smile-o .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") - if notification.target.parent_type == "Answer" = t(".heading_html", user: user_screen_name(notification.target.user), type: link_to(t(".#{notification.target.parent_type.downcase}.link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.parent.id), - target: "_top"), - time: time_tooltip(notification.target)) + target: "_top")) - elsif notification.target.parent_type == "Comment" = t(".heading_html", user: user_screen_name(notification.target.user), type: link_to(t(".#{notification.target.parent_type.downcase}.link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.parent.answer.id), - target: "_top"), - time: time_tooltip(notification.target)) + target: "_top")) + · + = time_tooltip(notification.target) .list-group .list-group-item %h6.notification__list-heading= t("activerecord.models.#{notification.target.parent_type.downcase}.one") diff --git a/app/views/question/_question.html.haml b/app/views/question/_question.html.haml deleted file mode 100644 index 1128bb5f..00000000 --- a/app/views/question/_question.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -.card.question--fixed{ class: hidden ? 'question--hidden' : '', tabindex: hidden ? -1 : '', aria: { hidden: hidden } } - .container - .card-body - .d-flex - - unless question.author_is_anonymous - .flex-shrink-0 - %a{ href: unless hidden then user_path(question.user) end } - %img.answerbox__question-user-avatar.avatar-md{ src: question.user.profile_picture.url(:small) } - .flex-grow-1 - %h6.text-muted.answerbox__question-user - - identifier = question.author_is_anonymous ? question.author_identifier : nil - - if hidden - = user_screen_name question.user, author_identifier: identifier, url: false - - else - = t("answerbox.header.asked_html", user: user_screen_name(question.user, author_identifier: identifier), time: time_tooltip(question)) - .answerbox__question-body{ data: { controller: question.long? ? "collapse" : nil } } - .answerbox__question-text{ class: question.long? ? "collapsed" : "", data: { collapse_target: "content" } } - = question_markdown question.content - - if question.long? - = render "shared/collapse", type: "question" - - if user_signed_in? - .flex-shrink-0.ms-auto - .btn-group - %button.btn.btn-link.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - - unless hidden - = render "actions/question", question: question diff --git a/app/views/question/show.html.haml b/app/views/question/show.html.haml index e1d0b238..542b7726 100644 --- a/app/views/question/show.html.haml +++ b/app/views/question/show.html.haml @@ -1,12 +1,17 @@ - provide(:title, question_title(@question)) -= render "question", question: @question, hidden: false -= render "question", question: @question, hidden: true +.card.question--sticky + .container + .card-body + = render QuestionComponent.new(question: @question) .container.question-page #answers{ data: { controller: "navigation" } } %button.d-none{ data: { hotkey: "j", action: "navigation#down" } } %button.d-none{ data: { hotkey: "k", action: "navigation#up" } } + - if @answers.empty? + = render "shared/empty", icon: "fa-regular fa-comments", translation_key: ".question" + - @answers.each do |a| - = render "answerbox", a:, show_question: false, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a:, show_question: false - if @more_data_available .d-flex.justify-content-center.justify-content-sm-start#paginator diff --git a/app/views/question/show.turbo_stream.haml b/app/views/question/show.turbo_stream.haml index 96facf13..335a808b 100644 --- a/app/views/question/show.turbo_stream.haml +++ b/app/views/question/show.turbo_stream.haml @@ -1,6 +1,6 @@ = turbo_stream.append "answers" do - @answers.each do |a| - = render "answerbox", a:, show_question: false, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a:, show_question: false = turbo_stream.update "paginator" do - if @more_data_available diff --git a/app/views/reactions/_create.html.haml b/app/views/reactions/_create.html.haml new file mode 100644 index 00000000..d7c1d7ec --- /dev/null +++ b/app/views/reactions/_create.html.haml @@ -0,0 +1,19 @@ +- if type == "Answer" + = button_to create_reactions_path(id: target.id, username: target.user.screen_name), + form: { class: "d-inline-block", + id: "reaction-#{type}-#{target.id}", + data: { controller: :reaction, action: "turbo:submit-start->reaction#disable turbo:submit-end->reaction#enable" } }, + class: "btn btn-link answerbox__action smile", + data: { reaction_target: :button } do + %i.fa.fa-smile-o + %span= target.smile_count + +- if type == "Comment" + = button_to create_comment_reactions_path(id: target.id, username: target.user.screen_name), + form: { class: "d-inline-block", + id: "reaction-#{type}-#{target.id}", + data: { controller: :reaction, action: "turbo:submit-start->reaction#disable turbo:submit-end->reaction#enable" } }, + class: "btn btn-link answerbox__action smile", + data: { reaction_target: :button } do + %i.fa.fa-smile-o + %span= target.smile_count diff --git a/app/views/reactions/_destroy.html.haml b/app/views/reactions/_destroy.html.haml new file mode 100644 index 00000000..dff2520a --- /dev/null +++ b/app/views/reactions/_destroy.html.haml @@ -0,0 +1,21 @@ +- if type == "Answer" + = button_to destroy_reactions_path(id: target.id, username: target.user.screen_name), + method: :delete, + form: { class: "d-inline-block", + id: "reaction-#{type}-#{target.id}", + data: { controller: :reaction, action: "turbo:submit-start->reaction#disable turbo:submit-end->reaction#enable" } }, + class: "btn btn-link answerbox__action unsmile", + data: { reaction_target: :button } do + %i.fa.fa-smile-o + %span= target.smile_count + +- if type == "Comment" + = button_to destroy_comment_reactions_path(id: target.id, username: target.user.screen_name), + method: :delete, + form: { class: "d-inline-block", + id: "reaction-#{type}-#{target.id}", + data: { controller: :reaction, action: "turbo:submit-start->reaction#disable turbo:submit-end->reaction#enable" } }, + class: "btn btn-link answerbox__action unsmile", + data: { reaction_target: :button } do + %i.fa.fa-smile-o + %span= target.smile_count diff --git a/app/views/reactions/index.html.haml b/app/views/reactions/index.html.haml new file mode 100644 index 00000000..b717a281 --- /dev/null +++ b/app/views/reactions/index.html.haml @@ -0,0 +1,2 @@ += turbo_frame_tag "ab-reactions-list-#{a.id}" do + %div{ id: "ab-smiles-#{a.id}" }= render "answerbox/smiles", a: diff --git a/app/views/relationships/_create.html.haml b/app/views/relationships/_create.html.haml new file mode 100644 index 00000000..23c91782 --- /dev/null +++ b/app/views/relationships/_create.html.haml @@ -0,0 +1,13 @@ +- if type == "follow" + = button_to relationships_path(screen_name:, type:), form: { id: "#{type}-#{screen_name}" }, class: "btn btn-primary", form_class: "d-grid" do + = t("voc.follow") + +- if type == "block" + = button_to relationships_path(screen_name:, type:), form: { id: "#{type}-#{screen_name}" }, class: "dropdown-item" do + %i.fa.fa-minus-circle.fa-fw + = t("voc.block") + +- if type == "mute" + = button_to relationships_path(screen_name:, type:), form: { id: "#{type}-#{screen_name}" }, class: "dropdown-item" do + %i.fa.fa-volume-off.fa-fw + = t("voc.mute") diff --git a/app/views/relationships/_destroy.html.haml b/app/views/relationships/_destroy.html.haml new file mode 100644 index 00000000..f26ec521 --- /dev/null +++ b/app/views/relationships/_destroy.html.haml @@ -0,0 +1,14 @@ +- if type == "follow" + = button_to relationships_path(screen_name:, type:), method: :delete, form: { id: "#{type}-#{screen_name}" }, class: "btn btn-primary", + form_class: "d-grid" do + = t("voc.unfollow") + +- if type == "block" + = button_to relationships_path(screen_name:, type:), method: :delete, form: { id: "#{type}-#{screen_name}" }, class: "dropdown-item" do + %i.fa.fa-minus-circle.fa-fw + = t("voc.unblock") + +- if type == "mute" + = button_to relationships_path(screen_name:, type:), method: :delete, form: { id: "#{type}-#{screen_name}" }, class: "dropdown-item" do + %i.fa.fa-volume-off.fa-fw + = t("voc.unmute") diff --git a/app/views/settings/blocks/index.html.haml b/app/views/settings/blocks/index.html.haml index 51e18a11..e11aa255 100644 --- a/app/views/settings/blocks/index.html.haml +++ b/app/views/settings/blocks/index.html.haml @@ -6,7 +6,7 @@ - @blocks.each do |block| %li.list-group-item .d-flex - %img.avatar-md.d-none.d-sm-inline.me-2{ src: block.target.profile_picture.url(:small) } + = render AvatarComponent.new(user: block.target, size: "md", classes: ["d-none", "d-sm-inline", "me-2"]) %div %p.mb-0= user_screen_name(block.target) %p.text-muted.mb-0= t(".blocked", time: time_ago_in_words(block.created_at)) diff --git a/app/views/settings/mutes/_form.html.haml b/app/views/settings/mutes/_form.html.haml index 33213d32..06c1e610 100644 --- a/app/views/settings/mutes/_form.html.haml +++ b/app/views/settings/mutes/_form.html.haml @@ -1,6 +1,5 @@ #form.form-group %form{ action: settings_muted_path, method: "post" } .input-group - %input.form-control#muted_phrase{ name: :muted_phrase, placeholder: t(".placeholder"), data: { controller: :autofocus } } - .input-group-append - %button.btn.btn-primary{ type: "submit" }= t("voc.add") + %input.form-control#muted-phrase{ name: :muted_phrase, placeholder: t(".placeholder"), required: true, minlength: 1, data: { controller: :autofocus } } + %button.btn.btn-primary{ type: "submit" }= t("voc.add") diff --git a/app/views/settings/mutes/_rule.html.haml b/app/views/settings/mutes/_rule.html.haml index 0bee6d13..a6322acc 100644 --- a/app/views/settings/mutes/_rule.html.haml +++ b/app/views/settings/mutes/_rule.html.haml @@ -1,6 +1,5 @@ .form-group.mb-3{ id: "rule_#{rule.id}" } .input-group %input.form-control{ disabled: true, value: rule.muted_phrase } - .input-group-append - = button_to settings_muted_destroy_path(rule.id), method: :delete, class: "btn btn-danger" do - = t("voc.remove") + = button_to settings_muted_destroy_path(rule.id), method: :delete, class: "btn btn-danger" do + = t("voc.remove") diff --git a/app/views/settings/mutes/_user.html.haml b/app/views/settings/mutes/_user.html.haml index e669270a..a2324bcc 100644 --- a/app/views/settings/mutes/_user.html.haml +++ b/app/views/settings/mutes/_user.html.haml @@ -1,5 +1,5 @@ .d-flex.mb-2 - %img.avatar-md.me-2{ src: user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user:, size: "md", classes: ["me-2"]) %p.align-self-center.m-0= user_screen_name(user, context_user: current_user) .ms-auto.d-inline-flex %button.btn.btn-default.align-self-center{ data: { action: :unmute, target: user.screen_name } } diff --git a/app/views/settings/profile/edit.html.haml b/app/views/settings/profile/edit.html.haml index 32721590..262ddfaf 100644 --- a/app/views/settings/profile/edit.html.haml +++ b/app/views/settings/profile/edit.html.haml @@ -5,9 +5,9 @@ %div{ data: { controller: "cropper", cropper_aspect_ratio_value: "1" } } .d-flex .flex-shrink-0 - %img.avatar-lg.me-3{ src: current_user.profile_picture.url(:medium) } + = render AvatarComponent.new(user: current_user, size: "lg", classes: ["me-3"]) .flex-grow-1 - = f.file_field :profile_picture, accept: APP_CONFIG[:accepted_image_formats].join(","), data: { cropper_target: "input", action: "cropper#change" } + = f.file_field :profile_picture, accept: current_user.profile_picture.content_type_whitelist.join(','), data: { cropper_target: "input", action: "cropper#change" } .row.d-none{ data: { cropper_target: "controls" } } .col-sm-10.col-md-8 @@ -22,7 +22,7 @@ .col-xs-12.col-md-6 %img.mw-100.me-3{ src: current_user.profile_header.url(:mobile) } .col-xs-12.col-md-6.mt-3.mt-sm-0.ps-3.pe-3 - = f.file_field :profile_header, accept: APP_CONFIG[:accepted_image_formats].join(","), data: { cropper_target: "input", action: "cropper#change" } + = f.file_field :profile_header, accept: current_user.profile_header.content_type_whitelist.join(','), data: { cropper_target: "input", action: "cropper#change" } .row.d-none{ data: { cropper_target: "controls" } } .col-sm-10.col-md-8 diff --git a/app/views/settings/theme/_input.html.haml b/app/views/settings/theme/_input.html.haml new file mode 100644 index 00000000..81a1c6ee --- /dev/null +++ b/app/views/settings/theme/_input.html.haml @@ -0,0 +1,2 @@ +.col-sm-6 + = f.text_field field_name, class: "color", data: { default:, theme_target: "color", action: "theme#updatePreview" }, readonly: :readonly diff --git a/app/views/settings/theme/edit.html.haml b/app/views/settings/theme/edit.html.haml index a5064c23..1d5c84b0 100644 --- a/app/views/settings/theme/edit.html.haml +++ b/app/views/settings/theme/edit.html.haml @@ -17,25 +17,19 @@ %p= t(".general.body") .row - .col-sm-6 - = f.text_field :background_color, class: "color", data: { default: 0xF0EDF4, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :body_text, class: "color", data: { default: 0x000000, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :background_color, default: 0xF0EDF4 + = render "settings/theme/input", f:, field_name: :body_text, default: 0x000000 .card .card-body %h2= t(".raised.heading") %p= t(".raised.body") .row - .col-sm-6 - = f.text_field :raised_background, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :raised_text, class: "color", data: { default: 0x000000, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :raised_background, default: 0xFFFFFF + = render "settings/theme/input", f:, field_name: :raised_text, default: 0x000000 .row - .col-sm-6 - = f.text_field :raised_accent, class: "color", data: { default: 0xF7F7F7, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :raised_accent_text, class: "color", data: { default: 0x000000, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :raised_accent, default: 0xF7F7F7 + = render "settings/theme/input", f:, field_name: :raised_accent_text, default: 0x000000 .card-footer %p= t(".raised.accent.example") .card @@ -44,57 +38,42 @@ %p= t(".colors.body") .row - .col-sm-6 - = f.text_field :primary_color, class: "color", data: { default: 0x5E35B1, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :primary_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :primary_color, default: 0x5E35B1 + = render "settings/theme/input", f:, field_name: :primary_text, default: 0xFFFFFF .col-sm-12 .alert.alert-primary= t(".colors.alert.example", type: t(".colors.alert.type.primary")) .row - .col-sm-6 - = f.text_field :danger_color, class: "color", data: { default: 0xDC3545, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :danger_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :danger_color, default: 0xDC3545 + = render "settings/theme/input", f:, field_name: :danger_text, default: 0xFFFFFF .col-sm-12 .alert.alert-danger= t(".colors.alert.example", type: t(".colors.alert.type.danger")) .row - .col-sm-6 - = f.text_field :warning_color, class: "color", data: { default: 0xFFC107, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :warning_text, class: "color", data: { default: 0x292929, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :warning_color, default: 0xFFC107 + = render "settings/theme/input", f:, field_name: :warning_text, default: 0x292929 .col-sm-12 .alert.alert-warning= t(".colors.alert.example", type: t(".colors.alert.type.warning")) .row - .col-sm-6 - = f.text_field :info_color, class: "color", data: { default: 0x17A2B8, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :info_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :info_color, default: 0x17A2B8 + = render "settings/theme/input", f:, field_name: :info_text, default: 0xFFFFFF .col-sm-12 .alert.alert-info= t(".colors.alert.example", type: t(".colors.alert.type.info")) .row - .col-sm-6 - = f.text_field :success_color, class: "color", data: { default: 0x28A745, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :success_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :success_color, default: 0x28A745 + = render "settings/theme/input", f:, field_name: :success_text, default: 0xFFFFFF .col-sm-12 .alert.alert-success= t(".colors.alert.example", type: t(".colors.alert.type.success")) .row - .col-sm-6 - = f.text_field :dark_color, class: "color", data: { default: 0x343A40, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :dark_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :dark_color, default: 0x343A40 + = render "settings/theme/input", f:, field_name: :dark_text, default: 0xFFFFFF .col-sm-12 %a.btn.btn-dark.mb-3{ href: "#" }= t(".colors.button.example", type: t(".colors.button.type.dark")) .row - .col-sm-6 - = f.text_field :light_color, class: "color", data: { default: 0xF8F9FA, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :light_text, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :light_color, default: 0xF8F9FA + = render "settings/theme/input", f:, field_name: :light_text, default: 0xFFFFFF .col-sm-12 %a.btn.btn-light.mb-3{ href: "#" }= t(".colors.button.example", type: t(".colors.button.type.light")) .row - .col-sm-6 - = f.text_field :muted_text, class: "color", data: { default: 0x6C757D, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :muted_text, default: 0x6C757D .col-sm-6 %p.pt-4.text-muted= t(".colors.text.example") .card @@ -103,14 +82,11 @@ %p= t(".forms.body") .row - .col-sm-6 - = f.text_field :input_color, class: "color", data: { default: 0xFFFFFF, theme_target: "color", action: "theme#updatePreview" } - .col-sm-6 - = f.text_field :input_text, class: "color", data: { default: 0x000000, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :input_color, default: 0xFFFFFF + = render "settings/theme/input", f:, field_name: :input_text, default: 0x000000 .row - .col-sm-6 - = f.text_field :input_placeholder, class: "color", data: { default: 0x6C757D, theme_target: "color", action: "theme#updatePreview" } + = render "settings/theme/input", f:, field_name: :input_placeholder, default: 0x6C757D .col-sm-6 .form-group %label.form-label Example Input diff --git a/app/views/settings/two_factor_authentication/otp_authentication/_totp_setup.html.haml b/app/views/settings/two_factor_authentication/otp_authentication/_totp_setup.html.haml index 6c707a48..4388bf05 100644 --- a/app/views/settings/two_factor_authentication/otp_authentication/_totp_setup.html.haml +++ b/app/views/settings/two_factor_authentication/otp_authentication/_totp_setup.html.haml @@ -36,6 +36,12 @@ %a{ href: "https://apps.apple.com/gb/app/microsoft-authenticator/id983156458" }= t(".source.app_store") %li.list-inline-item %a{ href: "https://play.google.com/store/apps/details?id=com.azure.authenticator" }= t(".source.google_play") + %li + %i.fa.fa-apple + = t(".app.ios") + %ul.list-inline + %li.list-inline-item + %a{ href: "https://support.apple.com/en-gb/guide/iphone/ipha6173c19f/ios" }= t(".source.apple_support") %p= t(".setup_qr", app_name: APP_CONFIG['site_name']) = f.text_field :otp_validation, class: "totp-setup__code-field", inputmode: :numeric, label: t(".otp_validation"), autofocus: true = f.primary diff --git a/app/views/shared/_empty.html.haml b/app/views/shared/_empty.html.haml new file mode 100644 index 00000000..eaf5a353 --- /dev/null +++ b/app/views/shared/_empty.html.haml @@ -0,0 +1,34 @@ +- type ||= nil +.card{ class: type == "inbox" ? "empty" : nil } + .card-body.py-5.text-center + - if type == "timeline" + %p.mb-3 + %i.fa-regular.fa-comments.icon--showcase.text-muted + %h3= t(".timeline.heading") + %p= t(".timeline.text") + %p + %a.btn.btn-primary{ href: inbox_path }= t(".timeline.actions.inbox") + %a.btn.btn-default{ href: public_timeline_path }= t(".timeline.actions.public") + - elsif type == "inbox" + %p.mb-3 + %i.fa.fa-inbox.icon--showcase.text-muted + %h3= t(".inbox.heading") + %p= t(".inbox.text") + .d-block.d-sm-flex.justify-content-center + = button_to inbox_create_path, class: "btn btn-info me-auto" do + = t("inbox.actions.questions.button") + .button-group.ms-1 + %button.btn.btn-default{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } + %i.fa.fa-fw.fa-share-alt + %span= t("inbox.actions.share.heading") + .dropdown-menu.dropdown-menu-end{ role: :menu } + %a.dropdown-item{ href: "https://twitter.com/intent/tweet?text=Ask%20me%20anything%21&url=#{user_url(current_user)}", target: "_blank" } + %i.fa.fa-fw.fa-twitter + = t("inbox.actions.share.button", service: "Twitter") + %a.dropdown-item{ href: "https://www.tumblr.com/share/link?url=#{user_url(current_user)}&name=Ask%20me%20anything%21", target: "_blank" } + %i.fa.fa-fw.fa-tumblr + = t("inbox.actions.share.button", service: "Tumblr") + - else + %p.mb-3 + %i.icon--showcase.text-muted{ class: icon } + %p= t(translation_key) diff --git a/app/views/shared/_question.html.haml b/app/views/shared/_question.html.haml index e1a4df80..022cc71a 100644 --- a/app/views/shared/_question.html.haml +++ b/app/views/shared/_question.html.haml @@ -1,30 +1,4 @@ - type ||= nil .card.questionbox{ data: { id: q.id } } .card-body{ data: { controller: q.long? ? "collapse" : nil } } - .d-flex - - if type == "discover" - .flex-shrink-0 - %a{ href: user_screen_name(q.user, link_only: true) } - %img.avatar-md.me-2{ src: q.user&.profile_picture&.url(:small), loading: :lazy } - .flex-grow-1 - %h6.text-muted.answerbox__question-user - - if type.nil? && q.direct - - if user_signed_in? && q.user == current_user - %i.fa.fa-eye-slash{ data: { bs_toggle: "tooltip", bs_title: t(".visible_to_you") } } - - elsif moderation_view? - %i.fa.fa-eye-slash{ data: { bs_toggle: "tooltip", bs_title: t(".visible_mod_mode") } } - = t("answerbox.header.asked_html", user: user_screen_name(q.user), time: time_tooltip(q)) - - if q.answer_count > 1 - · - %a{ href: question_path(q.user.screen_name, q.id) } - = pluralize(q.answer_count, t("voc.answer")) - .answerbox__question-text{ class: q.long? ? "collapsed" : "", data: { collapse_target: "content" } } - = question_markdown q.content - - if q.long? - = render "shared/collapse", type: "question" - - if user_signed_in? - .flex-shrink-0.ms-auto - .btn-group - %button.btn.btn-link.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } - %span.caret - = render "actions/question", question: q + = render QuestionComponent.new(question: q, hide_avatar: type == "discover" ? false : true, profile_question: true) diff --git a/app/views/subscriptions/_create.html.haml b/app/views/subscriptions/_create.html.haml new file mode 100644 index 00000000..64d289a9 --- /dev/null +++ b/app/views/subscriptions/_create.html.haml @@ -0,0 +1,3 @@ += button_to subscriptions_path(answer: answer.id), class: "dropdown-item", form: { id: "subscription-#{answer.id}" } do + %i.fa.fa-fw.fa-bell + = t("voc.subscribe") diff --git a/app/views/subscriptions/_destroy.html.haml b/app/views/subscriptions/_destroy.html.haml new file mode 100644 index 00000000..31e6d967 --- /dev/null +++ b/app/views/subscriptions/_destroy.html.haml @@ -0,0 +1,3 @@ += button_to subscriptions_path(answer: answer.id), method: :delete, class: "dropdown-item", form: { id: "subscription-#{answer.id}" } do + %i.fa.fa-fw.fa-bell-slash + = t("voc.unsubscribe") diff --git a/app/views/tabs/_feed.html.haml b/app/views/tabs/_feed.html.haml index 778c1cea..20d06717 100644 --- a/app/views/tabs/_feed.html.haml +++ b/app/views/tabs/_feed.html.haml @@ -17,8 +17,8 @@ - else %p.px-4.pb-2 - list.members.each do |member| - %a{ href: user_path(member.user), title: member.user.screen_name, data: { bs_toggle: :tooltip, bs_placement: :top } } - %img.avatar-xs{ src: member.user.profile_picture.url(:small), loading: :lazy } + %a{ href: user_path(member.user), title: member.user.screen_name, data: { controller: :tooltip, bs_placement: :top } } + = render AvatarComponent.new(user: member.user, size: "xs") - if !list && lists.empty? .p-3= t(".lists.notice_html") - lists.each do |list| diff --git a/app/views/tabs/_moderation.html.haml b/app/views/tabs/_moderation.html.haml index bc4b481c..b24dfbbe 100644 --- a/app/views/tabs/_moderation.html.haml +++ b/app/views/tabs/_moderation.html.haml @@ -1,13 +1,6 @@ .card .list-group - = list_group_item t(".all"), moderation_reports_path - = list_group_item t(".answers"), moderation_reports_path("answer") - = list_group_item t(".comments"), moderation_reports_path("comment") - = list_group_item t(".users"), moderation_reports_path("user") - = list_group_item t(".questions"), moderation_reports_path("question") - -.card - .list-group + = list_group_item t(".reports"), moderation_reports_path = list_group_item t(".site_wide_blocks"), mod_anon_block_index_path .d-none.d-sm-block= render "shared/links" diff --git a/app/views/timeline/timeline.html.haml b/app/views/timeline/timeline.html.haml index 4ed197a8..10754661 100644 --- a/app/views/timeline/timeline.html.haml +++ b/app/views/timeline/timeline.html.haml @@ -1,8 +1,11 @@ #timeline{ data: { controller: "navigation" } } %button.d-none{ data: { hotkey: "j", action: "navigation#down" } } %button.d-none{ data: { hotkey: "k", action: "navigation#up" } } + - if @timeline.empty? + = render "shared/empty", type: "timeline" + - @timeline.each do |answer| - = render "answerbox", a: answer, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a: answer - if @more_data_available .d-flex.justify-content-center#paginator diff --git a/app/views/timeline/timeline.turbo_stream.haml b/app/views/timeline/timeline.turbo_stream.haml index 1802f630..561309e6 100644 --- a/app/views/timeline/timeline.turbo_stream.haml +++ b/app/views/timeline/timeline.turbo_stream.haml @@ -1,6 +1,6 @@ = turbo_stream.append "timeline" do - @timeline.each do |answer| - = render "answerbox", a: answer, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a: answer = turbo_stream.update "paginator" do - if @more_data_available diff --git a/app/views/user/_actions.html.haml b/app/views/user/_actions.html.haml index 23960a38..c30a482c 100644 --- a/app/views/user/_actions.html.haml +++ b/app/views/user/_actions.html.haml @@ -9,11 +9,9 @@ - elsif user_signed_in? .d-grid.gap-2 - if own_followings&.include?(user.id) || current_user.following?(user) - %button.btn.btn-primary{ type: :button, name: 'user-action', data: { action: :unfollow, type: type, target: user.screen_name } } - = t("voc.unfollow") + = render "relationships/destroy", type: "follow", screen_name: user.screen_name - else - %button.btn.btn-primary{ type: :button, name: 'user-action', data: { action: :follow, type: type, target: user.screen_name } } - = t("voc.follow") + = render "relationships/create", type: "follow", screen_name: user.screen_name .btn-group %button.btn.btn-light.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } } = t(".title") @@ -23,28 +21,26 @@ %i.fa.fa-list.fa-fw = t(".list") - if own_blocks&.include?(user.id) || current_user.blocking?(user) - %a.dropdown-item{ href: '#', data: { action: :unblock, target: user.screen_name } } - %i.fa.fa-minus-circle.fa-fw - %span.pe-none= t("voc.unblock") + = render "relationships/destroy", type: "block", screen_name: user.screen_name - else - %a.dropdown-item{ href: '#', data: { action: :block, target: user.screen_name } } - %i.fa.fa-minus-circle.fa-fw - %span.pe-none= t("voc.block") + = render "relationships/create", type: "block", screen_name: user.screen_name - if own_mutes&.include?(user.id) || current_user.muting?(user) - %a.dropdown-item{ href: '#', data: { action: :unmute, target: user.screen_name } } - %i.fa.fa-volume-off.fa-fw - %span.pe-none= t("voc.unmute") + = render "relationships/destroy", type: "mute", screen_name: user.screen_name - else - %a.dropdown-item{ href: '#', data: { action: :mute, target: user.screen_name } } - %i.fa.fa-volume-off.fa-fw - %span.pe-none= t("voc.mute") + = render "relationships/create", type: "mute", screen_name: user.screen_name %a.dropdown-item{ href: '#', data: { action: 'report-user', target: user.screen_name } } %i.fa.fa-exclamation-triangle.fa-fw = t("voc.report") - if current_user.mod? + %a.dropdown-item{ href: moderation_reports_path(user: user.screen_name) } + %i.far.fa-flag.fa-fw + = t(".reports_from", user: user.screen_name) + %a.dropdown-item{ href: moderation_reports_path(target_user: user.screen_name) } + %i.fas.fa-flag.fa-fw + = t(".reports_of", user: user.screen_name) %a.dropdown-item{ href: '#', data: { bs_target: '#modal-privileges', bs_toggle: :modal } } %i.fa.fa-wrench.fa-fw - = raw t(".privilege", user: user.screen_name) + = t(".privilege", user: user.screen_name) - unless user.has_cached_role?(:administrator) %a.dropdown-item{ href: '#', data: { bs_target: '#modal-ban', bs_toggle: :modal } } %i.fa.fa-ban.fa-fw diff --git a/app/views/user/_profile.html.haml b/app/views/user/_profile.html.haml index 7be81298..bd40d9f4 100644 --- a/app/views/user/_profile.html.haml +++ b/app/views/user/_profile.html.haml @@ -32,9 +32,10 @@ - unless user.profile.website.blank? .profile__website %i.fa.fa-fw.fa-globe - %a{ href: user.profile.website, target: "_blank", rel: "nofollow me" }= user.profile.display_website + %a{ href: user.profile.website, target: "_blank", rel: "me nofollow" }= user.profile.display_website - unless user.profile.location.blank? .profile__location %i.fa.fa-fw.fa-location-arrow = user.profile.location - = render "user/actions", user: user, type: :follower + - unless user.banned? && !current_user&.mod? + = render "user/actions", user: user, type: :follower diff --git a/app/views/user/questions.html.haml b/app/views/user/questions.html.haml index aa410ffd..9b94eb34 100644 --- a/app/views/user/questions.html.haml +++ b/app/views/user/questions.html.haml @@ -1,4 +1,7 @@ #questions + - if @questions.empty? + = render "shared/empty", icon: "fa-regular fa-comment", translation_key: ".user.questions" + - @questions.each do |q| = render 'shared/question', q: q, type: nil diff --git a/app/views/user/show.html.haml b/app/views/user/show.html.haml index e110ca20..baca6552 100644 --- a/app/views/user/show.html.haml +++ b/app/views/user/show.html.haml @@ -1,14 +1,16 @@ -- unless @user.banned? - %div{ data: { controller: "navigation" } } - %button.d-none{ data: { hotkey: "j", action: "navigation#down" } } - %button.d-none{ data: { hotkey: "k", action: "navigation#up" } } - #pinned-answers - - @pinned_answers.each do |a| - = render "answerbox", a:, subscribed_answer_ids: @subscribed_answer_ids +%div{ data: { controller: "navigation" } } + %button.d-none{ data: { hotkey: "j", action: "navigation#down" } } + %button.d-none{ data: { hotkey: "k", action: "navigation#up" } } + #pinned-answers + - @pinned_answers.each do |a| + = render "answerbox", a: - #answers - - @answers.each do |a| - = render "answerbox", a:, subscribed_answer_ids: @subscribed_answer_ids + #answers + - if @answers.empty? + = render "shared/empty", icon: "fa-regular fa-comments", translation_key: ".user.answers" + + - @answers.each do |a| + = render "answerbox", a: - if @more_data_available .d-flex.justify-content-center.justify-content-sm-start#paginator diff --git a/app/views/user/show.turbo_stream.haml b/app/views/user/show.turbo_stream.haml index d86873da..ae7889ad 100644 --- a/app/views/user/show.turbo_stream.haml +++ b/app/views/user/show.turbo_stream.haml @@ -1,6 +1,6 @@ = turbo_stream.append "answers" do - @answers.each do |a| - = render "answerbox", a:, subscribed_answer_ids: @subscribed_answer_ids + = render "answerbox", a: = turbo_stream.update "paginator" do - if @more_data_available diff --git a/app/views/user/show_follow.html.haml b/app/views/user/show_follow.html.haml index ca284ab8..1497f1bb 100644 --- a/app/views/user/show_follow.html.haml +++ b/app/views/user/show_follow.html.haml @@ -1,3 +1,6 @@ +- if @users.empty? + = render "shared/empty", icon: "fa-regular fa-user", translation_key: ".user.#{type}" + .row.row-cols-1.row-cols-sm-2.row-cols-md-3#users - @users.each do |user| .col.pb-3 diff --git a/app/workers/question_worker.rb b/app/workers/question_worker.rb index 969b725f..304bd2d3 100644 --- a/app/workers/question_worker.rb +++ b/app/workers/question_worker.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# @deprecated This is to be replaced by SendToInboxJob. Remaining here so that remaining QuestionWorker jobs can finish. class QuestionWorker include Sidekiq::Worker @@ -15,7 +16,7 @@ class QuestionWorker user.followers.each do |f| next if skip_inbox?(f, question, user) - inbox = Inbox.create(user_id: f.id, question_id:, new: true) + inbox = InboxEntry.create(user_id: f.id, question_id:, new: true) f.push_notification(webpush_app, inbox) if webpush_app end rescue StandardError => e @@ -35,7 +36,5 @@ class QuestionWorker false end - def muted?(user, question) - MuteRule.where(user:).any? { |rule| rule.applies_to? question } - end + def muted?(user, question) = MuteRule.where(user:).any? { |rule| rule.applies_to? question } end diff --git a/app/workers/scheduler/inbox_cleanup_scheduler.rb b/app/workers/scheduler/inbox_cleanup_scheduler.rb new file mode 100644 index 00000000..6f436ecf --- /dev/null +++ b/app/workers/scheduler/inbox_cleanup_scheduler.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Scheduler::InboxCleanupScheduler + include Sidekiq::Worker + + sidekiq_options retry: false + + def perform + orphaned_entries = InboxEntry.where(question_id: nil).includes(:user) + orphaned_entries.each do |inbox| + logger.info "Deleting orphaned inbox entry #{inbox.id} from user #{inbox.user.id}" + inbox.destroy + end + end +end diff --git a/app/workers/send_to_inbox_job.rb b/app/workers/send_to_inbox_job.rb new file mode 100644 index 00000000..aa640763 --- /dev/null +++ b/app/workers/send_to_inbox_job.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class SendToInboxJob + include Sidekiq::Job + + sidekiq_options queue: :question, retry: false + + # @param follower_id [Integer] user id passed from Devise + # @param question_id [Integer] newly created question id + def perform(follower_id, question_id) + follower = User.includes(:web_push_subscriptions, :mute_rules, :muted_users).find(follower_id) + question = Question.includes(:user).find(question_id) + webpush_app = Rpush::App.find_by(name: "webpush") + + return if skip_inbox?(follower, question) + + inbox = InboxEntry.create(user_id: follower.id, question_id:, new: true) + follower.push_notification(webpush_app, inbox) if webpush_app + end + + private + + def skip_inbox?(follower, question) + return true if follower.inbox_locked? + return true if follower.banned? + return true if muted?(follower, question) + return true if follower.muting?(question.user) + return true if question.long? && !follower.profile.allow_long_questions + + false + end + + # @param [User] user + # @param [Question] question + def muted?(user, question) = user.mute_rules.any? { |rule| rule.applies_to? question } +end diff --git a/config/application.rb b/config/application.rb index e832eb9e..a92cf4cb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,12 +27,14 @@ module Justask # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. - config.load_defaults 6.0 + config.load_defaults 7.0 # add `lib/` to the autoload paths so zeitwerk can find e.g. our `UseCase`s # without an explicit `require`, and also take care of hot reloading the code # (really useful in development!) - config.autoload_paths << config.root.join("lib") + config.autoload_once_paths << config.root.join("lib") config.eager_load_paths << config.root.join("lib") + # This lowers memory usage from Bootsnap + config.add_autoload_paths_to_load_path = false # Use Sidekiq for background jobs config.active_job.queue_adapter = :sidekiq diff --git a/config/environments/development.rb b/config/environments/development.rb index c2434c8c..609ec5dc 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -72,9 +72,13 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + # annotate rendered view with file names, really useful in dev! + config.action_view.annotate_rendered_view_with_filenames = true + # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. # config.file_watcher = ActiveSupport::EventedFileUpdateChecker + config.hosts += ENV["EXTRA_HOSTS"].split(':') if ENV["EXTRA_HOSTS"].present? end # For better_errors to work inside Docker we need diff --git a/config/initializers/cookie_rotator.rb b/config/initializers/cookie_rotator.rb new file mode 100644 index 00000000..65a33147 --- /dev/null +++ b/config/initializers/cookie_rotator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies| + salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt + secret_key_base = Rails.application.secret_key_base + + key_generator = ActiveSupport::KeyGenerator.new( + secret_key_base, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA1 + ) + key_len = ActiveSupport::MessageEncryptor.key_len + secret = key_generator.generate_key(salt, key_len) + + cookies.rotate :encrypted, secret + end +end diff --git a/config/initializers/rails_admin.rb b/config/initializers/rails_admin.rb index edc5eebb..881a087e 100644 --- a/config/initializers/rails_admin.rb +++ b/config/initializers/rails_admin.rb @@ -29,14 +29,13 @@ RailsAdmin.config do |config| end config.included_models = %w[ - Appendable - Appendable::Reaction + Reaction Answer AnonymousBlock Comment List ListMember - Inbox + InboxEntry MuteRule Notification Profile @@ -58,10 +57,9 @@ RailsAdmin.config do |config| { "AnonymousBlock" => "user-secret", "Answer" => "exclamation", - "Appendable" => "paperclip", - "Appendable::Reaction" => "smile", + "Reaction" => "smile", "Comment" => "comment", - "Inbox" => "inbox", + "InboxEntry" => "inbox", "List" => "list", "ListMember" => "users", "MuteRule" => "volume-mute", @@ -88,7 +86,7 @@ RailsAdmin.config do |config| # set up custom parents for certain models to group them nicely together { "AnonymousBlock" => User, - "Inbox" => User, + "InboxEntry" => User, "List" => User, "MuteRule" => User, "Notification" => User, @@ -111,6 +109,7 @@ RailsAdmin.config do |config| Answer Appendable Comment + Reaction Question User ], diff --git a/config/justask.yml.example b/config/justask.yml.example index 97616e4c..22f309a4 100644 --- a/config/justask.yml.example +++ b/config/justask.yml.example @@ -81,19 +81,6 @@ hcaptcha: # TOTP Drift period in seconds otp_drift_period: 30 -# This list controls the "accept" attribute on file upload fields -# This ensures mobile users get an appropriate file picker (one for only images) -# as well as preventing the upload of videos or formats we don't support -# including making iOS automatically convert HEIC files to JPEG -accepted_image_formats: - - image/jpeg - - .jpg - - .jpeg - - image/png - - .png - - image/gif - - .gif - # This list controls which hosts are excempt from the linkfilter # Note: `hostname` is always included by default allowed_hosts_in_markdown: diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index d7a879ae..30bb48f4 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -54,6 +54,8 @@ en: success_text: "Success text colour" warning_color: "Warning colour" warning_text: "Warning text colour" + reaction: + parent_id: "Target" user: created_at: "Account created at" current_password: "Current password" @@ -107,6 +109,11 @@ en: errors: messages: invalid_url: "does not look like a valid URL" + models: + reaction: + attributes: + parent_id: + taken: "already smiled" helpers: submit: user: diff --git a/config/locales/controllers.en.yml b/config/locales/controllers.en.yml index b0347cc9..3287e6fe 100644 --- a/config/locales/controllers.en.yml +++ b/config/locales/controllers.en.yml @@ -101,46 +101,12 @@ en: notfound: "Question does not exist." noauth: "You are not allowed to delete this question." success: "Successfully deleted question." - relationship: - create: - block: - success: "Successfully blocked user." - error: "You are already blocking that user." - follow: - success: "Successfully followed user." - error: "You are already following that user." - mute: - success: "Successfully muted user." - error: "You are already muting that user." - destroy: - block: - success: "Successfully unblocked user." - error: "You are not blocking that user." - follow: - success: "Successfully unfollowed user." - error: "You are not following that user." - mute: - success: "Successfully unmuted user." - error: "You are not muting that user." report: create: noauth: :ajax.noauth unknown: "You can't report this entity." notfound: "Could not find %{parameter}" success: "%{parameter} reported. A moderator will decide what happens with the %{parameter}." - smile: - create: - success: "Successfully smiled answer." - error: "You have already smiled that answer." - create_comment: - success: "Successfully smiled comment." - error: "You have already smiled that comment." - destroy: - success: "Successfully unsmiled answer." - error: "You have not smiled that answer." - destroy_comment: - success: "Successfully unsmiled comment." - error: "You have not smiled that comment." web_push: subscription_count: zero: "You are not currently subscribed to push notifications on any devices." @@ -196,10 +162,18 @@ en: error: :errors.invalid_otp destroy: success: "Two factor authentication has been disabled for your account." + subscriptions: + create: + success: "Successfully subscribed." + error: "Failed to subscribe to answer." + destroy: + success: "Successfully unsubscribed." + error: "Failed to unsubscribe from answer." user: sessions: create: banned: "I'm sorry, %{name}, I'm afraid I can't do that." + permanent: "You are banned permanently." reason: "Ban reason: %{reason}" until: "Banned until: %{time}" info: @@ -209,6 +183,42 @@ en: registrations: destroy: export_pending: "You may not delete your account while account data is currently being exported." + reactions: + create: + answer: + success: "Successfully smiled answer." + error: "You have already smiled that answer." + comment: + success: "Successfully smiled comment." + error: "You have already smiled that comment." + destroy: + answer: + success: "Successfully unsmiled answer." + error: "You have not smiled that answer." + comment: + success: "Successfully unsmiled comment." + error: "You have not smiled that comment." + relationships: + create: + block: + success: "Successfully blocked user." + error: "You are already blocking that user." + follow: + success: "Successfully followed user." + error: "You are already following that user." + mute: + success: "Successfully muted user." + error: "You are already muting that user." + destroy: + block: + success: "Successfully unblocked user." + error: "You are not blocking that user." + follow: + success: "Successfully unfollowed user." + error: "You are not following that user." + mute: + success: "Successfully unmuted user." + error: "You are not muting that user." timeline: public: title: "Public Timeline" diff --git a/config/locales/errors.en.yml b/config/locales/errors.en.yml index eae9157d..7b260eff 100644 --- a/config/locales/errors.en.yml +++ b/config/locales/errors.en.yml @@ -37,3 +37,5 @@ en: record_not_found: "Record not found" not_authorized: "You need to be logged in to perform this action" + + question_too_long: "Question is too long" diff --git a/config/locales/frontend.en.yml b/config/locales/frontend.en.yml index 96228659..fe55281a 100644 --- a/config/locales/frontend.en.yml +++ b/config/locales/frontend.en.yml @@ -6,12 +6,6 @@ en: error: title: "Uh-oh…" message: "An error occurred, a developer should check the console for details" - subscription: - subscribe: "Successfully subscribed." - unsubscribe: "Successfully unsubscribed." - fail: - subscribe: "Failed to subscribe to answer." - unsubscribe: "Failed to unsubscribe from answer." list: confirm: title: "Are you sure?" @@ -68,3 +62,6 @@ en: title: "Are you sure you want to report this %{type}?" text: "A moderator will review your report and decide what happens.\nYou can optionally specify a reason." input: "Specify a reason…" + clipboard_copy: + success: "Content copied to clipboard." + error: "Failed to copy content to clipboard." diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index 20994bce..f918d3e4 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -79,6 +79,8 @@ en: pin: "Pin to Profile" unpin: "Unpin from Profile" share: + bluesky: "Share on Bluesky" + copy: "Copy to Clipboard" twitter: "Share on Twitter" tumblr: "Share on Tumblr" telegram: "Share on Telegram" @@ -107,28 +109,22 @@ en: retries: "Retries" dead: "Dead" answerbox: - header: - anon_hint: :inbox.entry.anon_hint - answers: - zero: "0 answers" - one: "1 answer" - other: "%{count} answers" - asked_html: "%{user} asked %{time} ago" actions: share: title: "Share" - comments: - none: "There are no comments yet." - placeholder: "Comment..." - action: "Post comment" smiles: none: "No one smiled this yet." + comments: + none: "There are no comments yet." application: answerbox: read: "Read the entire answer" answered: "%{hide} %{user}" # resolves into "Answered by %{user}" hide: "Answered by" pinned: "Pinned" + comments: + action: "Post comment" + placeholder: "Comment…" questionbox: title: "Ask something!" placeholder: "Type your question here…" @@ -150,6 +146,10 @@ en: non_anonymous_html: | This user does not want to receive anonymous questions.
(%{sign_in} or %{sign_up}) + comment: + show_reactions: + title: "People who smiled this comment" + none: "No one has smiled this comment yet." devise: registrations: edit: @@ -213,11 +213,6 @@ en: title: "Feature Requests – Feedback" inbox: entry: - asked_html: "%{user} asked %{time} ago" - anon_hint: "This question was asked anonymously." - answers: - one: "1 answer" - other: "%{count} answers" options: "Sharing Options" placeholder: "Write your answer here…" sharing: @@ -257,14 +252,20 @@ en: confirm: "I understand the risk, proceed!" modal: ask: + user_note_html: | + Do you want to ask %{user} a question? + This dialog asks a question to all your followers. + Click here + to ask them a question directly! + follower_note_html: | + You don't have any followers! + Questions asked through this dialog will not arrive in anyone's inbox. title: "Ask your followers" placeholder: "Type your question here…" action: "Ask" loading: "Asking…" long_question_warning: "This question will only be sent to those who allow long questions in their profile settings." - comment_smiles: - title: "People who smiled this comment" - none: "No one has smiled this comment yet." + send_to_own_inbox: "Send copy to my inbox" list: title: "Manage list memberships" tab: @@ -342,10 +343,10 @@ en: none: "No new notifications." type: answer: - heading_html: "%{user} answered %{question} %{time} ago" + heading_html: "%{user} answered %{question}" link_text: "your question" comment: - heading_html: "%{user} commented on %{answer} %{time} ago" + heading_html: "%{user} commented on %{answer}" active: link_text: "your answer" passive: @@ -357,7 +358,7 @@ en: text_html: "Head over to %{settings_export} to download it." settings_export: "the settings page" reaction: - heading_html: "%{user} smiled %{type} %{time} ago" + heading_html: "%{user} smiled %{type}" answer: link_text: "your answer" comment: @@ -492,7 +493,9 @@ en: aegis: "Aegis Authenticator for Android" strongbox: "Strongbox Authenticator for iOS" microsoft: "Microsoft Authenticator" + ios: "iOS (Passwords/Camera)" source: + apple_support: "Apple Support" app_store: "App Store" fdroid: "F-Droid" google_play: "Google Play" @@ -568,6 +571,25 @@ en: question: show: "Show full question" hide: "Hide full question" + empty: + user: + answers: "This user hasn't answered any questions yet." + questions: "This user hasn't asked any questions yet." + follower: "This user has no followers." + friend: "This user is not following anyone." + moderation: + reports: "There are no open reports right now!" + inbox: "This users inbox is empty." + question: "No one answered this question yet." + timeline: + heading: "Your feed is empty!" + text: "Start answering questions or follow some people to fill your feed with answers." + actions: + inbox: "Go to your inbox" + public: "Go to the public timeline" + inbox: + heading: "Your inbox is empty!" + text: "To start answering, generate a question or share your profile on other sites to get questions." formatting: body_html: |

%{app_name} uses Markdown for formatting

@@ -582,9 +604,6 @@ en: anonymous_block: deleted_question: "Deleted question" blocked: "blocked %{time} ago" - question: - visible_to_you: "Only visible to you as it was asked directly" - visible_mod_mode: "You can see this because you are in moderation view" hotkeys: navigation: title: "Navigation" @@ -626,11 +645,7 @@ en: title: "Members" none: "No members yet." moderation: - all: "All reports" - answers: :activerecord.models.answer.other - comments: :activerecord.models.comment.other - users: :activerecord.models.user.other - questions: :activerecord.models.question.other + reports: "Reports" site_wide_blocks: "Site-wide anonymous blocks" notifications: all: "All notifications" @@ -676,7 +691,7 @@ en: questions: header: title_html: "Viewing all questions from
%{short}" - index: + show: title: "Questions from %{author_identifier}" user: show_follow: @@ -689,6 +704,8 @@ en: ban: "Ban Control" title: "Actions" list: "Manage list memberships" + reports_from: "Reports from %{user}" + reports_of: "Reports of %{user}" profile: badge: admin: "Admin" diff --git a/config/locales/voc.en.yml b/config/locales/voc.en.yml index a05407eb..0603cf05 100644 --- a/config/locales/voc.en.yml +++ b/config/locales/voc.en.yml @@ -1,6 +1,7 @@ en: voc: add: "Add" + all: "All" answer: "Answer" block: "Block" block_site_wide: "Block user site-wide" @@ -9,9 +10,11 @@ en: confirm: "Are you sure?" delete: "Delete" edit: "Edit" + filter: "Filter" follow: "Follow" format_markdown: "Styling with Markdown is supported" load: "Load more" + loading: "Loading…" login: "Sign in" logout: "Sign out" mute: "Mute" @@ -39,3 +42,43 @@ en: days: "days" weeks: "weeks" months: "months" + datetime: + distance_in_words: + short: + about_x_hours: + one: "1h" + other: "%{count}h" + about_x_months: + one: "1mo" + other: "%{count}mo" + about_x_years: + one: "1y" + other: "%{count}y" + almost_x_years: + one: "1y" + other: "%{count}y" + half_a_minute: 1m + less_than_x_seconds: + one: "1s" + other: "%{count}s" + less_than_x_minutes: + one: "1m" + other: "%{count}m" + over_x_years: + one: "1y" + other: "%{count}y" + x_seconds: + one: "1s" + other: "%{count}s" + x_minutes: + one: "1m" + other: "%{count}m" + x_days: + one: "1d" + other: "%{count}d" + x_months: + one: "1mo" + other: "%{count}mo" + x_years: + one: "1y" + other: "%{count}y" diff --git a/config/routes.rb b/config/routes.rb index 3af4b3a8..52d1f53b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,7 +26,7 @@ Rails.application.routes.draw do post "/moderation/unmask", to: "moderation#toggle_unmask", as: :moderation_toggle_unmask get "/moderation/blocks", to: "moderation/anonymous_block#index", as: :mod_anon_block_index get "/moderation/inbox/:user", to: "moderation/inbox#index", as: :mod_inbox_index - get "/moderation/reports(/:type)", to: "moderation/reports#index", as: :moderation_reports, defaults: { type: "all" } + get "/moderation/reports(/:type)", to: "moderation/reports#index", as: :moderation_reports get "/moderation/questions/:author_identifier", to: "moderation/questions#show", as: :moderation_questions namespace :ajax do post "/mod/destroy_report", to: "moderation#destroy_report", as: :mod_destroy_report @@ -118,18 +118,12 @@ Rails.application.routes.draw do post "/destroy_answer", to: "answer#destroy", as: :destroy_answer post "/create_relationship", to: "relationship#create", as: :create_relationship post "/destroy_relationship", to: "relationship#destroy", as: :destroy_relationship - post "/create_smile", to: "smile#create", as: :create_smile - post "/destroy_smile", to: "smile#destroy", as: :destroy_smile - post "/create_comment_smile", to: "smile#create_comment", as: :create_comment_smile - post "/destroy_comment_smile", to: "smile#destroy_comment", as: :destroy_comment_smile post "/create_comment", to: "comment#create", as: :create_comment post "/destroy_comment", to: "comment#destroy", as: :destroy_comment post "/report", to: "report#create", as: :report post "/create_list", to: "list#create", as: :create_list post "/destroy_list", to: "list#destroy", as: :destroy_list post "/list_membership", to: "list#membership", as: :list_membership - post "/subscribe", to: "subscription#subscribe", as: :subscribe_answer - post "/unsubscribe", to: "subscription#unsubscribe", as: :unsubscribe_answer get "/webpush/key", to: "web_push#key", as: :webpush_key post "/webpush/check", to: "web_push#check", as: :webpush_check post "/webpush", to: "web_push#subscribe", as: :webpush_subscribe @@ -148,12 +142,22 @@ Rails.application.routes.draw do post "/inbox/create", to: "inbox#create", as: :inbox_create get "/inbox", to: "inbox#show", as: :inbox + resource :subscriptions, controller: :subscriptions, only: %i[create destroy] + resource :relationships, only: %i[create destroy] + get "/user/:username", to: "user#show" get "/@:username", to: "user#show", as: :user get "/@:username/a/:id", to: "answer#show", as: :answer post "/@:username/a/:id/pin", to: "answer#pin", as: :pin_answer delete "/@:username/a/:id/pin", to: "answer#unpin", as: :unpin_answer + get "/@:username/a/:id/comments", to: "comments#index", as: :comments + get "/@:username/a/:id/reactions", to: "reactions#index", as: :reactions + post "/@:username/a/:id/reactions", to: "reactions#create", as: :create_reactions, defaults: { type: "Answer" } + delete "/@:username/a/:id/reactions", to: "reactions#destroy", as: :destroy_reactions, defaults: { type: "Answer" } get "/@:username/q/:id", to: "question#show", as: :question + get "/@:username/c/:id/reactions", to: "comments/reactions#index", as: :comment_reactions + post "/@:username/c/:id/reactions", to: "reactions#create", as: :create_comment_reactions, defaults: { type: "Comment" } + delete "/@:username/c/:id/reactions", to: "reactions#destroy", as: :destroy_comment_reactions, defaults: { type: "Comment" } get "/@:username/followers", to: "user#followers", as: :show_user_followers get "/@:username/followings", to: "user#followings", as: :show_user_followings get "/@:username/friends", to: redirect("/@%{username}/followings") @@ -178,5 +182,7 @@ Rails.application.routes.draw do get "/nodeinfo/2.1", to: "well_known/node_info#nodeinfo", as: :node_info + get "/modal/close", to: "modal#close", as: :modal_close + puts "processing time of routes.rb: #{"#{(Time.zone.now - start).round(3).to_s.ljust(5, '0')}s".light_green}" end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index b9c163be..b224130a 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -13,4 +13,11 @@ production: - question - export - push_notification + - scheduler +:scheduler: + :schedule: + inbox_cleanup: + every: 1m + class: Scheduler::InboxCleanupScheduler + queue: scheduler diff --git a/db/migrate/20140801095807_devise_create_users.rb b/db/migrate/20140801095807_devise_create_users.rb index 0115292e..85ab1dfb 100644 --- a/db/migrate/20140801095807_devise_create_users.rb +++ b/db/migrate/20140801095807_devise_create_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DeviseCreateUsers < ActiveRecord::Migration[4.2] def change create_table(:users) do |t| @@ -30,7 +32,6 @@ class DeviseCreateUsers < ActiveRecord::Migration[4.2] # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at - t.timestamps end diff --git a/db/migrate/20140801103309_add_screen_name_to_users.rb b/db/migrate/20140801103309_add_screen_name_to_users.rb index ad028c8f..a54abe42 100644 --- a/db/migrate/20140801103309_add_screen_name_to_users.rb +++ b/db/migrate/20140801103309_add_screen_name_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddScreenNameToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :screen_name, :string diff --git a/db/migrate/20140801174930_create_questions.rb b/db/migrate/20140801174930_create_questions.rb index ae872a7e..962748db 100644 --- a/db/migrate/20140801174930_create_questions.rb +++ b/db/migrate/20140801174930_create_questions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateQuestions < ActiveRecord::Migration[4.2] def change create_table :questions do |t| @@ -9,6 +11,6 @@ class CreateQuestions < ActiveRecord::Migration[4.2] t.timestamps end - add_index :questions, [:user_id, :created_at] + add_index :questions, %i[user_id created_at] end end diff --git a/db/migrate/20140801175112_create_answers.rb b/db/migrate/20140801175112_create_answers.rb index 510d73bc..21eff7a1 100644 --- a/db/migrate/20140801175112_create_answers.rb +++ b/db/migrate/20140801175112_create_answers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateAnswers < ActiveRecord::Migration[4.2] def change create_table :answers do |t| @@ -9,6 +11,6 @@ class CreateAnswers < ActiveRecord::Migration[4.2] t.timestamps end - add_index :answers, [:user_id, :created_at] + add_index :answers, %i[user_id created_at] end end diff --git a/db/migrate/20140801175137_create_comments.rb b/db/migrate/20140801175137_create_comments.rb index cb785fff..9d90f710 100644 --- a/db/migrate/20140801175137_create_comments.rb +++ b/db/migrate/20140801175137_create_comments.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateComments < ActiveRecord::Migration[4.2] def change create_table :comments do |t| @@ -7,6 +9,6 @@ class CreateComments < ActiveRecord::Migration[4.2] t.timestamps end - add_index :comments, [:user_id, :created_at] + add_index :comments, %i[user_id created_at] end end diff --git a/db/migrate/20141102153520_add_counts_to_users.rb b/db/migrate/20141102153520_add_counts_to_users.rb index 37db7152..92f934d3 100644 --- a/db/migrate/20141102153520_add_counts_to_users.rb +++ b/db/migrate/20141102153520_add_counts_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddCountsToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :friend_count, :integer, default: 0, null: false diff --git a/db/migrate/20141104143648_add_display_name_to_users.rb b/db/migrate/20141104143648_add_display_name_to_users.rb index 6db1c1c1..0ce6171c 100644 --- a/db/migrate/20141104143648_add_display_name_to_users.rb +++ b/db/migrate/20141104143648_add_display_name_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddDisplayNameToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :display_name, :string diff --git a/db/migrate/20141110210154_create_inboxes.rb b/db/migrate/20141110210154_create_inboxes.rb index bfb140d0..cbed4da6 100644 --- a/db/migrate/20141110210154_create_inboxes.rb +++ b/db/migrate/20141110210154_create_inboxes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateInboxes < ActiveRecord::Migration[4.2] def change create_table :inboxes do |t| diff --git a/db/migrate/20141113164048_add_smile_count_to_answers.rb b/db/migrate/20141113164048_add_smile_count_to_answers.rb index c40b7166..16346497 100644 --- a/db/migrate/20141113164048_add_smile_count_to_answers.rb +++ b/db/migrate/20141113164048_add_smile_count_to_answers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddSmileCountToAnswers < ActiveRecord::Migration[4.2] def change add_column :answers, :smile_count, :integer, default: 0, null: false diff --git a/db/migrate/20141113164314_add_smiled_count_to_users.rb b/db/migrate/20141113164314_add_smiled_count_to_users.rb index dda9e08b..b88e3e4d 100644 --- a/db/migrate/20141113164314_add_smiled_count_to_users.rb +++ b/db/migrate/20141113164314_add_smiled_count_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddSmiledCountToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :smiled_count, :integer, default: 0, null: false diff --git a/db/migrate/20141126154451_add_admin_to_users.rb b/db/migrate/20141126154451_add_admin_to_users.rb index d225ea61..3a745490 100644 --- a/db/migrate/20141126154451_add_admin_to_users.rb +++ b/db/migrate/20141126154451_add_admin_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddAdminToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :admin, :boolean, default: false, null: false diff --git a/db/migrate/20141129211448_add_motivation_header_to_users.rb b/db/migrate/20141129211448_add_motivation_header_to_users.rb index 7cd1d6dd..b87c1382 100644 --- a/db/migrate/20141129211448_add_motivation_header_to_users.rb +++ b/db/migrate/20141129211448_add_motivation_header_to_users.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class AddMotivationHeaderToUsers < ActiveRecord::Migration[4.2] def change - add_column :users, :motivation_header, :string, default: '', null: false + add_column :users, :motivation_header, :string, default: "", null: false end end diff --git a/db/migrate/20141130130221_create_relationships.rb b/db/migrate/20141130130221_create_relationships.rb index 98ab31e8..6b28185d 100644 --- a/db/migrate/20141130130221_create_relationships.rb +++ b/db/migrate/20141130130221_create_relationships.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateRelationships < ActiveRecord::Migration[4.2] def change create_table :relationships do |t| @@ -9,6 +11,6 @@ class CreateRelationships < ActiveRecord::Migration[4.2] add_index :relationships, :source_id add_index :relationships, :target_id - add_index :relationships, [:source_id, :target_id], unique: true + add_index :relationships, %i[source_id target_id], unique: true end end diff --git a/db/migrate/20141130175749_create_smiles.rb b/db/migrate/20141130175749_create_smiles.rb index de5004b4..3650c00d 100644 --- a/db/migrate/20141130175749_create_smiles.rb +++ b/db/migrate/20141130175749_create_smiles.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateSmiles < ActiveRecord::Migration[4.2] def change create_table :smiles do |t| @@ -9,6 +11,6 @@ class CreateSmiles < ActiveRecord::Migration[4.2] add_index :smiles, :user_id add_index :smiles, :answer_id - add_index :smiles, [:user_id, :answer_id], unique: true + add_index :smiles, %i[user_id answer_id], unique: true end end diff --git a/db/migrate/20141130180152_rename_columns_in_answers.rb b/db/migrate/20141130180152_rename_columns_in_answers.rb index 0a3fac1f..a94551c5 100644 --- a/db/migrate/20141130180152_rename_columns_in_answers.rb +++ b/db/migrate/20141130180152_rename_columns_in_answers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RenameColumnsInAnswers < ActiveRecord::Migration[4.2] def change rename_column :answers, :comments, :comment_count diff --git a/db/migrate/20141201191324_add_fields_to_users.rb b/db/migrate/20141201191324_add_fields_to_users.rb index 6ba0d729..38a18cf6 100644 --- a/db/migrate/20141201191324_add_fields_to_users.rb +++ b/db/migrate/20141201191324_add_fields_to_users.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class AddFieldsToUsers < ActiveRecord::Migration[4.2] def change - add_column :users, :website, :string, default: '', null: false - add_column :users, :location, :string, default: '', null: false - add_column :users, :bio, :text, default: '', null: false + add_column :users, :website, :string, default: "", null: false + add_column :users, :location, :string, default: "", null: false + add_column :users, :bio, :text, default: "", null: false end end diff --git a/db/migrate/20141207194424_add_answer_count_to_questions.rb b/db/migrate/20141207194424_add_answer_count_to_questions.rb index ff3acb3e..1ad3ee56 100644 --- a/db/migrate/20141207194424_add_answer_count_to_questions.rb +++ b/db/migrate/20141207194424_add_answer_count_to_questions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddAnswerCountToQuestions < ActiveRecord::Migration[4.2] def change add_column :questions, :answer_count, :integer, default: 0, null: false diff --git a/db/migrate/20141208111714_change_answer_content_column_type.rb b/db/migrate/20141208111714_change_answer_content_column_type.rb index 454b897c..f704a5d5 100644 --- a/db/migrate/20141208111714_change_answer_content_column_type.rb +++ b/db/migrate/20141208111714_change_answer_content_column_type.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ChangeAnswerContentColumnType < ActiveRecord::Migration[4.2] def change change_table :answers do |t| diff --git a/db/migrate/20141212193625_create_services.rb b/db/migrate/20141212193625_create_services.rb index 7426d605..471a51d0 100644 --- a/db/migrate/20141212193625_create_services.rb +++ b/db/migrate/20141212193625_create_services.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateServices < ActiveRecord::Migration[4.2] def change create_table :services do |t| diff --git a/db/migrate/20141213182609_create_notifications.rb b/db/migrate/20141213182609_create_notifications.rb index a3fa3bcc..c8652cb9 100644 --- a/db/migrate/20141213182609_create_notifications.rb +++ b/db/migrate/20141213182609_create_notifications.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateNotifications < ActiveRecord::Migration[4.2] def change create_table :notifications do |t| diff --git a/db/migrate/20141226115905_add_moderator_to_users.rb b/db/migrate/20141226115905_add_moderator_to_users.rb index 1ef1db05..db9b3d8e 100644 --- a/db/migrate/20141226115905_add_moderator_to_users.rb +++ b/db/migrate/20141226115905_add_moderator_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddModeratorToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :moderator, :boolean, default: false, null: false diff --git a/db/migrate/20141227130438_create_reports.rb b/db/migrate/20141227130438_create_reports.rb index ce3c795c..932bf9b4 100644 --- a/db/migrate/20141227130438_create_reports.rb +++ b/db/migrate/20141227130438_create_reports.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateReports < ActiveRecord::Migration[4.2] def change create_table :reports do |t| diff --git a/db/migrate/20141227130545_create_moderation_votes.rb b/db/migrate/20141227130545_create_moderation_votes.rb index 4e73c91a..14a3543e 100644 --- a/db/migrate/20141227130545_create_moderation_votes.rb +++ b/db/migrate/20141227130545_create_moderation_votes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateModerationVotes < ActiveRecord::Migration[4.2] def change create_table :moderation_votes do |t| @@ -10,6 +12,6 @@ class CreateModerationVotes < ActiveRecord::Migration[4.2] add_index :moderation_votes, :user_id add_index :moderation_votes, :report_id - add_index :moderation_votes, [:user_id, :report_id], unique: true + add_index :moderation_votes, %i[user_id report_id], unique: true end end diff --git a/db/migrate/20141227130618_create_moderation_comments.rb b/db/migrate/20141227130618_create_moderation_comments.rb index c06d8410..8c1025a7 100644 --- a/db/migrate/20141227130618_create_moderation_comments.rb +++ b/db/migrate/20141227130618_create_moderation_comments.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateModerationComments < ActiveRecord::Migration[4.2] def change create_table :moderation_comments do |t| @@ -8,6 +10,6 @@ class CreateModerationComments < ActiveRecord::Migration[4.2] t.timestamps end - add_index :moderation_comments, [:user_id, :created_at] + add_index :moderation_comments, %i[user_id created_at] end end diff --git a/db/migrate/20141228202825_add_deleted_to_reports.rb b/db/migrate/20141228202825_add_deleted_to_reports.rb index c8c6d343..d849baa8 100644 --- a/db/migrate/20141228202825_add_deleted_to_reports.rb +++ b/db/migrate/20141228202825_add_deleted_to_reports.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddDeletedToReports < ActiveRecord::Migration[4.2] def change add_column :reports, :deleted, :boolean, default: false diff --git a/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb b/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb index ff05535d..24d3accf 100644 --- a/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb +++ b/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddAttachmentProfilePictureToUsers < ActiveRecord::Migration[4.2] def self.up change_table :users do |t| diff --git a/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb b/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb index 0832cc17..47d882e8 100644 --- a/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb +++ b/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddProfilePictureProcessingToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :profile_picture_processing, :boolean diff --git a/db/migrate/20141229133149_add_crop_values_to_users.rb b/db/migrate/20141229133149_add_crop_values_to_users.rb index 8da16793..a41eab5d 100644 --- a/db/migrate/20141229133149_add_crop_values_to_users.rb +++ b/db/migrate/20141229133149_add_crop_values_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddCropValuesToUsers < ActiveRecord::Migration[4.2] def change # this is a ugly hack and will stay until I find a way to pass parameters diff --git a/db/migrate/20150102231343_add_supporter_to_users.rb b/db/migrate/20150102231343_add_supporter_to_users.rb index e23d5eb2..1cc574a9 100644 --- a/db/migrate/20150102231343_add_supporter_to_users.rb +++ b/db/migrate/20150102231343_add_supporter_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddSupporterToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :supporter, :boolean, default: false diff --git a/db/migrate/20150103200732_add_privacy_options_to_users.rb b/db/migrate/20150103200732_add_privacy_options_to_users.rb index 2d7ec342..cfe87cda 100644 --- a/db/migrate/20150103200732_add_privacy_options_to_users.rb +++ b/db/migrate/20150103200732_add_privacy_options_to_users.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + class AddPrivacyOptionsToUsers < ActiveRecord::Migration[4.2] def change - %i{ + %i[ privacy_allow_anonymous_questions privacy_allow_public_timeline privacy_allow_stranger_answers privacy_show_in_search - }.each do |sym| + ].each do |sym| add_column :users, sym, :boolean, default: true end end diff --git a/db/migrate/20150112210754_add_banned_to_users.rb b/db/migrate/20150112210754_add_banned_to_users.rb index 7a857608..5c256971 100644 --- a/db/migrate/20150112210754_add_banned_to_users.rb +++ b/db/migrate/20150112210754_add_banned_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddBannedToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :banned, :boolean, default: false diff --git a/db/migrate/20150112210755_create_groups.rb b/db/migrate/20150112210755_create_groups.rb index 9c44ef5c..ba7436e0 100644 --- a/db/migrate/20150112210755_create_groups.rb +++ b/db/migrate/20150112210755_create_groups.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateGroups < ActiveRecord::Migration[4.2] def change create_table :groups do |t| @@ -11,7 +13,7 @@ class CreateGroups < ActiveRecord::Migration[4.2] add_index :groups, :user_id add_index :groups, :name - add_index :groups, [:user_id, :name], unique: true + add_index :groups, %i[user_id name], unique: true create_table :group_members do |t| t.integer :group_id, null: false @@ -22,6 +24,6 @@ class CreateGroups < ActiveRecord::Migration[4.2] add_index :group_members, :group_id add_index :group_members, :user_id - add_index :group_members, [:group_id, :user_id], unique: true + add_index :group_members, %i[group_id user_id], unique: true end end diff --git a/db/migrate/20150125191224_add_blogger_to_users.rb b/db/migrate/20150125191224_add_blogger_to_users.rb index 05edee57..f2a89b12 100644 --- a/db/migrate/20150125191224_add_blogger_to_users.rb +++ b/db/migrate/20150125191224_add_blogger_to_users.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class AddBloggerToUsers < ActiveRecord::Migration[4.2] def change - add_column :users, :blogger, :boolean, default: :false + add_column :users, :blogger, :boolean, default: false end end diff --git a/db/migrate/20150419201122_add_contributor_to_users.rb b/db/migrate/20150419201122_add_contributor_to_users.rb index 91a548aa..72f5488c 100644 --- a/db/migrate/20150419201122_add_contributor_to_users.rb +++ b/db/migrate/20150419201122_add_contributor_to_users.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class AddContributorToUsers < ActiveRecord::Migration[4.2] def change - add_column :users, :contributor, :boolean, default: :false + add_column :users, :contributor, :boolean, default: false end end diff --git a/db/migrate/20150420232305_create_subscriptions.rb b/db/migrate/20150420232305_create_subscriptions.rb index 72061a84..26180ede 100644 --- a/db/migrate/20150420232305_create_subscriptions.rb +++ b/db/migrate/20150420232305_create_subscriptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateSubscriptions < ActiveRecord::Migration[4.2] def change create_table :subscriptions do |t| diff --git a/db/migrate/20150421120557_add_is_active_to_subscriptions.rb b/db/migrate/20150421120557_add_is_active_to_subscriptions.rb index 059d4a7b..50372127 100644 --- a/db/migrate/20150421120557_add_is_active_to_subscriptions.rb +++ b/db/migrate/20150421120557_add_is_active_to_subscriptions.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class AddIsActiveToSubscriptions < ActiveRecord::Migration[4.2] def change - add_column :subscriptions, :is_active, :boolean, default: :true + add_column :subscriptions, :is_active, :boolean, default: true end end diff --git a/db/migrate/20150422024104_add_reason_to_report.rb b/db/migrate/20150422024104_add_reason_to_report.rb index 51531b65..e3bef6df 100644 --- a/db/migrate/20150422024104_add_reason_to_report.rb +++ b/db/migrate/20150422024104_add_reason_to_report.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddReasonToReport < ActiveRecord::Migration[4.2] def change add_column :reports, :reason, :string, default: nil diff --git a/db/migrate/20150422224203_rename_banned_to_permanently_banned_in_users.rb b/db/migrate/20150422224203_rename_banned_to_permanently_banned_in_users.rb index 81954d3f..3e74cb2d 100644 --- a/db/migrate/20150422224203_rename_banned_to_permanently_banned_in_users.rb +++ b/db/migrate/20150422224203_rename_banned_to_permanently_banned_in_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RenameBannedToPermanentlyBannedInUsers < ActiveRecord::Migration[4.2] def up rename_column :users, :banned, :permanently_banned diff --git a/db/migrate/20150422224225_add_ban_reason_and_banned_until_to_users.rb b/db/migrate/20150422224225_add_ban_reason_and_banned_until_to_users.rb index 55936ff8..d312f8ca 100644 --- a/db/migrate/20150422224225_add_ban_reason_and_banned_until_to_users.rb +++ b/db/migrate/20150422224225_add_ban_reason_and_banned_until_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddBanReasonAndBannedUntilToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :ban_reason, :string, default: nil diff --git a/db/migrate/20150504004931_create_comment_smiles.rb b/db/migrate/20150504004931_create_comment_smiles.rb index ea86bca2..064cc1f0 100644 --- a/db/migrate/20150504004931_create_comment_smiles.rb +++ b/db/migrate/20150504004931_create_comment_smiles.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateCommentSmiles < ActiveRecord::Migration[4.2] def change create_table :comment_smiles do |t| @@ -9,7 +11,7 @@ class CreateCommentSmiles < ActiveRecord::Migration[4.2] add_index :comment_smiles, :user_id add_index :comment_smiles, :comment_id - add_index :comment_smiles, [:user_id, :comment_id], unique: true + add_index :comment_smiles, %i[user_id comment_id], unique: true add_column :users, :comment_smiled_count, :integer, default: 0, null: false add_column :comments, :smile_count, :integer, default: 0, null: false diff --git a/db/migrate/20150508144336_add_attachment_profile_header_to_users.rb b/db/migrate/20150508144336_add_attachment_profile_header_to_users.rb index fabf823d..2f704186 100644 --- a/db/migrate/20150508144336_add_attachment_profile_header_to_users.rb +++ b/db/migrate/20150508144336_add_attachment_profile_header_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddAttachmentProfileHeaderToUsers < ActiveRecord::Migration[4.2] def change change_table :users do |t| diff --git a/db/migrate/20150526031159_add_locale_to_user.rb b/db/migrate/20150526031159_add_locale_to_user.rb index 76c828c5..090c66a7 100644 --- a/db/migrate/20150526031159_add_locale_to_user.rb +++ b/db/migrate/20150526031159_add_locale_to_user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddLocaleToUser < ActiveRecord::Migration[4.2] def change add_column :users, :locale, :string diff --git a/db/migrate/20150619123121_add_translator_to_users.rb b/db/migrate/20150619123121_add_translator_to_users.rb index 8f8da49b..c340278a 100644 --- a/db/migrate/20150619123121_add_translator_to_users.rb +++ b/db/migrate/20150619123121_add_translator_to_users.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class AddTranslatorToUsers < ActiveRecord::Migration[4.2] def change - add_column :users, :translator, :boolean, default: :false + add_column :users, :translator, :boolean, default: false end end diff --git a/db/migrate/20150704072402_change_default_value_of_locale.rb b/db/migrate/20150704072402_change_default_value_of_locale.rb index ed8f0fe8..aa45d331 100644 --- a/db/migrate/20150704072402_change_default_value_of_locale.rb +++ b/db/migrate/20150704072402_change_default_value_of_locale.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class ChangeDefaultValueOfLocale < ActiveRecord::Migration[4.2] def change - change_column :users, :locale, :string, :default => 'en' + change_column :users, :locale, :string, default: "en" end end diff --git a/db/migrate/20150721154255_add_confirmable_to_devise.rb b/db/migrate/20150721154255_add_confirmable_to_devise.rb index fb435503..8167abab 100644 --- a/db/migrate/20150721154255_add_confirmable_to_devise.rb +++ b/db/migrate/20150721154255_add_confirmable_to_devise.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddConfirmableToDevise < ActiveRecord::Migration[4.2] def up add_column :users, :confirmation_token, :string diff --git a/db/migrate/20150825073030_create_themes.rb b/db/migrate/20150825073030_create_themes.rb index ac8da0e8..b556f09e 100644 --- a/db/migrate/20150825073030_create_themes.rb +++ b/db/migrate/20150825073030_create_themes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateThemes < ActiveRecord::Migration[4.2] def change create_table :themes do |t| @@ -38,6 +40,6 @@ class CreateThemes < ActiveRecord::Migration[4.2] t.timestamps null: false end - add_index :themes, [:user_id, :created_at] + add_index :themes, %i[user_id created_at] end end diff --git a/db/migrate/20150825180139_add_show_foreign_themes_to_users.rb b/db/migrate/20150825180139_add_show_foreign_themes_to_users.rb index 9d00452e..96164b0c 100644 --- a/db/migrate/20150825180139_add_show_foreign_themes_to_users.rb +++ b/db/migrate/20150825180139_add_show_foreign_themes_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddShowForeignThemesToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :show_foreign_themes, :boolean, default: true, null: false diff --git a/db/migrate/20150826224857_add_input_and_outline_to_theme.rb b/db/migrate/20150826224857_add_input_and_outline_to_theme.rb index e4d24e3c..69d3516c 100644 --- a/db/migrate/20150826224857_add_input_and_outline_to_theme.rb +++ b/db/migrate/20150826224857_add_input_and_outline_to_theme.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddInputAndOutlineToTheme < ActiveRecord::Migration[4.2] def change add_column :themes, :input_color, :integer, default: 0xFFFFFF, null: false diff --git a/db/migrate/20160105165913_add_export_fields_to_users.rb b/db/migrate/20160105165913_add_export_fields_to_users.rb index fe4a4fe8..7ffbbbcb 100644 --- a/db/migrate/20160105165913_add_export_fields_to_users.rb +++ b/db/migrate/20160105165913_add_export_fields_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddExportFieldsToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :export_url, :string diff --git a/db/migrate/20200419183714_create_announcements.rb b/db/migrate/20200419183714_create_announcements.rb index 1aedd713..8fe9990c 100644 --- a/db/migrate/20200419183714_create_announcements.rb +++ b/db/migrate/20200419183714_create_announcements.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateAnnouncements < ActiveRecord::Migration[5.2] def change create_table :announcements do |t| diff --git a/db/migrate/20200419185535_create_initial_roles.rb b/db/migrate/20200419185535_create_initial_roles.rb index f3f0788c..0b13b04b 100644 --- a/db/migrate/20200419185535_create_initial_roles.rb +++ b/db/migrate/20200419185535_create_initial_roles.rb @@ -7,11 +7,11 @@ class CreateInitialRoles < ActiveRecord::Migration[5.2] end { - admin: :administrator, - moderator: :moderator + admin: :administrator, + moderator: :moderator, }.each do |legacy_role, new_role| - User.where(legacy_role => true).each do |u| - puts "-- migrating #{u.screen_name} (#{u.id}) from field:#{legacy_role} to role:#{new_role}" + User.where(legacy_role => true).find_each do |u| + Rails.logger.debug { "-- migrating #{u.screen_name} (#{u.id}) from field:#{legacy_role} to role:#{new_role}" } u.add_role new_role u.public_send("#{legacy_role}=", false) u.save! @@ -22,10 +22,10 @@ class CreateInitialRoles < ActiveRecord::Migration[5.2] def down { administrator: :admin, - moderator: :moderator + moderator: :moderator, }.each do |new_role, legacy_role| User.with_role(new_role).each do |u| - puts "-- migrating #{u.screen_name} (#{u.id}) from role:#{new_role} to field:#{legacy_role}" + Rails.logger.debug { "-- migrating #{u.screen_name} (#{u.id}) from role:#{new_role} to field:#{legacy_role}" } u.public_send("#{legacy_role}=", true) u.save! end diff --git a/db/migrate/20200425194536_remove_unused_profile_flags.rb b/db/migrate/20200425194536_remove_unused_profile_flags.rb index 8ce20806..9804b080 100644 --- a/db/migrate/20200425194536_remove_unused_profile_flags.rb +++ b/db/migrate/20200425194536_remove_unused_profile_flags.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemoveUnusedProfileFlags < ActiveRecord::Migration[5.2] def change remove_column :users, :admin diff --git a/db/migrate/20200504214933_update_theme_fields.rb b/db/migrate/20200504214933_update_theme_fields.rb index ec577f1d..beda7b69 100644 --- a/db/migrate/20200504214933_update_theme_fields.rb +++ b/db/migrate/20200504214933_update_theme_fields.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UpdateThemeFields < ActiveRecord::Migration[5.2] def up # CSS file related fields diff --git a/db/migrate/20200517190138_rename_crop_fields.rb b/db/migrate/20200517190138_rename_crop_fields.rb index 12e81325..54781a64 100644 --- a/db/migrate/20200517190138_rename_crop_fields.rb +++ b/db/migrate/20200517190138_rename_crop_fields.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RenameCropFields < ActiveRecord::Migration[5.2] def change rename_column :users, :crop_h, :profile_picture_h diff --git a/db/migrate/20200517192431_remove_paperclip_fields.rb b/db/migrate/20200517192431_remove_paperclip_fields.rb index 00d4181e..7f4a4d02 100644 --- a/db/migrate/20200517192431_remove_paperclip_fields.rb +++ b/db/migrate/20200517192431_remove_paperclip_fields.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemovePaperclipFields < ActiveRecord::Migration[5.2] def change remove_column :users, :profile_picture_content_type diff --git a/db/migrate/20200704163504_use_timestamped_ids.rb b/db/migrate/20200704163504_use_timestamped_ids.rb index 49b44bd7..fa52f40d 100644 --- a/db/migrate/20200704163504_use_timestamped_ids.rb +++ b/db/migrate/20200704163504_use_timestamped_ids.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'securerandom' +require "securerandom" # This migration changes the IDs of several tables from serial to a # timestamped/"snowflake" one. @@ -30,15 +30,15 @@ class UseTimestampedIds < ActiveRecord::Migration[5.2] # we need to migrate related columns to bigints for this to work { - question: %i[answers inboxes], - answer: %i[comments smiles subscriptions], - comment: %i[comment_smiles], - user: %i[announcements answers comment_smiles comments inboxes list_members lists moderation_comments moderation_votes questions reports services smiles subscriptions themes users_roles], + question: %i[answers inboxes], + answer: %i[comments smiles subscriptions], + comment: %i[comment_smiles], + user: %i[announcements answers comment_smiles comments inboxes list_members lists moderation_comments moderation_votes questions reports services smiles subscriptions themes users_roles], # polymorphic tables go brrr recipient: %i[notifications], - source: %i[relationships], - target: %i[notifications relationships reports], + source: %i[relationships], + target: %i[notifications relationships reports], }.each do |ref, tbls| tbls.each do |tbl| say "Migrating #{tbl}.#{ref}_id to bigint" diff --git a/db/migrate/20201001172537_add_otp_secret_key_to_users.rb b/db/migrate/20201001172537_add_otp_secret_key_to_users.rb index 59a33b48..31dc4ed6 100644 --- a/db/migrate/20201001172537_add_otp_secret_key_to_users.rb +++ b/db/migrate/20201001172537_add_otp_secret_key_to_users.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddOtpSecretKeyToUsers < ActiveRecord::Migration[5.2] def change add_column :users, :otp_secret_key, :string diff --git a/db/migrate/20201101155648_create_totp_recovery_codes.rb b/db/migrate/20201101155648_create_totp_recovery_codes.rb index 3b506256..1d7fac98 100644 --- a/db/migrate/20201101155648_create_totp_recovery_codes.rb +++ b/db/migrate/20201101155648_create_totp_recovery_codes.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + class CreateTotpRecoveryCodes < ActiveRecord::Migration[5.2] def change create_table :totp_recovery_codes do |t| t.bigint :user_id t.string :code, limit: 8 end - add_index :totp_recovery_codes, [:user_id, :code] + add_index :totp_recovery_codes, %i[user_id code] end end diff --git a/db/migrate/20210811133004_add_direct_to_questions.rb b/db/migrate/20210811133004_add_direct_to_questions.rb index 092426b9..30632fd6 100644 --- a/db/migrate/20210811133004_add_direct_to_questions.rb +++ b/db/migrate/20210811133004_add_direct_to_questions.rb @@ -5,7 +5,7 @@ class AddDirectToQuestions < ActiveRecord::Migration[5.2] add_column :questions, :direct, :boolean, null: false, default: false # default all legacy questions to direct - execute 'UPDATE questions SET direct = true;' + execute "UPDATE questions SET direct = true;" # All questions where # - the author is not 'justask' (generated questions), and diff --git a/db/migrate/20210814134115_create_user_bans.rb b/db/migrate/20210814134115_create_user_bans.rb index a5146c90..7778d590 100644 --- a/db/migrate/20210814134115_create_user_bans.rb +++ b/db/migrate/20210814134115_create_user_bans.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateUserBans < ActiveRecord::Migration[5.2] def up create_table :user_bans do |t| @@ -15,7 +17,6 @@ class CreateUserBans < ActiveRecord::Migration[5.2] SELECT users.id, users.ban_reason, users.banned_until, users.updated_at, NOW() FROM users WHERE banned_until IS NOT NULL AND NOT permanently_banned;" - execute "INSERT INTO user_bans (user_id, reason, expires_at, created_at, updated_at) SELECT users.id, users.ban_reason, NULL, users.updated_at, NOW() FROM users diff --git a/db/migrate/20211219153054_create_profiles.rb b/db/migrate/20211219153054_create_profiles.rb index d6480af9..933be933 100644 --- a/db/migrate/20211219153054_create_profiles.rb +++ b/db/migrate/20211219153054_create_profiles.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + class CreateProfiles < ActiveRecord::Migration[5.2] def change create_table :profiles do |t| t.references :user, index: true, foreign_key: true t.string :display_name, length: 50 - t.string :description, length: 200, null: false, default: '' - t.string :location, length: 72, null: false, default: '' - t.string :website, null: false, default: '' - t.string :motivation_header, null: false, default: '' + t.string :description, length: 200, null: false, default: "" + t.string :location, length: 72, null: false, default: "" + t.string :website, null: false, default: "" + t.string :motivation_header, null: false, default: "" t.timestamps end transaction do - execute 'INSERT INTO profiles (user_id, display_name, description, location, website, motivation_header, created_at, updated_at) SELECT users.id as user_id, users.display_name, users.bio as description, users.location, users.website, users.motivation_header, users.created_at, users.updated_at FROM users;' + execute "INSERT INTO profiles (user_id, display_name, description, location, website, motivation_header, created_at, updated_at) SELECT users.id as user_id, users.display_name, users.bio as description, users.location, users.website, users.motivation_header, users.created_at, users.updated_at FROM users;" remove_column :users, :display_name remove_column :users, :bio diff --git a/db/migrate/20211222165159_create_mute_rules.rb b/db/migrate/20211222165159_create_mute_rules.rb index 0e548bc5..57057fc4 100644 --- a/db/migrate/20211222165159_create_mute_rules.rb +++ b/db/migrate/20211222165159_create_mute_rules.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateMuteRules < ActiveRecord::Migration[5.2] def change create_table :mute_rules do |t| diff --git a/db/migrate/20211228135426_add_indexes_to_notifications.rb b/db/migrate/20211228135426_add_indexes_to_notifications.rb index 15288920..46c9bae9 100644 --- a/db/migrate/20211228135426_add_indexes_to_notifications.rb +++ b/db/migrate/20211228135426_add_indexes_to_notifications.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddIndexesToNotifications < ActiveRecord::Migration[5.2] def change add_index :notifications, :recipient_id diff --git a/db/migrate/20220104180510_add_post_tag_to_services.rb b/db/migrate/20220104180510_add_post_tag_to_services.rb index ab09c201..85789e8e 100644 --- a/db/migrate/20220104180510_add_post_tag_to_services.rb +++ b/db/migrate/20220104180510_add_post_tag_to_services.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddPostTagToServices < ActiveRecord::Migration[5.2] def change add_column :services, :post_tag, :string, limit: 20 diff --git a/db/migrate/20220909220449_add_webpush_app.rb b/db/migrate/20220909220449_add_webpush_app.rb index 91b1d32d..f48a688b 100644 --- a/db/migrate/20220909220449_add_webpush_app.rb +++ b/db/migrate/20220909220449_add_webpush_app.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "webpush" +require "web-push" class AddWebpushApp < ActiveRecord::Migration[6.1] def up - vapid_keypair = Webpush.generate_key.to_hash + vapid_keypair = WebPush.generate_key.to_hash app = Rpush::Webpush::App.new app.name = "webpush" app.certificate = vapid_keypair.merge(subject: APP_CONFIG.fetch("contact_email")).to_json diff --git a/db/migrate/20231018172518_include_type_in_relationship_unique_constraint.rb b/db/migrate/20231018172518_include_type_in_relationship_unique_constraint.rb new file mode 100644 index 00000000..171fe00f --- /dev/null +++ b/db/migrate/20231018172518_include_type_in_relationship_unique_constraint.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class IncludeTypeInRelationshipUniqueConstraint < ActiveRecord::Migration[6.1] + def change + change_table :relationships do |t| + t.remove_index(%i[source_id target_id]) + t.index(%i[source_id target_id type], unique: true) + end + end +end diff --git a/db/migrate/20231026032527_rename_appendable_to_reaction.rb b/db/migrate/20231026032527_rename_appendable_to_reaction.rb new file mode 100644 index 00000000..6a7b5d9d --- /dev/null +++ b/db/migrate/20231026032527_rename_appendable_to_reaction.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class RenameAppendableToReaction < ActiveRecord::Migration[7.0] + def change + rename_table :appendables, :reactions + remove_column :reactions, :type, :string + end +end diff --git a/db/migrate/20231028091613_move_appendable_notifications.rb b/db/migrate/20231028091613_move_appendable_notifications.rb new file mode 100644 index 00000000..0763ed42 --- /dev/null +++ b/db/migrate/20231028091613_move_appendable_notifications.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MoveAppendableNotifications < ActiveRecord::Migration[7.0] + def up + Notification::where(target_type: "Appendable").update_all(target_type: "Reaction") # rubocop:disable Rails/SkipsModelValidations + end + + def down + Notification::where(target_type: "Reaction").update_all(type: "Appendable") # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20231107200845_optimise_indices.rb b/db/migrate/20231107200845_optimise_indices.rb new file mode 100644 index 00000000..955a5b0f --- /dev/null +++ b/db/migrate/20231107200845_optimise_indices.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class OptimiseIndices < ActiveRecord::Migration[7.0] + def change + add_index :users, "LOWER(screen_name)", order: :desc, unique: true + remove_index :users, :screen_name, unique: true + add_index :user_bans, :expires_at, order: :desc + add_index :announcements, %i[starts_at ends_at], order: :desc + remove_index :themes, %i[user_id created_at] + add_index :themes, :user_id + end +end diff --git a/db/migrate/20231209212629_add_index_on_user_bans_user_id.rb b/db/migrate/20231209212629_add_index_on_user_bans_user_id.rb new file mode 100644 index 00000000..cb6101d9 --- /dev/null +++ b/db/migrate/20231209212629_add_index_on_user_bans_user_id.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddIndexOnUserBansUserId < ActiveRecord::Migration[7.0] + def change + add_index :user_bans, :user_id + end +end diff --git a/db/migrate/20231220100445_remove_duplicate_reactions.rb b/db/migrate/20231220100445_remove_duplicate_reactions.rb new file mode 100644 index 00000000..d949e40c --- /dev/null +++ b/db/migrate/20231220100445_remove_duplicate_reactions.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class RemoveDuplicateReactions < ActiveRecord::Migration[7.0] + def up + execute <<~SQUIRREL + DELETE FROM reactions + WHERE id IN ( + SELECT id FROM ( + SELECT id, row_number() over (PARTITION BY parent_type, parent_id, user_id ORDER BY id) AS row_number FROM reactions + )s WHERE row_number >= 2 + ) + SQUIRREL + + add_index :reactions, %i[parent_type parent_id user_id], unique: true + end + + def down + remove_index :reactions, %i[parent_type parent_id user_id] + end +end diff --git a/db/migrate/20240123182422_add_target_user_to_reports.rb b/db/migrate/20240123182422_add_target_user_to_reports.rb new file mode 100644 index 00000000..a91ed9e6 --- /dev/null +++ b/db/migrate/20240123182422_add_target_user_to_reports.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class AddTargetUserToReports < ActiveRecord::Migration[7.0] + def up + add_reference :reports, :target_user, null: true, foreign_key: false + + execute <<~SQL.squish + UPDATE reports + SET target_user_id = users.id + FROM users + WHERE users.id = reports.target_id AND reports.type = 'Reports::User' + SQL + + execute <<~SQL.squish + UPDATE reports + SET target_user_id = users.id + FROM users, comments + WHERE users.id = comments.user_id AND comments.id = reports.target_id AND reports.type = 'Reports::Comment' + SQL + + execute <<~SQL.squish + UPDATE reports + SET target_user_id = users.id + FROM users, answers + WHERE users.id = answers.user_id AND answers.id = reports.target_id AND reports.type = 'Reports::Answer' + SQL + + execute <<~SQL.squish + UPDATE reports + SET target_user_id = users.id + FROM users, questions + WHERE users.id = questions.user_id AND questions.id = reports.target_id AND reports.type = 'Reports::Question' + SQL + end + + def down + remove_reference :reports, :target_user, null: true, foreign_key: false + end +end diff --git a/db/migrate/20240127112216_rename_inboxes_to_inbox_entries.rb b/db/migrate/20240127112216_rename_inboxes_to_inbox_entries.rb new file mode 100644 index 00000000..03a8f55b --- /dev/null +++ b/db/migrate/20240127112216_rename_inboxes_to_inbox_entries.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RenameInboxesToInboxEntries < ActiveRecord::Migration[7.0] + def change + rename_table :inboxes, :inbox_entries + end +end diff --git a/db/migrate/20240301203930_add_last_reports_visit_to_users.rb b/db/migrate/20240301203930_add_last_reports_visit_to_users.rb new file mode 100644 index 00000000..90b01615 --- /dev/null +++ b/db/migrate/20240301203930_add_last_reports_visit_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastReportsVisitToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :last_reports_visit, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index c052e79d..3d1fdcaf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_05_26_181715) do - +ActiveRecord::Schema[7.0].define(version: 2024_03_01_203930) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -19,11 +18,12 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.text "content", null: false t.string "link_text" t.string "link_href" - t.datetime "starts_at", null: false - t.datetime "ends_at", null: false + t.datetime "starts_at", precision: nil, null: false + t.datetime "ends_at", precision: nil, null: false t.bigint "user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["starts_at", "ends_at"], name: "index_announcements_on_starts_at_and_ends_at", order: :desc t.index ["user_id"], name: "index_announcements_on_user_id" end @@ -31,8 +31,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.bigint "user_id" t.string "identifier" t.bigint "question_id" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.bigint "target_user_id" t.index ["identifier"], name: "index_anonymous_blocks_on_identifier" t.index ["question_id"], name: "index_anonymous_blocks_on_question_id" @@ -45,54 +45,42 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.bigint "question_id" t.integer "comment_count", default: 0, null: false t.bigint "user_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.integer "smile_count", default: 0, null: false - t.datetime "pinned_at" + t.datetime "pinned_at", precision: nil t.index ["created_at"], name: "index_answers_on_created_at", order: :desc t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id", "created_at"], name: "index_answers_on_user_id_and_created_at" t.index ["user_id", "pinned_at"], name: "index_answers_on_user_id_and_pinned_at" end - create_table "appendables", force: :cascade do |t| - t.string "type", null: false - t.bigint "user_id", null: false - t.bigint "parent_id", null: false - t.string "parent_type", null: false - t.text "content" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["parent_id", "parent_type"], name: "index_appendables_on_parent_id_and_parent_type" - t.index ["user_id", "created_at"], name: "index_appendables_on_user_id_and_created_at" - end - create_table "comments", id: :bigint, default: -> { "gen_timestamp_id('comments'::text)" }, force: :cascade do |t| t.string "content" t.bigint "answer_id" t.bigint "user_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.integer "smile_count", default: 0, null: false t.index ["answer_id"], name: "index_comments_on_answer_id" t.index ["user_id", "created_at"], name: "index_comments_on_user_id_and_created_at" end - create_table "inboxes", id: :serial, force: :cascade do |t| + create_table "inbox_entries", id: :serial, force: :cascade do |t| t.bigint "user_id" t.bigint "question_id" t.boolean "new" - t.datetime "created_at" - t.datetime "updated_at" - t.index ["question_id"], name: "index_inboxes_on_question_id" - t.index ["user_id"], name: "index_inboxes_on_user_id" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil + t.index ["question_id"], name: "index_inbox_entries_on_question_id" + t.index ["user_id"], name: "index_inbox_entries_on_user_id" end create_table "list_members", id: :serial, force: :cascade do |t| t.integer "list_id", null: false t.bigint "user_id", null: false - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.index ["list_id", "user_id"], name: "index_list_members_on_list_id_and_user_id", unique: true end @@ -101,16 +89,16 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.string "name" t.string "display_name" t.boolean "private", default: true - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.index ["user_id", "name"], name: "index_lists_on_user_id_and_name", unique: true end create_table "mute_rules", id: :bigint, default: -> { "gen_timestamp_id('mute_rules'::text)" }, force: :cascade do |t| t.bigint "user_id" t.string "muted_phrase" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["user_id"], name: "index_mute_rules_on_user_id" end @@ -119,8 +107,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.bigint "target_id" t.bigint "recipient_id" t.boolean "new" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.string "type", null: false t.index ["new"], name: "index_notifications_on_new" t.index ["recipient_id"], name: "index_notifications_on_recipient_id" @@ -134,8 +122,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.string "location", default: "", null: false t.string "website", default: "", null: false t.string "motivation_header", default: "", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "anon_display_name" t.boolean "allow_long_questions", default: true t.index ["user_id"], name: "index_profiles_on_user_id" @@ -146,20 +134,32 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.boolean "author_is_anonymous" t.string "author_identifier" t.bigint "user_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.integer "answer_count", default: 0, null: false t.boolean "direct", default: false, null: false t.index ["user_id", "created_at"], name: "index_questions_on_user_id_and_created_at" end + create_table "reactions", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "parent_id", null: false + t.string "parent_type", null: false + t.text "content" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id", "parent_type"], name: "index_reactions_on_parent_id_and_parent_type" + t.index ["parent_type", "parent_id", "user_id"], name: "index_reactions_on_parent_type_and_parent_id_and_user_id", unique: true + t.index ["user_id", "created_at"], name: "index_reactions_on_user_id_and_created_at" + end + create_table "relationships", id: :serial, force: :cascade do |t| t.bigint "source_id" t.bigint "target_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.string "type", null: false - t.index ["source_id", "target_id"], name: "index_relationships_on_source_id_and_target_id", unique: true + t.index ["source_id", "target_id", "type"], name: "index_relationships_on_source_id_and_target_id_and_type", unique: true t.index ["source_id"], name: "index_relationships_on_source_id" t.index ["target_id"], name: "index_relationships_on_target_id" t.index ["type"], name: "index_relationships_on_type" @@ -169,10 +169,12 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.string "type", null: false t.bigint "target_id", null: false t.bigint "user_id", null: false - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "deleted", default: false t.string "reason" + t.bigint "target_user_id" + t.index ["target_user_id"], name: "index_reports_on_target_user_id" t.index ["type", "target_id"], name: "index_reports_on_type_and_target_id" t.index ["user_id", "created_at"], name: "index_reports_on_user_id_and_created_at" end @@ -181,10 +183,10 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.string "name" t.string "resource_type" t.bigint "resource_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" - t.index ["resource_type", "resource_id"], name: "index_roles_on_resource_type_and_resource_id" + t.index ["resource_type", "resource_id"], name: "index_roles_on_resource" end create_table "rpush_apps", force: :cascade do |t| @@ -193,14 +195,14 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.text "certificate" t.string "password" t.integer "connections", default: 1, null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "type", null: false t.string "auth_key" t.string "client_id" t.string "client_secret" t.string "access_token" - t.datetime "access_token_expiration" + t.datetime "access_token_expiration", precision: nil t.text "apn_key" t.string "apn_key_id" t.string "team_id" @@ -210,9 +212,9 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do create_table "rpush_feedback", force: :cascade do |t| t.string "device_token" - t.datetime "failed_at", null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "failed_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "app_id" t.index ["device_token"], name: "index_rpush_feedback_on_device_token" end @@ -225,14 +227,14 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.text "data" t.integer "expiry", default: 86400 t.boolean "delivered", default: false, null: false - t.datetime "delivered_at" + t.datetime "delivered_at", precision: nil t.boolean "failed", default: false, null: false - t.datetime "failed_at" + t.datetime "failed_at", precision: nil t.integer "error_code" t.text "error_description" - t.datetime "deliver_after" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "deliver_after", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "alert_is_json", default: false, null: false t.string "type", null: false t.string "collapse_key" @@ -241,7 +243,7 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.integer "app_id", null: false t.integer "retries", default: 0 t.string "uri" - t.datetime "fail_after" + t.datetime "fail_after", precision: nil t.boolean "processing", default: false, null: false t.integer "priority" t.text "url_args" @@ -259,8 +261,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do create_table "subscriptions", id: :serial, force: :cascade do |t| t.bigint "user_id", null: false t.bigint "answer_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["user_id", "answer_id"], name: "index_subscriptions_on_user_id_and_answer_id" end @@ -282,8 +284,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.integer "background_color", default: 15789556 t.integer "body_text", default: 0 t.integer "muted_text", default: 7107965 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "input_color", default: 15789556, null: false t.integer "input_text", default: 0, null: false t.integer "raised_accent", default: 16250871 @@ -292,7 +294,7 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.integer "input_placeholder", default: 7107965, null: false t.integer "raised_text", default: 0, null: false t.integer "raised_accent_text", default: 0, null: false - t.index ["user_id", "created_at"], name: "index_themes_on_user_id_and_created_at" + t.index ["user_id"], name: "index_themes_on_user_id" end create_table "totp_recovery_codes", force: :cascade do |t| @@ -304,25 +306,27 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do create_table "user_bans", force: :cascade do |t| t.bigint "user_id" t.string "reason" - t.datetime "expires_at" + t.datetime "expires_at", precision: nil t.bigint "banned_by_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["expires_at"], name: "index_user_bans_on_expires_at", order: :desc + t.index ["user_id"], name: "index_user_bans_on_user_id" end create_table "users", id: :bigint, default: -> { "gen_timestamp_id('users'::text)" }, force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" + t.datetime "reset_password_sent_at", precision: nil + t.datetime "remember_created_at", precision: nil t.integer "sign_in_count", default: 0, null: false - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" + t.datetime "current_sign_in_at", precision: nil + t.datetime "last_sign_in_at", precision: nil t.string "current_sign_in_ip" t.string "last_sign_in_ip" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.string "screen_name" t.integer "asked_count", default: 0, null: false t.integer "answered_count", default: 0, null: false @@ -347,13 +351,13 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.integer "profile_header_h" t.string "locale", default: "en" t.string "confirmation_token" - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" + t.datetime "confirmed_at", precision: nil + t.datetime "confirmation_sent_at", precision: nil t.string "unconfirmed_email" t.boolean "show_foreign_themes", default: true, null: false t.string "export_url" t.boolean "export_processing", default: false, null: false - t.datetime "export_created_at" + t.datetime "export_created_at", precision: nil t.string "otp_secret_key" t.integer "otp_module", default: 0, null: false t.boolean "privacy_lock_inbox", default: false @@ -363,12 +367,13 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do t.boolean "sharing_enabled", default: false t.boolean "sharing_autoclose", default: false t.string "sharing_custom_url" - t.datetime "notifications_updated_at" - t.datetime "inbox_updated_at" + t.datetime "notifications_updated_at", precision: nil + t.datetime "inbox_updated_at", precision: nil + t.datetime "last_reports_visit" + t.index "lower((screen_name)::text)", name: "index_users_on_LOWER_screen_name", unique: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true - t.index ["screen_name"], name: "index_users_on_screen_name", unique: true end create_table "users_roles", id: false, force: :cascade do |t| @@ -382,8 +387,8 @@ ActiveRecord::Schema.define(version: 2023_05_26_181715) do create_table "web_push_subscriptions", force: :cascade do |t| t.bigint "user_id", null: false t.json "subscription" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "failures", default: 0 t.index ["user_id"], name: "index_web_push_subscriptions_on_user_id" end diff --git a/db/seeds.rb b/db/seeds.rb index 9874789c..8136d5c0 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file should contain all the record creation needed to seed the database with its default values. # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). # diff --git a/lib/exporter.rb b/lib/exporter.rb index 07f2ae46..dca0361c 100644 --- a/lib/exporter.rb +++ b/lib/exporter.rb @@ -7,7 +7,7 @@ require "zip/filesystem" # require all data export use cases via Zeitwerk # rubocop:disable Lint/Void UseCase::DataExport::Answers -UseCase::DataExport::Appendables +UseCase::DataExport::Reactions UseCase::DataExport::Comments UseCase::DataExport::MuteRules UseCase::DataExport::Questions diff --git a/lib/retrospring/version.rb b/lib/retrospring/version.rb index 7a679293..a104f1e7 100644 --- a/lib/retrospring/version.rb +++ b/lib/retrospring/version.rb @@ -13,11 +13,11 @@ module Retrospring module Version module_function - def year = 2023 + def year = 2024 - def month = 9 + def month = 3 - def day = 1 + def day = 19 def patch = 0 diff --git a/lib/use_case/data_export/appendables.rb b/lib/use_case/data_export/appendables.rb deleted file mode 100644 index 19221aee..00000000 --- a/lib/use_case/data_export/appendables.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module UseCase - module DataExport - class Appendables < UseCase::DataExport::Base - def files = { - "appendables.json" => json_file!( - appendables: [ - *user.smiles.map(&method(:collect_appendable)) - ] - ) - } - - def collect_appendable(appendable) - {}.tap do |h| - column_names(::Appendable).each do |field| - h[field] = appendable[field] - end - end - end - end - end -end diff --git a/lib/use_case/data_export/reactions.rb b/lib/use_case/data_export/reactions.rb new file mode 100644 index 00000000..7710f8e7 --- /dev/null +++ b/lib/use_case/data_export/reactions.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module UseCase + module DataExport + class Reactions < UseCase::DataExport::Base + def files = { + "reactions.json" => json_file!( + reactions: [ + *user.smiles.map(&method(:collect_reaction)) + ], + ), + } + + def collect_reaction(reaction) + {}.tap do |h| + column_names(::Reaction).each do |field| + h[field] = reaction[field] + end + end + end + end + end +end diff --git a/lib/use_case/data_export/user.rb b/lib/use_case/data_export/user.rb index 2bd4ceb1..49094cc1 100644 --- a/lib/use_case/data_export/user.rb +++ b/lib/use_case/data_export/user.rb @@ -15,6 +15,7 @@ module UseCase reset_password_token inbox_updated_at notifications_updated_at + last_reports_visit ].freeze IGNORED_FIELDS_PROFILES = %i[ diff --git a/lib/use_case/mute_rule/create.rb b/lib/use_case/mute_rule/create.rb index 68a587aa..eee45dd6 100644 --- a/lib/use_case/mute_rule/create.rb +++ b/lib/use_case/mute_rule/create.rb @@ -7,7 +7,7 @@ module UseCase option :phrase, type: Types::Coercible::String def call - rule = ::MuteRule.create( + rule = ::MuteRule.create!( user:, muted_phrase: phrase ) diff --git a/lib/use_case/question/create.rb b/lib/use_case/question/create.rb index 17987a1d..2f228816 100644 --- a/lib/use_case/question/create.rb +++ b/lib/use_case/question/create.rb @@ -20,7 +20,7 @@ module UseCase increment_asked_count increment_metric - inbox = ::Inbox.create!(user: target_user, question:, new: true) + inbox = ::InboxEntry.create!(user: target_user, question:, new: true) notify(inbox) { @@ -65,6 +65,7 @@ module UseCase def check_user raise Errors::NotAuthorized if target_user.privacy_require_user && !source_user_id raise Errors::QuestionTooLong if content.length > ::Question::SHORT_QUESTION_MAX_LENGTH && !target_user.profile.allow_long_questions + raise Errors::QuestionTooLong if content.length > ::Question::LONG_QUESTION_MAX_LENGTH && target_user.profile.allow_long_questions end def create_question diff --git a/lib/use_case/question/create_followers.rb b/lib/use_case/question/create_followers.rb index 04fe3844..1c88a5ff 100644 --- a/lib/use_case/question/create_followers.rb +++ b/lib/use_case/question/create_followers.rb @@ -6,20 +6,25 @@ module UseCase option :source_user_id, type: Types::Coercible::Integer option :content, type: Types::Coercible::String option :author_identifier, type: Types::Coercible::String | Types::Nil + option :send_to_own_inbox, type: Types::Params::Bool, default: proc { false } def call + check_question + question = ::Question.create!( content:, author_is_anonymous: false, author_identifier:, user: source_user, - direct: false + direct: false, ) increment_asked_count increment_metric - QuestionWorker.perform_async(source_user_id, question.id) + args = source_user.followers.map { |f| [f.id, question.id] } + SendToInboxJob.perform_async(source_user_id, question.id) if send_to_own_inbox + SendToInboxJob.perform_bulk(args) { status: 201, @@ -29,6 +34,10 @@ module UseCase private + def check_question + raise Errors::QuestionTooLong if content.length > ::Question::LONG_QUESTION_MAX_LENGTH + end + def increment_asked_count source_user.increment(:asked_count) source_user.save @@ -40,12 +49,12 @@ module UseCase anonymous: false, followers: true, generated: false, - } + }, ) end def source_user - @source_user ||= ::User.find(source_user_id) + @source_user ||= ::User.includes(:followers).find(source_user_id) end end end diff --git a/lib/use_case/reaction/create.rb b/lib/use_case/reaction/create.rb new file mode 100644 index 00000000..1939f9d6 --- /dev/null +++ b/lib/use_case/reaction/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module UseCase + module Reaction + class Create < UseCase::Base + option :source_user_id, type: Types::Coercible::Integer + option :target, type: Types.Instance(::Answer) | Types.Instance(::Comment) + option :content, type: Types::Coercible::String, optional: true + + def call + reaction = source_user.smile target + + { + status: 201, + resource: reaction, + } + end + + private + + def source_user + @source_user ||= ::User.find(source_user_id) + end + end + end +end diff --git a/lib/use_case/reaction/destroy.rb b/lib/use_case/reaction/destroy.rb new file mode 100644 index 00000000..8d77219a --- /dev/null +++ b/lib/use_case/reaction/destroy.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module UseCase + module Reaction + class Destroy < UseCase::Base + option :source_user_id, type: Types::Coercible::Integer + option :target, type: Types.Instance(::Answer) | Types.Instance(::Comment) + + def call + source_user.unsmile target + + { + status: 204, + resource: nil, + } + end + + private + + def source_user + @source_user ||= ::User.find(source_user_id) + end + end + end +end diff --git a/package.json b/package.json index 49f471aa..80cc1712 100644 --- a/package.json +++ b/package.json @@ -8,32 +8,32 @@ }, "dependencies": { "@fontsource/lexend": "^4.5.15", - "@fortawesome/fontawesome-free": "^6.4.2", - "@github/hotkey": "^2.0.1", + "@fortawesome/fontawesome-free": "^6.5.2", + "@github/hotkey": "^3.1.1", "@hotwired/stimulus": "^3.2.2", - "@hotwired/turbo-rails": "^7.3.0", - "@melloware/coloris": "^0.21.1", + "@hotwired/turbo-rails": "^8.0.4", + "@melloware/coloris": "^0.24.0", "@popperjs/core": "^2.11", - "@rails/request.js": "^0.0.8", + "@rails/request.js": "^0.0.9", "bootstrap": "^5.2", "buffer": "^6.0.3", "cheet.js": "^0.3.3", "croppr": "^2.3.1", "i18n-js": "^4.0", "js-cookie": "2.2.1", - "sass": "^1.66.1", + "sass": "^1.77.6", "sweetalert": "1.1.3", "toastify-js": "^1.12.0", - "typescript": "^5.2.2" + "typescript": "^5.5.2" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^4.11.0", - "@typescript-eslint/parser": "^4.11.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^6.21.0", "esbuild": "^0.17.0", - "eslint": "^7.16.0", - "eslint-plugin-import": "^2.28.1", - "stylelint": "^15.10.3", - "stylelint-config-standard-scss": "^11.0.0", - "stylelint-scss": "^5.1.0" + "eslint": "^8.57.0", + "eslint-plugin-import": "^2.29.1", + "stylelint": "^15.11.0", + "stylelint-config-standard-scss": "^11.1.0", + "stylelint-scss": "^5.3.2" } } diff --git a/spec/components/avatar_component_spec.rb b/spec/components/avatar_component_spec.rb new file mode 100644 index 00000000..922c52d0 --- /dev/null +++ b/spec/components/avatar_component_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AvatarComponent, type: :component do + let(:user) { FactoryBot.create(:user) } + + it "renders an avatar" do + expect( + render_inline(described_class.new(user:, size: "sm")).to_html, + ).to include( + "no_avatar.png", + ) + end + + it "gets the medium version of a profile picture if requested" do + expect( + render_inline(described_class.new(user:, size: "md")).to_html, + ).to include( + "medium/", + ) + end + + it "gets the large version of a profile picture if requested" do + expect( + render_inline(described_class.new(user:, size: "xl")).to_html, + ).to include( + "large/", + ) + end + + it "includes additionally passed classes" do + expect( + render_inline(described_class.new(user:, size: "md", classes: %w[first-class second-class])).to_html, + ).to include( + 'class="avatar-md first-class second-class"', + ) + end +end diff --git a/spec/controllers/ajax/answer_controller_spec.rb b/spec/controllers/ajax/answer_controller_spec.rb index 18c65354..68218c2a 100644 --- a/spec/controllers/ajax/answer_controller_spec.rb +++ b/spec/controllers/ajax/answer_controller_spec.rb @@ -4,6 +4,8 @@ require "rails_helper" describe Ajax::AnswerController, :ajax_controller, type: :controller do + include ActiveSupport::Testing::TimeHelpers + let(:question) { FactoryBot.create(:question, user: FactoryBot.build(:user, privacy_allow_stranger_answers: asker_allows_strangers)) } let(:asker_allows_strangers) { true } @@ -26,6 +28,8 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do end include_examples "returns the expected response" + + include_examples "touches user timestamp", :inbox_updated_at end shared_examples "does not create the answer" do @@ -61,7 +65,7 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do let(:shared_services) { %w[twitter] } context "when inbox is true" do - let(:id) { FactoryBot.create(:inbox, user: inbox_user, question:).id } + let(:id) { FactoryBot.create(:inbox_entry, user: inbox_user, question:).id } let(:inbox) { true } context "when the inbox entry belongs to the user" do @@ -87,7 +91,10 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do let(:expected_response) do super().merge( "sharing" => { + "url" => a_string_matching("https://#{APP_CONFIG['hostname']}/"), + "text" => a_string_matching("Werfen Sie nicht länger das Fenster zum Geld hinaus!"), "twitter" => a_string_matching("https://twitter.com/"), + "bluesky" => a_string_matching("https://bsky.app/"), "tumblr" => a_string_matching("https://www.tumblr.com/"), "telegram" => a_string_matching("https://t.me/"), "custom" => a_string_matching(/Werfen\+Sie\+nicht\+l%C3%A4nger\+das\+Fenster\+zum\+Geld\+hinaus%21/), @@ -166,7 +173,10 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do let(:expected_response) do super().merge( "sharing" => { + "url" => a_string_matching("https://#{APP_CONFIG['hostname']}/"), + "text" => a_string_matching("Werfen Sie nicht länger das Fenster zum Geld hinaus!"), "twitter" => a_string_matching("https://twitter.com/"), + "bluesky" => a_string_matching("https://bsky.app/"), "tumblr" => a_string_matching("https://www.tumblr.com/"), "telegram" => a_string_matching("https://t.me/"), "custom" => a_string_matching(/Werfen\+Sie\+nicht\+l%C3%A4nger\+das\+Fenster\+zum\+Geld\+hinaus%21/), @@ -317,13 +327,22 @@ describe Ajax::AnswerController, :ajax_controller, type: :controller do include_examples "deletes the answer" it "returns the question back to the user's inbox" do - expect { subject }.to(change { Inbox.where(question_id: answer.question.id, user_id: user.id).count }.by(1)) + expect { subject }.to(change { InboxEntry.where(question_id: answer.question.id, user_id: user.id).count }.by(1)) end it "returns the question back to the user's inbox when the user has anonymous questions disabled" do user.privacy_allow_anonymous_questions = false user.save - expect { subject }.to(change { Inbox.where(question_id: answer.question.id, user_id: user.id).count }.by(1)) + expect { subject }.to(change { InboxEntry.where(question_id: answer.question.id, user_id: user.id).count }.by(1)) + end + + it "updates the inbox caching timestamp for the user who answered" do + initial_timestamp = 1.day.ago + answer.user.update(inbox_updated_at: initial_timestamp) + travel_to 1.day.from_now do + # using string representation to avoid precision issues + expect { subject }.to(change { answer.user.reload.inbox_updated_at.to_s }.from(initial_timestamp.to_s).to(DateTime.now)) + end end end diff --git a/spec/controllers/ajax/comment_controller_spec.rb b/spec/controllers/ajax/comment_controller_spec.rb index 0f4189b8..ace88ce6 100644 --- a/spec/controllers/ajax/comment_controller_spec.rb +++ b/spec/controllers/ajax/comment_controller_spec.rb @@ -4,6 +4,8 @@ require "rails_helper" describe Ajax::CommentController, :ajax_controller, type: :controller do + include ActiveSupport::Testing::TimeHelpers + let(:answer) { FactoryBot.create(:answer, user: FactoryBot.create(:user)) } describe "#create" do @@ -23,6 +25,18 @@ describe Ajax::CommentController, :ajax_controller, type: :controller do expect(answer.reload.comments.ids).to include(Comment.last.id) end + context "a user is subscribed to the answer" do + let(:subscribed_user) { FactoryBot.create(:user) } + + it "updates the notification caching timestamp for a subscribed user" do + Subscription.subscribe(subscribed_user, answer) + + travel_to(1.day.from_now) do + expect { subject }.to change { subscribed_user.reload.notifications_updated_at }.to(DateTime.now) + end + end + end + include_examples "returns the expected response" end diff --git a/spec/controllers/ajax/inbox_controller_spec.rb b/spec/controllers/ajax/inbox_controller_spec.rb index 3b50c43d..6e48e53e 100644 --- a/spec/controllers/ajax/inbox_controller_spec.rb +++ b/spec/controllers/ajax/inbox_controller_spec.rb @@ -7,17 +7,17 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do describe "#remove" do let(:params) do { - id: inbox_entry_id + id: inbox_entry_id, } end - subject { delete(:remove, params: params) } + subject { delete(:remove, params:) } context "when user is signed in" do before(:each) { sign_in(user) } context "when inbox entry exists" do - let(:inbox_entry) { FactoryBot.create(:inbox, user: inbox_user) } + let(:inbox_entry) { FactoryBot.create(:inbox_entry, user: inbox_user) } let(:inbox_entry_id) { inbox_entry.id } # ensure the inbox entry exists @@ -28,14 +28,14 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => true, - "status" => "okay", - "message" => anything + "status" => "okay", + "message" => anything, } end it "removes the inbox entry" do - expect { subject }.to(change { user.inboxes.count }.by(-1)) - expect { Inbox.find(inbox_entry.id) }.to raise_error(ActiveRecord::RecordNotFound) + expect { subject }.to(change { user.inbox_entries.count }.by(-1)) + expect { InboxEntry.find(inbox_entry.id) }.to raise_error(ActiveRecord::RecordNotFound) end include_examples "returns the expected response" @@ -46,14 +46,14 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "fail", - "message" => anything + "status" => "fail", + "message" => anything, } end it "does not remove the inbox entry" do - expect { subject }.not_to(change { Inbox.count }) - expect { Inbox.find(inbox_entry.id) }.not_to raise_error + expect { subject }.not_to(change { InboxEntry.count }) + expect { InboxEntry.find(inbox_entry.id) }.not_to raise_error end include_examples "returns the expected response" @@ -65,8 +65,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "not_found", - "message" => anything + "status" => "not_found", + "message" => anything, } end @@ -79,8 +79,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "not_found", - "message" => anything + "status" => "not_found", + "message" => anything, } end @@ -97,8 +97,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => true, - "status" => "okay", - "message" => anything + "status" => "okay", + "message" => anything, } end @@ -107,12 +107,12 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do context "when user has some inbox entries" do let(:some_other_user) { FactoryBot.create(:user) } before do - 10.times { FactoryBot.create(:inbox, user: user) } - 10.times { FactoryBot.create(:inbox, user: some_other_user) } + 10.times { FactoryBot.create(:inbox_entry, user:) } + 10.times { FactoryBot.create(:inbox_entry, user: some_other_user) } end it "deletes all the entries from the user's inbox" do - expect { subject }.to(change { [Inbox.count, user.inboxes.count] }.from([20, 10]).to([10, 0])) + expect { subject }.to(change { [InboxEntry.count, user.inbox_entries.count] }.from([20, 10]).to([10, 0])) end include_examples "returns the expected response" @@ -123,8 +123,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "err", - "message" => anything + "status" => "err", + "message" => anything, } end @@ -135,11 +135,11 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do describe "#remove_all_author" do let(:params) do { - author: author + author:, } end - subject { delete(:remove_all_author, params: params) } + subject { delete(:remove_all_author, params:) } context "when user is signed in" do before(:each) { sign_in(user) } @@ -148,8 +148,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => true, - "status" => "okay", - "message" => anything + "status" => "okay", + "message" => anything, } end @@ -162,13 +162,13 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do normal_question = FactoryBot.create(:question, user: some_other_user, author_is_anonymous: false) anon_question = FactoryBot.create(:question, user: some_other_user, author_is_anonymous: true) - 10.times { FactoryBot.create(:inbox, user: user) } - 3.times { FactoryBot.create(:inbox, user: user, question: normal_question) } - 2.times { FactoryBot.create(:inbox, user: user, question: anon_question) } + 10.times { FactoryBot.create(:inbox_entry, user:) } + 3.times { FactoryBot.create(:inbox_entry, user:, question: normal_question) } + 2.times { FactoryBot.create(:inbox_entry, user:, question: anon_question) } end it "deletes all the entries asked by some other user which are not anonymous from the user's inbox" do - expect { subject }.to(change { user.inboxes.count }.from(15).to(12)) + expect { subject }.to(change { user.inbox_entries.count }.from(15).to(12)) end include_examples "returns the expected response" @@ -179,8 +179,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "err", - "message" => anything + "status" => "err", + "message" => anything, } end @@ -193,8 +193,8 @@ describe Ajax::InboxController, :ajax_controller, type: :controller do let(:expected_response) do { "success" => false, - "status" => "err", - "message" => anything + "status" => "err", + "message" => anything, } end diff --git a/spec/controllers/ajax/moderation_controller_spec.rb b/spec/controllers/ajax/moderation_controller_spec.rb index 97462e81..33035467 100644 --- a/spec/controllers/ajax/moderation_controller_spec.rb +++ b/spec/controllers/ajax/moderation_controller_spec.rb @@ -225,7 +225,7 @@ describe Ajax::ModerationController, :ajax_controller, type: :controller do describe "#privilege" do valid_role_pairs = { moderator: :moderator, - admin: :administrator + administrator: :administrator, }.freeze let(:params) do diff --git a/spec/controllers/ajax/question_controller_spec.rb b/spec/controllers/ajax/question_controller_spec.rb index 021a4172..077ace60 100644 --- a/spec/controllers/ajax/question_controller_spec.rb +++ b/spec/controllers/ajax/question_controller_spec.rb @@ -14,8 +14,8 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do if check_for_inbox it "adds the question to the target users' inbox" do - expect { subject }.to(change { target_user.inboxes.count }.by(1)) - expect(target_user.inboxes.last.question.content).to eq(question_content) + expect { subject }.to(change { target_user.inbox_entries.count }.by(1)) + expect(target_user.inbox_entries.last.question.content).to eq(question_content) end end @@ -29,7 +29,7 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do if check_for_inbox it "does not add the question to the target users' inbox" do - expect { subject }.not_to(change { target_user.inboxes.count }) + expect { subject }.not_to(change { target_user.inbox_entries.count }) end end @@ -42,27 +42,31 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do end it "does not add the question to the target users' inbox" do - expect { subject }.not_to(change { target_user.inboxes.count }) + expect { subject }.not_to(change { target_user.inbox_entries.count }) end include_examples "returns the expected response" end - shared_examples "enqueues a QuestionWorker job" do |_expected_rcpt| - it "enqueues a QuestionWorker job" do - allow(QuestionWorker).to receive(:perform_async) + shared_examples "enqueues SendToInboxJob jobs" do + it "enqueues a SendToInboxJob job" do + allow(SendToInboxJob).to receive(:perform_bulk) subject - expect(QuestionWorker).to have_received(:perform_async).with(user.id, Question.last.id) + question_id = Question.last.id + bulk_args = followers.map { |f| [f.id, question_id] } + expect(SendToInboxJob).to have_received(:perform_bulk).with(bulk_args) end include_examples "returns the expected response" end - shared_examples "does not enqueue a QuestionWorker job" do - it "does not enqueue a QuestionWorker job" do - allow(QuestionWorker).to receive(:perform_async) + shared_examples "does not enqueue a SendToInboxJob job" do + it "does not enqueue a SendToInboxJob job" do + allow(SendToInboxJob).to receive(:perform_async) + allow(SendToInboxJob).to receive(:perform_bulk) subject - expect(QuestionWorker).not_to have_received(:perform_async) + expect(SendToInboxJob).not_to have_received(:perform_async) + expect(SendToInboxJob).not_to have_received(:perform_bulk) end include_examples "returns the expected response" @@ -73,9 +77,11 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do { question: question_content, anonymousQuestion: anonymous_question, - rcpt: rcpt + rcpt:, + sendToOwnInbox: send_to_own_inbox, } end + let(:send_to_own_inbox) { "false" } subject { post(:create, params: params) } @@ -94,6 +100,7 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do context "when rcpt is a valid user" do let(:rcpt) { target_user.id } + let(:send_to_own_inbox) { false } context "when user allows anonymous questions" do let(:user_allows_anonymous_questions) { true } @@ -194,13 +201,18 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do context "when rcpt is followers" do let(:rcpt) { "followers" } + let(:followers) { FactoryBot.create_list(:user, 3) } + + before do + followers.each { |follower| follower.follow(user) } + end context "when anonymousQuestion is true" do let(:anonymous_question) { "true" } let(:expected_question_anonymous) { false } include_examples "creates the question", false - include_examples "enqueues a QuestionWorker job", "followers" + include_examples "enqueues SendToInboxJob jobs" end context "when anonymousQuestion is false" do @@ -208,12 +220,43 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do let(:expected_question_anonymous) { false } include_examples "creates the question", false - include_examples "enqueues a QuestionWorker job", "followers" + include_examples "enqueues SendToInboxJob jobs" + end + + context "when sendToOwnInbox is true" do + let(:anonymous_question) { "false" } + let(:expected_question_anonymous) { false } + let(:user_allows_anonymous_questions) { false } + let(:send_to_own_inbox) { "true" } + + include_examples "creates the question", false + + it "sends question to the current user" do + allow(SendToInboxJob).to receive(:perform_async) + subject + expect(SendToInboxJob).to have_received(:perform_async).with(user.id, Question.last.id) + end + end + + context "when sendToOwnInbox is false" do + let(:anonymous_question) { "false" } + let(:expected_question_anonymous) { false } + let(:user_allows_anonymous_questions) { false } + let(:send_to_own_inbox) { "false" } + + include_examples "creates the question", false + + it "sends question to the current user" do + allow(SendToInboxJob).to receive(:perform_async) + subject + expect(SendToInboxJob).not_to have_received(:perform_async).with(user.id, Question.last.id) + end end end context "when rcpt is an invalid value" do let(:rcpt) { "tripmeister_eder" } + let(:send_to_own_inbox) { false } let(:anonymous_question) { "false" } let(:expected_response) do { @@ -230,6 +273,7 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do context "when rcpt is a non-existent user" do let(:rcpt) { "-1" } + let(:send_to_own_inbox) { false } let(:anonymous_question) { "false" } let(:expected_response) do { @@ -375,7 +419,7 @@ describe Ajax::QuestionController, :ajax_controller, type: :controller do } end - include_examples "does not enqueue a QuestionWorker job" + include_examples "does not enqueue a SendToInboxJob job" end context "when rcpt is an invalid value" do diff --git a/spec/controllers/ajax/smile_controller_spec.rb b/spec/controllers/ajax/smile_controller_spec.rb deleted file mode 100644 index 9d6d0506..00000000 --- a/spec/controllers/ajax/smile_controller_spec.rb +++ /dev/null @@ -1,340 +0,0 @@ -# coding: utf-8 -# frozen_string_literal: true - -require "rails_helper" - -describe Ajax::SmileController, :ajax_controller, type: :controller do - describe "#create" do - let(:params) do - { - id: answer_id - }.compact - end - let(:answer) { FactoryBot.create(:answer, user: user) } - - subject { post(:create, params: params) } - - context "when user is signed in" do - before(:each) { sign_in(user) } - - context "when answer exists" do - let(:answer_id) { answer.id } - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end - - it "creates a smile to the answer" do - expect { subject }.to(change { Appendable::Reaction.count }.by(1)) - expect(answer.reload.smiles.ids).to include(Appendable::Reaction.last.id) - end - - include_examples "returns the expected response" - end - - context "when answer does not exist" do - let(:answer_id) { "nein!" } - - let(:expected_response) do - { - "success" => false, - "status" => anything, - "message" => anything - } - end - - it "does not create a smile" do - expect { subject }.not_to(change { Appendable::Reaction.count }) - end - - include_examples "returns the expected response" - end - - context "when some parameters are missing" do - let(:answer_id) { nil } - - let(:expected_response) do - { - "success" => false, - "status" => "parameter_error", - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - context "when user is not signed in" do - let(:answer_id) { answer.id } - - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - include_examples "returns the expected response" - end - - context "when blocked by the answer's author" do - let(:other_user) { FactoryBot.create(:user) } - let(:answer) { FactoryBot.create(:answer, user: other_user) } - let(:answer_id) { answer.id } - - before do - other_user.block(user) - end - - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - it "does not create a smile" do - expect { subject }.not_to(change { Appendable::Reaction.count }) - end - - include_examples "returns the expected response" - end - - context "when blocking the answer's author" do - let(:other_user) { FactoryBot.create(:user) } - let(:answer) { FactoryBot.create(:answer, user: user) } - let(:answer_id) { answer.id } - - before do - user.block(other_user) - end - - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - it "does not create a smile" do - expect { subject }.not_to(change { Appendable::Reaction.count }) - end - - include_examples "returns the expected response" - end - end - - describe "#destroy" do - let(:answer) { FactoryBot.create(:answer, user: user) } - let(:smile) { FactoryBot.create(:smile, user: user, parent: answer) } - let(:answer_id) { answer.id } - - let(:params) do - { - id: answer_id - } - end - - subject { delete(:destroy, params: params) } - - context "when user is signed in" do - before(:each) { sign_in(user) } - - context "when the smile exists" do - # ensure we already have it in the db - before(:each) { smile } - - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end - - it "deletes the smile" do - expect { subject }.to(change { Appendable::Reaction.count }.by(-1)) - end - - include_examples "returns the expected response" - end - - context "when the smile does not exist" do - let(:answer_id) { "sonic_the_hedgehog" } - - let(:expected_response) do - { - "success" => false, - "status" => anything, - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - context "when user is not signed in" do - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - describe "#create_comment" do - let(:params) do - { - id: comment_id - }.compact - end - let(:answer) { FactoryBot.create(:answer, user: user) } - let(:comment) { FactoryBot.create(:comment, user: user, answer: answer) } - - subject { post(:create_comment, params: params) } - - context "when user is signed in" do - before(:each) { sign_in(user) } - - context "when comment exists" do - let(:comment_id) { comment.id } - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end - - it "creates a smile to the comment" do - expect { subject }.to(change { Appendable::Reaction.count }.by(1)) - expect(comment.reload.smiles.ids).to include(Appendable::Reaction.last.id) - end - - include_examples "returns the expected response" - end - - context "when comment does not exist" do - let(:comment_id) { "nein!" } - - let(:expected_response) do - { - "success" => false, - "status" => anything, - "message" => anything - } - end - - it "does not create a smile" do - expect { subject }.not_to(change { Appendable::Reaction.count }) - end - - include_examples "returns the expected response" - end - - context "when some parameters are missing" do - let(:comment_id) { nil } - - let(:expected_response) do - { - "success" => false, - "status" => "parameter_error", - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - context "when user is not signed in" do - let(:comment_id) { comment.id } - - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - describe "#destroy_comment" do - let(:answer) { FactoryBot.create(:answer, user: user) } - let(:comment) { FactoryBot.create(:comment, user: user, answer: answer) } - let(:comment_smile) { FactoryBot.create(:comment_smile, user: user, parent: comment) } - let(:comment_id) { comment.id } - - let(:params) do - { - id: comment_id - } - end - - subject { delete(:destroy_comment, params: params) } - - context "when user is signed in" do - before(:each) { sign_in(user) } - - context "when the smile exists" do - # ensure we already have it in the db - before(:each) { comment_smile } - - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end - - it "deletes the smile" do - expect { subject }.to(change { Appendable::Reaction.count }.by(-1)) - end - - include_examples "returns the expected response" - end - - context "when the smile does not exist" do - let(:answer_id) { "sonic_the_hedgehog" } - - let(:expected_response) do - { - "success" => false, - "status" => anything, - "message" => anything - } - end - - include_examples "returns the expected response" - end - end - - context "when user is not signed in" do - let(:expected_response) do - { - "success" => false, - "status" => "fail", - "message" => anything - } - end - - include_examples "returns the expected response" - end - end -end diff --git a/spec/controllers/anonymous_block_controller_spec.rb b/spec/controllers/anonymous_block_controller_spec.rb index a1619a51..b024651c 100644 --- a/spec/controllers/anonymous_block_controller_spec.rb +++ b/spec/controllers/anonymous_block_controller_spec.rb @@ -15,7 +15,7 @@ describe AnonymousBlockController, type: :controller do context "when all required parameters are given" do let(:question) { FactoryBot.create(:question, author_identifier: "someidentifier") } - let!(:inbox) { FactoryBot.create(:inbox, user:, question:) } + let!(:inbox) { FactoryBot.create(:inbox_entry, user:, question:) } let(:params) do { question: question.id } end @@ -50,7 +50,7 @@ describe AnonymousBlockController, type: :controller do context "when blocking a user globally" do let(:question) { FactoryBot.create(:question, author_identifier: "someidentifier") } - let!(:inbox) { FactoryBot.create(:inbox, user:, question:) } + let!(:inbox) { FactoryBot.create(:inbox_entry, user:, question:) } let(:params) do { question: question.id, global: "true" } end diff --git a/spec/controllers/comments/reactions_controller_spec.rb b/spec/controllers/comments/reactions_controller_spec.rb new file mode 100644 index 00000000..083b89b5 --- /dev/null +++ b/spec/controllers/comments/reactions_controller_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Comments::ReactionsController, type: :controller do + describe "#index" do + let(:answer_author) { FactoryBot.create(:user) } + let(:answer) { FactoryBot.create(:answer, user: answer_author) } + let(:commenter) { FactoryBot.create(:user) } + let(:comment) { FactoryBot.create(:comment, answer:, user: commenter) } + + context "a regular web navigation request" do + subject { get :index, params: { username: commenter.screen_name, id: comment.id } } + + it "should redirect to the answer page" do + subject + + expect(response).to redirect_to answer_path(username: answer_author.screen_name, id: answer.id) + end + end + + context "a Turbo Frame request" do + subject { get :index, params: { username: commenter.screen_name, id: comment.id } } + + it "renders the index template" do + @request.headers["Turbo-Frame"] = "some_id" + + subject + + expect(response).to render_template(:index) + end + end + end +end diff --git a/spec/controllers/comments_controller_spec.rb b/spec/controllers/comments_controller_spec.rb new file mode 100644 index 00000000..209c1886 --- /dev/null +++ b/spec/controllers/comments_controller_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe CommentsController, type: :controller do + describe "#index" do + shared_examples_for "succeeds" do + it "returns the correct response" do + subject + expect(response).to have_rendered :index + expect(response).to have_http_status(200) + expect(assigns(:comments)).to eq(comments) + expect(assigns(:comments)).to_not include(unrelated_comment) + end + end + + subject { get :index, params: { username: answer_author.screen_name, id: answer.id } } + + let(:answer_author) { FactoryBot.create(:user) } + let(:answer) { FactoryBot.create(:answer, user: answer_author) } + let(:commenter) { FactoryBot.create(:user) } + let!(:comments) { FactoryBot.create_list(:comment, num_comments, answer:, user: commenter) } + let!(:unrelated_comment) do + FactoryBot.create(:comment, + answer: FactoryBot.create(:answer, user: FactoryBot.create(:user)), + user: commenter,) + end + + [0, 1, 5, 30].each do |num_comments| + context "#{num_comments} comments" do + let(:num_comments) { num_comments } + + include_examples "succeeds" + end + end + end +end diff --git a/spec/controllers/concerns/turbo_streamable_spec.rb b/spec/controllers/concerns/turbo_streamable_spec.rb index 3a2c9c60..19e0079c 100644 --- a/spec/controllers/concerns/turbo_streamable_spec.rb +++ b/spec/controllers/concerns/turbo_streamable_spec.rb @@ -6,7 +6,7 @@ describe TurboStreamable, type: :controller do controller do include TurboStreamable - turbo_stream_actions :create, :blocked, :not_found + turbo_stream_actions :create, :blocked, :not_found, :invalid_record def create params.require :message @@ -25,6 +25,10 @@ describe TurboStreamable, type: :controller do def not_found raise ActiveRecord::RecordNotFound end + + def invalid_record + MuteRule.create!(muted_phrase: "", user: FactoryBot.create(:user)) + end end before do @@ -32,6 +36,7 @@ describe TurboStreamable, type: :controller do get "create" => "anonymous#create" get "blocked" => "anonymous#blocked" get "not_found" => "anonymous#not_found" + get "invalid_record" => "anonymous#invalid_record" end end @@ -68,4 +73,5 @@ describe TurboStreamable, type: :controller do it_behaves_like "it returns a toast as Turbo Stream response", :create, "Message is required" it_behaves_like "it returns a toast as Turbo Stream response", :blocked, "You have been blocked from performing this request" it_behaves_like "it returns a toast as Turbo Stream response", :not_found, "Record not found" + it_behaves_like "it returns a toast as Turbo Stream response", :invalid_record, "too short" end diff --git a/spec/controllers/inbox_controller_spec.rb b/spec/controllers/inbox_controller_spec.rb index 94a8accf..689a70ae 100644 --- a/spec/controllers/inbox_controller_spec.rb +++ b/spec/controllers/inbox_controller_spec.rb @@ -46,7 +46,7 @@ describe InboxController, type: :controller do end context "when inbox has an amount of questions less than page size" do - let!(:inbox_entry) { Inbox.create(user:, new: true, question: FactoryBot.create(:question)) } + let!(:inbox_entry) { InboxEntry.create(user:, new: true, question: FactoryBot.create(:question)) } include_examples "sets the expected ivars" do let(:expected_assigns) do @@ -65,12 +65,7 @@ describe InboxController, type: :controller do expect { subject }.to change { inbox_entry.reload.new? }.from(true).to(false) end - it "updates the the timestamp used for caching" do - user.update(inbox_updated_at: original_inbox_updated_at) - travel 1.second do - expect { subject }.to change { user.reload.inbox_updated_at.floor }.from(original_inbox_updated_at.floor).to(Time.now.utc.floor) - end - end + include_examples "touches user timestamp", :inbox_updated_at context "when requested the turbo stream format" do subject { get :show, format: :turbo_stream } @@ -84,13 +79,13 @@ describe InboxController, type: :controller do context "when inbox has an amount of questions more than page size" do let(:inbox_entry_fillers_page1) do # 9 times => 1 entry less than default page size - 9.times.map { Inbox.create(user:, question: FactoryBot.create(:question)) } + 9.times.map { InboxEntry.create(user:, question: FactoryBot.create(:question)) } end - let(:last_inbox_entry_page1) { Inbox.create(user:, question: FactoryBot.create(:question)) } + let(:last_inbox_entry_page1) { InboxEntry.create(user:, question: FactoryBot.create(:question)) } let(:inbox_entry_fillers_page2) do - 5.times.map { Inbox.create(user:, question: FactoryBot.create(:question)) } + 5.times.map { InboxEntry.create(user:, question: FactoryBot.create(:question)) } end - let(:last_inbox_entry_page2) { Inbox.create(user:, question: FactoryBot.create(:question)) } + let(:last_inbox_entry_page2) { InboxEntry.create(user:, question: FactoryBot.create(:question)) } before do # create inbox entries in reverse so pagination works as expected @@ -108,7 +103,7 @@ describe InboxController, type: :controller do more_data_available: true, inbox_count: 16, delete_id: "ib-delete-all", - disabled: nil + disabled: nil, } end end @@ -124,7 +119,7 @@ describe InboxController, type: :controller do more_data_available: false, inbox_count: 16, delete_id: "ib-delete-all", - disabled: nil + disabled: nil, } end end @@ -136,52 +131,23 @@ describe InboxController, type: :controller do let!(:unrelated_user) { FactoryBot.create(:user) } let!(:generic_inbox_entry1) do - Inbox.create( + InboxEntry.create( user:, question: FactoryBot.create( :question, user: unrelated_user, - author_is_anonymous: false - ) + author_is_anonymous: false, + ), ) end - let!(:generic_inbox_entry2) { Inbox.create(user:, question: FactoryBot.create(:question)) } + let!(:generic_inbox_entry2) { InboxEntry.create(user:, question: FactoryBot.create(:question)) } subject { get :show, params: { author: author_param } } - context "with a nonexisting screen name" do - let(:author_param) { "xXx420MegaGamer2003xXx" } - - it "sets the error flash" do - subject - expect(flash[:error]).to eq "No user with the name @xXx420MegaGamer2003xXx found, showing entries from all users instead!" - end - - include_examples "sets the expected ivars" do - let(:expected_assigns) do - { - inbox: [generic_inbox_entry2, generic_inbox_entry1], - inbox_last_id: generic_inbox_entry1.id, - more_data_available: false, - inbox_count: 2, - delete_id: "ib-delete-all", - disabled: nil - } - end - end - end - context "with an existing screen name" do let(:author_param) { other_user.screen_name } context "with no questions from the other user in the inbox" do - it { is_expected.to redirect_to inbox_path } - - it "sets the info flash" do - subject - expect(flash[:info]).to eq "No questions from @#{other_user.screen_name} found, showing entries from all users instead!" - end - include_examples "sets the expected ivars" do # these are the ivars set before the redirect happened let(:expected_assigns) do @@ -189,7 +155,7 @@ describe InboxController, type: :controller do inbox: [], inbox_last_id: nil, more_data_available: false, - inbox_count: 0 + inbox_count: 0, } end end @@ -197,23 +163,16 @@ describe InboxController, type: :controller do context "with no non-anonymous questions from the other user in the inbox" do let!(:anonymous_inbox_entry) do - Inbox.create( + InboxEntry.create( user:, question: FactoryBot.create( :question, user: other_user, - author_is_anonymous: true - ) + author_is_anonymous: true, + ), ) end - it { is_expected.to redirect_to inbox_path } - - it "sets the info flash" do - subject - expect(flash[:info]).to eq "No questions from @#{other_user.screen_name} found, showing entries from all users instead!" - end - include_examples "sets the expected ivars" do # these are the ivars set before the redirect happened let(:expected_assigns) do @@ -221,7 +180,7 @@ describe InboxController, type: :controller do inbox: [], inbox_last_id: nil, more_data_available: false, - inbox_count: 0 + inbox_count: 0, } end end @@ -229,23 +188,23 @@ describe InboxController, type: :controller do context "with both non-anonymous and anonymous questions from the other user in the inbox" do let!(:non_anonymous_inbox_entry) do - Inbox.create( + InboxEntry.create( user:, question: FactoryBot.create( :question, user: other_user, - author_is_anonymous: false - ) + author_is_anonymous: false, + ), ) end let!(:anonymous_inbox_entry) do - Inbox.create( + InboxEntry.create( user:, question: FactoryBot.create( :question, user: other_user, - author_is_anonymous: true - ) + author_is_anonymous: true, + ), ) end @@ -257,13 +216,75 @@ describe InboxController, type: :controller do more_data_available: false, inbox_count: 1, delete_id: "ib-delete-all-author", - disabled: nil + disabled: nil, } end end end end end + + context "when passed the anonymous param" do + let!(:other_user) { FactoryBot.create(:user) } + let!(:generic_inbox_entry) do + InboxEntry.create( + user:, + question: FactoryBot.create( + :question, + user: other_user, + author_is_anonymous: false, + ), + ) + end + + let!(:inbox_entry_fillers) do + # 9 times => 1 entry less than default page size + 9.times.map { InboxEntry.create(user:, question: FactoryBot.create(:question, author_is_anonymous: true)) } + end + + subject { get :show, params: { anonymous: true } } + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + inbox: [*inbox_entry_fillers.reverse], + more_data_available: false, + inbox_count: 9, + } + end + end + end + + context "when passed the anonymous and the author param" do + let!(:other_user) { FactoryBot.create(:user) } + let!(:generic_inbox_entry) do + InboxEntry.create( + user:, + question: FactoryBot.create( + :question, + user: other_user, + author_is_anonymous: false, + ), + ) + end + + let!(:inbox_entry_fillers) do + # 9 times => 1 entry less than default page size + 9.times.map { InboxEntry.create(user:, question: FactoryBot.create(:question, author_is_anonymous: true)) } + end + + subject { get :show, params: { anonymous: true, author: "some_name" } } + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + inbox: [], + more_data_available: false, + inbox_count: 0, + } + end + end + end end end @@ -278,8 +299,10 @@ describe InboxController, type: :controller do before(:each) { sign_in(user) } it "creates an inbox entry" do - expect { subject }.to(change { user.inboxes.count }.by(1)) + expect { subject }.to(change { user.inbox_entries.count }.by(1)) end + + include_examples "touches user timestamp", :inbox_updated_at end end end diff --git a/spec/controllers/modal_controller_spec.rb b/spec/controllers/modal_controller_spec.rb new file mode 100644 index 00000000..d927173a --- /dev/null +++ b/spec/controllers/modal_controller_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ModalController, type: :controller do + describe "#close" do + context "a regular web navigation request" do + subject { get :close } + + it "should redirect to the root page" do + subject + + expect(response).to redirect_to root_path + end + end + + context "a Turbo Frame request" do + subject { get :close } + + it "renders the show_reaction template" do + @request.headers["Turbo-Frame"] = "some_id" + + subject + + expect(response.body).to include('') + end + end + end +end diff --git a/spec/controllers/moderation/inbox_controller_spec.rb b/spec/controllers/moderation/inbox_controller_spec.rb index 0cf89d2d..70b8f7ed 100644 --- a/spec/controllers/moderation/inbox_controller_spec.rb +++ b/spec/controllers/moderation/inbox_controller_spec.rb @@ -7,7 +7,7 @@ describe Moderation::InboxController do subject { get :index, params: params } let(:target_user) { FactoryBot.create(:user) } - let!(:inboxes) { FactoryBot.create_list(:inbox, 60, user: target_user) } + let!(:inboxes) { FactoryBot.create_list(:inbox_entry, 60, user: target_user) } let(:params) { { user: target_user.screen_name } } context "moderator signed in" do diff --git a/spec/controllers/moderation/reports_controller_spec.rb b/spec/controllers/moderation/reports_controller_spec.rb index 0a845606..daed0ab8 100644 --- a/spec/controllers/moderation/reports_controller_spec.rb +++ b/spec/controllers/moderation/reports_controller_spec.rb @@ -6,15 +6,116 @@ describe Moderation::ReportsController, type: :controller do let(:user) { FactoryBot.create :user, roles: ["moderator"] } describe "#index" do - subject { get :index } + shared_examples_for "sets the expected ivars" do + let(:expected_assigns) { {} } - before do - sign_in user + it "sets the expected ivars" do + subject + + expected_assigns.each do |name, value| + expect(assigns[name]).to eq(value) + end + end end - it "renders the moderation/reports/index template" do - subject - expect(response).to render_template("moderation/reports/index") + context "template rendering" do + let(:other_user) { FactoryBot.create :user } + let(:report) { Report.create(user:, target_id: other_user.id, type: "Reports::User") } + + subject { get :index } + + before do + report + sign_in user + end + + it "renders the moderation/reports/index template" do + subject + expect(response).to render_template("moderation/reports/index") + end + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + reports: [report], + reports_last_id: report.id, + } + end + end + end + + context "filtering for target users" do + let(:other_user) { FactoryBot.create :user } + let(:question) { FactoryBot.create :question } + let(:report) { Report.create(user:, target_id: other_user.id, target_user_id: other_user.id, type: "Reports::User") } + let(:report2) { Report.create(user:, target_id: question.id, target_user_id: nil, type: "Reports::Question") } + + subject { get :index, params: { target_user: other_user.screen_name } } + + before do + report + report2 + sign_in user + end + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + reports: [report], + reports_last_id: report.id, + } + end + end + end + + context "filtering for users" do + let(:report_user) { FactoryBot.create :user } + let(:other_user) { FactoryBot.create :user } + let(:question) { FactoryBot.create :question } + let(:report) { Report.create(user:, target_id: other_user.id, target_user_id: other_user.id, type: "Reports::User") } + let(:report2) { Report.create(user: report_user, target_id: question.id, target_user_id: nil, type: "Reports::Question") } + + subject { get :index, params: { user: report_user.screen_name } } + + before do + report + report2 + sign_in user + end + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + reports: [report2], + reports_last_id: report2.id, + } + end + end + end + + context "filtering for type" do + let(:report_user) { FactoryBot.create :user } + let(:other_user) { FactoryBot.create :user } + let(:question) { FactoryBot.create :question } + let(:report) { Report.create(user:, target_id: other_user.id, target_user_id: other_user.id, type: "Reports::User") } + let(:report2) { Report.create(user: report_user, target_id: question.id, target_user_id: nil, type: "Reports::Question") } + + subject { get :index, params: { type: "question" } } + + before do + report + report2 + sign_in user + end + + include_examples "sets the expected ivars" do + let(:expected_assigns) do + { + reports: [report2], + reports_last_id: report2.id, + } + end + end end end end diff --git a/spec/controllers/reactions_controller_spec.rb b/spec/controllers/reactions_controller_spec.rb new file mode 100644 index 00000000..1f391e40 --- /dev/null +++ b/spec/controllers/reactions_controller_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ReactionsController, type: :controller do + render_views + + describe "#index" do + shared_examples_for "succeeds" do + it "returns the correct response" do + subject + expect(response).to have_rendered :index + expect(response).to have_http_status(200) + end + end + + subject { get :index, params: { username: answer_author.screen_name, id: answer.id } } + + let(:answer_author) { FactoryBot.create(:user) } + let(:answer) { FactoryBot.create(:answer, user: answer_author) } + let!(:reactees) { FactoryBot.create_list(:user, num_comments) } + + [0, 1, 5, 30].each do |num_comments| + context "#{num_comments} reactions" do + let(:num_comments) { num_comments } + + before do + reactees.each { _1.smile(answer) } + end + + include_examples "succeeds" + end + end + end + + describe "#create" do + let(:user) { FactoryBot.create(:user) } + + context "target type is Answer" do + let(:params) do + { + username: user.screen_name, + id: answer_id, + type: "Answer", + }.compact + end + let(:answer) { FactoryBot.create(:answer, user:) } + + subject { post(:create, params:, format: :turbo_stream) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + context "when answer exists" do + let(:answer_id) { answer.id } + + it "creates a reaction to the answer" do + expect { subject }.to(change { Reaction.count }.by(1)) + expect(answer.reload.smiles.ids).to include(Reaction.last.id) + end + end + + context "when answer does not exist" do + let(:answer_id) { "nein!" } + + it "does not create a reaction" do + expect { subject }.not_to(change { Reaction.count }) + end + end + end + + context "when blocked by the answer's author" do + let(:other_user) { FactoryBot.create(:user) } + let(:answer) { FactoryBot.create(:answer, user: other_user) } + let(:answer_id) { answer.id } + + before do + other_user.block(user) + end + + it "does not create a reaction" do + expect { subject }.not_to(change { Reaction.count }) + end + end + + context "when blocking the answer's author" do + let(:other_user) { FactoryBot.create(:user) } + let(:answer) { FactoryBot.create(:answer, user:) } + let(:answer_id) { answer.id } + + before do + user.block(other_user) + end + + it "does not create a reaction" do + expect { subject }.not_to(change { Reaction.count }) + end + end + end + + context "target type is Comment" do + let(:params) do + { + username: user.screen_name, + id: comment_id, + type: "Comment", + }.compact + end + let(:answer) { FactoryBot.create(:answer, user:) } + let(:comment) { FactoryBot.create(:comment, user:, answer:) } + + subject { post(:create, params:, format: :turbo_stream) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + context "when comment exists" do + let(:comment_id) { comment.id } + + it "creates a smile to the comment" do + expect { subject }.to(change { Reaction.count }.by(1)) + expect(comment.reload.smiles.ids).to include(Reaction.last.id) + end + end + + context "when comment does not exist" do + let(:comment_id) { "nein!" } + + it "does not create a smile" do + expect { subject }.not_to(change { Reaction.count }) + end + end + end + end + end + + describe "#destroy" do + let(:user) { FactoryBot.create(:user) } + + context "target type is Answer" do + let(:answer) { FactoryBot.create(:answer, user:) } + let(:smile) { FactoryBot.create(:smile, user:, parent: answer) } + let(:answer_id) { answer.id } + + let(:params) do + { + username: user.screen_name, + id: answer_id, + type: "Answer", + } + end + + subject { delete(:destroy, params:, format: :turbo_stream) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + context "when the smile exists" do + # ensure we already have it in the db + before(:each) { smile } + + it "deletes the reaction" do + expect { subject }.to(change { Reaction.count }.by(-1)) + end + end + + context "when the reaction does not exist" do + let(:answer_id) { "sonic_the_hedgehog" } + + include_examples "turbo does not succeed", "Record not found" + end + end + end + + context "target type is Comment" do + let(:answer) { FactoryBot.create(:answer, user:) } + let(:comment) { FactoryBot.create(:comment, user:, answer:) } + let(:comment_smile) { FactoryBot.create(:comment_smile, user:, parent: comment) } + let(:comment_id) { comment.id } + + let(:params) do + { + username: user.screen_name, + id: comment_id, + type: "Comment", + } + end + + subject { delete(:destroy, params:, format: :turbo_stream) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + context "when the reaction exists" do + # ensure we already have it in the db + before(:each) { comment_smile } + + it "deletes the reaction" do + expect { subject }.to(change { Reaction.count }.by(-1)) + end + end + + context "when the reaction does not exist" do + let(:answer_id) { "sonic_the_hedgehog" } + + include_examples "turbo does not succeed", "Record not found" + end + end + end + end +end diff --git a/spec/controllers/ajax/relationship_controller_spec.rb b/spec/controllers/relationships_controller_spec.rb similarity index 82% rename from spec/controllers/ajax/relationship_controller_spec.rb rename to spec/controllers/relationships_controller_spec.rb index 11d2ddbd..9642b34b 100644 --- a/spec/controllers/ajax/relationship_controller_spec.rb +++ b/spec/controllers/relationships_controller_spec.rb @@ -3,11 +3,13 @@ require "rails_helper" -describe Ajax::RelationshipController, type: :controller do +describe RelationshipsController, type: :controller do + render_views + shared_examples_for "params is empty" do let(:params) { {} } - include_examples "ajax does not succeed", "is required" + include_examples "turbo does not succeed", "is required" end let!(:user) { FactoryBot.create(:user) } @@ -20,13 +22,13 @@ describe Ajax::RelationshipController, type: :controller do context "screen_name does not exist" do let(:screen_name) { "peter-witzig" } - include_examples "ajax does not succeed", "not found" + include_examples "turbo does not succeed", "not found" end context "screen_name is current user" do let(:screen_name) { user.screen_name } - include_examples "ajax does not succeed", "yourself" + include_examples "turbo does not succeed", "yourself" end context "screen_name is different from current_user" do @@ -41,9 +43,9 @@ describe Ajax::RelationshipController, type: :controller do let(:type) { "Sauerkraut" } let(:screen_name) { user2.screen_name } - let(:params) { { type: type, screen_name: screen_name } } + let(:params) { { type:, screen_name: } } - subject { post(:create, params: params) } + subject { post(:create, params:, format: :turbo_stream) } it_behaves_like "requires login" @@ -82,7 +84,7 @@ describe Ajax::RelationshipController, type: :controller do let(:type) { "dick" } it_behaves_like "params is empty" - include_examples "ajax does not succeed", "Invalid parameter" + include_examples "turbo does not succeed", "Invalid parameter" end end end @@ -110,15 +112,15 @@ describe Ajax::RelationshipController, type: :controller do context "screen_name does not exist" do let(:screen_name) { "peter-witzig" } - include_examples "ajax does not succeed", "not found" + include_examples "turbo does not succeed", "not found" end end let(:type) { "Sauerkraut" } let(:screen_name) { user2.screen_name } - let(:params) { { type: type, screen_name: screen_name } } + let(:params) { { type:, screen_name: } } - subject { delete(:destroy, params: params) } + subject { delete(:destroy, params:, format: :turbo_stream) } it_behaves_like "requires login" @@ -146,7 +148,7 @@ describe Ajax::RelationshipController, type: :controller do context "type = 'dick'" do let(:type) { "dick" } - include_examples "ajax does not succeed", "Invalid parameter" + include_examples "turbo does not succeed", "Invalid parameter" end end end diff --git a/spec/controllers/settings/export_controller_spec.rb b/spec/controllers/settings/export_controller_spec.rb index 0750ed6e..0bc87796 100644 --- a/spec/controllers/settings/export_controller_spec.rb +++ b/spec/controllers/settings/export_controller_spec.rb @@ -17,18 +17,20 @@ describe Settings::ExportController, type: :controller do end context "when user has a new DataExported notification" do - let(:notification) do + let!(:notification) do Notification::DataExported.create( target_id: user.id, target_type: "User::DataExport", recipient: user, - new: true + new: true, ) end it "marks the notification as read" do expect { subject }.to change { notification.reload.new }.from(true).to(false) end + + include_examples "touches user timestamp", :notifications_updated_at end end end diff --git a/spec/controllers/settings/privacy_controller_spec.rb b/spec/controllers/settings/privacy_controller_spec.rb index ad47b751..6ca5a979 100644 --- a/spec/controllers/settings/privacy_controller_spec.rb +++ b/spec/controllers/settings/privacy_controller_spec.rb @@ -13,7 +13,7 @@ describe Settings::PrivacyController, type: :controller do it "renders the edit template" do subject - expect(response).to render_template("edit") + expect(response).to render_template(:edit) end end end @@ -43,7 +43,7 @@ describe Settings::PrivacyController, type: :controller do it "redirects to the privacy settings page" do subject - expect(response).to redirect_to(:settings_privacy) + expect(response).to render_template(:edit) end end end diff --git a/spec/controllers/settings/profile_controller_spec.rb b/spec/controllers/settings/profile_controller_spec.rb index 299fecf7..f91b7eb4 100644 --- a/spec/controllers/settings/profile_controller_spec.rb +++ b/spec/controllers/settings/profile_controller_spec.rb @@ -37,7 +37,7 @@ describe Settings::ProfileController, type: :controller do it "redirects to the edit_user_profile page" do subject - expect(response).to redirect_to(:settings_profile) + expect(response).to render_template(:edit) end end end diff --git a/spec/controllers/settings/profile_picture_controller_spec.rb b/spec/controllers/settings/profile_picture_controller_spec.rb index 727e8f61..bb3f464d 100644 --- a/spec/controllers/settings/profile_picture_controller_spec.rb +++ b/spec/controllers/settings/profile_picture_controller_spec.rb @@ -32,7 +32,8 @@ describe Settings::ProfilePictureController, type: :controller do it "redirects to the edit_user_profile page" do subject - expect(response).to redirect_to(:settings_profile) + expect(response).to have_http_status(:ok) + expect(response).to have_rendered(:edit) end end end diff --git a/spec/controllers/settings/theme_controller_spec.rb b/spec/controllers/settings/theme_controller_spec.rb index a2d4451e..bf25644f 100644 --- a/spec/controllers/settings/theme_controller_spec.rb +++ b/spec/controllers/settings/theme_controller_spec.rb @@ -61,7 +61,7 @@ describe Settings::ThemeController, type: :controller do it "renders the edit template" do subject - expect(response).to redirect_to(:settings_theme) + expect(response).to render_template(:edit) end end @@ -75,7 +75,7 @@ describe Settings::ThemeController, type: :controller do it "renders the edit template" do subject - expect(response).to redirect_to(:settings_theme) + expect(response).to render_template(:edit) end end end diff --git a/spec/controllers/ajax/subscription_controller_spec.rb b/spec/controllers/subscriptions_controller_spec.rb similarity index 66% rename from spec/controllers/ajax/subscription_controller_spec.rb rename to spec/controllers/subscriptions_controller_spec.rb index 23499211..6b9951d4 100644 --- a/spec/controllers/ajax/subscription_controller_spec.rb +++ b/spec/controllers/subscriptions_controller_spec.rb @@ -2,41 +2,33 @@ require "rails_helper" -describe Ajax::SubscriptionController, :ajax_controller, type: :controller do +describe SubscriptionsController, type: :controller do # need to use a different user here, as after a create the user owning the # answer is automatically subscribed to it let(:answer_user) { FactoryBot.create(:user) } let(:answer) { FactoryBot.create(:answer, user: answer_user) } + let(:user) { FactoryBot.create(:user) } - describe "#subscribe" do + describe "#create" do let(:params) do { - answer: answer_id + answer: answer_id, } end - subject { post(:subscribe, params: params) } + subject { post(:create, params:, format: :turbo_stream) } context "when user is signed in" do before(:each) { sign_in(user) } context "when answer exists" do let(:answer_id) { answer.id } - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end context "when subscription does not exist" do it "creates a subscription on the answer" do expect { subject }.to(change { answer.subscriptions.count }.by(1)) expect(answer.subscriptions.map { |s| s.user.id }.sort).to eq([answer_user.id, user.id].sort) end - - include_examples "returns the expected response" end context "when subscription already exists" do @@ -46,26 +38,15 @@ describe Ajax::SubscriptionController, :ajax_controller, type: :controller do expect { subject }.to(change { answer.subscriptions.count }.by(0)) expect(answer.subscriptions.map { |s| s.user.id }.sort).to eq([answer_user.id, user.id].sort) end - - include_examples "returns the expected response" end end context "when answer does not exist" do let(:answer_id) { "Bielefeld" } - let(:expected_response) do - { - "success" => false, - "status" => "not_found", - "message" => anything - } - end it "does not create a new subscription" do expect { subject }.not_to(change { Subscription.count }) end - - include_examples "returns the expected response" end end @@ -79,27 +60,20 @@ describe Ajax::SubscriptionController, :ajax_controller, type: :controller do end end - describe "#unsubscribe" do + describe "#destroy" do let(:params) do { - answer: answer_id + answer: answer_id, } end - subject { post(:unsubscribe, params: params) } + subject { delete(:destroy, params:, format: :turbo_stream) } context "when user is signed in" do before(:each) { sign_in(user) } context "when answer exists" do let(:answer_id) { answer.id } - let(:expected_response) do - { - "success" => true, - "status" => "okay", - "message" => anything - } - end context "when subscription exists" do before(:each) { Subscription.subscribe(user, answer) } @@ -108,43 +82,22 @@ describe Ajax::SubscriptionController, :ajax_controller, type: :controller do expect { subject }.to(change { answer.subscriptions.count }.by(-1)) expect(answer.subscriptions.map { |s| s.user.id }.sort).to eq([answer_user.id].sort) end - - include_examples "returns the expected response" end context "when subscription does not exist" do - let(:expected_response) do - { - "success" => false, - "status" => "okay", - "message" => anything - } - end - it "does not modify the answer's subscriptions" do expect { subject }.to(change { answer.subscriptions.count }.by(0)) expect(answer.subscriptions.map { |s| s.user.id }.sort).to eq([answer_user.id].sort) end - - include_examples "returns the expected response" end end context "when answer does not exist" do let(:answer_id) { "Bielefeld" } - let(:expected_response) do - { - "success" => false, - "status" => "not_found", - "message" => anything - } - end it "does not create a new subscription" do expect { subject }.not_to(change { Subscription.count }) end - - include_examples "returns the expected response" end end diff --git a/spec/controllers/user/sessions_controller_spec.rb b/spec/controllers/user/sessions_controller_spec.rb index 66d6bdf0..a4a616f6 100644 --- a/spec/controllers/user/sessions_controller_spec.rb +++ b/spec/controllers/user/sessions_controller_spec.rb @@ -82,7 +82,7 @@ describe User::SessionsController do it "redirects to the sign in page" do expect(subject).to redirect_to :new_user_session - expect(flash[:notice]).to eq "#{I18n.t('user.sessions.create.banned', name: user.screen_name)}\n#{I18n.t('user.sessions.create.reason', reason: 'Do not feed the animals')}" + expect(flash[:notice]).to eq "#{I18n.t('user.sessions.create.banned', name: user.screen_name)}\n#{I18n.t('user.sessions.create.reason', reason: 'Do not feed the animals')}\n#{I18n.t('user.sessions.create.permanent')}" end end diff --git a/spec/factories/comment_smile.rb b/spec/factories/comment_smile.rb index 6923e121..09c65313 100644 --- a/spec/factories/comment_smile.rb +++ b/spec/factories/comment_smile.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :comment_smile, class: Appendable::Reaction do + factory :comment_smile, class: Reaction do user { FactoryBot.build(:user) } parent { FactoryBot.build(:comment) } content { "🙂" } diff --git a/spec/factories/inbox.rb b/spec/factories/inbox_entry.rb similarity index 82% rename from spec/factories/inbox.rb rename to spec/factories/inbox_entry.rb index 6345fa72..182e69c2 100644 --- a/spec/factories/inbox.rb +++ b/spec/factories/inbox_entry.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :inbox do + factory :inbox_entry do question { FactoryBot.build(:question) } new { true } end diff --git a/spec/factories/smile.rb b/spec/factories/smile.rb index 43bd9bf4..1726e9fa 100644 --- a/spec/factories/smile.rb +++ b/spec/factories/smile.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :smile, class: Appendable::Reaction do + factory :smile, class: Reaction do user { FactoryBot.create(:user) } parent { FactoryBot.create(:answer, user: FactoryBot.create(:user)) } content { "🙂" } diff --git a/spec/helpers/application_helper/graph_methods_spec.rb b/spec/helpers/application_helper/graph_methods_spec.rb index 63baa848..1d79b78c 100644 --- a/spec/helpers/application_helper/graph_methods_spec.rb +++ b/spec/helpers/application_helper/graph_methods_spec.rb @@ -1,20 +1,22 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" -describe ApplicationHelper::GraphMethods, :type => :helper do +describe ApplicationHelper::GraphMethods, type: :helper do describe "#user_opengraph" do context "sample user" do - let(:user) { FactoryBot.create(:user, - profile: { display_name: 'Cunes', - description: 'A bunch of raccoons in a trenchcoat.' }, - screen_name: 'raccoons') } + let(:user) do + FactoryBot.create(:user, + profile: { display_name: "Cunes", + description: "A bunch of raccoons in a trenchcoat.", }, + screen_name: "raccoons",) + end subject { user_opengraph(user) } - it 'should generate a matching OpenGraph structure for a user' do - allow(APP_CONFIG).to receive(:[]).with('site_name').and_return('pineapplespring') - expect(subject).to eq(<<~EOS.chomp) + it "should generate a matching OpenGraph structure for a user" do + allow(APP_CONFIG).to receive(:[]).with("site_name").and_return("pineapplespring") + expect(subject).to eq(<<~META.chomp) @@ -22,55 +24,63 @@ describe ApplicationHelper::GraphMethods, :type => :helper do - EOS + META end end end describe "#user_twitter_card" do context "sample user" do - let(:user) { FactoryBot.create(:user, - profile: { - display_name: '', - description: 'A bunch of raccoons in a trenchcoat.'}, - screen_name: 'raccoons') } + let(:user) do + FactoryBot.create(:user, + profile: { + display_name: "", + description: "A bunch of raccoons in a trenchcoat.", + }, + screen_name: "raccoons",) + end subject { user_twitter_card(user) } - it 'should generate a matching OpenGraph structure for a user' do - expect(subject).to eq(<<~EOS.chomp) + it "should generate a matching OpenGraph structure for a user" do + expect(subject).to eq(<<~META.chomp) - EOS + META end end end describe "#answer_opengraph" do context "sample user and answer" do - let!(:user) { FactoryBot.create(:user, - profile: { - display_name: '', - description: 'A bunch of raccoons in a trenchcoat.'}, - screen_name: 'raccoons') } - let(:answer) { FactoryBot.create(:answer, - user_id: user.id,) } + let!(:user) do + FactoryBot.create(:user, + profile: { + display_name: "", + description: "A bunch of raccoons in a trenchcoat.", + }, + screen_name: "raccoons",) + end + let(:answer) do + FactoryBot.create(:answer, + user_id: user.id,) + end subject { answer_opengraph(answer) } - it 'should generate a matching OpenGraph structure for a user' do - allow(APP_CONFIG).to receive(:[]).with('site_name').and_return('pineapplespring') - expect(subject).to eq(<<~EOS.chomp) + it "should generate a matching OpenGraph structure for a user" do + allow(APP_CONFIG).to receive(:[]).with("site_name").and_return("pineapplespring") + expect(subject).to eq(<<~META.chomp) - EOS + META end end end -end \ No newline at end of file +end diff --git a/spec/helpers/application_helper/title_methods_spec.rb b/spec/helpers/application_helper/title_methods_spec.rb index 2b3c8bf0..92dc74a8 100644 --- a/spec/helpers/application_helper/title_methods_spec.rb +++ b/spec/helpers/application_helper/title_methods_spec.rb @@ -12,8 +12,8 @@ describe ApplicationHelper::TitleMethods, type: :helper do "anonymous_name" => "Anonymous", "https" => true, "items_per_page" => 5, - "sharing" => {} - }) + "sharing" => {}, + },) user.profile.display_name = "Cool Man" user.profile.save! @@ -42,7 +42,7 @@ describe ApplicationHelper::TitleMethods, type: :helper do context "user has custom anonymous display name" do before do - FactoryBot.create(:answer, question: question, user: user) + FactoryBot.create(:answer, question:, user:) user.profile.anon_display_name = "Amogus" user.profile.save! end @@ -55,9 +55,9 @@ describe ApplicationHelper::TitleMethods, type: :helper do describe "#answer_title" do let(:answer) do - FactoryBot.create(:answer, user: user, + FactoryBot.create(:answer, user:, content: "a", - question_content: "q") + question_content: "q",) end it "should generate a proper title" do diff --git a/spec/helpers/bootstrap_helper_spec.rb b/spec/helpers/bootstrap_helper_spec.rb index b996f162..02e98f65 100644 --- a/spec/helpers/bootstrap_helper_spec.rb +++ b/spec/helpers/bootstrap_helper_spec.rb @@ -1,107 +1,110 @@ +# frozen_string_literal: true + require "rails_helper" -describe BootstrapHelper, :type => :helper do +describe BootstrapHelper, type: :helper do include ActiveSupport::Testing::TimeHelpers - describe '#nav_entry' do - it 'should return a HTML navigation item which links to a given address' do + describe "#nav_entry" do + it "should return a HTML navigation item which links to a given address" do allow(self).to receive(:current_page?).and_return(false) - expect(nav_entry('Example', '/example')).to( - eq('') + expect(nav_entry("Example", "/example")).to( + eq(''), ) end - it 'should return with an active attribute if the link matches the current URL' do + it "should return with an active attribute if the link matches the current URL" do allow(self).to receive(:current_page?).and_return(true) - expect(nav_entry('Example', '/example')).to( - eq('') + expect(nav_entry("Example", "/example")).to( + eq(''), ) end - it 'should include an icon if given' do + it "should include an icon if given" do allow(self).to receive(:current_page?).and_return(false) - expect(nav_entry('Example', '/example', icon: 'beaker')).to( - eq('') + expect(nav_entry("Example", "/example", icon: "beaker")).to( + eq(''), ) end - it 'should only include an icon if wanted' do + it "should only include an icon if wanted" do allow(self).to receive(:current_page?).and_return(false) - expect(nav_entry('Example', '/example', icon: 'beaker', icon_only: true)).to( - eq('') + expect(nav_entry("Example", "/example", icon: "beaker", icon_only: true)).to( + eq(''), ) end - it 'should include a badge if given' do + it "should include a badge if given" do allow(self).to receive(:current_page?).and_return(false) - expect(nav_entry('Example', '/example', badge: 3)).to( - eq('') + expect(nav_entry("Example", "/example", badge: 3)).to( + eq(''), ) - expect(nav_entry('Example', '/example', badge: 3, badge_color: 'primary', badge_pill: true)).to( - eq('') + expect(nav_entry("Example", "/example", badge: 3, badge_color: "primary", badge_pill: true)).to( + eq(''), + ) + end + + it "should put an ID on the entry an id if given" do + allow(self).to receive(:current_page?).and_return(false) + expect(nav_entry("Example", "/example", id: "testing")).to( + eq("
  • Example
  • "), ) end end describe "#list_group_item" do - it 'should return a HTML navigation item which links to a given address' do + it "should return a HTML navigation item which links to a given address" do allow(self).to receive(:current_page?).and_return(false) - expect(list_group_item('Example', '/example')).to( - eq('Example') + expect(list_group_item("Example", "/example")).to( + eq('Example'), ) end - it 'should return with an active attribute if the link matches the current URL' do + it "should return with an active attribute if the link matches the current URL" do allow(self).to receive(:current_page?).and_return(true) - expect(list_group_item('Example', '/example')).to( - eq('Example') + expect(list_group_item("Example", "/example")).to( + eq('Example'), ) end - it 'should include a badge if given' do + it "should include a badge if given" do allow(self).to receive(:current_page?).and_return(false) - expect(list_group_item('Example', '/example', badge: 3)).to( - eq('Example 3') + expect(list_group_item("Example", "/example", badge: 3)).to( + eq('Example 3'), ) end end describe "#bootstrap_color" do - it 'should map error and alert to danger' do + it "should map error and alert to danger" do expect(bootstrap_color("error")).to eq("danger") expect(bootstrap_color("alert")).to eq("danger") end - it 'should map notice to info' do + it "should map notice to info" do expect(bootstrap_color("notice")).to eq("info") end - it 'should return any uncovered value' do + it "should return any uncovered value" do expect(bootstrap_color("success")).to eq("success") end end describe "#tooltip" do - it 'should return the proper markup' do - expect(tooltip("Example Text", "This is in a tooltip")).to eq("Example Text") + it "should return the proper markup" do + expect(tooltip("Example Text", "This is in a tooltip")).to eq("Example Text") end end describe "#time_tooltip" do - it 'should return a tooltip with proper time values' do + it "should return a tooltip with proper time values" do travel_to(Time.utc(1984)) do @user = FactoryBot.create(:user) travel 10.minutes - expect(time_tooltip(@user)).to eq("10 minutes") + expect(time_tooltip(@user)).to eq("10m") end end end - - describe "#hidespan" do - it 'should return the proper markup' do - expect(hidespan("Hidden Text", "d-none")).to eq("Hidden Text") - end - end end diff --git a/spec/helpers/feedback_helper_spec.rb b/spec/helpers/feedback_helper_spec.rb index 13d2245e..c5aa16df 100644 --- a/spec/helpers/feedback_helper_spec.rb +++ b/spec/helpers/feedback_helper_spec.rb @@ -11,9 +11,9 @@ describe FeedbackHelper, type: :helper do "canny" => { sso: "sso", feature_board: "feature", - bug_board: "bug" - } - }) + bug_board: "bug", + }, + },) end describe "#canny_token" do diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index b4c22b76..39270551 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -10,8 +10,8 @@ describe MarkdownHelper, type: :helper do "items_per_page" => 5, "allowed_hosts" => [ "twitter.com" - ] - }) + ], + },) end describe "#markdown" do diff --git a/spec/helpers/social_helper/bluesky_methods_spec.rb b/spec/helpers/social_helper/bluesky_methods_spec.rb new file mode 100644 index 00000000..b2457089 --- /dev/null +++ b/spec/helpers/social_helper/bluesky_methods_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe SocialHelper::BlueskyMethods, type: :helper do + include SocialHelper::TwitterMethods + + let(:user) { FactoryBot.create(:user) } + let(:question_content) { "q" * 255 } + let(:answer_content) { "a" * 255 } + let(:answer) do + FactoryBot.create(:answer, user:, + content: answer_content, + question_content:,) + end + + before do + stub_const("APP_CONFIG", { + "hostname" => "example.com", + "https" => true, + "items_per_page" => 5, + },) + end + + describe "#bluesky_share_url" do + subject { bluesky_share_url(answer) } + + it "should return a proper share link" do + expect(subject).to eq("https://bsky.app/intent/compose?text=#{CGI.escape(prepare_tweet(answer))}") + end + end +end diff --git a/spec/helpers/social_helper/telegram_methods_spec.rb b/spec/helpers/social_helper/telegram_methods_spec.rb index 5e180373..3a72cff8 100644 --- a/spec/helpers/social_helper/telegram_methods_spec.rb +++ b/spec/helpers/social_helper/telegram_methods_spec.rb @@ -9,7 +9,7 @@ describe SocialHelper::TelegramMethods, type: :helper do :answer, user:, content: "this is an answer\nwith multiple lines\nand **FORMATTING**", - question_content: "this is a question .... or is it?" + question_content: "this is a question .... or is it?", ) end @@ -18,7 +18,7 @@ describe SocialHelper::TelegramMethods, type: :helper do "hostname" => "example.com", "https" => true, "items_per_page" => 5, - }) + },) end describe "#telegram_text" do diff --git a/spec/helpers/social_helper/tumblr_methods_spec.rb b/spec/helpers/social_helper/tumblr_methods_spec.rb index 512a35e7..29ba43d0 100644 --- a/spec/helpers/social_helper/tumblr_methods_spec.rb +++ b/spec/helpers/social_helper/tumblr_methods_spec.rb @@ -1,33 +1,35 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" -describe SocialHelper::TumblrMethods, :type => :helper do +describe SocialHelper::TumblrMethods, type: :helper do let(:user) { FactoryBot.create(:user) } - let(:answer) { FactoryBot.create(:answer, user: user, - content: 'aaaa', - question_content: 'q') } + let(:answer) do + FactoryBot.create(:answer, user:, + content: "aaaa", + question_content: "q",) + end before do stub_const("APP_CONFIG", { - 'hostname' => 'example.com', - 'anonymous_name' => 'Anonymous', - 'https' => true, - 'items_per_page' => 5, - 'sharing' => {} - }) + "hostname" => "example.com", + "anonymous_name" => "Anonymous", + "https" => true, + "items_per_page" => 5, + "sharing" => {}, + },) end - describe '#tumblr_title' do - context 'Asker is anonymous' do + describe "#tumblr_title" do + context "Asker is anonymous" do subject { tumblr_title(answer) } - it 'should return a proper title' do - expect(subject).to eq('Anonymous asked: q') + it "should return a proper title" do + expect(subject).to eq("Anonymous asked: q") end end - context 'Asker is known' do + context "Asker is known" do before do @user = FactoryBot.create(:user) answer.question.user = @user @@ -36,24 +38,24 @@ describe SocialHelper::TumblrMethods, :type => :helper do subject { tumblr_title(answer) } - it 'should return a proper title' do + it "should return a proper title" do expect(subject).to eq("#{answer.question.user.profile.display_name} asked: q") end end end - describe '#tumblr_body' do + describe "#tumblr_body" do subject { tumblr_body(answer) } - it 'should return a proper body' do + it "should return a proper body" do expect(subject).to eq("aaaa\n\n[Smile or comment on the answer here](https://example.com/@#{answer.user.screen_name}/a/#{answer.id})") end end - describe '#tumblr_share_url' do + describe "#tumblr_share_url" do subject { tumblr_share_url(answer) } - it 'should return a proper share link' do + it "should return a proper share link" do expect(subject).to eq("https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title=#{CGI.escape(tumblr_title(answer))}&url=#{CGI.escape("https://example.com/@#{answer.user.screen_name}/a/#{answer.id}")}&caption=&content=#{CGI.escape(tumblr_body(answer))}") end end diff --git a/spec/helpers/social_helper/twitter_methods_spec.rb b/spec/helpers/social_helper/twitter_methods_spec.rb index 7074f6e6..6157298f 100644 --- a/spec/helpers/social_helper/twitter_methods_spec.rb +++ b/spec/helpers/social_helper/twitter_methods_spec.rb @@ -1,72 +1,85 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" -describe SocialHelper::TwitterMethods, :type => :helper do +describe SocialHelper::TwitterMethods, type: :helper do let(:user) { FactoryBot.create(:user) } - let(:question_content) { 'q' * 255 } - let(:answer_content) { 'a' * 255 } - let(:answer) { FactoryBot.create(:answer, user: user, - content: answer_content, - question_content: question_content) } + let(:question_content) { "q" * 255 } + let(:answer_content) { "a" * 255 } + let(:answer) do + FactoryBot.create(:answer, user:, + content: answer_content, + question_content:,) + end before do stub_const("APP_CONFIG", { - 'hostname' => 'example.com', - 'https' => true, - 'items_per_page' => 5 - }) + "hostname" => "example.com", + "https" => true, + "items_per_page" => 5, + },) end - describe '#prepare_tweet' do - context 'when the question and answer need to be shortened' do + describe "#prepare_tweet" do + context "when the question and answer need to be shortened" do subject { prepare_tweet(answer) } - it 'should return a properly formatted tweet' do + it "should return a properly formatted tweet" do expect(subject).to eq("#{'q' * 123}… — #{'a' * 124}… https://example.com/@#{user.screen_name}/a/#{answer.id}") end end - context 'when a suffix has been passed' do - let(:question_content) { 'question' } - let(:answer_content) { 'answer' } + context "when a suffix has been passed" do + let(:question_content) { "question" } + let(:answer_content) { "answer" } - subject { prepare_tweet(answer, '#askracc') } + subject { prepare_tweet(answer, "#askracc") } - it 'should include the suffix after the link' do + it "should include the suffix after the link" do expect(subject).to eq("question — answer #askracc https://example.com/@#{user.screen_name}/a/#{answer.id}") end end - context 'when a suffix has been passed and the tweet needs to be shortened' do - subject { prepare_tweet(answer, '#askracc') } + context "when the url should be omitted" do + let(:question_content) { "question" } + let(:answer_content) { "answer" } - it 'should shorten the tweet while keeping the suffix intact' do + subject { prepare_tweet(answer, nil, true) } + + it "should include the suffix after the link" do + expect(subject).to eq("question — answer") + end + end + + context "when a suffix has been passed and the tweet needs to be shortened" do + subject { prepare_tweet(answer, "#askracc") } + + it "should shorten the tweet while keeping the suffix intact" do expect(subject).to eq("#{'q' * 120}… — #{'a' * 120}… #askracc https://example.com/@#{user.screen_name}/a/#{answer.id}") end end - context 'when the question and answer are short' do + context "when the question and answer are short" do before do - answer.question.content = 'Why are raccoons so good?' + answer.question.content = "Why are raccoons so good?" answer.question.save! - answer.content = 'Because they are good cunes.' + answer.content = "Because they are good cunes." answer.save! end subject { prepare_tweet(answer) } - it 'should return a properly formatted tweet' do + it "should return a properly formatted tweet" do expect(subject).to eq("#{answer.question.content} — #{answer.content} https://example.com/@#{user.screen_name}/a/#{answer.id}") end end end - describe '#twitter_share_url' do + describe "#twitter_share_url" do subject { twitter_share_url(answer) } - it 'should return a proper share link' do + it "should return a proper share link" do expect(subject).to eq("https://twitter.com/intent/tweet?text=#{CGI.escape(prepare_tweet(answer))}") end end -end \ No newline at end of file +end diff --git a/spec/helpers/social_helper_spec.rb b/spec/helpers/social_helper_spec.rb new file mode 100644 index 00000000..b56d6ab3 --- /dev/null +++ b/spec/helpers/social_helper_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe SocialHelper, type: :helper do + let(:user) { FactoryBot.create(:user) } + let(:answer) do + FactoryBot.create( + :answer, + user:, + content: "this is an answer\nwith multiple lines\nand **FORMATTING**", + question_content: "this is a question .... or is it?", + ) + end + + before do + stub_const("APP_CONFIG", { + "hostname" => "example.com", + "https" => true, + "items_per_page" => 5, + },) + end + + describe "#answer_share_url" do + subject { answer_share_url(answer) } + + it "returns a proper share link" do + expect(subject).to eq(<<~URL.strip) + https://example.com/@#{answer.user.screen_name}/a/#{answer.id} + URL + end + end +end diff --git a/spec/helpers/theme_helper_spec.rb b/spec/helpers/theme_helper_spec.rb index 3a72ed34..f7de9441 100644 --- a/spec/helpers/theme_helper_spec.rb +++ b/spec/helpers/theme_helper_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ThemeHelper, :type => :helper do +describe ThemeHelper, type: :helper do describe "#render_theme" do context "when target page doesn't have a theme" do it "returns no theme" do @@ -18,7 +18,7 @@ describe ThemeHelper, :type => :helper do end it "returns a theme" do - expect(helper.render_theme).to include('