This commit is contained in:
Kay Faraday 2023-08-13 05:56:32 +00:00
commit d900bd8eb4
152 changed files with 2608 additions and 1156 deletions

View File

@ -20,7 +20,7 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- name: Discover build-time variables
run: |
@ -37,11 +37,12 @@ jobs:
;;
esac
- name: Login to Docker Hub
- name: Login to registry
uses: docker/login-action@v2
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
if: github.event_name != 'pull_request'
- name: Build and push
@ -54,4 +55,4 @@ jobs:
context: .
file: Containerfile
push: ${{ github.event_name != 'pull_request' }}
tags: retrospring/retrospring:${{ env.RETROSPRING_VERSION }}
tags: ghcr.io/retrospring/retrospring:${{ env.RETROSPRING_VERSION }}

View File

@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3.3.0
uses: actions/checkout@v3.5.3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@ -11,70 +11,102 @@ jobs:
name: Rubocop
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v37
with:
files: "**/*.rb"
- name: Install dependencies
run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev
if: steps.changed-files.outputs.any_changed == 'true'
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
if: steps.changed-files.outputs.any_changed == 'true'
- name: Run rubocop
uses: reviewdog/action-rubocop@v2
with:
rubocop_version: gemfile
rubocop_extensions: rubocop-rails:gemfile
reporter: github-pr-check
if: steps.changed-files.outputs.any_changed == 'true'
eslint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v37
with:
files: "**/*.ts"
- name: Set up Node 14
uses: actions/setup-node@v3
with:
node-version: '14'
cache: 'yarn'
if: steps.changed-files.outputs.any_changed == 'true'
- name: Install node modules
run: |
npm i -g yarn
yarn install --frozen-lockfile
if: steps.changed-files.outputs.any_changed == 'true'
- uses: reviewdog/action-eslint@v1
with:
reporter: github-check
eslint_flags: '--ext .ts app/javascript'
if: steps.changed-files.outputs.any_changed == 'true'
haml-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v37
with:
files: "**/*.haml"
- name: Install dependencies
run: sudo apt update && sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev libmagickwand-dev imagemagick libidn11-dev
if: steps.changed-files.outputs.any_changed == 'true'
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: patch-technology/action-haml-lint@0.4
if: steps.changed-files.outputs.any_changed == 'true'
- uses: patch-technology/action-haml-lint@0.5
with:
reporter: github-check
rubocop_version: gemfile
if: steps.changed-files.outputs.any_changed == 'true'
stylelint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v37
with:
files: "**/*.scss"
- name: Set up Node 14
uses: actions/setup-node@v3
with:
node-version: '14'
cache: 'yarn'
if: steps.changed-files.outputs.any_changed == 'true'
- name: Install node modules
run: |
npm i -g yarn
yarn install --frozen-lockfile
if: steps.changed-files.outputs.any_changed == 'true'
- name: stylelint
uses: pixeldesu/action-stylelint@5ec750b03a94da735352bdb02e9dfc3d5af33aba
uses: reviewdog/action-stylelint@v1.17.1
with:
github_token: ${{ secrets.github_token }}
reporter: github-pr-check
stylelint_input: 'app/assets/stylesheets/**/*.scss'
if: steps.changed-files.outputs.any_changed == 'true'

View File

@ -41,7 +41,7 @@ jobs:
BUNDLE_WITHOUT: 'production'
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/checkout@v3.5.3
- 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

View File

@ -134,3 +134,6 @@ Style/EndlessMethod:
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: consistent_comma
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: consistent_comma

View File

@ -2,6 +2,11 @@
FROM registry.opensuse.org/opensuse/leap:15.4
LABEL org.opencontainers.image.title="Retrospring (production)"
LABEL org.opencontainers.image.description="Image containing everything to run Retrospring in production mode. Do not use this for development."
LABEL org.opencontainers.image.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

14
Gemfile
View File

@ -6,7 +6,7 @@ gem "i18n-js", "4.0"
gem "rails", "~> 6.1"
gem "rails-i18n", "~> 7.0"
gem "cssbundling-rails", "~> 1.1"
gem "cssbundling-rails", "~> 1.2"
gem "jsbundling-rails", "~> 1.1"
gem "sassc-rails"
gem "sprockets", "~> 4.1"
@ -16,7 +16,7 @@ gem "pg"
gem "turbo-rails"
gem "bcrypt", "~> 3.1.18"
gem "bcrypt", "~> 3.1.19"
gem "active_model_otp"
gem "bootsnap", require: false
@ -24,7 +24,7 @@ gem "bootstrap_form", "~> 5.0"
gem "carrierwave", "~> 2.0"
gem "carrierwave_backgrounder", git: "https://github.com/raccube/carrierwave_backgrounder.git"
gem "colorize"
gem "devise", "~> 4.0"
gem "devise", "~> 4.9"
gem "devise-async"
gem "devise-i18n"
gem "fog-aws"
@ -42,8 +42,6 @@ gem "rolify", "~> 6.0"
gem "dry-initializer", "~> 3.1"
gem "dry-types", "~> 1.7"
gem "ruby-progressbar"
gem "pghero"
gem "rails_admin"
gem "sentry-rails"
@ -92,8 +90,8 @@ group :development, :test do
gem "rspec-mocks"
gem "rspec-rails", "~> 6.0"
gem "rspec-sidekiq", "~> 3.0", require: false
gem "rubocop", "~> 1.45"
gem "rubocop-rails", "~> 2.17"
gem "rubocop", "~> 1.55"
gem "rubocop-rails", "~> 2.20"
gem "shoulda-matchers", "~> 5.3"
gem "simplecov", require: false
gem "simplecov-cobertura", require: false
@ -118,4 +116,4 @@ gem "openssl", "~> 3.1"
# mail 2.8.0 breaks sendmail usage: https://github.com/mikel/mail/issues/1538
gem "mail", "~> 2.7.1"
gem "prometheus-client", "~> 4.0"
gem "prometheus-client", "~> 4.2"

View File

