diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 676e6b7a3..953f09b72 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -83,7 +83,7 @@ jobs: - name: Build and push (${{ matrix.platform }}) id: build - uses: docker/build-push-action@v6.18.0 + uses: docker/build-push-action@v6.19.2 with: context: . file: Dockerfile diff --git a/AGENTS.md b/AGENTS.md index d1c837079..64c69151a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,14 @@ bin/jobs # Manage Solid Queue jobs bin/kamal deploy # Deploy (requires 1Password CLI for secrets) ``` +## Deploy + +Default branch: `main` +Pre-deploy: `bin/rails saas:enable` +Deploy: `bin/kamal deploy -d ` +Destinations: production, staging, beta, beta1, beta2, beta3, beta4 +Note: `beta` is a template requiring `BETA_NUMBER` env var; typical targets are `beta1`-`beta4`. + ## Architecture Overview ### Multi-Tenancy (URL-Based) @@ -140,7 +148,7 @@ Key recurring tasks (via `config/recurring.yml`): ### Imports and exports Allow people to move between OSS and SAAS Fizzy instances: -- Exports/Imports can be wirtten to/read from local or S3 storage depending on the config of the instance (both myst be supported) +- Exports/Imports can be written to/read from local or S3 storage depending on the config of the instance (both must be supported) - Must be able to handle very large ZIP files (500+GB) - Models in `app/models/account/data_transfer/`, `app/models/zip_file` diff --git a/Gemfile b/Gemfile index c524ee3c1..17e7496c0 100644 --- a/Gemfile +++ b/Gemfile @@ -16,10 +16,10 @@ gem "kamal", require: false gem "puma", ">= 5.0" gem "solid_cable", ">= 3.0" gem "solid_cache", "~> 1.0" -gem "solid_queue", "~> 1.2" +gem "solid_queue", "~> 1.3" gem "sqlite3", ">= 2.0" gem "thruster", require: false -gem "trilogy", "~> 2.9" +gem "trilogy", "~> 2.10" # Features gem "bcrypt", "~> 3.1.7" @@ -54,7 +54,7 @@ group :development, :test do end group :development do - gem "web-console" + gem "web-console", github: "rails/web-console" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 559b2990b..4e3653aeb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: 4bcf8d0213971560e90734493f8bd835addc3642 + revision: b7f0f1429df5fa425eac043191ae2b65edee7488 specs: - lexxy (0.7.4.beta) + lexxy (0.7.6.beta) rails (>= 8.0.2) GIT @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 + revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 branch: main specs: actioncable (8.2.0.alpha) @@ -47,7 +47,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -113,18 +113,27 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rails/web-console.git + revision: 90e3474306f2367cfeaf2875e91e2bc2d71b5f0f + specs: + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + GEM remote: https://rubygems.org/ specs: action_text-trix (2.1.16) railties - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1203.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1218.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -132,11 +141,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -147,9 +156,9 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.22.0) + bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.1) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) @@ -180,7 +189,7 @@ GEM dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) - erb (6.0.1) + erb (6.0.2) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -208,20 +217,21 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.2.2) + importmap-rails (2.2.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.0) + json (2.18.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -258,7 +268,8 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) mission_control-jobs (1.1.0) actioncable (>= 7.1) @@ -271,12 +282,12 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (3.0.1) + mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -291,21 +302,21 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) ostruct (0.6.3) @@ -319,7 +330,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -327,12 +338,12 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.1.0) + public_suffix (7.0.2) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-session (2.1.1) @@ -346,12 +357,12 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (7.0.3) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -399,7 +410,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.40.0) + selenium-webdriver (4.41.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -414,21 +425,21 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.3.2) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0-aarch64-linux-gnu) - sqlite3 (2.8.0-aarch64-linux-musl) - sqlite3 (2.8.0-arm-linux-gnu) - sqlite3 (2.8.0-arm-linux-musl) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -436,20 +447,21 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct - stackprof (0.2.27) + stackprof (0.2.28) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) thor (1.5.0) - thruster (0.1.17) - thruster (0.1.17-aarch64-linux) - thruster (0.1.17-arm64-darwin) - thruster (0.1.17-x86_64-darwin) - thruster (0.1.17-x86_64-linux) + thruster (0.1.18) + thruster (0.1.18-aarch64-linux) + thruster (0.1.18-arm64-darwin) + thruster (0.1.18-x86_64-darwin) + thruster (0.1.18-x86_64-linux) timeout (0.6.0) - trilogy (2.9.0) + trilogy (2.10.0) + bigdecimal tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -459,11 +471,6 @@ GEM unicode-emoji (4.1.0) uri (1.1.1) vcr (6.4.0) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) web-push (3.1.0) jwt (~> 3.0) openssl (>= 3.0) @@ -478,7 +485,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) zip_kit (6.3.4) PLATFORMS @@ -488,7 +495,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin - x86_64-darwin-25 + x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -526,16 +533,16 @@ DEPENDENCIES selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) - solid_queue (~> 1.2) + solid_queue (~> 1.3) sqlite3 (>= 2.0) stackprof stimulus-rails thruster - trilogy (~> 2.9) + trilogy (~> 2.10) turbo-rails useragent! vcr - web-console + web-console! web-push webmock zip_kit diff --git a/Gemfile.saas b/Gemfile.saas index b93efb1ee..39aa49a71 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -4,12 +4,16 @@ eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" +gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" +# Native push notifications (iOS/Android) +gem "action_push_native" + # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" gem "sentry-ruby" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index b09872738..0e35effac 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -23,9 +23,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: 4bcf8d0213971560e90734493f8bd835addc3642 + revision: b7f0f1429df5fa425eac043191ae2b65edee7488 specs: - lexxy (0.7.4.beta) + lexxy (0.7.6.beta) rails (>= 8.0.2) GIT @@ -39,9 +39,9 @@ GIT GIT remote: https://github.com/basecamp/rails-structured-logging - revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 + revision: f6633fd8fee5906d8affa2329c61de7a3450eb7e specs: - rails_structured_logging (0.2.1) + rails_structured_logging (0.3.0) json rails (>= 6.0.0) @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 + revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 branch: main specs: actioncable (8.2.0.alpha) @@ -96,7 +96,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -162,6 +162,15 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rails/web-console.git + revision: 90e3474306f2367cfeaf2875e91e2bc2d71b5f0f + specs: + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + PATH remote: saas specs: @@ -186,8 +195,18 @@ PATH GEM remote: https://rubygems.org/ specs: + action_push_native (0.3.1) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) action_text-trix (2.1.16) railties + actionpack-xml_parser (2.0.1) + actionpack (>= 5.0) + railties (>= 5.0) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) @@ -196,15 +215,15 @@ GEM activemodel (>= 7.0) activemodel-serializers-xml (~> 1.0) activesupport (>= 7.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) anyway_config (2.7.2) ruby-next-core (~> 1.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1203.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1218.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -212,11 +231,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -227,9 +246,9 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.22.0) + bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.1) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) @@ -261,17 +280,24 @@ GEM drb (2.2.3) dry-initializer (3.2.0) ed25519 (1.4.0) - erb (6.0.1) + erb (6.0.2) erubi (1.13.1) et-orbi (1.4.0) tzinfo faker (3.6.0) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -282,27 +308,43 @@ GEM addressable (>= 2.5.0) globalid (1.3.0) activesupport (>= 6.1) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-logging-utils (0.2.0) + googleauth (1.16.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) gvltools (0.4.0) hashdiff (1.2.1) + http-2 (1.1.1) + httpx (1.7.1) + http-2 (>= 1.0.0) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.2.2) + importmap-rails (2.2.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.0) + json (2.18.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -339,7 +381,8 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) mission_control-jobs (1.1.0) actioncable (>= 7.1) @@ -352,12 +395,15 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (3.0.1) + mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.19.1) + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -372,21 +418,24 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) + os (1.1.4) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -398,7 +447,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) prometheus-client-mmap (1.4.0) base64 bigdecimal @@ -419,6 +468,11 @@ GEM bigdecimal logger rb_sys (~> 0.9.117) + prometheus-client-mmap (1.4.0-x86_64-darwin) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) prometheus-client-mmap (1.4.0-x86_64-linux-gnu) base64 bigdecimal @@ -436,12 +490,12 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.1.0) + public_suffix (7.0.2) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-session (2.1.1) @@ -455,15 +509,15 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) rake-compiler-dock (1.9.1) rb_sys (0.9.117) rake-compiler-dock (= 1.9.1) - rdoc (7.0.3) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -513,7 +567,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.40.0) + selenium-webdriver (4.41.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -525,6 +579,11 @@ GEM sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) sniffer (0.5.0) anyway_config (>= 1.0) dry-initializer (~> 3) @@ -537,20 +596,21 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.3.2) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0-aarch64-linux-gnu) - sqlite3 (2.8.0-aarch64-linux-musl) - sqlite3 (2.8.0-arm-linux-gnu) - sqlite3 (2.8.0-arm-linux-musl) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -558,20 +618,22 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct - stackprof (0.2.27) + stackprof (0.2.28) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) stripe (18.0.1) thor (1.5.0) - thruster (0.1.17) - thruster (0.1.17-aarch64-linux) - thruster (0.1.17-arm64-darwin) - thruster (0.1.17-x86_64-linux) + thruster (0.1.18) + thruster (0.1.18-aarch64-linux) + thruster (0.1.18-arm64-darwin) + thruster (0.1.18-x86_64-darwin) + thruster (0.1.18-x86_64-linux) timeout (0.6.0) - trilogy (2.9.0) + trilogy (2.10.0) + bigdecimal tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -581,11 +643,6 @@ GEM unicode-emoji (4.1.0) uri (1.1.1) vcr (6.4.0) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) web-push (3.1.0) jwt (~> 3.0) openssl (>= 3.0) @@ -628,7 +685,7 @@ GEM anyway_config (>= 1.3, < 3) railties yabeda (~> 0.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) zip_kit (6.3.4) PLATFORMS @@ -638,11 +695,14 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES + action_push_native + actionpack-xml_parser activeresource audits1984! autotuner @@ -685,17 +745,17 @@ DEPENDENCIES sentry-ruby solid_cable (>= 3.0) solid_cache (~> 1.0) - solid_queue (~> 1.2) + solid_queue (~> 1.3) sqlite3 (>= 2.0) stackprof stimulus-rails stripe (~> 18.0) thruster - trilogy (~> 2.9) + trilogy (~> 2.10) turbo-rails useragent! vcr - web-console + web-console! web-push webmock webrick diff --git a/app/assets/images/menu-dots-horizontal.svg b/app/assets/images/menu-dots-horizontal.svg index 4e60fdd40..cb0f0124a 100644 --- a/app/assets/images/menu-dots-horizontal.svg +++ b/app/assets/images/menu-dots-horizontal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 5f15dc278..a4e500139 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -18,6 +18,7 @@ padding: var(--btn-padding, 0.5em 1.1em); pointer-events: auto; position: relative; + text-decoration: none; transition: 100ms ease-out; transition-property: background-color, border, box-shadow, color, opacity, scale; @@ -127,7 +128,7 @@ } @media (min-width: 640px) { - .btn--circle-mobile .icon--mobile-only { + .btn .icon--mobile-only { display: none !important; } } @@ -271,4 +272,26 @@ inline-size: 100%; } } + + /* Button utilities + /* ------------------------------------------------------------------------ */ + + :is([data-platform~=mobile], [data-platform~=native]) { + .btn--ensure-tap-target-size { + --tap-target-z-index: 1; + --tap-target-min-size: 44px; + + z-index: var(--tap-target-z-index); + + &::before { + content: ""; + display: block; + block-size: var(--tap-target-min-size); + inline-size: var(--tap-target-min-size); + inset: calc(50% - var(--tap-target-min-size) / 2) auto auto calc(50% - var(--tap-target-min-size) / 2); + opacity: 0; + position: absolute; + } + } + } } diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index e2b576058..871e61a59 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -286,6 +286,7 @@ inset-inline-end: 0; position: absolute; translate: 100%; + z-index: 2; } @media (min-width: 640px) { @@ -769,13 +770,16 @@ --panel-padding: 2ch; --panel-size: 100%; - display: flex; flex-wrap: wrap; gap: var(--gap); inset-block-start: 0; justify-content: center; position: absolute; z-index: 1; + + &[open] { + display: flex; + } } /* On Deck (Not Now) @@ -819,6 +823,10 @@ position: absolute; translate: 20% -20%; z-index: 1; + + @media (max-width: 639px) { + translate: 20% 0%; + } } /* Maybe column: position bubble relative to the title, not the container */ diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 68d0cb2db..3dc16d5a2 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -36,20 +36,10 @@ @media (max-width: 639px) { &:has(.card__closed) { - padding-block-start: 2rem; - } + padding-block-start: 4.5rem; - &:has(.reactions .reaction) { - .card__meta-avatars--assignees { - margin-inline-end: unset; - } - } - - &:not(:has(.reactions .reaction)) { - &:has(.card__closed) { - .card__meta-text--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - } + .card-perma__bg { + box-shadow: 0 -2rem 0 0 var(--color-container); } } } @@ -110,6 +100,7 @@ @media (max-width: 639px) { flex-direction: column; padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5); + position: static; } } @@ -186,7 +177,7 @@ } .card__meta-avatars--assignees { - margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block)); + margin-inline: 0; margin-block-start: var(--meta-spacer-block); } @@ -232,6 +223,14 @@ } } + .card__closed { + @media (max-width: 639px) { + inset: auto 0 100% auto; + translate: 0 30%; + scale: 75%; + } + } + .card__footer { --btn-size: 2.5rem; @@ -251,8 +250,12 @@ } @media (max-width: 639px) { + display: grid; font-size: var(--text-x-small); - position: relative; + grid-template-columns: 1fr auto; + grid-template-areas: + "meta bg-zoom" + "meta reactions"; } } @@ -286,17 +289,11 @@ } &:not(:has(.reaction)) { + margin: 0; + .reactions__trigger { --btn-border-color: var(--color-ink-light); } - - @media (max-width: 639px) { - inset-inline-end: 0; - } - - @media (min-width: 640px) { - position: static; - } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 27dbb2c46..76f08fdc5 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -207,6 +207,10 @@ line-height: 1.15; text-wrap: balance; + &.overflow-line-clamp { + text-wrap: unset; /* text-wrap: balance breaks -webkit-line-clamp in Safari */ + } + .card-field__title { overflow: hidden; /* prevent scrolling on windows */ padding-block: var(--autosize-block-padding); @@ -398,6 +402,7 @@ position: absolute; rotate: 5deg; transform-origin: top right; + z-index: 2; .cards & { display: none; @@ -409,12 +414,6 @@ } } } - - @media (max-width: 639px) { - inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; - translate: 0 40%; - scale: 75%; - } } .card:has(.card__closed), diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 01a9fbe91..2369d62b3 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -84,6 +84,17 @@ .reactions { margin-block-start: var(--block-space-half); margin-inline: calc(var(--column-gap) / -1); + + &:not(:has(.reaction)) { + inset-block-end: var(--comment-padding-block); + inset-inline-end: calc(var(--comment-padding-inline) / 2); + margin: 0; + position: absolute; + + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + } } } diff --git a/app/assets/stylesheets/flash.css b/app/assets/stylesheets/flash.css index c57e9b6a4..0d369dfbb 100644 --- a/app/assets/stylesheets/flash.css +++ b/app/assets/stylesheets/flash.css @@ -1,7 +1,7 @@ @layer components { .flash { display: flex; - inset-block-start: var(--block-space); + inset-block-start: calc(var(--block-space) + var(--custom-safe-inset-top)); inset-inline-start: 50%; justify-content: center; position: fixed; @@ -16,7 +16,9 @@ color: var(--flash-color, var(--color-ink-inverted)); display: inline-flex; font-size: var(--font-size-medium); + inline-size: max-content; margin: 0 auto; + max-inline-size: 90vw; padding: 0.7em 1.4em; } } diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 0b33162fb..8fb7658ee 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -12,10 +12,10 @@ --lexxy-color-canvas: var(--color-canvas); - --lexxy-color-accent-dark: var(--color-accent-dark); - --lexxy-color-accent-medium: var(--color-accent-medium); - --lexxy-color-accent-light: var(--color-accent-light); - --lexxy-color-accent-lightest: var(--color-accent-lightest); + --lexxy-color-accent-dark: var(--color-ink-dark); + --lexxy-color-accent-medium: var(--color-ink-medium); + --lexxy-color-accent-light: var(--color-ink-light); + --lexxy-color-accent-lightest: var(--color-ink-lighter); --lexxy-color-red: oklch(var(--lch-red-medium)); --lexxy-color-green: oklch(var(--lch-green-medium)); @@ -38,6 +38,8 @@ --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected); --lexxy-color-table-cell-toggle: var(--lexxy-color-selected); --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%); + + --lexxy-focus-ring-offset: 2px; } @layer components { @@ -139,7 +141,9 @@ } ol, ul { - padding-inline-start: 1ch; + &:not(.lexxy-prompt-menu) { + padding-inline-start: 1ch; + } } blockquote { diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 34259e9c6..591ce8890 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -20,6 +20,16 @@ /* Header /* ------------------------------------------------------------------------ */ + .header { + padding-block-start: var(--custom-safe-inset-top); + } + + .header--mobile-actions-stack { + .header__title { + margin-block-start: 0; + } + } + .header:is( :not(:has(.header__title, .header__actions)), :not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child) @@ -68,13 +78,32 @@ .card-perma--draft { .card-perma__bg { - box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container); + padding-inline: 0; + padding-block-start: 0; + background-color: unset; + } + + .card { + background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); + box-shadow: 0 101vh 0 100vh var(--card-bg-color); + } + + .card__board { + border-radius: 0 var(--border-radius) var(--border-radius) 0; + } + + .card-perma__notch--bottom { + z-index: 1; } } /* Search /* ------------------------------------------------------------------------ */ + .search { + overscroll-behavior: auto; + } + .search__title { text-decoration: none; } diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index 6af8da7b7..83bc7bbaa 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -69,11 +69,11 @@ 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); - grid-template-rows: auto 1fr auto; gap: var(--nav-section-gap); + grid-template-rows: auto 1fr auto; inset: 0 0 auto 0; margin-inline: auto; - max-block-size: calc(100vh - var(--block-space) - var(--footer-height)); + max-block-size: calc(100dvh - var(--block-space) - var(--footer-height)); overflow: hidden; padding-block-end: 0; scrollbar-gutter: stable both-edges; @@ -82,7 +82,7 @@ z-index: var(--z-nav); @media (max-height: 668px) { - max-block-size: calc(100vh - var(--block-space)); + max-block-size: calc(100dvh - var(--block-space)); } } diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css index cd55b5196..7ced7b3a1 100644 --- a/app/assets/stylesheets/print.css +++ b/app/assets/stylesheets/print.css @@ -232,8 +232,7 @@ /* Settings /* ------------------------------------------------------------------------ */ - .settings__user-filter .input, - .settings__user-list-tips { + .settings__user-filter .input { display: none; } diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index efd487f64..508809273 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -10,6 +10,7 @@ flex-wrap: wrap; gap: var(--inline-space-half); inline-size: 100%; + z-index: 3; &:has([open]) { z-index: var(--z-popup); @@ -17,14 +18,6 @@ &:not(:has(.reaction)) { inline-size: auto; - inset-block-end: var(--comment-padding-block); - inset-inline-end: calc(var(--comment-padding-inline) / 2); - margin: 0; - position: absolute; - - @media (max-width: 640px) { - inset-inline-end: calc(var(--comment-padding-inline) / 3); - } .reactions__list { display: none; diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index 5691ac5e5..724d00f1a 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -117,7 +117,7 @@ summary { .search-perma { .search__form > label, - .search__reset { + .search__form:has(.search__input:placeholder-shown) .search__reset { display: none; } diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index 2a2d0c9cd..bd12916fc 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -3,13 +3,20 @@ --settings-spacer: var(--block-space); --settings-item-padding-inline: 0.5ch; - display: grid; + align-items: start; + display: flex; gap: calc(var(--settings-spacer) * 2); + flex-direction: column; + justify-content: center; margin: 0 auto; max-inline-size: min(100ch, 100%); @media (min-width: 960px) { - grid-template-columns: repeat(2, 1fr); + flex-direction: row; + + .settings__panel { + max-inline-size: 50%; + } } } @@ -55,10 +62,7 @@ } } - /* Users - /* ------------------------------------------------------------------------ */ - - .settings__section--users { + .settings__section:has(.settings__scrollable-list) { @media (min-width: 640px) { display: flex; flex: 1; @@ -66,24 +70,8 @@ min-height: 0; } } - .settings__user-filter { - --btn-size: 3.5ch; - --avatar-size: var(--btn-size); - display: flex; - flex-direction: column; - gap: calc(var(--settings-spacer) / 2); - min-height: 0; - } - - .settings__user-filter--bg { - background-color: var(--color-ink-lightest); - border-radius: 0.5em; - margin-block: 0; - padding: var(--settings-spacer); - } - - .settings__user-list { + .settings__scrollable-list { flex: 1 1 auto; list-style: none; margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline)); @@ -116,15 +104,23 @@ } } - .settings__user-list-tips { - border-top: 1px solid var(--color-ink-light); - color: var(--color-ink-dark); - font-size: var(--text-small); - padding-block-start: var(--settings-spacer); + /* Users + /* ------------------------------------------------------------------------ */ - .settings__user-filter--bg & { - margin-inline: calc(-1 * var(--settings-spacer)); - padding-inline: var(--settings-spacer); - } + .settings__user-filter { + --btn-size: 3.5ch; + --avatar-size: var(--btn-size); + + display: flex; + flex-direction: column; + gap: calc(var(--settings-spacer) / 2); + min-height: 0; + } + + .settings__user-filter--bg { + background-color: var(--color-ink-lightest); + border-radius: 0.5em; + margin-block: 0; + padding: var(--settings-spacer); } } diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 5b8763b71..604938deb 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,7 +5,8 @@ class BoardsController < ApplicationController before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index - set_page_and_extract_portion_from Current.user.boards + set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity) + fresh_when etag: @page.records end def show diff --git a/app/controllers/client_configurations_controller.rb b/app/controllers/client_configurations_controller.rb new file mode 100644 index 000000000..0b0f2f670 --- /dev/null +++ b/app/controllers/client_configurations_controller.rb @@ -0,0 +1,15 @@ +class ClientConfigurationsController < ApplicationController + skip_before_action :require_account, :require_authentication + allow_unauthorized_access + + def show + expires_in 1.minute, public: true + + render action: client_configuration_name + end + + private + def client_configuration_name + "#{params.require(:platform)}_v#{params.require(:version)}" + end +end diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index fdff7ba8f..acfc4dbf1 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -7,14 +7,10 @@ module RequestForgeryProtection private def verified_via_header_only? - super || allowed_api_request? || allowed_insecure_context_request? + super || allowed_api_request? end def allowed_api_request? sec_fetch_site_value.nil? && request.format.json? end - - def allowed_insecure_context_request? - sec_fetch_site_value.nil? && !request.ssl? && !Rails.configuration.force_ssl - end end diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index 0adc0e17b..46dc445cf 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -1,9 +1,27 @@ class Notifications::TraysController < ApplicationController + MAX_ENTRIES_LIMIT = 100 + def show - @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100) + @notifications = unread_notifications + if include_read? + @notifications += read_notifications + end # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. - fresh_when Current.user.notifications + fresh_when etag: [ Current.user.notifications, include_read? ] end + + private + def unread_notifications + Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) + end + + def read_notifications + Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) + end + + def include_read? + ActiveModel::Type::Boolean.new.cast(params[:include_read]) + end end diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index 5176cea02..b1ec5a0d2 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -2,10 +2,12 @@ class SearchesController < ApplicationController include Turbo::DriveHelper def show - if card = Current.user.accessible_cards.find_by_id(params[:q]) + @query = params[:q].blank? ? nil : params[:q] + + if card = Current.user.accessible_cards.find_by_id(@query) @card = card else - set_page_and_extract_portion_from Current.user.search(params[:q]) + set_page_and_extract_portion_from Current.user.search(@query) @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) end end diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index 0d45f2f8a..de3d81b63 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -29,7 +29,7 @@ class Signups::CompletionsController < ApplicationController redirect_to landing_url(script_name: @signup.account.slug) end - format.json { render json: { account_id: @signup.account.id }, status: :created } + format.json { head :created } end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 54c18704c..7062b85e5 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ class UsersController < ApplicationController before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index - set_page_and_extract_portion_from Current.account.users.active.alphabetically + set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity) end def show diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 8ae4346ac..d80685349 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,13 +1,6 @@ -require "zlib" - module AvatarsHelper - AVATAR_COLORS = %w[ - #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53 - #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E - ] - def avatar_background_color(user) - AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size] + user.avatar_background_color end def avatar_tag(user, hidden_for_screen_reader: false, **options) diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index eae2f3665..5f12b9259 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,12 +9,4 @@ module BoardsHelper icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end - - def bridged_button_to_board(board) - link_to "Go to #{board.name}", board, hidden: true, data: { - bridge__buttons_target: "button", - bridge_icon_url: bridge_icon("board"), - bridge_title: "Go to #{board.name}" - } - end end diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb index 5fb4508b6..ffebe6459 100644 --- a/app/helpers/bridge_helper.rb +++ b/app/helpers/bridge_helper.rb @@ -2,4 +2,36 @@ module BridgeHelper def bridge_icon(name) asset_url("#{name}.svg") end + + def bridged_button_to_board(board) + link_to "Go to #{board.name}", board, hidden: true, data: { + bridge__buttons_target: "button", + bridge_icon_url: bridge_icon("board"), + bridge_title: "Go to #{board.name}" + } + end + + def bridged_share_url_button(description = nil) + tag.button "Share", hidden: true, data: { + controller: "bridge--share", + action: "bridge--share#shareUrl", + bridge__overflow_menu_target: "item", + bridge_title: "Share", + bridge_share_description: description + } + end + + def bridge_share_card_description(card) + date_added = card.created_at.strftime("%b %e") + date_updated = card.last_active_at.strftime("%b %e") + author = card.creator.familiar_name + assignees = card.assignees.any? ? "assigned to #{card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence}" : "not assigned" + "Added #{date_added} by #{author} and #{assignees}. Updated #{date_updated}" + end + + def bridge_share_board_description(board) + count_open = board.cards.active.count + count_in_stream = board.cards.awaiting_triage.count + "#{count_open} open cards, #{count_in_stream} in MAYBE?" + end end diff --git a/app/helpers/my/menu_helper.rb b/app/helpers/my/menu_helper.rb index 753bad6a2..7cad2a25f 100644 --- a/app/helpers/my/menu_helper.rb +++ b/app/helpers/my/menu_helper.rb @@ -11,6 +11,7 @@ module My::MenuHelper aria: { activedescendant: "" }, data: { "1p-ignore": "true", + dialog_target: "focusMouse", filter_target: "input", nav_section_expander_target: "input", navigable_list_target: "input", diff --git a/app/javascript/controllers/bar_controller.js b/app/javascript/controllers/bar_controller.js index e83bce9a9..70f63990f 100644 --- a/app/javascript/controllers/bar_controller.js +++ b/app/javascript/controllers/bar_controller.js @@ -22,15 +22,6 @@ export default class extends Controller { this.#hideItem(this.searchTarget) } - clearInput() { - if (this.searchInputTarget.value) { - this.searchInputTarget.value = "" - this.searchInputTarget.focus() - } else { - this.reset() - } - } - showModalAndSubmit(event) { this.showModal() this.formTarget.requestSubmit() diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js index 47d09a158..3e3aa4a60 100644 --- a/app/javascript/controllers/bridge/buttons_controller.js +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -5,6 +5,26 @@ export default class extends BridgeComponent { static component = "buttons" static targets = [ "button" ] + connect() { + super.connect() + + if (!this.beforeUnloadHandler) { + this.beforeUnloadHandler = this.handleBeforeUnload.bind(this) + } + + window.addEventListener("beforeunload", this.beforeUnloadHandler) + } + + disconnect() { + super.disconnect() + + if (this.beforeUnloadHandler) { + window.removeEventListener("beforeunload", this.beforeUnloadHandler) + } + + this.notifyBridgeOfDisconnect() + } + buttonTargetConnected() { this.notifyBridgeOfConnect() } @@ -27,6 +47,14 @@ export default class extends BridgeComponent { }) } + notifyBridgeOfDisconnect() { + this.send("disconnect") + } + + handleBeforeUnload() { + this.notifyBridgeOfDisconnect() + } + #clickButton(message) { const selectedIndex = message.data.selectedIndex this.#enabledButtonTargets[selectedIndex].click() diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js index a3c780e62..4ebb3022c 100644 --- a/app/javascript/controllers/bridge/form_controller.js +++ b/app/javascript/controllers/bridge/form_controller.js @@ -6,13 +6,31 @@ export default class extends BridgeComponent { static targets = [ "submit", "cancel" ] static values = { submitTitle: String } + connect() { + super.connect() + + if (!this.beforeUnloadHandler) { + this.beforeUnloadHandler = this.handleBeforeUnload.bind(this) + } + + window.addEventListener("beforeunload", this.beforeUnloadHandler) + } + + disconnect() { + super.disconnect() + + if (this.beforeUnloadHandler) { + window.removeEventListener("beforeunload", this.beforeUnloadHandler) + } + } + submitTargetConnected() { this.notifyBridgeOfConnect() this.#observeSubmitTarget() } submitTargetDisconnected() { - this.notifyBridgeOfDisonnect() + this.notifyBridgeOfDisconnect() this.submitObserver?.disconnect() } @@ -37,7 +55,7 @@ export default class extends BridgeComponent { } } - notifyBridgeOfDisonnect() { + notifyBridgeOfDisconnect() { this.send("disconnect") } @@ -49,6 +67,10 @@ export default class extends BridgeComponent { this.send("submitEnd") } + handleBeforeUnload() { + this.notifyBridgeOfDisconnect() + } + #observeSubmitTarget() { this.submitObserver = new MutationObserver(() => { this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") diff --git a/app/javascript/controllers/bridge/share_controller.js b/app/javascript/controllers/bridge/share_controller.js new file mode 100644 index 000000000..75f0241cf --- /dev/null +++ b/app/javascript/controllers/bridge/share_controller.js @@ -0,0 +1,14 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "share" + + shareUrl() { + const description = this.bridgeElement.bridgeAttribute("share-description") + this.send("shareUrl", { + title: document.title, + url: window.location.href, + description: description + }) + } +} diff --git a/app/javascript/controllers/search_form_controller.js b/app/javascript/controllers/search_form_controller.js new file mode 100644 index 000000000..d48741cd5 --- /dev/null +++ b/app/javascript/controllers/search_form_controller.js @@ -0,0 +1,14 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["searchInput"] + + clearInput() { + if (this.searchInputTarget.value) { + this.searchInputTarget.value = "" + this.searchInputTarget.focus() + } else { + this.dispatch("reset") + } + } +} diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 7ddeb1b33..438cf22e1 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -3,7 +3,9 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge" BridgeElement.prototype.getButton = function() { return { title: this.title, - icon: this.getIcon() + icon: this.getIcon(), + displayTitle: this.getDisplayTitle(), + displayAsPrimaryAction: this.getDisplayAsPrimaryAction() } } @@ -16,3 +18,11 @@ BridgeElement.prototype.getIcon = function() { return null } + +BridgeElement.prototype.getDisplayTitle = function() { + return !!this.bridgeAttribute(`display-title`) +} + +BridgeElement.prototype.getDisplayAsPrimaryAction = function() { + return !!this.bridgeAttribute(`display-as-primary-action`) +} diff --git a/app/jobs/notification/push_job.rb b/app/jobs/notification/push_job.rb new file mode 100644 index 000000000..233762b37 --- /dev/null +++ b/app/jobs/notification/push_job.rb @@ -0,0 +1,5 @@ +class Notification::PushJob < ApplicationJob + def perform(notification) + notification.push + end +end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb index c912e141d..862564927 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(notification) - NotificationPusher.new(notification).push + notification.push end end diff --git a/app/models/account/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb index b7869eb73..a5151f05f 100644 --- a/app/models/account/data_transfer/account_record_set.rb +++ b/app/models/account/data_transfer/account_record_set.rb @@ -31,7 +31,7 @@ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet account_data = load(files.first) join_code_data = account_data.delete("join_code") - account.update!(name: account_data.fetch("name")) + account.update!(name: account_data.fetch("name"), cards_count: account_data.fetch("cards_count", 0)) account.join_code.update!(join_code_data.slice("usage_count", "usage_limit")) account.join_code.update(code: join_code_data.fetch("code")) end diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text/rich_text_record_set.rb similarity index 53% rename from app/models/account/data_transfer/action_text_rich_text_record_set.rb rename to app/models/account/data_transfer/action_text/rich_text_record_set.rb index fa1a23cb9..01e046dbe 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text/rich_text_record_set.rb @@ -1,4 +1,4 @@ -class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet ATTRIBUTES = %w[ account_id body @@ -11,16 +11,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer ].freeze def initialize(account) - super(account: account, model: ActionText::RichText) + super(account: account, model: ::ActionText::RichText) end private def records - ActionText::RichText.where(account: account) + ::ActionText::RichText.where(account: account) end def export_record(rich_text) - data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body)) + data = rich_text.as_json.merge("body" => transform_body_for_export(rich_text.body)) zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json end @@ -31,11 +31,11 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer def import_batch(files) batch_data = files.map do |file| data = load(file) - data["body"] = convert_gids_to_sgids(data["body"]) + data["body"] = transform_body_for_import(data["body"]) data.slice(*ATTRIBUTES).merge("account_id" => account.id) end - ActionText::RichText.insert_all!(batch_data) + ::ActionText::RichText.insert_all!(batch_data) end def check_record(file_path) @@ -54,11 +54,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer check_associations_dont_exist(data) end - def convert_sgids_to_gids(content) + def transform_body_for_export(content) return nil if content.blank? + html = convert_sgids_to_gids(content) + relativize_urls(html) + end + + def convert_sgids_to_gids(content) content.send(:attachment_nodes).each do |node| - sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) + sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME) record = begin sgid&.find @@ -75,11 +80,35 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer content.fragment.source.to_html end - def convert_gids_to_sgids(html) - return html if html.blank? + def relativize_urls(html) + host = Rails.application.routes.default_url_options[:host] + return html unless host fragment = Nokogiri::HTML.fragment(html) + fragment.css("a[href]").each do |link| + uri = URI.parse(link["href"]) rescue nil + + if uri.respond_to?(:host) && uri.host == host + link["href"] = uri.path + link["href"] += "?#{uri.query}" if uri.query + link["href"] += "##{uri.fragment}" if uri.fragment + end + end + + fragment.to_html + end + + def transform_body_for_import(body) + return body if body.blank? + + Nokogiri::HTML.fragment(body) + .then { convert_gids_to_sgids(it) } + .then { replace_account_slugs(it) } + .to_html + end + + def convert_gids_to_sgids(fragment) fragment.css("action-text-attachment[gid]").each do |node| gid = GlobalID.parse(node["gid"]) @@ -97,6 +126,20 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer end end - fragment.to_html + fragment + end + + def replace_account_slugs(fragment) + fragment.css("a[href]").each do |link| + match = link["href"].match(AccountSlug::PATH_INFO_MATCH) + + if match + path = match.post_match.presence || "/" + valid_path = Rails.application.routes.recognize_path(path) rescue nil + link["href"] = "#{account.slug}#{path}" if valid_path + end + end + + fragment end end diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb similarity index 55% rename from app/models/account/data_transfer/active_storage_blob_record_set.rb rename to app/models/account/data_transfer/active_storage/blob_record_set.rb index 10ee326ad..1637768f3 100644 --- a/app/models/account/data_transfer/active_storage_blob_record_set.rb +++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb @@ -1,9 +1,9 @@ -class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet def initialize(account) super( account: account, - model: ActiveStorage::Blob, - attributes: ActiveStorage::Blob.column_names - %w[service_name] + model: ::ActiveStorage::Blob, + attributes: ::ActiveStorage::Blob.column_names - %w[service_name] ) end @@ -13,7 +13,7 @@ class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer: data = load(file) data.slice(*attributes).merge( "account_id" => account.id, - "service_name" => ActiveStorage::Blob.service.name + "service_name" => ::ActiveStorage::Blob.service.name ) end diff --git a/app/models/account/data_transfer/blob_file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb similarity index 72% rename from app/models/account/data_transfer/blob_file_record_set.rb rename to app/models/account/data_transfer/active_storage/file_record_set.rb index 4bbfcba10..15d359353 100644 --- a/app/models/account/data_transfer/blob_file_record_set.rb +++ b/app/models/account/data_transfer/active_storage/file_record_set.rb @@ -1,11 +1,11 @@ -class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet def initialize(account) - super(account: account, model: ActiveStorage::Blob) + super(account: account, model: ::ActiveStorage::Blob) end private def records - ActiveStorage::Blob.where(account: account) + ::ActiveStorage::Blob.where(account: account) end def export_record(blob) @@ -23,7 +23,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe def import_batch(files) files.each do |file| key = File.basename(file) - blob = ActiveStorage::Blob.find_by(key: key, account: account) + blob = ::ActiveStorage::Blob.find_by(key: key, account: account) next unless blob zip.read(file) do |stream| @@ -35,7 +35,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe def check_record(file_path) key = File.basename(file_path) - unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account) + unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account) # File exists without corresponding blob record - could be orphaned or blob not yet imported # We allow this since blob metadata is imported before files end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index b5976e3d5..2159926d5 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -35,10 +35,10 @@ class Account::DataTransfer::Manifest ::Filter, ::Webhook::DelinquencyTracker, ::Event, ::Notification, ::Notification::Bundle, ::Webhook::Delivery ), - Account::DataTransfer::ActiveStorageBlobRecordSet.new(account), + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account), *build_record_sets(::ActiveStorage::Attachment), - Account::DataTransfer::ActionTextRichTextRecordSet.new(account), - Account::DataTransfer::BlobFileRecordSet.new(account) + Account::DataTransfer::ActionText::RichTextRecordSet.new(account), + Account::DataTransfer::ActiveStorage::FileRecordSet.new(account) ] end diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 14ee376ea..50ec3769d 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -48,6 +48,7 @@ class Account::Import < ApplicationRecord end add_importer_to_all_access_boards + reconcile_cards_count reconcile_account_storage mark_completed @@ -78,6 +79,10 @@ class Account::Import < ApplicationRecord ImportMailer.failed(self).deliver_later end + def reconcile_cards_count + account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max + end + def add_importer_to_all_access_boards importer = account.users.find_by!(identity: identity) diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index df8563ba3..5f7dc99ce 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -17,11 +17,11 @@ module Mentions rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ") end - private - def scan_mentionees - mentionees_from_attachments & mentionable_users - end + def scan_mentionees + mentionees_from_attachments & mentionable_users + end + private def mentionees_from_attachments rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb deleted file mode 100644 index e7bfdc56a..000000000 --- a/app/models/concerns/push_notifiable.rb +++ /dev/null @@ -1,13 +0,0 @@ -module PushNotifiable - extend ActiveSupport::Concern - - included do - after_create_commit :push_notification_later - after_update_commit :push_notification_later, if: :source_id_previously_changed? - end - - private - def push_notification_later - PushNotificationJob.perform_later(self) - end -end diff --git a/app/models/identity.rb b/app/models/identity.rb index 7495e37c3..18f4e3163 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -28,6 +28,10 @@ class Identity < ApplicationRecord end end + def users_with_active_accounts + users.joins(:account).merge(Account.active).includes(:account) + end + private def deactivate_users users.find_each(&:deactivate) diff --git a/app/models/notification.rb b/app/models/notification.rb index 3d7df5885..a5eab3ccd 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,5 +1,5 @@ class Notification < ApplicationRecord - include PushNotifiable + include Notification::Pushable belongs_to :account, default: -> { user.account } belongs_to :user @@ -10,7 +10,13 @@ class Notification < ApplicationRecord scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } - scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } + scope :preloaded, -> { + preload( + :creator, :account, + card: [ :board, :column, :closure, :not_now ], + source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ] + ) + } before_validation :set_card after_create :bundle @@ -21,6 +27,7 @@ class Notification < ApplicationRecord after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source + delegate :identity, to: :user class << self def read_all diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb new file mode 100644 index 000000000..98f104856 --- /dev/null +++ b/app/models/notification/default_payload.rb @@ -0,0 +1,57 @@ +class Notification::DefaultPayload + attr_reader :notification + + delegate :card, to: :notification + + def initialize(notification) + @notification = notification + end + + def to_h + { title: title, body: body, url: url } + end + + def title + "New notification" + end + + def body + "You have a new notification" + end + + def url + notifications_url + end + + def category + "default" + end + + def high_priority? + false + end + + def base_url + Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/") + end + + def avatar_url + Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options) + end + + private + def card_url(card) + Rails.application.routes.url_helpers.card_url(card, **url_options) + end + + def notifications_url + Rails.application.routes.url_helpers.notifications_url(**url_options) + end + + def url_options + base_options = Rails.application.routes.default_url_options.presence || + Rails.application.config.action_mailer.default_url_options || + {} + base_options.merge(script_name: notification.account.slug) + end +end diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb new file mode 100644 index 000000000..0154625e5 --- /dev/null +++ b/app/models/notification/event_payload.rb @@ -0,0 +1,107 @@ +class Notification::EventPayload < Notification::DefaultPayload + include ExcerptHelper + + def title + case event.action + when "comment_created" + "RE: #{card_title}" + else + card_title + end + end + + def body + case event.action + when "comment_created" + format_excerpt(event.eventable.body, length: 200) + when "card_assigned" + "Assigned to you by #{event.creator.name}" + when "card_published" + "Added by #{event.creator.name}" + when "card_closed" + card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" + when "card_reopened" + "Reopened by #{event.creator.name}" + when "card_triaged" + if column_name.present? + "Moved to #{column_name} by #{event.creator.name}" + else + "Moved by #{event.creator.name}" + end + when "card_sent_back_to_triage" + "Moved back to Maybe? by #{event.creator.name}" + when "card_board_changed", "card_collection_changed" + if new_location_name.present? + "Moved to #{new_location_name} by #{event.creator.name}" + else + "Moved by #{event.creator.name}" + end + when "card_title_changed" + if new_title.present? + "Renamed to #{new_title} by #{event.creator.name}" + else + "Renamed by #{event.creator.name}" + end + when "card_postponed" + "Moved to Not Now by #{event.creator.name}" + when "card_auto_postponed" + "Moved to Not Now due to inactivity" + else + "Updated by #{event.creator.name}" + end + end + + def url + case event.action + when "comment_created" + card_url_with_comment_anchor(event.eventable) + else + card_url(card) + end + end + + def category + case event.action + when "card_assigned" then "assignment" + when "comment_created" then "comment" + else "card" + end + end + + def high_priority? + event.action.card_assigned? + end + + private + def event + notification.source + end + + def card_title + card.title.presence || "Card #{card.number}" + end + + def event_particulars + event.particulars.dig("particulars") || {} + end + + def column_name + event_particulars["column"] + end + + def new_location_name + event_particulars["new_board"] || event_particulars["new_collection"] + end + + def new_title + event_particulars["new_title"] + end + + def card_url_with_comment_anchor(comment) + Rails.application.routes.url_helpers.card_url( + comment.card, + anchor: ActionView::RecordIdentifier.dom_id(comment), + **url_options + ) + end +end diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb new file mode 100644 index 000000000..480771c15 --- /dev/null +++ b/app/models/notification/mention_payload.rb @@ -0,0 +1,28 @@ +class Notification::MentionPayload < Notification::DefaultPayload + include ExcerptHelper + + def title + "#{mention.mentioner.first_name} mentioned you" + end + + def body + format_excerpt(mention.source.mentionable_content, length: 200) + end + + def url + card_url(card) + end + + def category + "mention" + end + + def high_priority? + true + end + + private + def mention + notification.source + end +end diff --git a/app/models/notification/push_target.rb b/app/models/notification/push_target.rb new file mode 100644 index 000000000..1fc250259 --- /dev/null +++ b/app/models/notification/push_target.rb @@ -0,0 +1,17 @@ +class Notification::PushTarget + attr_reader :notification + + delegate :card, to: :notification + + def self.process(notification) + new(notification).process + end + + def initialize(notification) + @notification = notification + end + + def process + raise NotImplementedError + end +end diff --git a/app/models/notification/push_target/web.rb b/app/models/notification/push_target/web.rb new file mode 100644 index 000000000..9bf839906 --- /dev/null +++ b/app/models/notification/push_target/web.rb @@ -0,0 +1,12 @@ +class Notification::PushTarget::Web < Notification::PushTarget + def process + if subscriptions.any? + Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions) + end + end + + private + def subscriptions + @subscriptions ||= notification.user.push_subscriptions + end +end diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb new file mode 100644 index 000000000..e02f3a8de --- /dev/null +++ b/app/models/notification/pushable.rb @@ -0,0 +1,52 @@ +module Notification::Pushable + extend ActiveSupport::Concern + + included do + class_attribute :push_targets, default: [] + + after_save_commit :push_later, if: :source_id_previously_changed? + end + + class_methods do + def register_push_target(target) + target = resolve_push_target(target) + push_targets << target unless push_targets.include?(target) + end + + private + def resolve_push_target(target) + if target.is_a?(Symbol) + "Notification::PushTarget::#{target.to_s.classify}".constantize + else + target + end + end + end + + def push_later + Notification::PushJob.perform_later(self) + end + + def push + return unless pushable? + + self.class.push_targets.each { |target| push_to(target) } + end + + def payload + "Notification::#{payload_type}Payload".constantize.new(self) + end + + private + def pushable? + !creator.system? && user.active? && account.active? + end + + def push_to(target) + target.process(self) + end + + def payload_type + source_type.presence_in(%w[ Event Mention ]) || "Default" + end +end diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb deleted file mode 100644 index d6425e561..000000000 --- a/app/models/notification_pusher.rb +++ /dev/null @@ -1,124 +0,0 @@ -class NotificationPusher - include Rails.application.routes.url_helpers - include ExcerptHelper - - attr_reader :notification - - def initialize(notification) - @notification = notification - end - - def push - return unless should_push? - - build_payload.tap do |payload| - push_to_user(payload) - end - end - - private - def should_push? - notification.user.push_subscriptions.any? && - !notification.creator.system? && - notification.user.active? && - notification.account.active? - end - - def build_payload - case notification.source_type - when "Event" - build_event_payload - when "Mention" - build_mention_payload - else - build_default_payload - end - end - - def build_event_payload - event = notification.source - card = event.card - - base_payload = { - title: card_notification_title(card), - path: card_path(card) - } - - case event.action - when "comment_created" - base_payload.merge( - title: "RE: #{base_payload[:title]}", - body: comment_notification_body(event), - path: card_path_with_comment_anchor(event.eventable) - ) - when "card_assigned" - base_payload.merge( - body: "Assigned to you by #{event.creator.name}" - ) - when "card_published" - base_payload.merge( - body: "Added by #{event.creator.name}" - ) - when "card_closed" - base_payload.merge( - body: card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" - ) - when "card_reopened" - base_payload.merge( - body: "Reopened by #{event.creator.name}" - ) - else - base_payload.merge( - body: event.creator.name - ) - end - end - - def build_mention_payload - mention = notification.source - card = mention.card - - { - title: "#{mention.mentioner.first_name} mentioned you", - body: format_excerpt(mention.source.mentionable_content, length: 200), - path: card_path(card) - } - end - - def build_default_payload - { - title: "New notification", - body: "You have a new notification", - path: notifications_path(script_name: notification.account.slug) - } - end - - def push_to_user(payload) - subscriptions = notification.user.push_subscriptions - enqueue_payload_for_delivery(payload, subscriptions) - end - - def enqueue_payload_for_delivery(payload, subscriptions) - Rails.configuration.x.web_push_pool.queue(payload, subscriptions) - end - - def card_notification_title(card) - card.title.presence || "Card #{card.number}" - end - - def comment_notification_body(event) - format_excerpt(event.eventable.body, length: 200) - end - - def card_path(card) - Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug) - end - - def card_path_with_comment_anchor(comment) - Rails.application.routes.url_helpers.card_path( - comment.card, - anchor: ActionView::RecordIdentifier.dom_id(comment), - script_name: notification.account.slug - ) - end -end diff --git a/app/models/notifier/card_event_notifier.rb b/app/models/notifier/card_event_notifier.rb index 6179a4404..11e6ee8a0 100644 --- a/app/models/notifier/card_event_notifier.rb +++ b/app/models/notifier/card_event_notifier.rb @@ -8,9 +8,9 @@ class Notifier::CardEventNotifier < Notifier when "card_assigned" source.assignees.excluding(creator) when "card_published" - board.watchers.without(creator, *card.mentionees).including(*card.assignees).uniq + board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq when "comment_created" - card.watchers.without(creator, *source.eventable.mentionees) + card.watchers.without(creator, *source.eventable.scan_mentionees) else board.watchers.without(creator) end diff --git a/app/models/notifier/comment_event_notifier.rb b/app/models/notifier/comment_event_notifier.rb index ee342748b..7a108a701 100644 --- a/app/models/notifier/comment_event_notifier.rb +++ b/app/models/notifier/comment_event_notifier.rb @@ -3,7 +3,7 @@ class Notifier::CommentEventNotifier < Notifier private def recipients - card.watchers.without(creator, *source.eventable.mentionees) + card.watchers.without(creator, *source.eventable.scan_mentionees) end def card diff --git a/app/models/search/stemmer.rb b/app/models/search/stemmer.rb index f6a6c56d4..21cb75f97 100644 --- a/app/models/search/stemmer.rb +++ b/app/models/search/stemmer.rb @@ -5,7 +5,7 @@ module Search::Stemmer def stem(value) if value.present? - value.gsub(/[^\w\s]/, "").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ") + value.gsub(/[^\w\s]/, " ").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ") else value end diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c62d87e09..970104d6f 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -1,8 +1,14 @@ +require "zlib" + module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze + AVATAR_COLORS = %w[ + #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53 + #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E + ].freeze included do has_one_attached :avatar do |attachable| @@ -22,6 +28,10 @@ module User::Avatar avatar.variable? ? avatar.variant(:thumb) : avatar end + def avatar_background_color + AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size] + end + # Avatars are always publicly accessible def publicly_accessible? true diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 3b34af041..f008aa934 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -36,6 +36,7 @@ class Webhook < ApplicationRecord after_create :create_delinquency_tracker! normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS } + normalizes :url, with: -> { it.strip } validates :name, presence: true validate :validate_url diff --git a/app/models/zip_file/reader.rb b/app/models/zip_file/reader.rb index 2dc563f96..12ddd6fae 100644 --- a/app/models/zip_file/reader.rb +++ b/app/models/zip_file/reader.rb @@ -2,7 +2,7 @@ class ZipFile::Reader def initialize(io) @io = io @reader = ZipKit::FileReader.read_zip_structure(io: io) - rescue ZipKit::FileReader::InvalidStructure => e + rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e raise ZipFile::InvalidFileError, e.message end diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb index 5c493edd5..49f87512f 100644 --- a/app/views/account/settings/_cancellation.html.erb +++ b/app/views/account/settings/_cancellation.html.erb @@ -1,26 +1,28 @@ <% if Current.account.cancellable? && Current.user.owner? %> -
-