@ -15,80 +15,80 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.7.2)
actionpack (= 6.1.7.2)
activesupport (= 6.1.7.2)
actioncable (6.1.7.4)
actionpack (= 6.1.7.4)
activesupport (= 6.1.7.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.2)
actionpack (= 6.1.7.2)
activejob (= 6.1.7.2)
activerecord (= 6.1.7.2)
activestorage (= 6.1.7.2)
activesupport (= 6.1.7.2)
actionmailbox (6.1.7.4)
actionpack (= 6.1.7.4)
activejob (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
mail (>= 2.7.1)
actionmailer (6.1.7.2)
actionpack (= 6.1.7.2)
actionview (= 6.1.7.2)
activejob (= 6.1.7.2)
activesupport (= 6.1.7.2)
actionmailer (6.1.7.4)
actionpack (= 6.1.7.4)
actionview (= 6.1.7.4)
activejob (= 6.1.7.4)
activesupport (= 6.1.7.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.7.2)
actionview (= 6.1.7.2)
activesupport (= 6.1.7.2)
actionpack (6.1.7.4)
actionview (= 6.1.7.4)
activesupport (= 6.1.7.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.2)
actionpack (= 6.1.7.2)
activerecord (= 6.1.7.2)
activestorage (= 6.1.7.2)
activesupport (= 6.1.7.2)
actiontext (6.1.7.4)
actionpack (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
nokogiri (>= 1.8.5)
actionview (6.1.7.2)
activesupport (= 6.1.7.2)
actionview (6.1.7.4)
activesupport (= 6.1.7.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.1)
active_model_otp (2.3.2)
activemodel
rotp (~> 6.2.0)
activejob (6.1.7.2)
activesupport (= 6.1.7.2)
activejob (6.1.7.4)
activesupport (= 6.1.7.4)
globalid (>= 0.3.6)
activemodel (6.1.7.2)
activesupport (= 6.1.7.2)
activemodel (6.1.7.4)
activesupport (= 6.1.7.4)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (6.1.7.2)
activemodel (= 6.1.7.2)
activesupport (= 6.1.7.2)
activestorage (6.1.7.2)
actionpack (= 6.1.7.2)
activejob (= 6.1.7.2)
activerecord (= 6.1.7.2)
activesupport (= 6.1.7.2)
activerecord (6.1.7.4)
activemodel (= 6.1.7.4)
activesupport (= 6.1.7.4)
activestorage (6.1.7.4)
actionpack (= 6.1.7.4)
activejob (= 6.1.7.4)
activerecord (= 6.1.7.4)
activesupport (= 6.1.7.4)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.7.2)
activesupport (6.1.7.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
bcrypt (3.1.18)
better_errors (2.9.1)
coderay (>= 1.0.0)
bcrypt (3.1.19)
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)
@ -108,22 +108,21 @@ GEM
mimemagic (>= 0.3.0)
mini_mime (>= 0.1.3)
chunky_png (1.4.0)
coderay (1.1.3)
colorize (0.8.1)
concurrent-ruby (1.2.0)
connection_pool (2.3.0)
colorize (1.1.0)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
crass (1.0.6)
cssbundling-rails (1.1.2)
cssbundling-rails (1.2.0)
railties (>= 6.0.0)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.1)
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.8.1)
devise (4.9.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@ -132,7 +131,7 @@ GEM
devise-async (1.0.0)
activejob (>= 5.0)
devise (>= 4.0)
devise-i18n (1.10.2)
devise-i18n (1.10.3)
devise (>= 4.8.0)
diff-lcs (1.5.0)
docile (1.4.0)
@ -141,15 +140,15 @@ GEM
zeitwerk (~> 2.6)
dry-inflector (1.0.0)
dry-initializer (3.1.1)
dry-logic (1.4.0)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-types (1.7.0)
dry-types (1.7.1)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-logic (>= 1.4, < 2)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erubi (1.12.0)
excon (0.99.0)
@ -164,7 +163,7 @@ GEM
faker (3.1.1)
i18n (>= 1.8.11, < 2)
ffi (1.15.5)
fog-aws (3.17.0)
fog-aws (3.19.0)
fog-core (~> 2.1)
fog-json (~> 1.1)
fog-xml (~> 0.1)
@ -189,11 +188,11 @@ GEM
temple (>= 0.8.2)
thor
tilt
haml_lint (0.45.0)
haml_lint (0.49.2)
haml (>= 4.0, < 6.2)
parallel (~> 1.10)
rainbow
rubocop (>= 0.50.0)
rubocop (>= 1.0)
sysexits (~> 1.1)
hcaptcha (7.1.0)
json
@ -202,7 +201,7 @@ GEM
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.12.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
i18n-js (4.0.0)
glob
@ -211,12 +210,12 @@ GEM
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
jsbundling-rails (1.1.1)
jsbundling-rails (1.1.2)
railties (>= 6.0.0)
json (2.6.3)
json-schema (3.0.0)
json-schema (4.0.0)
addressable (>= 2.8)
jwt (2.7.0)
jwt (2.7.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -229,32 +228,33 @@ GEM
activerecord
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.12.0)
lograge (0.13.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.19.1)
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
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)
mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105)
mime-types-data (3.2023.0218.1)
mimemagic (0.4.3)
nokogiri (~> 1)
rake
mini_magick (4.12.0)
mini_mime (1.1.2)
mini_portile2 (2.8.1)
minitest (5.17.0)
mini_portile2 (2.8.4)
minitest (5.19.0)
msgpack (1.6.0)
multi_json (1.15.0)
multi_xml (0.6.0)
@ -263,7 +263,7 @@ GEM
connection_pool (~> 2.2)
net-http2 (0.18.4)
http-2 (~> 0.11)
net-imap (0.3.4)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
@ -272,65 +272,68 @@ GEM
timeout
net-smtp (0.3.3)
net-protocol
nio4r (2.5.8)
nokogiri (1.14.1)
mini_portile2 (~> 2.8.0)
nio4r (2.5.9)
nokogiri (1.15.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.14.2)
oj (3.15.1)
openssl (3.1.0)
orm_adapter (0.5.0)
parallel (1.22.1)
parser (3.2.1.0)
parallel (1.23.0)
parser (3.2.2.3)
ast (~> 2.4.1)
pg (1.4.5)
pghero (3.1.0)
racc
pg (1.5.3)
pghero (3.3.3)
activerecord (>= 6)
prometheus-client (4.0.0)
public_suffix (4.0.7)
puma (6.1.0)
prometheus-client (4.2.0)
public_suffix (5.0.1)
puma (6.3.0)
nio4r (~> 2.0)
pundit (2.3.0)
pundit (2.3.1)
activesupport (>= 3.0.0)
racc (1.6.2)
rack (2.2.6.2)
rack-test (2.0.2)
racc (1.7.1)
rack (2.2.8)
rack-test (2.1.0)
rack (>= 1.3)
rails (6.1.7.2)
actioncable (= 6.1.7.2)
actionmailbox (= 6.1.7.2)
actionmailer (= 6.1.7.2)
actionpack (= 6.1.7.2)
actiontext (= 6.1.7.2)
actionview (= 6.1.7.2)
activejob (= 6.1.7.2)
activemodel (= 6.1.7.2)
activerecord (= 6.1.7.2)
activestorage (= 6.1.7.2)
activesupport (= 6.1.7.2)
rails (6.1.7.4)
actioncable (= 6.1.7.4)
actionmailbox (= 6.1.7.4)
actionmailer (= 6.1.7.4)
actionpack (= 6.1.7.4)
actiontext (= 6.1.7.4)
actionview (= 6.1.7.4)
activejob (= 6.1.7.4)
activemodel (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
bundler (>= 1.15.0)
railties (= 6.1.7.2)
railties (= 6.1.7.4)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
rails-dom-testing (2.1.1)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1)
rails-i18n (7.0.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (7.0.7)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails_admin (3.1.1)
rails_admin (3.1.2)
activemodel-serializers-xml (>= 1.0)
kaminari (>= 0.14, < 2.0)
nested_form (~> 0.3)
rails (>= 6.0, < 8)
turbo-rails (~> 1.0)
railties (6.1.7.2)
actionpack (= 6.1.7.2)
activesupport (= 6.1.7.2)
railties (6.1.7.4)
actionpack (= 6.1.7.4)
activesupport (= 6.1.7.4)
method_source
rake (>= 12.2)
thor (~> 1.0)
@ -338,15 +341,16 @@ GEM
rake (13.0.6)
redcarpet (3.6.0)
redis (4.8.0)
regexp_parser (2.7.0)
regexp_parser (2.8.1)
request_store (1.5.1)
rack (>= 1.4)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
responders (3.1.0)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.6)
rolify (6.0.1)
rotp (6.2.0)
rotp (6.2.2)
rouge (4.1.2)
rpush (7.0.1)
activesupport (>= 5.2)
jwt (>= 1.5.6)
@ -357,54 +361,55 @@ GEM
rainbow
thor (>= 0.18.1, < 2.0)
webpush (~> 1.0)
rqrcode (2.1.2)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-its (1.3.0)
rspec-core (>= 3.0.0)
rspec-expectations (>= 3.0.0)
rspec-mocks (3.12.3)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.0.1)
rspec-rails (6.0.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.11)
rspec-expectations (~> 3.11)
rspec-mocks (~> 3.11)
rspec-support (~> 3.11)
rspec-core (~> 3.12)
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-sidekiq (3.1.0)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.12.0)
rubocop (1.45.1)
rspec-support (3.12.1)
rubocop (1.55.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.0.0)
parser (>= 3.2.2.3)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.24.1, < 2.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.26.0)
rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
rubocop-rails (2.17.4)
rubocop-rails (2.20.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
ruby-progressbar (1.11.0)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
rubyzip (2.3.2)
sanitize (6.0.1)
sanitize (6.0.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
sassc (2.4.0)
@ -415,13 +420,13 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sentry-rails (5.8.0)
sentry-rails (5.10.0)
railties (>= 5.0)
sentry-ruby (~> 5.8.0)
sentry-ruby (5.8.0)
sentry-ruby (~> 5.10.0)
sentry-ruby (5.10.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.8.0)
sentry-ruby (~> 5.8.0)
sentry-sidekiq (5.10.0)
sentry-ruby (~> 5.10.0)
sidekiq (>= 3.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
@ -450,14 +455,14 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sysexits (1.2.0)
temple (0.10.0)
thor (1.2.1)
tilt (2.0.11)
timeout (0.3.1)
temple (0.10.2)
thor (1.2.2)
tilt (2.2.0)
timeout (0.4.0)
tldv (0.1.0)
tldv-data (~> 1.0)
tldv-data (1.0.2022121701)
turbo-rails (1.3.3)
tldv-data (1.0.2023031000)
turbo-rails (1.4.0)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
@ -479,14 +484,14 @@ GEM
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.6.6)
zeitwerk (2.6.10)
PLATFORMS
ruby
DEPENDENCIES
active_model_otp
bcrypt (~> 3.1.18)
bcrypt (~> 3.1.19)
better_errors
binding_of_caller
bootsnap
@ -496,9 +501,9 @@ DEPENDENCIES
carrierwave_backgrounder!
colorize
connection_pool
cssbundling-rails (~> 1.1)
cssbundling-rails (~> 1.2)
database_cleaner
devise (~> 4.0)
devise (~> 4.9)
devise-async
devise-i18n
dry-initializer (~> 3.1)
@ -528,7 +533,7 @@ DEPENDENCIES
openssl (~> 3.1)
pg
pghero
prometheus-client (~> 4.0)
prometheus-client (~> 4.2)
puma
pundit (~> 2.3)
questiongenerator (~> 1.2)!
@ -546,9 +551,8 @@ DEPENDENCIES
rspec-mocks
rspec-rails (~> 6.0)
rspec-sidekiq (~> 3.0)
rubocop (~> 1.45)
rubocop-rails (~> 2.17)
ruby-progressbar
rubocop (~> 1.55)
rubocop-rails (~> 2.20)
rubyzip (~> 2.3)
sanitize
sassc-rails

View File

@ -100,26 +100,27 @@ $unicodeRangeValues in Lexend.$unicodeMap {
*/
@import "components/announcements",
"components/answerbox",
"components/avatars",
"components/buttons",
"components/collapse",
"components/comments",
"components/container",
"components/entry",
"components/icons",
"components/inbox-actions",
"components/inbox-entry",
"components/mobile-nav",
"components/navbar",
"components/notifications",
"components/profile",
"components/push-settings",
"components/question",
"components/smiles",
"components/themes",
"components/totp-setup",
"components/userbox";
"components/answerbox",
"components/avatars",
"components/buttons",
"components/collapse",
"components/comments",
"components/container",
"components/entry",
"components/hotkey",
"components/icons",
"components/inbox-actions",
"components/inbox-entry",
"components/mobile-nav",
"components/navbar",
"components/notifications",
"components/profile",
"components/push-settings",
"components/question",
"components/smiles",
"components/themes",
"components/totp-setup",
"components/userbox";
/**
UTILITIES

View File

@ -24,18 +24,20 @@
}
&__input {
padding-right: 2.5rem;
&.is-invalid {
background-image: none;
}
}
&__character-count {
position: absolute;
z-index: 5;
right: .5rem;
top: .5rem;
&__compose-wrapper {
display: flex;
}
&__submit-wrapper {
display: flex;
flex-direction: column;
text-align: center;
margin-left: 0.5rem;
}
}

View File

@ -0,0 +1,6 @@
.js-hotkey-navigating {
.js-hotkey-current-selection {
outline: var(--primary) solid 4px;
}
}

View File

@ -47,6 +47,13 @@
&__recovery {
&-container {
max-width: 455px;
@media print {
.card {
box-shadow: none;
}
}
}
&-icon {

View File

@ -5,6 +5,7 @@ require "cgi"
class Ajax::AnswerController < AjaxController
include SocialHelper::TwitterMethods
include SocialHelper::TumblrMethods
include SocialHelper::TelegramMethods
def create
params.require :id
@ -41,19 +42,13 @@ class Ajax::AnswerController < AjaxController
@response[:message] = t(".success")
@response[:success] = true
if current_user.sharing_enabled
@response[:sharing] = {
twitter: twitter_share_url(answer),
tumblr: tumblr_share_url(answer),
custom: CGI.escape(prepare_tweet(answer))
}
end
@response[:sharing] = sharing_hash(answer) if current_user.sharing_enabled
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 })
@response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false, subscribed_answer_ids: [answer.id] })
end
def destroy
@ -74,4 +69,13 @@ class Ajax::AnswerController < AjaxController
@response[:message] = t(".success")
@response[:success] = true
end
private
def sharing_hash(answer) = {
twitter: twitter_share_url(answer),
tumblr: tumblr_share_url(answer),
telegram: telegram_share_url(answer),
custom: CGI.escape(prepare_tweet(answer)),
}
end

View File

@ -4,14 +4,14 @@ class Ajax::SubscriptionController < AjaxController
def subscribe
params.require :answer
@response[:status] = :okay
state = Subscription.subscribe(current_user, Answer.find(params[:answer])).nil?
@response[:success] = state == false
result = Subscription.subscribe(current_user, Answer.find(params[:answer]))
@response[:success] = result.present?
end
def unsubscribe
params.require :answer
@response[:status] = :okay
state = Subscription.unsubscribe(current_user, Answer.find(params[:answer])).nil?
@response[:success] = state == false
result = Subscription.unsubscribe(current_user, Answer.find(params[:answer]))
@response[:success] = result&.destroyed? || false
end
end

View File

@ -10,17 +10,12 @@ class AnswerController < ApplicationController
def show
@answer = Answer.includes(comments: %i[user smiles], question: [:user], smiles: [:user]).find(params[:id])
@display_all = true
@subscribed_answer_ids = []
if user_signed_in?
notif = Notification.where(type: "Notification::QuestionAnswered", target_id: @answer.id, recipient_id: current_user.id, new: true).first
notif&.update(new: false)
notif = Notification.where(type: "Notification::Commented", target_id: @answer.comments.pluck(:id), recipient_id: current_user.id, new: true)
notif.update_all(new: false) unless notif.empty?
notif = Notification.where(type: "Notification::Smiled", target_id: @answer.smiles.pluck(:id), recipient_id: current_user.id, new: true)
notif.update_all(new: false) unless notif.empty?
notif = Notification.where(type: "Notification::CommentSmiled", target_id: @answer.comment_smiles.pluck(:id), recipient_id: current_user.id, new: true)
notif.update_all(new: false) unless notif.empty?
end
return unless user_signed_in?
@subscribed_answer_ids = Subscription.where(user: current_user, answer: @answer).pluck(:answer_id)
mark_notifications_as_read
end
def pin
@ -52,4 +47,15 @@ class AnswerController < ApplicationController
end
end
end
private
def mark_notifications_as_read
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
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
module PaginatesAnswers
def paginate_answers
@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?
end
end

View File

@ -13,6 +13,9 @@ class DiscoverController < ApplicationController
@popular_questions = Question.where("created_at > ?", Time.now.ago(1.week)).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').

View File

@ -82,10 +82,13 @@ class InboxController < ApplicationController
.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) # rubocop:disable Rails/SkipsModelValidations
@inbox&.dup&.update_all(new: false)
current_user.touch(:inbox_updated_at)
end
# rubocop:enable Rails/SkipsModelValidations
def increment_metric
Retrospring::Metrics::QUESTIONS_ASKED.increment(

View File

@ -25,6 +25,17 @@ class NotificationsController < ApplicationController
end
end
def read
current_user.notifications.where(new: true).update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
current_user.touch(:notifications_updated_at)
respond_to do |format|
format.turbo_stream do
render "navigation/notifications", locals: { notifications: [], notification_count: nil }
end
end
end
private
def paginate_notifications
@ -38,10 +49,13 @@ class NotificationsController < ApplicationController
.count(:target_type)
end
# rubocop:disable Rails/SkipsModelValidations
def mark_notifications_as_read
# using .dup to not modify @notifications -- useful in tests
@notifications&.dup&.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
@notifications&.dup&.update_all(new: false)
current_user.touch(:notifications_updated_at)
end
# rubocop:enable Rails/SkipsModelValidations
def cursored_notifications_for(type:, last_id:, size: nil)
cursor_params = { last_id: last_id, size: size }.compact

View File

@ -1,9 +1,15 @@
# frozen_string_literal: true
class QuestionController < ApplicationController
include PaginatesAnswers
def show
@question = Question.find(params[:id])
@answers = @question.cursored_answers(last_id: params[:last_id])
@answers_last_id = @answers.map(&:id).min
@more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1).count.zero?
@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?
respond_to do |format|
format.html

View File

@ -42,6 +42,6 @@ class Settings::TwoFactorAuthentication::OtpAuthenticationController < Applicati
def reset
current_user.totp_recovery_codes.delete_all
@recovery_keys = TotpRecoveryCode.generate_for(current_user)
render "settings/two_factor_authentication/otp_authentication/recovery_keys"
render "settings/two_factor_authentication/otp_authentication/recovery_keys", status: :see_other
end
end

View File

@ -11,12 +11,12 @@ class TimelineController < ApplicationController
def list
@title = list_title(@list)
paginate_timeline { |args| @list.cursored_timeline(**args) }
paginate_timeline { |args| @list.cursored_timeline(**args, current_user:) }
end
def public
@title = generate_title(t(".title"))
paginate_timeline { |args| Answer.cursored_public_timeline(**args) }
paginate_timeline { |args| Answer.cursored_public_timeline(**args, current_user:) }
end
private
@ -32,8 +32,10 @@ class TimelineController < ApplicationController
def paginate_timeline
@timeline = yield(last_id: params[:last_id])
@timeline_last_id = @timeline.map(&:id).min
timeline_ids = @timeline.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)
respond_to do |format|
format.html { render "timeline/timeline" }

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
class User::SessionsController < Devise::SessionsController
def new
session.delete(:user_sign_in_uid)
@ -5,35 +7,13 @@ class User::SessionsController < Devise::SessionsController
end
def create
if session.has_key?(:user_sign_in_uid)
self.resource = User.find(session.delete(:user_sign_in_uid))
else
self.resource = warden.authenticate!(auth_options)
end
authenticate!
if resource.active_for_authentication? && resource.otp_module_enabled?
if params[:user][:otp_attempt].blank?
session[:user_sign_in_uid] = resource.id
sign_out(resource)
warden.lock!
render "auth/two_factor_authentication"
prompt_for_2fa
else
if params[:user][:otp_attempt].length == 8
found = TotpRecoveryCode.where(user_id: resource.id, code: params[:user][:otp_attempt].downcase).delete_all
if found == 1
flash[:info] = t(".info", count: TotpRecoveryCode.where(user_id: resource.id).count)
continue_sign_in(resource, resource_name)
else
flash[:error] = t(".error")
redirect_to new_user_session_url
end
elsif resource.authenticate_otp(params[:user][:otp_attempt], drift: APP_CONFIG.fetch(:otp_drift_period, 30).to_i)
continue_sign_in(resource, resource_name)
else
sign_out(resource)
flash[:error] = t(".error")
redirect_to new_user_session_url
end
attempt_2fa
end
else
continue_sign_in(resource, resource_name)
@ -42,10 +22,47 @@ class User::SessionsController < Devise::SessionsController
private
def authenticate!
self.resource = session.key?(:user_sign_in_uid) ? User.find(session.delete(:user_sign_in_uid)) : warden.authenticate!(auth_options)
end
def continue_sign_in(resource, resource_name)
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end
def prompt_for_2fa
session[:user_sign_in_uid] = resource.id
sign_out(resource)
warden.lock!
render "auth/two_factor_authentication"
end
def attempt_2fa
if params[:user][:otp_attempt].length == 8
try_recovery_code
elsif resource.authenticate_otp(params[:user][:otp_attempt], drift: APP_CONFIG.fetch(:otp_drift_period, 30).to_i)
continue_sign_in(resource, resource_name)
else
fail_2fa
end
end
def try_recovery_code
found = TotpRecoveryCode.where(user_id: resource.id, code: params[:user][:otp_attempt].downcase).delete_all
if found == 1
flash[:info] = t(".info", count: TotpRecoveryCode.where(user_id: resource.id).count)
continue_sign_in(resource, resource_name)
else
fail_2fa
end
end
def fail_2fa
sign_out(resource)
flash[:error] = t(".error")
redirect_to new_user_session_url
end
end

View File

@ -1,19 +1,19 @@
# frozen_string_literal: true
class UserController < ApplicationController
include PaginatesAnswers
before_action :set_user
before_action :hidden_social_graph_redirect, only: %i[followers followings]
after_action :mark_notification_as_read, only: %i[show]
def show
@answers = @user.cursored_answers(last_id: params[:last_id])
@pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10)
@answers_last_id = @answers.map(&:id).min
@more_data_available = !@user.cursored_answers(last_id: @answers_last_id, size: 1).count.zero?
paginate_answers { |args| @user.cursored_answers(**args) }
respond_to do |format|
format.html
format.turbo_stream
format.turbo_stream { render layout: false }
end
end

View File

@ -4,30 +4,6 @@ module ApplicationHelper
include ApplicationHelper::GraphMethods
include ApplicationHelper::TitleMethods
def inbox_count
return 0 unless user_signed_in?
count = Inbox.select("COUNT(id) AS count")
.where(new: true)
.where(user_id: current_user.id)
.group(:user_id)
.order(:count)
.first
return nil if count.nil?
return nil unless count.count.positive?
count.count
end
def notification_count
return 0 unless user_signed_in?
count = Notification.for(current_user).where(new: true).count
return nil unless count.positive?
count
end
def privileged?(user)
!current_user.nil? && ((current_user == user) || current_user.mod?)
end

View File

@ -5,8 +5,10 @@ module BootstrapHelper
options = {
badge: nil,
badge_color: nil,
badge_attr: {},
icon: nil,
class: ""
class: "",
hotkey: nil,
}.merge(options)
classes = [
@ -22,17 +24,17 @@ module BootstrapHelper
"#{content_tag(:i, '', class: "fa fa-#{options[:icon]}")} #{body}"
end
end
unless options[:badge].nil?
if options[:badge].present? || options.dig(:badge_attr, :data)&.has_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)}".html_safe
body += " #{content_tag(:span, options[:badge], class: badge_class, **options[:badge_attr])}".html_safe
end
content_tag(:li, link_to(body.html_safe, path, class: "nav-link"), class: classes)
content_tag(:li, link_to(body.html_safe, path, class: "nav-link", data: { hotkey: options[:hotkey] }), class: classes)
end
def list_group_item(body, path, options = {})

View File

@ -1,4 +1,7 @@
# frozen_string_literal: true
module SocialHelper
include SocialHelper::TwitterMethods
include SocialHelper::TumblrMethods
include SocialHelper::TelegramMethods
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::TelegramMethods
include MarkdownHelper
def telegram_text(answer)
# using twitter_markdown here as it removes all formatting
"#{twitter_markdown answer.question.content}\n———\n#{twitter_markdown answer.content}"
end
def telegram_share_url(answer)
url = answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http)
)
%(https://t.me/share/url?url=#{CGI.escape(url)}&text=#{CGI.escape(telegram_text(answer))})
end
end

View File

@ -12,7 +12,7 @@ module UserHelper
if url
return user_path(user) if link_only
return profile_link(user)
return profile_link(user, target: "_top")
end
user.profile.safe_name.strip
end
@ -23,8 +23,8 @@ module UserHelper
private
def profile_link(user)
link_to(user.profile.safe_name, user_path(user), class: ("user--banned" if user.banned?).to_s)
def profile_link(user, target: nil)
link_to(user.profile.safe_name, user_path(user), class: ("user--banned" if user.banned?).to_s, target:)
end
def should_unmask?(author_identifier)

View File

@ -1,10 +1,14 @@
import '@hotwired/turbo-rails';
import initializeBootstrap from './initializers/bootstrap';
import initializeHotkey from './initializers/hotkey';
import initializeServiceWorker from './initializers/serviceWorker';
import initializeStimulus from './initializers/stimulus';
export default function start(): void {
try {
initializeBootstrap();
initializeHotkey();
initializeServiceWorker();
initializeStimulus();
} catch (e) {
// initialization errors

View File

@ -0,0 +1,12 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller<HTMLElement> {
connect(): void {
install(this.element);
}
disconnect(): void {
uninstall(this.element);
}
}

View File

@ -1,10 +1,11 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['twitter', 'tumblr', 'custom'];
static targets = ['twitter', 'tumblr', 'telegram', 'custom'];
declare readonly twitterTarget: HTMLAnchorElement;
declare readonly tumblrTarget: HTMLAnchorElement;
declare readonly telegramTarget: HTMLAnchorElement;
declare readonly customTarget: HTMLAnchorElement;
declare readonly hasCustomTarget: boolean;
@ -20,6 +21,7 @@ export default class extends Controller {
if (this.autoCloseValue) {
this.twitterTarget.addEventListener('click', () => this.close());
this.tumblrTarget.addEventListener('click', () => this.close());
this.telegramTarget.addEventListener('click', () => this.close());
if (this.hasCustomTarget) {
this.customTarget.addEventListener('click', () => this.close());
@ -36,6 +38,7 @@ export default class extends Controller {
this.twitterTarget.href = this.configValue['twitter'];
this.tumblrTarget.href = this.configValue['tumblr'];
this.telegramTarget.href = this.configValue['telegram'];
if (this.hasCustomTarget) {
this.customTarget.href = `${this.customTarget.href}${this.configValue['custom']}`;

View File

@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller {
static classes = ["current"];
static targets = ["current", "traversable"];
declare readonly hasCurrentTarget: boolean;
declare readonly currentTarget: HTMLElement;
declare readonly traversableTargets: HTMLElement[];
traversableTargetConnected(target: HTMLElement): void {
if (!("navigationIndex" in target.dataset)) {
target.dataset.navigationIndex = this.traversableTargets.indexOf(target).toString();
}
if (!this.hasCurrentTarget) {
const first = this.traversableTargets[0];
first.dataset.navigationTarget += " current";
}
}
currentTargetConnected(target: HTMLElement): void {
target.classList.add("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => install(el, el.dataset.selectionHotkey));
}
currentTargetDisconnected(target: HTMLElement): void {
target.classList.remove("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => uninstall(el));
}
up(): void {
const prevIndex = this.traversableTargets.indexOf(this.currentTarget) - 1;
if (prevIndex == -1) return;
this.navigate(this.traversableTargets[prevIndex]);
}
down(): void {
const nextIndex = this.traversableTargets.indexOf(this.currentTarget) + 1;
if (nextIndex == this.traversableTargets.length) return;
this.navigate(this.traversableTargets[nextIndex]);
}
navigate(target: HTMLElement): void {
if (!document.body.classList.contains("js-hotkey-navigating")) {
document.body.classList.add("js-hotkey-navigating");
}
if (target.dataset.navigationTarget == "traversable") {
this.currentTarget.dataset.navigationTarget = "traversable";
target.dataset.navigationTarget = "traversable current";
target.scrollIntoView({ block: "center", inline: "center" });
}
}
}

View File

@ -0,0 +1,18 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller<HTMLElement> {
isPwa: boolean;
badgeCapable: boolean;
initialize(): void {
this.isPwa = window.matchMedia('(display-mode: standalone)').matches;
this.badgeCapable = "setAppBadge" in navigator;
}
connect(): void {
if (this.isPwa && this.badgeCapable) {
const count = Number.parseInt(this.element.innerText) || 0;
navigator.setAppBadge(count);
}
}
}

View File

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

View File

@ -1,18 +1,21 @@
import registerEvents from "retrospring/utilities/registerEvents";
import { commentDestroyHandler } from "./destroy";
import {commentComposeEnd, commentComposeStart, commentCreateHandler} from "./new";
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";
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 },
{ type: 'compositionend', target: '[name=ab-comment-new]', handler: commentComposeEnd, global: true },
{ type: 'keyup', target: '[name=ab-comment-new]', handler: commentCreateHandler, global: true }
{ type: 'keydown', target: '[name=ab-comment-new]', handler: commentCreateKeyboardHandler, global: true },
{ type: 'click', target: '[name=ab-comment-new-submit]', handler: commentCreateClickHandler, global: true }
]);
}

View File

@ -5,20 +5,7 @@ import { showNotification, showErrorNotification } from 'utilities/notifications
let compositionJustEnded = false;
export function commentCreateHandler(event: KeyboardEvent): boolean {
if (compositionJustEnded && event.which == 13) {
compositionJustEnded = false;
return;
}
const input = event.target as HTMLInputElement;
const id = input.dataset.aId;
const counter = document.querySelector(`#ab-comment-charcount-${id}`);
const group = document.querySelector(`[name=ab-comment-new-group][data-a-id="${id}"]`);
if (event.which === 13) {
event.preventDefault();
function createComment(input: HTMLInputElement, id: string, counter: Element, group: Element) {
if (input.value.length > 512) {
group.classList.add('has-error');
return true;
@ -59,7 +46,36 @@ export function commentCreateHandler(event: KeyboardEvent): boolean {
.finally(() => {
input.disabled = false;
});
}
export function commentCreateKeyboardHandler(event: KeyboardEvent): boolean {
if (compositionJustEnded && event.which == 13) {
compositionJustEnded = false;
return;
}
const input = event.target as HTMLInputElement;
const id = input.dataset.aId;
const counter = document.querySelector(`#ab-comment-charcount-${id}`);
const group = document.querySelector(`[name=ab-comment-new-group][data-a-id="${id}"]`);
if ((event.ctrlKey || event.metaKey) && event.which === 13) {
event.preventDefault();
createComment(input, id, counter, group);
}
}
export function commentCreateClickHandler(event: MouseEvent): void {
event.preventDefault();
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
const input = document.querySelector<HTMLInputElement>(`[name="ab-comment-new"][data-a-id="${id}"]`);
const counter = document.querySelector(`#ab-comment-charcount-${id}`);
const group = document.querySelector(`[name=ab-comment-new-group][data-a-id="${id}"]`);
createComment(input, id, counter, group);
}
export function commentComposeStart(): boolean {

View File

@ -8,7 +8,7 @@ export function enableHandler (event: Event): void {
const sender = event.target as HTMLButtonElement;
try {
installServiceWorker()
getServiceWorker()
.then(subscribe)
.then(async subscription => {
return Notification.requestPermission().then(permission => {
@ -51,8 +51,8 @@ export function enableHandler (event: Event): void {
}
}
async function installServiceWorker(): Promise<ServiceWorkerRegistration> {
return navigator.serviceWorker.register("/service_worker.js", { scope: "/" });
async function getServiceWorker(): Promise<ServiceWorkerRegistration> {
return navigator.serviceWorker.getRegistration("/");
}
async function getServerKey(): Promise<Buffer> {

View File

@ -0,0 +1,7 @@
import { install } from '@github/hotkey'
export default function (): void {
document.addEventListener('turbo:load', () => {
document.querySelectorAll('[data-hotkey]').forEach(el => install(el as HTMLElement));
});
}

View File

@ -0,0 +1,3 @@
export default function (): void {
navigator.serviceWorker.register("/service_worker.js", { scope: "/" });
}

View File

@ -8,8 +8,11 @@ import CollapseController from "retrospring/controllers/collapse_controller";
import ThemeController from "retrospring/controllers/theme_controller";
import CapabilitiesController from "retrospring/controllers/capabilities_controller";
import CropperController from "retrospring/controllers/cropper_controller";
import HotkeyController from "retrospring/controllers/hotkey_controller";
import InboxSharingController from "retrospring/controllers/inbox_sharing_controller";
import ToastController from "retrospring/controllers/toast_controller";
import PwaBadgeController from "retrospring/controllers/pwa_badge_controller";
import NavigationController from "retrospring/controllers/navigation_controller";
/**
* This module sets up Stimulus and our controllers
@ -28,7 +31,10 @@ export default function (): void {
window['Stimulus'].register('collapse', CollapseController);
window['Stimulus'].register('cropper', CropperController);
window['Stimulus'].register('format-popup', FormatPopupController);
window['Stimulus'].register('hotkey', HotkeyController);
window['Stimulus'].register('inbox-sharing', InboxSharingController);
window['Stimulus'].register('pwa-badge', PwaBadgeController);
window['Stimulus'].register('navigation', NavigationController);
window['Stimulus'].register('theme', ThemeController);
window['Stimulus'].register('toast', ToastController);
}

View File

@ -1,8 +1,8 @@
class Answer < ApplicationRecord
extend Answer::TimelineMethods
belongs_to :user
belongs_to :question
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 :subscriptions, dependent: :destroy
@ -18,15 +18,12 @@ class Answer < ApplicationRecord
SHORT_ANSWER_MAX_LENGTH = 640
# rubocop:disable Rails/SkipsModelValidations
after_create do
Inbox.where(user: self.user, question: self.question).destroy_all
Notification.notify self.question.user, self unless self.question.user == self.user or self.question.user.nil?
Subscription.subscribe self.user, self
Subscription.subscribe self.question.user, self unless self.question.author_is_anonymous
user.increment! :answered_count
question.increment! :answer_count
end
before_destroy do
@ -39,19 +36,15 @@ class Answer < ApplicationRecord
end
end
user&.decrement! :answered_count
question&.decrement! :answer_count
self.smiles.each do |smile|
Notification.denotify self.user, smile
end
self.comments.each do |comment|
comment.user&.decrement! :commented_count
Subscription.denotify comment, self
end
Notification.denotify question&.user, self
Subscription.destruct self
end
# rubocop:enable Rails/SkipsModelValidations
def notification_type(*_args)
Notification::QuestionAnswered

View File

@ -5,8 +5,21 @@ module Answer::TimelineMethods
define_cursor_paginator :cursored_public_timeline, :public_timeline
def public_timeline
def public_timeline(current_user: nil)
joins(:user)
.then do |query|
next query unless current_user
blocked_and_muted_user_ids = current_user.blocked_user_ids_cached + current_user.muted_user_ids_cached
next query if blocked_and_muted_user_ids.empty?
# build a more complex query if we block or mute someone
# otherwise the query ends up as "anon OR (NOT anon AND user_id NOT IN (NULL))" which will only return anonymous questions
query
.joins(:question)
.where("questions.author_is_anonymous OR (NOT questions.author_is_anonymous AND questions.user_id NOT IN (?))", blocked_and_muted_user_ids)
.where.not(answers: { user_id: blocked_and_muted_user_ids })
end
.where(users: { privacy_allow_public_timeline: true })
.order(:created_at)
.reverse_order

View File

@ -1,18 +1,15 @@
class Comment < ApplicationRecord
belongs_to :user
belongs_to :answer
belongs_to :user, counter_cache: :commented_count
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
validates :content, length: { maximum: 512 }
# rubocop:disable Rails/SkipsModelValidations
after_create do
Subscription.subscribe self.user, answer, false
Subscription.subscribe user, answer
Subscription.notify self, answer
user.increment! :commented_count
answer.increment! :comment_count
end
before_destroy do
@ -25,10 +22,7 @@ class Comment < ApplicationRecord
end
Subscription.denotify self, answer
user&.decrement! :commented_count
answer&.decrement! :comment_count
end
# rubocop:enable Rails/SkipsModelValidations
def notification_type(*_args)
Notification::Commented

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Inbox < ApplicationRecord
belongs_to :user
belongs_to :user, touch: :inbox_updated_at
belongs_to :question
attr_accessor :returning
@ -27,9 +27,10 @@ class Inbox < ApplicationRecord
self.destroy
end
def notification_icon = question.author_is_anonymous ? "/icons/maskable_icon_x128.png" : question.user.profile_picture.url(:small)
def as_push_notification
{
type: :inbox,
title: I18n.t(
"frontend.push_notifications.inbox.title",
user: if question.author_is_anonymous
@ -38,8 +39,11 @@ class Inbox < ApplicationRecord
question.user.profile.safe_name
end
),
icon: question.author_is_anonymous ? "/icons/maskable_icon_x128.png" : question.user.profile_picture.url(:small),
body: question.content.truncate(Question::SHORT_QUESTION_MAX_LENGTH)
icon: notification_icon,
body: question.content.truncate(Question::SHORT_QUESTION_MAX_LENGTH),
data: {
click_url: "/inbox",
},
}
end
end

View File

@ -6,7 +6,21 @@ module List::TimelineMethods
define_cursor_paginator :cursored_timeline, :timeline
# @return [Array] the lists' timeline
def timeline
Answer.where('user_id in (?)', members.pluck(:user_id)).order(:created_at).reverse_order
def timeline(current_user: nil)
Answer
.then do |query|
next query unless current_user
blocked_and_muted_user_ids = current_user.blocked_user_ids_cached + current_user.muted_user_ids_cached
next query if blocked_and_muted_user_ids.empty?
# build a more complex query if we block or mute someone
# otherwise the query ends up as "anon OR (NOT anon AND user_id NOT IN (NULL))" which will only return anonymous questions
query
.joins(:question)
.where("questions.author_is_anonymous OR (NOT questions.author_is_anonymous AND questions.user_id NOT IN (?))", blocked_and_muted_user_ids)
.where.not(answers: { user_id: blocked_and_muted_user_ids })
end
.where(answers: { user_id: members.pluck(:user_id) }).order(:created_at).reverse_order
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Notification < ApplicationRecord
belongs_to :recipient, class_name: "User"
belongs_to :recipient, class_name: "User", touch: :notifications_updated_at
belongs_to :target, polymorphic: true
class << self
@ -11,11 +11,11 @@ class Notification < ApplicationRecord
define_cursor_paginator :cursored_for_type, :for_type
def for(recipient, **kwargs)
where(kwargs.merge!(recipient:)).includes(:target).order(:created_at).reverse_order
where(kwargs.merge!(recipient:)).order(:created_at).reverse_order
end
def for_type(recipient, type, **kwargs)
where(kwargs.merge!(recipient:)).includes(:target).where(type:).order(:created_at).reverse_order
where(kwargs.merge!(recipient:)).where(type:).order(:created_at).reverse_order
end
def notify(recipient, target)

View File

@ -5,8 +5,17 @@ module Question::AnswerMethods
define_cursor_paginator :cursored_answers, :ordered_answers
def ordered_answers
def ordered_answers(current_user: nil)
answers
.then do |query|
next query unless current_user
blocked_and_muted_user_ids = current_user.blocked_user_ids_cached + current_user.muted_user_ids_cached
next query if blocked_and_muted_user_ids.empty?
query
.where.not(answers: { user_id: blocked_and_muted_user_ids })
end
.order(:created_at)
.reverse_order
end

View File

@ -1,73 +1,52 @@
# frozen_string_literal: true
class Subscription < ApplicationRecord
belongs_to :user
belongs_to :answer
class << self
def for(target)
Subscription.where(answer: target)
end
def is_subscribed(recipient, target)
def subscribe(recipient, target)
existing = Subscription.find_by(user: recipient, answer: target)
if existing.nil?
false
else
existing.is_active
end
end
return true if existing.present?
def subscribe(recipient, target, force = true)
existing = Subscription.find_by(user: recipient, answer: target)
if existing.nil?
Subscription.new(user: recipient, answer: target).save!
elsif force
existing.update(is_active: true)
end
Subscription.create!(user: recipient, answer: target)
end
def unsubscribe(recipient, target)
if recipient.nil? or target.nil?
return nil
end
return nil if recipient.nil? || target.nil?
subs = Subscription.find_by(user: recipient, answer: target)
subs.update(is_active: false) unless subs.nil?
subs&.destroy
end
def destruct(target)
if target.nil?
return nil
end
return nil if target.nil?
Subscription.where(answer: target).destroy_all
end
def destruct_by(recipient, target)
if recipient.nil? or target.nil?
return nil
end
subs = Subscription.find_by(user: recipient, answer: target)
subs.destroy unless subs.nil?
end
def notify(source, target)
if source.nil? or target.nil?
return nil
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 }
end
Subscription.where(answer: target, is_active: true).each do |subs|
next unless not subs.user == source.user
Notification.notify subs.user, source
end
Notification.insert_all!(notifications) unless notifications.empty? # rubocop:disable Rails/SkipsModelValidations
end
def denotify(source, target)
if source.nil? or target.nil?
return nil
end
Subscription.where(answer: target).each do |subs|
Notification.denotify subs.user, source
end
return nil if source.nil? || target.nil?
subs = Subscription.where(answer: target)
Notification.where(target:, recipient: subs.map(&:user)).delete_all
end
end
end

View File

@ -8,6 +8,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
include User::AnswerMethods
include User::BanMethods
include User::InboxMethods
include User::NotificationMethods
include User::QuestionMethods
include User::PushNotificationMethods
include User::ReactionMethods
@ -84,8 +85,14 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
end
end
after_destroy do
Retrospring::Metrics::USERS_DESTROYED.increment
end
after_create do
Profile.create(user_id: id) if Profile.where(user_id: id).count.zero?
Retrospring::Metrics::USERS_CREATED.increment
end
# use the screen name as parameter for url helpers

View File

@ -12,4 +12,18 @@ module User::InboxMethods
.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)
# Returning +nil+ here in order to not display a counter
# at all when there isn't anything in the user's inbox
return nil unless count.positive?
count
end
end
def inbox_cache_key = "#{cache_key}/unread_inbox_count-#{inbox_updated_at}"
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module User::NotificationMethods
def unread_notification_count
Rails.cache.fetch(notification_cache_key) do
count = Notification.for(self).where(new: true).count(:id)
# Returning +nil+ here in order to not display a counter
# at all when there aren't any notifications
return nil unless count.positive?
count
end
end
def notification_cache_key = "#{cache_key}/unread_notification_count-#{notifications_updated_at}"
def notification_dropdown_cache_key = "#{cache_key}/notification_dropdown-#{notifications_updated_at}"
end

View File

@ -9,11 +9,17 @@ module User::PushNotificationMethods
n.app = app
n.registration_ids = [s.subscription.symbolize_keys]
n.data = {
message: resource.as_push_notification.to_json
message: resource.as_push_notification.merge(notification_data).to_json,
}
n.save!
PushNotificationWorker.perform_async(n.id)
end
end
def notification_data = {
data: {
badge: unread_inbox_count,
},
}
end

View File

@ -21,12 +21,16 @@ class User
raise Errors::BlockingSelf if target_user == self
unfollow_and_remove(target_user)
create_relationship(active_block_relationships, target_user)
create_relationship(active_block_relationships, target_user).tap do
expire_blocked_user_ids_cache
end
end
# Unblock an user
def unblock(target_user)
destroy_relationship(active_block_relationships, target_user)
destroy_relationship(active_block_relationships, target_user).tap do
expire_blocked_user_ids_cache
end
end
# Is <tt>self</tt> blocking <tt>target_user</tt>?
@ -34,6 +38,16 @@ class User
relationship_active?(blocked_users, target_user)
end
# Expire the blocked user ids cache
def expire_blocked_user_ids_cache = Rails.cache.delete(cache_key_blocked_user_ids)
# Cached ids of the blocked users
def blocked_user_ids_cached
Rails.cache.fetch(cache_key_blocked_user_ids, expires_in: 1.hour) do
blocked_user_ids
end
end
private
def unfollow_and_remove(target_user)
@ -43,6 +57,8 @@ class User
inboxes.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
def cache_key_blocked_user_ids = "#{cache_key}/blocked_user_ids"
end
end
end

View File

@ -16,19 +16,41 @@ class User
has_many :muted_by_users, through: :passive_mute_relationships, source: :source
end
# Mute a user
def mute(target_user)
raise Errors::MutingSelf if target_user == self
create_relationship(active_mute_relationships, target_user)
create_relationship(active_mute_relationships, target_user).tap do
expire_muted_user_ids_cache
end
end
# Unmute an user
def unmute(target_user)
destroy_relationship(active_mute_relationships, target_user)
destroy_relationship(active_mute_relationships, target_user).tap do
expire_muted_user_ids_cache
end
end
# Is <tt>self</tt> muting <tt>target_user</tt>?
def muting?(target_user)
relationship_active?(muted_users, target_user)
end
# Expires the muted user ids cache
def expire_muted_user_ids_cache = Rails.cache.delete(cache_key_muted_user_ids)
# Cached ids of the muted users
def muted_user_ids_cached
Rails.cache.fetch(cache_key_muted_user_ids, expires_in: 1.hour) do
muted_user_ids
end
end
private
# Cache key for the muted_user_ids
def cache_key_muted_user_ids = "#{cache_key}/muted_user_ids"
end
end
end

View File

@ -8,7 +8,17 @@ module User::TimelineMethods
# @return [ActiveRecord::Relation<Answer>] the user's timeline
def timeline
Answer
.where("user_id in (?) OR user_id = ?", following_ids, id)
.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?
# build a more complex query if we block or mute someone
# otherwise the query ends up as "anon OR (NOT anon AND user_id NOT IN (NULL))" which will only return anonymous questions
query
.joins(:question)
.where("questions.author_is_anonymous OR (NOT questions.author_is_anonymous AND questions.user_id NOT IN (?))", blocked_and_muted_user_ids)
end
.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])

View File

@ -22,12 +22,14 @@ class TypoedEmailValidator < ActiveModel::EachValidator
gmali.com
gmaul.com
gnail.com
hornail.com
hotamil.com
hotmai.com
hotmaill.com
iclooud.com
iclould.com
icluod.com
maibox.org
protonail.com
xn--gmail-xk1c.com
yahooo.com

View File

@ -1,5 +1,5 @@
.dropdown-menu.dropdown-menu-end{ role: :menu }
- if Subscription.is_subscribed(current_user, answer)
- 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

View File

@ -5,5 +5,8 @@
%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" }
= t(".other")

View File

@ -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
= render "answerbox", a: @answer, display_all: @display_all, subscribed_answer_ids: @subscribed_answer_ids

View File

@ -1,8 +1,8 @@
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-smile", data: { a_id: a.id, action: current_user&.smiled?(a) ? :unsmile : :smile }, disabled: !user_signed_in? }
%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
- unless display_all
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-comments", data: { a_id: a.id, state: :hidden } }
%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
@ -13,4 +13,4 @@
.btn-group
%button.btn.btn-default.btn-sm.dropdown-toggle{ data: { bs_toggle: :dropdown }, aria: { expanded: false } }
%span.caret
= render "actions/answer", answer: a
= render "actions/answer", answer: a, subscribed_answer_ids:

View File

@ -25,10 +25,19 @@
%span.caret
= render "actions/comment", comment: comment, answer: a
- if user_signed_in?
.form-group.has-feedback.comment__input-group.input-group{
%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 }
}
%input.form-control.comment__input{ type: :text, placeholder: t(".placeholder"), name: "ab-comment-new", data: { a_id: a.id, "character-count-target": "input" } }
.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
%button.btn.btn-primary.d-none{ type: :button, name: "ab-comment-new-submit", data: { a_id: a.id, "character-count-target": "action" } }= t(".action")

View File

@ -11,7 +11,7 @@
= 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) }
%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" } }

View File

@ -1,5 +1,5 @@
- display_all ||= nil
.card.answerbox{ data: { id: a.id, q_id: a.question.id } }
.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-body
@ -19,9 +19,9 @@
%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))
= 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: a, display_all: display_all
= render "answerbox/actions", a:, display_all:, subscribed_answer_ids:
- else
.row
.col-md-6.text-start.text-muted
@ -33,7 +33,7 @@
%i.fa.fa-thumbtack
= t(".pinned")
.col-md-6.d-md-flex.answerbox__actions
= render "answerbox/actions", a: a, display_all: display_all
= render "answerbox/actions", a:, display_all:, subscribed_answer_ids:
.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

View File

@ -5,7 +5,7 @@
.card.mt-3
.card-body
%h1 Resend confirmation instructions
= bootstrap_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }, data: { turbo: false }) do |f|
= bootstrap_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.text_field :screen_name, autofocus: true, label: 'User name'

View File

@ -5,7 +5,7 @@
.card.mt-3
.card-body
%h1 Change your password
= bootstrap_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }, data: { turbo: false }) do |f|
= bootstrap_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, data: { turbo: false } }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.hidden_field :reset_password_token

View File

@ -5,7 +5,7 @@
.card.mt-3
.card-body
%h1 Forgot your password?
= bootstrap_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }, data: { turbo: false }) do |f|
= bootstrap_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.email_field :email, autofocus: true, label: 'Email address'

View File

@ -5,7 +5,7 @@
.card.mt-3
.card-body
%h1= t(".title")
= bootstrap_form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }) do |f|
= bootstrap_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
= render "devise/shared/error_messages", resource: resource
= render "layouts/messages"

View File

@ -3,7 +3,7 @@
%h1 Resend unlock instructions
= render 'layouts/messages'
= bootstrap_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }, data: { turbo: false }) do |f|
= bootstrap_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.email_field :email, autofocus: true, label: 'Email address'

View File

@ -17,9 +17,9 @@
%li.nav-item{ role: "presentation" }
%a.nav-link{ href: "#comments", role: :tab, aria: { controls: "comments" }, data: { bs_toggle: :tab } }= t(".content.tab.comments")
.tab-content.mt-3
= render "discover/tab/answers", answers: @popular_answers
= render "discover/tab/answers", answers: @popular_answers, subscribed_answer_ids: @subscribed_answer_ids
= render "discover/tab/questions", questions: @popular_questions
= render "discover/tab/discussed", comments: @most_discussed
= render "discover/tab/discussed", comments: @most_discussed, subscribed_answer_ids: @subscribed_answer_ids
.col-md-5.col-sm-6
%h2= t(".people.heading")
%p= t(".people.description")

View File

@ -1,3 +1,3 @@
.tab-pane.active.fade.show{ role: :tabpanel, id: "answers" }
- answers.each do |a|
= render "answerbox", a: a
= render "answerbox", a:, subscribed_answer_ids:

View File

@ -1,3 +1,3 @@
.tab-pane.fade{ role: :tabpanel, id: "comments" }
- comments.each do |a|
= render "answerbox", a: a
= render "answerbox", a:, subscribed_answer_ids:

View File

@ -49,6 +49,9 @@
%a.btn.btn-primary{ 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" }
%i.fab.fa-telegram.fa-fw
Telegram
- if current_user.sharing_custom_url.present?
%a.btn.btn-primary{ href: current_user.sharing_custom_url, data: { inbox_sharing_target: "custom" }, target: "_blank" }
= current_user.display_sharing_custom_url

View File

@ -11,4 +11,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id, author: @author }.compact,
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id, author: @author }.compact,
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -18,7 +18,7 @@
%link{ rel: 'icon', href: '/icons/maskable_icon_x192.png', sizes: '192x192' }
%link{ rel: 'icon', href: '/images/favicon/favicon-32.png', sizes: '32x32' }
%title= yield(:title)
= stylesheet_link_tag 'application', data: { 'turbo-track': 'reload' }
= stylesheet_link_tag "application", data: { 'turbo-track': "reload" }, media: "all"
= javascript_include_tag 'application', data: { 'turbo-track': 'reload' }, defer: true
= csrf_meta_tags
= yield(:og)
@ -31,6 +31,7 @@
= render 'shared/announcements'
= yield
= render "shared/formatting"
= render "shared/hotkeys"
.d-none#toasts
- if Rails.env.development?
#debug

View File

@ -14,4 +14,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -8,6 +8,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @reports_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- parent_layout "moderation"

View File

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @reports_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -9,28 +9,33 @@
%span.badge.rounded-pill.bg-warning.text-bg-warning.fs-7
DEV
%ul.nav.navbar-nav.me-auto
= nav_entry t("navigation.timeline"), root_path, icon: 'home'
= nav_entry t("navigation.inbox"), '/inbox', icon: 'inbox', badge: inbox_count
= 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"
- if APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod?
= nav_entry t("navigation.discover"), discover_path, icon: 'compass'
= 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") }
%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")
= nav_entry t("navigation.notifications"), notifications_path, badge: notification_count, class: 'd-block d-sm-none'
= nav_entry t("navigation.notifications"), notifications_path, class: "d-block d-sm-none", hotkey: "g n"
%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/dropdown/notifications', notifications: notifications, size: "desktop"
.dropdown-menu.dropdown-menu-end.notification-dropdown
%turbo-frame#notifications-dropdown-list
- cache current_user.notification_dropdown_cache_key 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') }
%a.nav-link{ href: '#', name: 'toggle-all-ask', data: { bs_target: '#modal-ask-followers', bs_toggle: :modal } }
%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 } }

View File

@ -1,7 +1,9 @@
- notifications = Notification.for(current_user).where(new: true).includes([:target]).limit(4)
= render 'navigation/desktop', notifications: notifications
= render 'navigation/mobile', notifications: notifications
:ruby
inbox_count = current_user.unread_inbox_count
notification_count = current_user.unread_notification_count
= render "navigation/desktop", inbox_count:, notification_count:
= render "navigation/mobile", inbox_count:, notification_count:
= render 'modal/ask'
%button.btn.btn-primary.btn-fab.d-block.d-lg-none{ data: { bs_target: '#modal-ask-followers', bs_toggle: :modal }, type: 'button' }
= render "modal/ask"
%button.btn.btn-primary.btn-fab.d-block.d-lg-none.d-print-none{ data: { bs_target: "#modal-ask-followers", bs_toggle: :modal }, type: "button" }
%i.fa.fa-pencil-square-o

View File

@ -9,7 +9,7 @@
- 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", icon_only: true
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) }

View File

@ -1,16 +1,18 @@
.dropdown-menu.dropdown-menu-end.notification-dropdown{ id: "rs-#{size}-nav-notifications" }
- if notifications.count.zero?
%a.dropdown-item.text-center{ href: notifications_path('all') }
- if notifications.count.zero?
%a.dropdown-item.text-center{ href: notifications_path('all'), data: { turbo_frame: "_top" } }
%i.fa.fa-fw.fa-chevron-right
= t(".all")
.dropdown-item.text-center.p-2
%i.fa.fa-bell-o.notification__bell-icon
%p= t(".none")
- else
%a.dropdown-item.text-center{ href: notifications_path }
- else
%a.dropdown-item.text-center{ href: notifications_path, data: { turbo_frame: "_top" } }
%i.fa.fa-fw.fa-chevron-right
= t(".new")
= link_to notifications_read_path, class: "dropdown-item text-center", data: { turbo_stream: true, turbo_method: :post } do
%i.fa.fa-fw.fa-check-double
= t(".mark_as_read")
- notifications.each do |notification|
.dropdown-item
= render "notifications/type/#{notification.target.class.name.downcase.split('::').last}", notification: notification
= render "notifications/type/#{notification.target.class.name.downcase.split('::').last}", notification:

View File

@ -1,6 +1,6 @@
.dropdown-menu.dropdown-menu-end.profile-dropdown{ id: "rs-#{size}-nav-profile" }
%h6.dropdown-header.d-none.d-sm-block= current_user.screen_name
%a.dropdown-item{ href: user_path(current_user) }
%a.dropdown-item{ href: user_path(current_user), data: { hotkey: "g p" } }
%i.fa.fa-fw.fa-user
= t(".profile")
%a.dropdown-item{ href: edit_user_registration_path }
@ -34,6 +34,10 @@
%i.fa.fa-fw.fa-flask
= t(".feedback.features")
.dropdown-divider
%a.dropdown-item{ href: "#", data: { bs_target: "#modal-hotkeys", bs_toggle: "modal", hotkey: "Shift+?,?,Shift+ß" } }
%i.fa.fa-keyboard
= t(".hotkeys")
.dropdown-divider
= link_to destroy_user_session_path, data: { turbo_method: :delete }, class: "dropdown-item" do
%i.fa.fa-fw.fa-sign-out
= t("voc.logout")

View File

@ -0,0 +1,13 @@
= turbo_stream.update "notifications-dropdown-list" do
= render "navigation/dropdown/notifications", notifications:
= turbo_stream.update "notification-desktop-icon" do
- if notification_count.nil?
%i.fa.fa-bell-o
- else
%i.fa.fa-bell
%span.visually-hidden= t("navigation.notifications")
%span.badge= notification_count
= turbo_stream.update "notification-mobile-count" do
%span.badge.badge-primary= notification_count

View File

@ -22,6 +22,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @notifications_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, generate_title(t(".title")))

View File

@ -11,4 +11,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @notifications_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -6,12 +6,12 @@
%img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy }
= 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)),
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))
.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 ? '[...]' : '')
= 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 ? '[...]' : '')
= markdown notification.target.content[0..60] + (notification.target.content.length > 60 ? "[…]" : "")

View File

@ -7,22 +7,32 @@
- 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)),
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))
- 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)),
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))
- else
= t(".heading_html",
user: user_screen_name(notification.target.user),
answer: link_to(t(".other.link_text_html", user: user_screen_name(notification.target.answer.user, url: false)), answer_path(username: notification.target.user.screen_name, id: notification.target.answer.id)),
answer: link_to(t(".other.link_text_html",
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))
.list-group
.list-group-item
%h6.notification__list-heading= t("activerecord.models.answer.one")
= markdown notification.target.answer.content[0..60] + (notification.target.answer.content.length > 60 ? '[...]' : '')
= markdown notification.target.answer.content[0..60] + (notification.target.answer.content.length > 60 ? "[…]" : "")
.list-group-item
%h6.notification__list-heading= t("activerecord.models.comment.one")
= markdown notification.target.content[0..60] + (notification.target.content.length > 60 ? '[...]' : '')
= markdown notification.target.content[0..60] + (notification.target.content.length > 60 ? "[…]" : "")

View File

@ -5,4 +5,4 @@
%h6.notification__user
= t(".heading")
.notification__text
= t(".text_html", settings_export: link_to(t(".settings_export"), settings_export_path))
= t(".text_html", settings_export: link_to(t(".settings_export"), settings_export_path, target: "_top"))

View File

@ -5,4 +5,4 @@
%h6.notification__user
= user_screen_name notification.target.source
.notification__text
= t(".heading_html", time: time_ago_in_words(notification.target.created_at))
= t(".heading_html", time: time_ago_in_words(notification.target.created_at), target: "_top")

View File

@ -0,0 +1,2 @@
-# This is a stub to prevent errors on broken notifications on content being asynchronously destroyed.
- logger.error "Notification ##{notification.id} has a target which doesn't exist. (Target: #{notification.target_type}##{notification.target_id})"

View File

@ -4,17 +4,23 @@
.flex-grow-1
.notification__heading
%img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy }
- if notification.target.parent_type == 'Answer'
- 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)),
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))
- elsif notification.target.parent_type == 'Comment'
- 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)),
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))
.list-group
.list-group-item
%h6.notification__list-heading= t("activerecord.models.#{notification.target.parent_type.downcase}.one")
= markdown notification.target.parent.content[0..60] + (notification.target.parent.content.length > 60 ? '[...]' : '')
= markdown notification.target.parent.content[0..60] + (notification.target.parent.content.length > 60 ? "[…]" : "")

View File

@ -7,4 +7,4 @@
%h6.notification__user
= t(".heading")
.notification__text
= t(".text_html", settings_push: link_to(t(".settings_push"), settings_push_notifications_path))
= t(".text_html", settings_push: link_to(t(".settings_push"), settings_push_notifications_path, target: "_top"))

View File

@ -2,9 +2,11 @@
= render "question", question: @question, hidden: false
= render "question", question: @question, hidden: true
.container.question-page
#answers
#answers{ data: { controller: "navigation" } }
%button.d-none{ data: { hotkey: "j", action: "navigation#down" } }
%button.d-none{ data: { hotkey: "k", action: "navigation#up" } }
- @answers.each do |a|
= render "answerbox", a: a, show_question: false
= render "answerbox", a:, show_question: false, subscribed_answer_ids: @subscribed_answer_ids
- if @more_data_available
.d-flex.justify-content-center.justify-content-sm-start#paginator
@ -12,6 +14,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- if user_signed_in? && !current_user.answered?(@question) && current_user != @question.user && @question.user&.privacy_allow_stranger_answers

View File

@ -1,6 +1,6 @@
= turbo_stream.append "answers" do
- @answers.each do |a|
= render "answerbox", a: a, show_question: false
= render "answerbox", a:, show_question: false, subscribed_answer_ids: @subscribed_answer_ids
= turbo_stream.update "paginator" do
- if @more_data_available
@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View File

@ -4,7 +4,7 @@
.card
.card-body
.d-none.d-print-block.totp-setup__recovery-icon
%i.fa.fa-comments
%img{ src: "/icons/icon.svg", width: 96 }
%h1.totp-setup__recovery-title= t(".heading", app_name: APP_CONFIG['site_name'])
%ul.totp-setup__recovery-codes

View File

@ -0,0 +1,80 @@
.modal.fade#modal-hotkeys{ aria: { hidden: true, labelledby: "modal-hotkeys-label" }, role: :dialog, tabindex: -1 }
.modal-dialog
.modal-content
.modal-header
%h5.modal-title#modal-hotkeys-label= t(".title")
%button.btn-close{ data: { bs_dismiss: :modal }, type: :button }
%span.visually-hidden= t("voc.close")
.modal-body
.row
.col-6
.card
.card-header
%h5.card-title= t(".navigation.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t("navigation.timeline")
%kbd
%kbd g
%kbd t
%li.list-group-item
= t("navigation.inbox")
%kbd
%kbd g
%kbd i
- if APP_CONFIG.dig(:features, :discover, :enabled) || current_user&.mod?
%li.list-group-item
= t("navigation.discover")
%kbd
%kbd g
%kbd d
%li.list-group-item
= t("navigation.notifications")
%kbd
%kbd g
%kbd n
%li.list-group-item
= t("navigation.dropdown.profile.profile")
%kbd
%kbd g
%kbd p
.col-6
.card
.card-header
%h5.card-title= t(".global.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t(".global.navigation.up")
%kbd k
%li.list-group-item
= t(".global.navigation.down")
%kbd j
%li.list-group-item
= t("navigation.desktop.ask_question")
%kbd n
%li.list-group-item
= t("voc.load")
%kbd .
%li.list-group-item
= t(".show_dialog")
%kbd ?
.card
.card-header
%h5.card-title= t(".answer.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t("voc.smile")
%kbd s
%li.list-group-item
= t(".answer.view_comments")
%kbd x
%li.list-group-item
= t(".answer.comment")
%kbd c
%li.list-group-item
= t(".answer.all_answers")
%kbd a
%li.list-group-item
= t(".answer.view_answer")
%kbd l

View File

@ -1,6 +1,8 @@
#timeline
#timeline{ data: { controller: "navigation" } }
%button.d-none{ data: { hotkey: "j", action: "navigation#down" } }
%button.d-none{ data: { hotkey: "k", action: "navigation#up" } }
- @timeline.each do |answer|
= render "answerbox", a: answer
= render "answerbox", a: answer, subscribed_answer_ids: @subscribed_answer_ids
- if @more_data_available
.d-flex.justify-content-center#paginator
@ -8,6 +10,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @timeline_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, @title || APP_CONFIG["site_name"])

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