Cancel account

-

Delete your Fizzy account

-
+
+
+

Cancel account

+
Delete your Fizzy account.
+
-
- +
+ - -

Delete your account?

-
    -
  • All users, including you, will lose access
  • - <% if Current.account.try(:active_subscription) %> -
  • Your subscription will be canceled
  • - <% end %> -
  • After 30 days your data will be permanently deleted
  • -
+ +

Delete your account?

+
    +
  • All users, including you, will lose access
  • + <% if Current.account.try(:active_subscription) %> +
  • Your subscription will be canceled
  • + <% end %> +
  • After 30 days, your data will be permanently deleted
  • +
-
- - <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> -
-
-
+
+ + <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> +
+ +
+
<% end %> diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index ceb17ff23..137532d75 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,4 +1,4 @@ -
+

People on this account

@@ -13,7 +13,7 @@
-
    +
      <%= render partial: "account/settings/user", collection: users %>
diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb index cd28b29a4..5895ef65f 100644 --- a/app/views/boards/edit.html.erb +++ b/app/views/boards/edit.html.erb @@ -16,13 +16,13 @@
<%= form_with model: @board, class: "display-contents", data: { - controller: "form boards-form", + controller: "form boards-form bridge--form", boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You won’t be able to get back in unless someone invites you.", action: "turbo:submit-start->boards-form#submitWithWarning" } do |form| %> <%= render "boards/edit/name", form: form, board: @board %> <%= render "boards/edit/users", board: @board, selected_users: @selected_users, unselected_users: @unselected_users, form: form %> - diff --git a/app/views/boards/edit/_users.html.erb b/app/views/boards/edit/_users.html.erb index d8c16484a..eeb7d94a1 100644 --- a/app/views/boards/edit/_users.html.erb +++ b/app/views/boards/edit/_users.html.erb @@ -33,7 +33,7 @@
-
    +
      <%= access_toggles_for selected_users, selected: true, disabled: %> <%= access_toggles_for unselected_users, selected: false, disabled: %>
    diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index bcccf4512..93c1447bb 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -30,3 +30,5 @@ <%= render "boards/show/columns", page: @page, board: @board %> <% end %> <% end %> + +<%= bridged_share_url_button(bridge_share_board_description(@board)) %> diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 191d18cbc..6d700f47e 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -13,6 +13,7 @@ navigable_list_auto_select_value: false, navigable_list_auto_scroll_value: false, card_hotkeys_navigable_list_outlet: ".cards__transition-container", + native_prevent_pull_to_refresh: true, action: " keydown->navigable-list#navigate keydown->card-hotkeys#handleKeydown diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index f8e727732..bccf51d6e 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -8,6 +8,7 @@ json.cache! card do json.tags card.tags.pluck(:title).sort json.closed card.closed? + json.postponed card.postponed? json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc diff --git a/app/views/cards/columns/edit.html.erb b/app/views/cards/columns/edit.html.erb index 162bfbbeb..8aea6f09e 100644 --- a/app/views/cards/columns/edit.html.erb +++ b/app/views/cards/columns/edit.html.erb @@ -24,13 +24,16 @@ <%= button_to_set_column @card, column %> <% end %> - <%= button_to "Done", card_closure_path(@card), + <%= button_to card_closure_path(@card), class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ], style: "--column-color: var(--color-card-complete)", disabled: @card.closed?, role: "radio", aria: { checked: @card.closed? }, data: { scroll_to_target: @card.closed? ? "target" : nil }, - form_class: "flex gap-half" %> + form_class: "flex gap-half" do %> + <%= icon_tag "check", class: "icon--mobile-only" %> + Done + <% end %> <% end %> diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder index eaf1805d6..58e2f6290 100644 --- a/app/views/cards/comments/_comment.json.jbuilder +++ b/app/views/cards/comments/_comment.json.jbuilder @@ -13,9 +13,9 @@ json.cache! comment do json.card do json.id comment.card_id - json.url card_url(comment.card_id) + json.url card_url(comment.card) end - json.reactions_url card_comment_reactions_url(comment.card_id, comment.id) - json.url card_comment_url(comment.card_id, comment.id) + json.reactions_url card_comment_reactions_url(comment.card, comment) + json.url card_comment_url(comment.card, comment) end diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index d2de939a5..ed7d6ff44 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -4,7 +4,7 @@
    -
    +
    <%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form local-save", local_save_key_value: "comment-#{card.id}", diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index 15e3cbd4a..c37b36b4e 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -5,7 +5,7 @@
    -
    +
    <%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> diff --git a/app/views/cards/container/_closure.html.erb b/app/views/cards/container/_closure.html.erb index 1afbaaaac..6da99c7ee 100644 --- a/app/views/cards/container/_closure.html.erb +++ b/app/views/cards/container/_closure.html.erb @@ -5,6 +5,7 @@ <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> Undo <% end %> + <%= bridged_button_to_board(card.board) %>
    <% else %>
    diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 990a17982..27db6590e 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,10 +7,12 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done d <% end %> + + <%= bridged_button_to_board(card.board) %>
    diff --git a/app/views/cards/container/_image.html.erb b/app/views/cards/container/_image.html.erb index e044b9b0c..c8892f0d4 100644 --- a/app/views/cards/container/_image.html.erb +++ b/app/views/cards/container/_image.html.erb @@ -1,12 +1,12 @@ <% if card.image.attached? %> - <%= button_to card_image_path(card), method: :delete, class: "btn", - data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %> + <%= button_to card_image_path(card), method: :delete, class: "btn hide-on-native", + data: { controller: "tooltip" } do %> <%= icon_tag "picture-remove" %> Remove background image <% end %> <% elsif !card.closed? %> <%= form_with model: card, data: { controller: "form" } do |form| %> -
diff --git a/app/views/notifications/trays/show.json.jbuilder b/app/views/notifications/trays/show.json.jbuilder new file mode 100644 index 000000000..0c2928cef --- /dev/null +++ b/app/views/notifications/trays/show.json.jbuilder @@ -0,0 +1 @@ +json.array! @notifications, partial: "notifications/notification", as: :notification diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index 1216c550d..91903e735 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) { self.addEventListener("notificationclick", (event) => { event.notification.close() - const url = new URL(event.notification.data.path, self.location.origin).href + const url = new URL(event.notification.data.url, self.location.origin).href event.waitUntil(openURL(url)) }) diff --git a/app/views/searches/_form.html.erb b/app/views/searches/_form.html.erb index 0ea00678f..88bcafee0 100644 --- a/app/views/searches/_form.html.erb +++ b/app/views/searches/_form.html.erb @@ -1,4 +1,10 @@ -<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { bar_target: "form", turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %> +<%= form_with url: search_path, method: :get, + class: "search__form flex align-center justify-center gap-half", + data: { + controller: "search-form", + action: "search-form:reset->bar#reset", + bar_target: "form", + turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %> <%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %> <%= text_field_tag :q, query_terms, class: "search__input input", @@ -7,9 +13,11 @@ autocomplete: "off", autofocus: true, data: { + search_form_target: "searchInput", bar_target: "searchInput", action: "keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset" } %> - + diff --git a/app/views/searches/_results.html.erb b/app/views/searches/_results.html.erb index 0b622e95d..19c5b90fb 100644 --- a/app/views/searches/_results.html.erb +++ b/app/views/searches/_results.html.erb @@ -1,6 +1,6 @@