diff --git a/.kamal/hooks/pre-build b/.kamal/hooks/pre-build new file mode 100755 index 000000000..99de8a824 --- /dev/null +++ b/.kamal/hooks/pre-build @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +def check_branch + if ENV["KAMAL_DESTINATION"] == "production" + current_branch = `git branch --show-current`.strip + + if current_branch != "main" + exit_with_error "Only the `main` branch should be deployed to production, current branch is #{current_branch}. If this is expected, try again with `SKIP_GIT_CHECKS=1` prepended to the command" + end + end +end + +def check_for_uncommitted_changes + if `git status --porcelain`.strip.length != 0 + exit_with_error "You have uncommitted changes, aborting" + end +end + +def check_local_and_remote_heads_match + remote_head = `git ls-remote origin --tags $(git branch --show-current) | cut -f1 | head -1`.strip + local_head = `git rev-parse HEAD`.strip + + if local_head != remote_head + exit_with_error "Remote HEAD #{remote_head}, differs from local HEAD #{local_head}, aborting" + end +end + +unless ENV["SKIP_GIT_CHECKS"] + check_branch + check_for_uncommitted_changes + check_local_and_remote_heads_match +end diff --git a/.kamal/secrets.beta b/.kamal/secrets.beta index 9d9a33d9f..87583b01f 100644 --- a/.kamal/secrets.beta +++ b/.kamal/secrets.beta @@ -1,6 +1,12 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..13beab928 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest"] + }, + "sentry": { + "type": "stdio", + "command": "npx", + "args": ["-y", "mcp-remote@latest", "https://mcp.sentry.dev/mcp"] + }, + "grafana": { + "type": "http", + "url": "https://grafana.37signals.com/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e8d1f5d63 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,179 @@ +# Fizzy + +This file provides guidance to AI coding agents working with this repository. + +## What is Fizzy? + +Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments. + +## Development Commands + +### Setup and Server +```bash +bin/setup # Initial setup (installs gems, creates DB, loads schema) +bin/dev # Start development server (runs on port 3006) +``` + +Development URL: http://fizzy.localhost:3006 +Login with: david@37signals.com (development fixtures), password will appear in the browser console + +### Testing +```bash +bin/rails test # Run unit tests (fast) +bin/rails test test/path/file_test.rb # Run single test file +bin/rails test:system # Run system tests (Capybara + Selenium) +bin/ci # Run full CI suite (style, security, tests) + +# For parallel test execution issues, use: +PARALLEL_WORKERS=1 bin/rails test +``` + +CI pipeline (`bin/ci`) runs: +1. Rubocop (style) +2. Bundler audit (gem security) +3. Importmap audit +4. Brakeman (security scan) +5. Application tests +6. System tests + +### Database +```bash +bin/rails db:fixtures:load # Load fixture data +bin/rails db:migrate # Run migrations +bin/rails db:reset # Drop, create, and load schema +``` + +### Other Utilities +```bash +bin/rails dev:email # Toggle letter_opener for email preview +bin/jobs # Manage Solid Queue jobs +bin/kamal deploy # Deploy (requires 1Password CLI for secrets) +``` + +## Architecture Overview + +### Multi-Tenancy (URL-Based) + +Fizzy uses **URL path-based multi-tenancy**: +- Each Account (tenant) has a unique `external_account_id` (7+ digits) +- URLs are prefixed: `/{account_id}/boards/...` +- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account` +- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path +- All models include `account_id` for data isolation +- Background jobs automatically serialize and restore account context + +**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler. + +### Authentication & Authorization + +**Passwordless magic link authentication**: +- Global `Identity` (email-based) can have `Users` in multiple Accounts +- Users belong to an Account and have roles: admin, member, system +- Sessions managed via signed cookies +- Board-level access control via `Access` records + +### Core Domain Models + +**Account** → The tenant/organization +- Has users, boards, cards, tags, webhooks +- Has entropy configuration for auto-postponement + +**Identity** → Global user (email) +- Can have Users in multiple Accounts +- Session management tied to Identity + +**User** → Account membership +- Belongs to Account and Identity +- Has role (admin/member/system) +- Board access via explicit `Access` records + +**Board** → Primary organizational unit +- Has columns for workflow stages +- Can be "all access" or selective +- Can be published publicly with shareable key + +**Card** → Main work item (task/issue) +- Sequential number within each Account +- Rich text description and attachments +- Lifecycle: triage → columns → closed/not_now +- Automatically postpones after inactivity ("entropy") + +**Event** → Records all significant actions +- Polymorphic association to changed object +- Drives activity timeline, notifications, webhooks +- Has JSON `particulars` for action-specific data + +### Entropy System + +Cards automatically "postpone" (move to "not now") after inactivity: +- Account-level default entropy period +- Board-level entropy override +- Prevents endless todo lists from accumulating +- Configurable via Account/Board settings + +### UUID Primary Keys + +All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings): +- Custom fixture UUID generation maintains deterministic ordering for tests +- Fixtures are always "older" than runtime records +- `.first`/`.last` work correctly in tests + +### Background Jobs (Solid Queue) + +Database-backed job queue (no Redis): +- Custom `FizzyActiveJobExtensions` prepended to ActiveJob +- Jobs automatically capture/restore `Current.account` +- Mission Control::Jobs for monitoring + +Key recurring tasks (via `config/recurring.yml`): +- Deliver bundled notifications (every 30 min) +- Auto-postpone stale cards (hourly) +- Cleanup jobs for expired links, deliveries + +### Sharded Full-Text Search + +16-shard MySQL full-text search instead of Elasticsearch: +- Shards determined by account ID hash (CRC32) +- Search records denormalized for performance +- Models in `app/models/search/` + +## Production Observability + +Grafana MCP tools provide access to production metrics and logs for performance analysis. + +### Datasources +| Name | UID | Use | +|------|-----|-----| +| Thanos (Prometheus) | `PC96415006F908B67` | Metrics, latencies | +| Loki | `e38bdfea-097e-47fa-a7ab-774fd2487741` | Application logs | + +### Key Metrics +- `rails_request_duration_seconds_bucket:rate1m:sum_by_app:quantiles{app="fizzy"}` - Request latency percentiles +- `rails_request_total:rate1m:sum_by_controller_action{app="fizzy"}` - Request rates by endpoint +- `fizzy_replica_wait_seconds` - Database replica consistency wait times + +### Loki Log Labels +Query: `{service_namespace="fizzy", deployment_environment_name="production", service_name="rails"}` + +Useful fields: `event_duration_ms`, `performance_time_db_ms`, `performance_time_cpu_ms`, `rails_endpoint`, `rails_controller` + +### Instrumentation +Yabeda-based metrics exported at `:9394/metrics`. Config in `config/initializers/yabeda.rb`. + +### Chrome MCP (Local Dev) +URL: `http://fizzy.localhost:3006` +Login: david@37signals.com (passwordless magic link auth - check rails console for link) + +Use Chrome MCP tools to interact with the running dev app for UI testing and debugging. + +### Sentry Error Tracking +Organization: `basecamp` | Project: `fizzy` | Region: `https://us.sentry.io` + +Use Sentry MCP tools to investigate production errors: +- `search_issues` - Find grouped issues by natural language query +- `get_issue_details` - Get full stacktrace and context for a specific issue +- `analyze_issue_with_seer` - AI-powered root cause analysis with code fix suggestions + +## Coding style + +Please read the separate file `STYLE.md` for some guidance on coding style. diff --git a/CLAUDE.md b/CLAUDE.md index a7141f6a6..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,142 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What is Fizzy? - -Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments. - -## Development Commands - -### Setup and Server -```bash -bin/setup # Initial setup (installs gems, creates DB, loads schema) -bin/dev # Start development server (runs on port 3006) -``` - -Development URL: http://fizzy.localhost:3006 -Login with: david@37signals.com (development fixtures), password will appear in the browser console - -### Testing -```bash -bin/rails test # Run unit tests (fast) -bin/rails test test/path/file_test.rb # Run single test file -bin/rails test:system # Run system tests (Capybara + Selenium) -bin/ci # Run full CI suite (style, security, tests) - -# For parallel test execution issues, use: -PARALLEL_WORKERS=1 bin/rails test -``` - -CI pipeline (`bin/ci`) runs: -1. Rubocop (style) -2. Bundler audit (gem security) -3. Importmap audit -4. Brakeman (security scan) -5. Application tests -6. System tests - -### Database -```bash -bin/rails db:fixtures:load # Load fixture data -bin/rails db:migrate # Run migrations -bin/rails db:reset # Drop, create, and load schema -``` - -### Other Utilities -```bash -bin/rails dev:email # Toggle letter_opener for email preview -bin/jobs # Manage Solid Queue jobs -bin/kamal deploy # Deploy (requires 1Password CLI for secrets) -``` - -## Architecture Overview - -### Multi-Tenancy (URL-Based) - -Fizzy uses **URL path-based multi-tenancy**: -- Each Account (tenant) has a unique `external_account_id` (7+ digits) -- URLs are prefixed: `/{account_id}/boards/...` -- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account` -- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path -- All models include `account_id` for data isolation -- Background jobs automatically serialize and restore account context - -**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler. - -### Authentication & Authorization - -**Passwordless magic link authentication**: -- Global `Identity` (email-based) can have `Users` in multiple Accounts -- Users belong to an Account and have roles: admin, member, system -- Sessions managed via signed cookies -- Board-level access control via `Access` records - -### Core Domain Models - -**Account** → The tenant/organization -- Has users, boards, cards, tags, webhooks -- Has entropy configuration for auto-postponement - -**Identity** → Global user (email) -- Can have Users in multiple Accounts -- Session management tied to Identity - -**User** → Account membership -- Belongs to Account and Identity -- Has role (admin/member/system) -- Board access via explicit `Access` records - -**Board** → Primary organizational unit -- Has columns for workflow stages -- Can be "all access" or selective -- Can be published publicly with shareable key - -**Card** → Main work item (task/issue) -- Sequential number within each Account -- Rich text description and attachments -- Lifecycle: triage → columns → closed/not_now -- Automatically postpones after inactivity ("entropy") - -**Event** → Records all significant actions -- Polymorphic association to changed object -- Drives activity timeline, notifications, webhooks -- Has JSON `particulars` for action-specific data - -### Entropy System - -Cards automatically "postpone" (move to "not now") after inactivity: -- Account-level default entropy period -- Board-level entropy override -- Prevents endless todo lists from accumulating -- Configurable via Account/Board settings - -### UUID Primary Keys - -All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings): -- Custom fixture UUID generation maintains deterministic ordering for tests -- Fixtures are always "older" than runtime records -- `.first`/`.last` work correctly in tests - -### Background Jobs (Solid Queue) - -Database-backed job queue (no Redis): -- Custom `FizzyActiveJobExtensions` prepended to ActiveJob -- Jobs automatically capture/restore `Current.account` -- Mission Control::Jobs for monitoring - -Key recurring tasks (via `config/recurring.yml`): -- Deliver bundled notifications (every 30 min) -- Auto-postpone stale cards (hourly) -- Cleanup jobs for expired links, deliveries - -### Sharded Full-Text Search - -16-shard MySQL full-text search instead of Elasticsearch: -- Shards determined by account ID hash (CRC32) -- Search records denormalized for performance -- Models in `app/models/search/` - -## Coding style - -Please read the separate file `STYLE.md` for some guidance on coding style. +@AGENTS.md diff --git a/Gemfile b/Gemfile index a74c6ed43..d008f4907 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,7 @@ gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" gem "mittens" +gem "useragent", bc: "useragent" # Telemetry, logging, and operations gem "mission_control-jobs" @@ -49,7 +50,7 @@ gem "yabeda-prometheus-mmap" gem "yabeda-puma-plugin" gem "yabeda-rails" gem "webrick" # required for yabeda-prometheus metrics server -gem "prometheus-client-mmap", "~> 1.1" +gem "prometheus-client-mmap", "~> 1.3" gem "autotuner" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings diff --git a/Gemfile.lock b/Gemfile.lock index d7a3121dc..f8001333c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,6 +16,12 @@ GIT json rails (>= 6.0.0) +GIT + remote: https://github.com/basecamp/useragent + revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e + specs: + useragent (0.16.11) + GIT remote: https://github.com/basecamp/yabeda-activejob.git revision: 684973f77ff01d8b3dd75874538fae55961e15e6 @@ -27,7 +33,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 077c3ad60db4c43cccb8f4637b53b8d8eb7a3c19 + revision: b22cb0c0cb39b103d816a66d560991acf0a57163 branch: main specs: actioncable (8.2.0.alpha) @@ -147,15 +153,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.0.2) + autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1175.0) - aws-sdk-core (3.234.0) + aws-partitions (1.1187.0) + aws-sdk-core (3.239.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -163,10 +169,10 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.115.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.201.0) + aws-sdk-s3 (1.205.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -178,9 +184,9 @@ GEM benchmark (0.5.0) bigdecimal (3.3.1) bindex (0.8.1) - bootsnap (1.18.6) + bootsnap (1.19.0) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.1) racc builder (3.3.0) bundler-audit (0.9.2) @@ -199,7 +205,7 @@ GEM logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.5) - connection_pool (2.5.4) + connection_pool (2.5.5) crack (1.0.1) bigdecimal rexml @@ -290,7 +296,7 @@ GEM logger mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.1) + minitest (5.26.2) mission_control-jobs (1.1.0) actioncable (>= 7.1) actionpack (>= 7.1) @@ -302,7 +308,7 @@ GEM stimulus-rails turbo-rails mittens (0.3.0) - mocha (2.7.1) + mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.6) @@ -333,10 +339,10 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) - openssl (3.3.1) + openssl (3.3.2) ostruct (0.6.3) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc platform_agent (1.0.1) @@ -346,31 +352,31 @@ GEM prettyprint prettyprint (0.2.0) prism (1.6.0) - prometheus-client-mmap (1.2.10) + prometheus-client-mmap (1.3.0) base64 bigdecimal logger - rb_sys (~> 0.9.109) - prometheus-client-mmap (1.2.10-arm64-darwin) + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-arm64-darwin) base64 bigdecimal logger - rb_sys (~> 0.9.109) - prometheus-client-mmap (1.2.10-x86_64-darwin) + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-x86_64-darwin) base64 bigdecimal logger - rb_sys (~> 0.9.109) - prometheus-client-mmap (1.2.10-x86_64-linux-gnu) + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-x86_64-linux-gnu) base64 bigdecimal logger - rb_sys (~> 0.9.109) - prometheus-client-mmap (1.2.10-x86_64-linux-musl) + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-x86_64-linux-musl) base64 bigdecimal logger - rb_sys (~> 0.9.109) + rb_sys (~> 0.9.117) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -378,7 +384,7 @@ GEM psych (5.2.6) date stringio - public_suffix (6.0.2) + public_suffix (7.0.0) puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) @@ -405,7 +411,7 @@ GEM rake-compiler-dock (1.9.1) rb_sys (0.9.117) rake-compiler-dock (= 1.9.1) - rdoc (6.15.1) + rdoc (6.16.0) erb psych (>= 4.0.0) tsort @@ -418,8 +424,8 @@ GEM rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) - rubocop (1.81.1) + rqrcode_core (2.0.1) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -430,14 +436,14 @@ GEM rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.47.1) + rubocop-ast (1.48.0) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.26.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rails (2.33.4) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.1) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -453,7 +459,7 @@ GEM ffi (~> 1.12) logger ruby2_keywords (0.0.5) - rubyzip (3.2.1) + rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.38.0) base64 (~> 0.2) @@ -461,10 +467,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.0.0) + sentry-rails (6.1.2) railties (>= 5.2.0) - sentry-ruby (~> 6.0.0) - sentry-ruby (6.0.0) + sentry-ruby (~> 6.1.2) + sentry-ruby (6.1.2) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) sniffer (0.5.0) @@ -475,23 +481,23 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_cache (1.0.8) + solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.2) + solid_queue (1.2.4) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.7.4) + sqlite3 (2.8.0) mini_portile2 (~> 2.8.0) - sqlite3 (2.7.4-arm64-darwin) - sqlite3 (2.7.4-x86_64-darwin) - sqlite3 (2.7.4-x86_64-linux-gnu) - sqlite3 (2.7.4-x86_64-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) sshkit (1.24.0) base64 logger @@ -519,7 +525,6 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.1.0) uri (1.1.1) - useragent (0.16.11) vcr (6.3.1) base64 web-console (4.2.1) @@ -530,7 +535,7 @@ GEM web-push (3.0.2) jwt (~> 3.0) openssl (~> 3.0) - webmock (3.26.0) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -560,7 +565,7 @@ GEM yabeda-prometheus-mmap (0.4.0) prometheus-client-mmap yabeda (~> 0.10) - yabeda-puma-plugin (0.8.0) + yabeda-puma-plugin (0.9.0) json puma yabeda (~> 0.5) @@ -604,7 +609,7 @@ DEPENDENCIES mocha net-http-persistent platform_agent - prometheus-client-mmap (~> 1.1) + prometheus-client-mmap (~> 1.3) propshaft puma (>= 5.0) queenbee! @@ -626,6 +631,7 @@ DEPENDENCIES thruster trilogy (~> 2.9) turbo-rails + useragent! vcr web-console web-push diff --git a/app/assets/images/system_user.png b/app/assets/images/system_user.png new file mode 100644 index 000000000..5204feb44 Binary files /dev/null and b/app/assets/images/system_user.png differ diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 9c0b30f0f..414d4c696 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -74,11 +74,11 @@ --z-events-column-header: 1; --z-events-day-header: 3; --z-popup: 10; - --z-bar: 20; - --z-tray: 21; --z-nav: 30; --z-flash: 40; --z-tooltip: 50; + --z-bar: 60; + --z-tray: 61; /* OKLCH colors: Fixed */ --lch-black: 0% 0 0; diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css index 1aa2494de..86183ccb0 100644 --- a/app/assets/stylesheets/bar.css +++ b/app/assets/stylesheets/bar.css @@ -11,7 +11,9 @@ inset: auto 0 0 0; max-block-size: 100%; padding-block: var(--block-space) calc(var(--block-space) + env(safe-area-inset-bottom)); - padding-inline: calc(var(--tray-size) + calc(var(--inline-space) * 3)); + padding-inline: + calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-left)) + calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-right)); place-content: center; position: fixed; view-transition-name: bar; @@ -46,7 +48,7 @@ inline-size: 100vw; inset: auto 0 0 0; max-inline-size: 100vw; - margin-block-end: calc(var(--footer-height) - 0.3rem); + margin-block-end: calc(var(--footer-height) - 0.3rem + env(safe-area-inset-bottom)); position: fixed; z-index: -1; diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 5e5dad254..208c9d5d3 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -54,6 +54,10 @@ ::selection { background-color: var(--color-selected); + + @media (prefers-color-scheme: dark) { + background-color: var(--color-selected-dark); + } } :where(ul, ol):where([role="list"]) { diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 458cdf46a..a4e7199e0 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -109,12 +109,19 @@ aspect-ratio: 1; padding: 0.5em; + kbd, span:last-of-type { display: none; } } } + @media (min-width: 640px) { + .btn--circle-mobile .icon--mobile-only { + display: none !important; + } + } + .btn--negative { --btn-background: var(--color-negative); --btn-border-color: var(--color-negative); @@ -219,6 +226,7 @@ } } + /* Button groups /* ------------------------------------------------------------------------ */ diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 217f9a5c2..501be0d3e 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -67,6 +67,7 @@ --column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas)); inline-size: var(--column-width-expanded); + outline: none; position: relative; scroll-snap-align: start; @@ -111,7 +112,6 @@ } } - @media (any-hover: hover) { .card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) { filter: blur(3px) brightness(1.2); @@ -172,6 +172,15 @@ .cards--grid & { display: contents; } + + [aria-selected] & .card[aria-selected] { + outline: var(--focus-ring-size) solid var(--color-selected-dark); + outline-offset: var(--focus-ring-offset); + + @media (prefers-color-scheme: dark) { + outline-color: oklch(var(--lch-blue-medium)); + } + } } .cards__new-column { @@ -429,9 +438,7 @@ /* Cover bubbles and cards on small viewports */ @media (max-width: 639px) { - &:not(.cards--closed &) { - margin-inline: calc(-1 * var(--cards-gap) - var(--main-padding)); - } + margin-inline: calc(-1 * var(--cards-gap) - var(--main-padding)); } &, .cards__filter { @@ -650,6 +657,7 @@ /* On Deck (Not Now) /* ------------------------------------------------------------------------ */ + .cards--closed, .cards--on-deck { --card-color: var(--color-ink-light) !important; @@ -662,10 +670,18 @@ } } + /* Closed (Done) + /* ------------------------------------------------------------------------ */ + + .cards--closed { + + } + /* Doing /* -------------------------------------------------------------------------- */ /* Surface a mini bubble if there are cards with bubbles inside */ + .cards--considering:has(.bubble:not([hidden])), .cards--doing.is-collapsed:has(.bubble:not([hidden])) { .cards__transition-container { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); @@ -685,6 +701,11 @@ translate: 20% -20%; z-index: 1; } + + .cards--considering &:before { + inset: 0 auto auto 0; + translate: 100% 75%; + } } } diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css index e1c3f87e2..bc0ebdf98 100644 --- a/app/assets/stylesheets/filters.css +++ b/app/assets/stylesheets/filters.css @@ -1,4 +1,8 @@ @layer components { + #header:has(.filters) { + position: relative; + } + .filters { align-items: center; display: flex; @@ -13,10 +17,10 @@ --btn-border-color: var(--color-ink-medium); --input-background: var(--color-canvas); } - } - #header:has(.filters) { - position: relative; + &:has(.filter-toggle:hover) { + z-index: calc(var(--z-nav) + 1); + } } .filter { @@ -170,5 +174,17 @@ .filters--has-filters-set .filters__manage { display: flex; } + + .filters__show-when-expanded { + .filters:not(.filters--expanded) & { + display: none; + } + } + + .filters__show-when-collapsed { + .filters--expanded & { + display: none; + } + } } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index f4157a270..e82992dfc 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -12,7 +12,9 @@ inline-size: 100dvw; margin-inline: auto; max-inline-size: 100dvw; - padding-inline: var(--main-padding); + padding-inline: + calc(var(--main-padding) + env(safe-area-inset-left)) + calc(var(--main-padding) + env(safe-area-inset-right)); text-align: center; } diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index b37201e1d..2753a82c5 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -75,10 +75,15 @@ 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); grid-template-rows: auto 1fr auto; gap: var(--nav-section-gap); + max-block-size: calc(100vh - var(--block-space) - var(--footer-height)); overflow: hidden; padding-block-end: 0; scrollbar-gutter: stable both-edges; z-index: var(--z-nav); + + @media (max-height: 668px) { + max-block-size: calc(100vh - var(--block-space)); + } } .nav__scroll-container { @@ -209,5 +214,9 @@ padding: 1.5ch; text-align: center; z-index: 1; + + @media (max-height: 668px) { + font-size: var(--text-x-small); + } } } diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index ab1e6ce74..6eb86837c 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -9,8 +9,9 @@ --popup-display: flex; inset: 0 auto auto 50%; - min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2))); + max-block-size: 70dvh; max-inline-size: min(55ch, calc(100vw - (var(--panel-padding) * 2))); + min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2))); overflow: auto; position: absolute; transform: translateX(-50%); diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css index 890e27ac3..c79493458 100644 --- a/app/assets/stylesheets/print.css +++ b/app/assets/stylesheets/print.css @@ -125,13 +125,11 @@ .card--new, .cards__decoration, .card-columns:before, - .cards--considering:before, - .cards--closed:before { + .cards--considering:before { display: none; } - .card-columns, - .cards--closed { + .card-columns { border-block-start: var(--border-light); margin-block-end: 1ch; min-block-size: unset; @@ -156,21 +154,6 @@ } } - .cards--closed { - .card:nth-of-type(7) { - margin-block: 0; - } - - .card:nth-of-type(n+7) { - border-inline: none; - max-inline-size: none; - } - - .card:nth-of-type(n+8) { - margin-block-start: calc(-1 * var(--cards-gap) - 1px); - } - } - /* Card perma /* ------------------------------------------------------------------------ */ diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 80bde798a..d736b03cb 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -74,10 +74,14 @@ gap: 0.25ch; max-inline-size: 100%; opacity: 1; - padding: 0.1em 0.12em; - postion: relative; + padding: 0.1em 0.36em 0.1em 0.12em; + position: relative; transition: opacity 100ms ease-in-out, transform 150ms ease-in-out; + &:has(span.txt-small.txt-medium) { /* emoji only reactions */ + padding: 0.1em 0.12em; + } + .btn { font-size: 0.6em; inline-size: auto; diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 5e871260f..38c7722e2 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -28,10 +28,19 @@ } } - :where(b, strong) { + :where(b, strong, .lexxy-content__bold) { font-weight: 700; } + :where(i, em, .lexxy-content__italic) { + font-style: italic; + } + + :where(s, .lexxy-content__strikethrough) { + text-decoration: line-through; + } + + :where(p, blockquote) { letter-spacing: -0.005ch; } @@ -75,10 +84,6 @@ padding-block: var(--block-margin); } - .lexxy-content__strikethrough { - text-decoration: line-through; - } - /* Code */ :where(code, pre) { background-color: var(--color-canvas); diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index bf6b44c07..c5390e793 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -50,35 +50,19 @@ summary { .search__reset { --btn-background: var(--color-terminal-bg); - --btn-size: 1.25lh; - - @media (min-width: 800px) { - .search__input:placeholder-shown ~ & { - opacity: 0; - } - } + --btn-size: 1.5lh; } /* Content /* ------------------------------------------------------------------------ */ - .search__header { - align-items: center; - display: flex; - inset: 0 0 auto 0; - justify-content: flex-end; - padding: var(--block-space-half) var(--block-space); - pointer-events: none; - position: absolute; - } - .search__list { display: grid; gap: var(--block-space); list-style: none; margin: 0 auto; - max-inline-size: 80ch; + max-inline-size: min(80ch, 100%); padding: 0; text-align: start; } diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index 3e271137f..f106859ac 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -33,7 +33,7 @@ transform-origin: center; transition: 150ms transform ease-in-out; } - + &:checked::before { transform: scale(1) rotate(10deg); } @@ -73,6 +73,7 @@ @supports (field-sizing: content) { field-sizing: content; + max-inline-size: 100%; min-inline-size: 15ch; } } diff --git a/app/assets/stylesheets/tooltips.css b/app/assets/stylesheets/tooltips.css index 419664fc6..f6ff54ffc 100644 --- a/app/assets/stylesheets/tooltips.css +++ b/app/assets/stylesheets/tooltips.css @@ -10,7 +10,7 @@ font-size: var(--text-x-small); font-weight: normal; inset: -1ch auto auto 50%; - max-inline-size: 50ch; + max-inline-size: min(50ch, calc(100vw - (var(--inline-space) * 2))); opacity: 0; padding: 0.25ch 1ch; transition: var(--tooltip-duration) ease-out allow-discrete; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index de3bd535a..89b94f079 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,9 +2,10 @@ class ApplicationController < ActionController::Base include Authentication include Authorization include CurrentRequest, CurrentTimezone, SetPlatform + include RequestForgeryProtection include TurboFlash, ViewTransitions include Saas - include RoutingHeaders, WriterAffinity + include RoutingHeaders etag { "v1" } stale_when_importmap_changes diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index fdd2a86dc..886537fa1 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -2,7 +2,8 @@ class Cards::AssignmentsController < ApplicationController include CardScoped def new - @users = @board.users.active.alphabetically + @assigned_to = @card.assignees.active.alphabetically.where.not(id: Current.user) + @users = @board.users.active.alphabetically.where.not(id: @card.assignees).where.not(id: Current.user) fresh_when etag: [ @users, @card.assignees ] end diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb index 887b5dd3d..95acc7511 100644 --- a/app/controllers/cards/readings_controller.rb +++ b/app/controllers/cards/readings_controller.rb @@ -1,8 +1,6 @@ class Cards::ReadingsController < ApplicationController include CardScoped - skip_writer_affinity - def create @notifications = @card.read_by(Current.user) record_board_access diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb index d388fae22..9190b20f1 100644 --- a/app/controllers/cards/taggings_controller.rb +++ b/app/controllers/cards/taggings_controller.rb @@ -2,7 +2,8 @@ class Cards::TaggingsController < ApplicationController include CardScoped def new - @tags = Current.account.tags.all.alphabetically + @tagged_with = @card.tags.alphabetically + @tags = Current.account.tags.all.alphabetically.where.not(id: @tagged_with) fresh_when etag: [ @tags, @card.tags ] end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index a8762a9cb..cb40079e8 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -22,7 +22,6 @@ class CardsController < ApplicationController def update @card.update! card_params - redirect_to @card end def destroy diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb new file mode 100644 index 000000000..682e88a7d --- /dev/null +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -0,0 +1,50 @@ +module RequestForgeryProtection + extend ActiveSupport::Concern + + included do + after_action :append_set_fetch_site_to_vary_header + end + + private + def append_set_fetch_site_to_vary_header + vary_header = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:blank?) + response.headers["Vary"] = (vary_header + [ "Sec-Fetch-Site" ]).join(",") + end + + def verified_request? + return true if request.get? || request.head? || !protect_against_forgery? + + origin = valid_request_origin? + token = any_authenticity_token_valid? + sec_fetch_site = safe_fetch_site? + report_on_forgery_protection_results(origin:, token:, sec_fetch_site:) + + origin && token + end + + SAFE_FETCH_SITES = %w[ same-origin same-site ] + + def safe_fetch_site? + SAFE_FETCH_SITES.include?(safe_fetch_site_value) + end + + def safe_fetch_site_value + request.headers["Sec-Fetch-Site"].to_s.downcase + end + + def report_on_forgery_protection_results(origin:, token:, sec_fetch_site:) + results = { origin:, token:, sec_fetch_site: } + + unless results.values.all? + info = results.transform_values { it ? "pass" : "fail" } + info[:origin] += " (#{request.origin})" + info[:sec_fetch_site] += " (#{safe_fetch_site_value})" + + Rails.logger.info "CSRF protection check: " + info.map { it.join(" ") }.join(", ") + + if (origin && token) != sec_fetch_site + Sentry.capture_message "CSRF protection mismatch", level: :info, extra: { info: info } + end + end + end +end diff --git a/app/controllers/concerns/writer_affinity.rb b/app/controllers/concerns/writer_affinity.rb deleted file mode 100644 index da03e1a42..000000000 --- a/app/controllers/concerns/writer_affinity.rb +++ /dev/null @@ -1,14 +0,0 @@ -module WriterAffinity - extend ActiveSupport::Concern - - class_methods do - def skip_writer_affinity(**) - before_action :set_writer_affinity_opt_out_header, ** - end - end - - private - def set_writer_affinity_opt_out_header - response.headers["X-Writer-Affinity"] = "false" - end -end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 8adb04b3c..76bd62974 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,6 +1,8 @@ class NotificationsController < ApplicationController + MAX_UNREAD_NOTIFICATIONS = 500 + def index - @unread = Current.user.notifications.unread.ordered.preloaded unless current_page_param + @unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded respond_to do |format| diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 425c194b1..61ce75f9f 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -7,8 +7,12 @@ class Users::AvatarsController < ApplicationController before_action :ensure_permission_to_administer_user, only: :destroy def show - if stale? @user, cache_control: { max_age: (30.minutes unless Current.user == @user), stale_while_revalidate: 1.week }.compact - render_avatar_or_initials + if @user.system? + redirect_to view_context.image_path("system_user.png") + elsif @user.avatar.attached? + redirect_to rails_blob_url(@user.avatar.variant(:thumb), disposition: "inline") + elsif stale? @user, cache_control: cache_control + render_initials end end @@ -26,11 +30,11 @@ class Users::AvatarsController < ApplicationController head :forbidden unless Current.user.can_change?(@user) end - def render_avatar_or_initials - if @user.avatar.attached? - send_blob_stream @user.avatar + def cache_control + if @user == Current.user + {} else - render_initials + { max_age: 30.minutes, stale_while_revalidate: 1.week } end end diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 56699d584..67c7cdca0 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -9,9 +9,38 @@ module ColumnsHelper data: { turbo_frame: "_top" } end + def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block) + classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed) + + data = { + drag_and_drop_target: "container", + navigable_list_target: "item", + column_name: name, + drag_and_drop_url: drop_url + }.merge(data) + + data[:action] = token_list( + "turbo:before-morph-attribute->collapsible-columns#preventToggle", + "focus->navigable-list#select", + data.delete(:action) + ) + + tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do + tag.div(class: "cards__transition-container", data: { + controller: "navigable-list", + navigable_list_supports_horizontal_navigation_value: "false", + navigable_list_prevent_handled_keys_value: "true", + navigable_list_auto_select_value: "false", + navigable_list_actionable_items_value: "true", + navigable_list_only_act_on_focused_items_value: "true", + action: "keydown->navigable-list#navigate" + }, &block) + end + end + def column_frame_tag(id, src: nil, data: {}, **options, &block) data = data.reverse_merge \ - "drag-and-drop-refresh": true, + drag_and_drop_refresh: true, controller: "frame", action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload" options[:refresh] = :morph if src.present? diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index a29085d44..acc78da95 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -9,7 +9,7 @@ module EventsHelper "comment" when "card_title_changed" "rename" - when "card_board_changed", "card_triaged", "card_postponed" + when "card_board_changed", "card_triaged", "card_postponed", "card_auto_postponed" "move" else "person" diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index e0e0e3387..f3bb077c8 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -10,7 +10,7 @@ module WebhooksHelper card_closed: "Card moved to “Done”", card_reopened: "Card reopened", card_postponed: "Card moved to “Not Now”", - card_auto_postponed: "Card auto-closed as “Not Now”", + card_auto_postponed: "Card moved to “Not Now” due to inactivity", card_sent_back_to_triage: "Card moved back to “Maybe?”" }.with_indifferent_access.freeze diff --git a/app/javascript/controllers/bar_controller.js b/app/javascript/controllers/bar_controller.js index 6b10a27b4..5e72e4e07 100644 --- a/app/javascript/controllers/bar_controller.js +++ b/app/javascript/controllers/bar_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" +import { nextFrame } from "helpers/timing_helpers"; export default class extends Controller { static targets = [ "turboFrame", "search", "searchInput", "form", "buttonsContainer" ] @@ -21,6 +22,15 @@ 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() @@ -50,11 +60,13 @@ export default class extends Controller { this.turboFrameTarget.innerHtml = "" } - #showItem(element) { + async #showItem(element) { element.removeAttribute("hidden") const autofocusElement = element.querySelector("[autofocus]") + autofocusElement?.focus() + await nextFrame() autofocusElement?.select() } diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index b11821f3f..2c97fcd09 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -40,6 +40,13 @@ export default class extends Controller { await this.#restoreColumnsDisablingTransitions() } + focusOnColumn({ target }) { + if (this.#isCollapsed(target)) { + this.#collapseAllExcept(target) + this.#expand(target) + } + } + async #restoreColumnsDisablingTransitions() { this.#disableTransitions() this.#restoreColumns() diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index b024bce2b..3df135c12 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -1,6 +1,5 @@ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" -import { limitHeightToViewport } from "helpers/sizing_helpers" export default class extends Controller { static targets = [ "dialog" ] @@ -23,8 +22,6 @@ export default class extends Controller { orient(this.dialogTarget) } - limitHeightToViewport(this.dialogTarget, this.sizingValue) - this.loadLazyFrames() this.dialogTarget.setAttribute("aria-hidden", "false") this.dispatch("show") @@ -43,7 +40,6 @@ export default class extends Controller { this.dialogTarget.setAttribute("aria-hidden", "true") this.dialogTarget.blur() orient(this.dialogTarget, false) - limitHeightToViewport(this.dialogTarget, false) this.dispatch("close") } diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index 94acaf28b..f1d22e92d 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -8,11 +8,22 @@ export default class extends Controller { selectionAttribute: { type: String, default: "aria-selected" }, focusOnSelection: { type: Boolean, default: true }, actionableItems: { type: Boolean, default: false }, - reverseNavigation: { type: Boolean, default: false } + reverseNavigation: { type: Boolean, default: false }, + supportsHorizontalNavigation: { type: Boolean, default: true }, + supportsVerticalNavigation: { type: Boolean, default: true }, + hasNestedNavigation: { type: Boolean, default: false }, + preventHandledKeys: { type: Boolean, default: false }, + autoSelect: { type: Boolean, default: true }, + autoScroll: { type: Boolean, default: true }, + onlyActOnFocusedItems: { type: Boolean, default: false } } connect() { - this.reset() + if (this.autoSelectValue) { + this.reset() + } else { + this.#activateManualSelection() + } } // Actions @@ -27,10 +38,11 @@ export default class extends Controller { navigate(event) { this.#keyHandlers[event.key]?.call(this, event) + this.#relayNavigationToParentNavigableList(event) } select({ target }) { - this.#setCurrentFrom(target) + this.selectItem(target, true) } selectCurrentOrReset(event) { @@ -49,12 +61,109 @@ export default class extends Controller { this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1]) } + deselectWhenClickingOutside(event) { + if (this.element.contains(event.target)) { + return + } + + this.#clearSelection() + } + + // Public + + async selectItem(item, skipFocus = false) { + await this.#selectCurrentElementInParent() + + this.#clearSelection() + item.setAttribute(this.selectionAttributeValue, "true") + this.currentItem = item + this.#refreshActiveDescendant() + + await nextFrame() + + if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) } + if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() } + + if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) } + } + + isSelected(item) { + return item === this.currentItem + } + // Private - get #visibleItems() { - return this.itemTargets.filter(item => { - return item.checkVisibility() && !item.hidden - }) + async #setCurrentFrom(element) { + const selectedItem = this.#visibleItems.find(item => item.contains(element)) + + if (selectedItem) { + await this.selectItem(selectedItem) + } + } + + get #parentNavigableListController() { + const parentNavigableList = this.element.parentElement?.closest("[data-controller~='navigable-list']") + if (parentNavigableList) { + return this.application.getControllerForElementAndIdentifier(parentNavigableList, "navigable-list") + } + return null + } + + async #selectCurrentElementInParent() { + const parentController = this.#parentNavigableListController + if (parentController) { + const parentItem = this.element.closest("[data-navigable-list-target~='item']") + const isAlreadySelected = parentController.isSelected(parentItem) + if (!isAlreadySelected) { + await parentController.selectItem(parentItem, true) + } + } + } + + #clearSelection() { + for (const item of this.itemTargets) { + item.removeAttribute(this.selectionAttributeValue) + } + } + + #refreshActiveDescendant() { + const id = this.currentItem?.getAttribute("id") + if (this.hasInputTarget && id) { + this.inputTarget.setAttribute("aria-activedescendant", id) + } + } + + #activateNestedNavigableList() { + const nestedController = this.#nestedNavigableListController() + if (nestedController) { + nestedController.selectCurrentOrReset() + return true + } + return false + } + + #nestedNavigableListController() { + const nestedElement = this.currentItem?.querySelector('[data-controller~="navigable-list"]') + if (nestedElement) { + return this.application.getControllerForElementAndIdentifier(nestedElement, "navigable-list") + } + return null + } + + #activateManualSelection() { + const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue)) + if (preselectedItem) { + this.#setCurrentFrom(preselectedItem) + } + } + + // Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers. + #relayNavigationToParentNavigableList(event) { + const parentController = this.#parentNavigableListController + if (parentController) { + parentController.element.focus({ preventScroll: !this.autoScrollValue }) + parentController.navigate(event) + } } #selectPrevious() { @@ -71,46 +180,26 @@ export default class extends Controller { } } - async #setCurrentFrom(element) { - const selectedItem = this.#visibleItems.find(item => item.contains(element)) - const id = selectedItem?.getAttribute("id") - - if (selectedItem) { - this.#clearSelection() - selectedItem.setAttribute(this.selectionAttributeValue, "true") - this.currentItem = selectedItem - await nextFrame() - try { - this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) - } catch (e) {} - - if (this.focusOnSelectionValue) { this.currentItem.focus() } - if (this.hasInputTarget && id) { - this.inputTarget.setAttribute("aria-activedescendant", id) - } - } - } - - #clearSelection() { - for (const item of this.itemTargets) { - item.removeAttribute(this.selectionAttributeValue) - } - } - - #handleArrowKey(event, fn, preventDefault = true) { + #handleArrowKey(event, fn) { if (event.shiftKey || event.metaKey || event.ctrlKey) { return } fn.call() - if (preventDefault) { event.preventDefault() } + if (this.preventHandledKeysValue) { + event.preventDefault() + } } #clickCurrentItem(event) { - if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) { + if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) { const clickableElement = this.currentItem.querySelector("a,button") || this.currentItem clickableElement.click() event.preventDefault() } } + get #isFocusContainedOnNavigableItem() { + return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement)) + } + #toggleCurrentItem(event) { if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) { const toggleable = this.currentItem.querySelector("input[type=checkbox]") @@ -126,20 +215,34 @@ export default class extends Controller { } } + get #visibleItems() { + return this.itemTargets.filter(item => { + return item.checkVisibility() && !item.hidden + }) + } + #keyHandlers = { ArrowDown(event) { - const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this) - this.#handleArrowKey(event, selectMethod) + if (this.supportsVerticalNavigationValue) { + const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this) + this.#handleArrowKey(event, selectMethod) + } }, ArrowUp(event) { - const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this) - this.#handleArrowKey(event, selectMethod) + if (this.supportsVerticalNavigationValue) { + const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this) + this.#handleArrowKey(event, selectMethod) + } }, ArrowRight(event) { - this.#handleArrowKey(event, this.#selectNext.bind(this), false) + if (this.supportsHorizontalNavigationValue) { + this.#handleArrowKey(event, this.#selectNext.bind(this)) + } }, ArrowLeft(event) { - this.#handleArrowKey(event, this.#selectPrevious.bind(this), false) + if (this.supportsHorizontalNavigationValue) { + this.#handleArrowKey(event, this.#selectPrevious.bind(this)) + } }, Enter(event) { if (event.shiftKey) { diff --git a/app/javascript/helpers/orientation_helpers.js b/app/javascript/helpers/orientation_helpers.js index 4e3f1a3a5..b79d66360 100644 --- a/app/javascript/helpers/orientation_helpers.js +++ b/app/javascript/helpers/orientation_helpers.js @@ -1,15 +1,18 @@ const EDGE_THRESHOLD = 16 export function orient(el, orient = true) { - const directions = [ - ["orient-left", spaceOnRight], - ["orient-right", spaceOnLeft], - ["orient-top", spaceOnBottom] - ]; + el.classList.remove("orient-left", "orient-right") - directions.forEach(([className, fn]) => - el.classList[orient && fn(el) < EDGE_THRESHOLD ? "add" : "remove"](className) - ); + if (!orient) return + + const rightSpace = spaceOnRight(el) + const leftSpace = spaceOnLeft(el) + + if (rightSpace < EDGE_THRESHOLD && rightSpace < leftSpace) { + el.classList.add("orient-left") + } else if (leftSpace < EDGE_THRESHOLD && leftSpace < rightSpace) { + el.classList.add("orient-right") + } } function spaceOnLeft(el) { @@ -19,7 +22,3 @@ function spaceOnLeft(el) { function spaceOnRight(el) { return window.innerWidth - el.getBoundingClientRect().right } - -function spaceOnBottom(el) { - return window.innerHeight - el.getBoundingClientRect().bottom -} diff --git a/app/javascript/helpers/sizing_helpers.js b/app/javascript/helpers/sizing_helpers.js deleted file mode 100644 index 4b7e99645..000000000 --- a/app/javascript/helpers/sizing_helpers.js +++ /dev/null @@ -1,12 +0,0 @@ -export function limitHeightToViewport(el, sizing = true, margin = 64) { - if (!el) return - - if (sizing) { - const rect = el.getBoundingClientRect() - const top = Math.max(rect.top, margin) - const max = Math.max(0, window.innerHeight - margin - top) - el.style.maxHeight = `${max}px` - } else { - el.style.maxHeight = "" - } -} \ No newline at end of file diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb index db3c81bc7..89fea208a 100644 --- a/app/mailers/notification/bundle_mailer.rb +++ b/app/mailers/notification/bundle_mailer.rb @@ -11,6 +11,6 @@ class Notification::BundleMailer < ApplicationMailer mail \ to: bundle.user.identity.email_address, - subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: Latest Activity" + subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications" end end diff --git a/app/models/account.rb b/app/models/account.rb index 0e7708847..29912092c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -31,4 +31,8 @@ class Account < ApplicationRecord def account self end + + def system_user + users.where(role: :system).first! + end end diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb index f17e734ff..2f668ae4f 100644 --- a/app/models/board/accessible.rb +++ b/app/models/board/accessible.rb @@ -49,7 +49,7 @@ module Board::Accessible end def watchers - users.without(User.system).where(accesses: { involvement: :watching }) + users.active.where(accesses: { involvement: :watching }) end private diff --git a/app/models/card/entropic.rb b/app/models/card/entropic.rb index b238156f9..ebaad78c7 100644 --- a/app/models/card/entropic.rb +++ b/app/models/card/entropic.rb @@ -26,7 +26,7 @@ module Card::Entropic class_methods do def auto_postpone_all_due due_to_be_postponed.find_each do |card| - card.auto_postpone(user: User.system) + card.auto_postpone(user: card.account.system_user) end end end diff --git a/app/models/card/eventable/system_commenter.rb b/app/models/card/eventable/system_commenter.rb index ace5701bf..0e76700b3 100644 --- a/app/models/card/eventable/system_commenter.rb +++ b/app/models/card/eventable/system_commenter.rb @@ -8,7 +8,7 @@ class Card::Eventable::SystemCommenter def comment return unless comment_body.present? - card.comments.create! creator: User.system, body: comment_body, created_at: event.created_at + card.comments.create! creator: card.account.system_user, body: comment_body, created_at: event.created_at end private @@ -25,7 +25,7 @@ class Card::Eventable::SystemCommenter when "card_postponed" "#{event.creator.name} moved this to “Not Now”" when "card_auto_postponed" - "Closed as “Not Now” due to inactivity" + "Moved to “Not Now” due to inactivity" when "card_title_changed" "#{event.creator.name} changed the title from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”." when "card_board_changed" diff --git a/app/models/event/description.rb b/app/models/event/description.rb index 7718a795c..672784ddb 100644 --- a/app/models/event/description.rb +++ b/app/models/event/description.rb @@ -64,7 +64,7 @@ class Event::Description when "card_postponed" %(#{creator} moved #{card_title} to "Not Now") when "card_auto_postponed" - %(#{card_title} was closed as "Not Now" due to inactivity) + %(#{card_title} moved to "Not Now" due to inactivity) when "card_resumed" "#{creator} resumed #{card_title}" when "card_title_changed" diff --git a/app/models/identity.rb b/app/models/identity.rb index fc1c12bcd..e9ff507a1 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -18,10 +18,6 @@ class Identity < ApplicationRecord end end - def staff? - email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") - end - private def deactivate_users users.find_each(&:deactivate) diff --git a/app/models/notification.rb b/app/models/notification.rb index 4b69dd6e6..416506783 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -12,7 +12,7 @@ class Notification < ApplicationRecord after_create_commit :broadcast_unread after_destroy_commit :broadcast_read - after_create_commit :bundle + after_create :bundle scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator ]) } diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index e6f63acaa..8617a0f99 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -15,7 +15,7 @@ class Notification::Bundle < ApplicationRecord ) end - before_create :set_default_window + before_validation :set_default_window, if: :new_record? validate :validate_no_overlapping @@ -57,12 +57,12 @@ class Notification::Bundle < ApplicationRecord deliver_later end - private - def set_default_window - self.starts_at ||= Time.current - self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period - end + def set_default_window + self.starts_at ||= Time.current + self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period + end + private def window starts_at..ends_at end diff --git a/app/models/user.rb b/app/models/user.rb index 4458712a5..976f1bdc0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,7 +3,9 @@ class User < ApplicationRecord Mentionable, Named, Notifiable, Role, Searcher, Watcher include Timelined # Depends on Accessor - has_one_attached :avatar + has_one_attached :avatar do |attachable| + attachable.variant :thumb, resize_to_fill: [ 256, 256 ] + end belongs_to :account belongs_to :identity, optional: true @@ -17,8 +19,6 @@ class User < ApplicationRecord scope :with_avatars, -> { preload(:account, :avatar_attachment) } - delegate :staff?, to: :identity, allow_nil: true - def deactivate transaction do accesses.destroy_all diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb index 623433126..b9ae4941a 100644 --- a/app/models/user/day_timeline.rb +++ b/app/models/user/day_timeline.rb @@ -38,7 +38,7 @@ class User::DayTimeline end def closed_column - @closed_column ||= build_column("Closed", 3, events.where(action: "card_closed")) + @closed_column ||= build_column("Done", 3, events.where(action: "card_closed")) end def cache_key @@ -55,6 +55,7 @@ class User::DayTimeline card_collection_changed card_board_changed card_postponed + card_auto_postponed card_triaged card_sent_back_to_triage comment_created diff --git a/app/models/user/notifiable.rb b/app/models/user/notifiable.rb index ef5249611..f6788aaac 100644 --- a/app/models/user/notifiable.rb +++ b/app/models/user/notifiable.rb @@ -16,13 +16,20 @@ module User::Notifiable private def find_or_create_bundle_for(notification) - find_bundle_for(notification) || create_bundle_for(notification) + find_bundle_for(notification) || expand_pending_bundle_for(notification) || create_bundle_for(notification) end def find_bundle_for(notification) notification_bundles.pending.containing(notification).last end + def expand_pending_bundle_for(notification) + pending = notification_bundles.pending.last + if pending.present? && notification.created_at < pending.starts_at + pending.update!(starts_at: notification.created_at) # expand the window to include this notification + end + end + def create_bundle_for(notification) notification_bundles.create!(starts_at: notification.created_at) end diff --git a/app/models/user/role.rb b/app/models/user/role.rb index e12bf0191..3c2101f68 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -8,12 +8,6 @@ module User::Role scope :active, -> { where(active: true, role: %i[ admin member ]) } end - class_methods do - def system - find_or_create_by!(role: :system) { it.name = "System" } - end - end - def can_change?(other) admin? || other == self end diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb index 11ff9b326..ec9c58302 100644 --- a/app/views/account/settings/_entropy.html.erb +++ b/app/views/account/settings/_entropy.html.erb @@ -1,7 +1,7 @@

Auto close

-

Fizzy doesn’t let stale cards stick around forever. Cards automatically close as “Not Now” without activity for specific period of time. This is the default, global setting — you can override it on each board.

+

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board.

<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> diff --git a/app/views/boards/edit/_auto_close.html.erb b/app/views/boards/edit/_auto_close.html.erb index 2e4dc55a2..c32c1c050 100644 --- a/app/views/boards/edit/_auto_close.html.erb +++ b/app/views/boards/edit/_auto_close.html.erb @@ -1,7 +1,7 @@ <%= turbo_frame_tag @board, :entropy do %>

Auto close

-

Fizzy doesn’t let stale cards stick around forever. Cards automatically close as “Not now” if no one updates, comments, or moves a card for…

+

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…

<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %>
<% end %> diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb index 51c587c78..5d2820399 100644 --- a/app/views/boards/show/_closed.html.erb +++ b/app/views/boards/show/_closed.html.erb @@ -1,16 +1,12 @@ - +<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);", + data: { + drag_and_strum_target: "container", + collapsible_columns_target: "column", + action: "focus->collapsible-columns#focusOnColumn" + } do %> +
+ <%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %> + <%= render "boards/show/menu/maximize", column_path: board_columns_closed_path(board) %> +
+ <%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %> +<% end %> diff --git a/app/views/boards/show/_column.html.erb b/app/views/boards/show/_column.html.erb index c763479ef..35beb5338 100644 --- a/app/views/boards/show/_column.html.erb +++ b/app/views/boards/show/_column.html.erb @@ -1,23 +1,18 @@ - + <%= link_to board_column_path(column.board, column), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> + <%= icon_tag "grid", class: "translucent" %> + Maximize column + <% end %> + + <%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %> +<% end %> diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 2d2553d12..a6001da79 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,17 +1,24 @@ <%= tag.div class: "card-columns hide-scrollbar", data: { - controller: "collapsible-columns drag-and-drop drag-and-strum", + controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", collapsible_columns_board_value: board.id, collapsible_columns_collapsed_class: "is-collapsed", collapsible_columns_no_transitions_class: "no-transitions", collapsible_columns_title_not_visible_class: "is-off-screen", + navigable_list_supports_vertical_navigation_value: false, + navigable_list_has_nested_navigation_value: true, + navigable_list_prevent_handled_keys_value: true, + navigable_list_auto_select_value: false, + navigable_list_auto_scroll_value: false, action: " + keydown->navigable-list#navigate dragstart->drag-and-drop#dragStart dragover->drag-and-drop#dragOver dragenter->drag-and-strum#dragEnter drop->drag-and-drop#drop - dragend->drag-and-drop#dragEnd" } do %> + dragend->drag-and-drop#dragEnd + click@document->navigable-list#deselectWhenClickingOutside" } do %>
<%= render "boards/show/not_now", board: board %>
diff --git a/app/views/boards/show/_expander.html.erb b/app/views/boards/show/_expander.html.erb index d01debadf..be5768e21 100644 --- a/app/views/boards/show/_expander.html.erb +++ b/app/views/boards/show/_expander.html.erb @@ -1,4 +1,4 @@ - <% end %> diff --git a/app/views/join_codes/inactive.html.erb b/app/views/join_codes/inactive.html.erb index 1bddb6e42..375a0e6cc 100644 --- a/app/views/join_codes/inactive.html.erb +++ b/app/views/join_codes/inactive.html.erb @@ -1,12 +1,12 @@ -<% @page_title = "You can't join #{@join_code.account.name} right now." %> +<% @page_title = "That code is all used up" %>

<%= @page_title %>

-

This join code has no invitations left on it.

+

Ask someone from <%= @join_code.account.name %> to send you a new link or increase the limit.

- <%= link_to "Check out Fizzy", "https://www.fizzy.do" %>. + <%= link_to "OK", "https://www.fizzy.do", class: "btn btn--link" %>

diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index f878fba23..51acf3ea0 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -49,13 +49,15 @@ } .avatar { - border-radius: 50%; + aspect-ratio: 1; + border-radius: 2.5em; color: white; display: block; font-weight: 600; height: 2.5em; line-height: 2.5em; mso-line-height-rule: exactly; + object-fit: cover; overflow: hidden; text-align: center; width: 2.5em; diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 550b61eaa..ecee1abc0 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -7,7 +7,7 @@ <% end %> - + <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %> diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index a2353af5d..e1fec4895 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -1,6 +1,6 @@ -

Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>

+

Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>

You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.

diff --git a/app/views/mailers/notification/bundle_mailer/notification.text.erb b/app/views/mailers/notification/bundle_mailer/notification.text.erb index 4a6f84d30..7c5798262 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.text.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.text.erb @@ -1,4 +1,4 @@ -Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %> +Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %> You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>. <% @notifications.group_by(&:card).each do |card, notifications| %> diff --git a/app/views/my/_menu.html.erb b/app/views/my/_menu.html.erb index fe4336fec..2b5c1d0ea 100644 --- a/app/views/my/_menu.html.erb +++ b/app/views/my/_menu.html.erb @@ -2,7 +2,7 @@ data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames"> <%= tag.button class:"nav__trigger input input--select center flex-inline align-center txt-normal", data: { - action: "click->dialog#open:stop keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click", + action: "click->dialog#open keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click", controller: "hotkey" } do %> <%= image_tag "logo.png" %> diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index eae50830b..70fee45b8 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -13,30 +13,8 @@
-
- <% if @unread.any? %> -
-

New for you

- <%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %> -
- <% else %> -
- Nothing new for you. -
- <% end %> - -
- <%= render partial: "notifications/index/notification", collection: @unread, cached: true %> -
-
- -
-

Previously seen

- -
- <%= render partial: "notifications/index/notification", collection: @page.records, cached: true %> -
-
+ <%= render "notifications/index/unread_notifications", unread: @unread if @unread %> + <%= render "notifications/index/read_notifications", page: @page %>
<%= notifications_next_page_link(@page) if @page.records.any? %> diff --git a/app/views/notifications/index/_read_notifications.html.erb b/app/views/notifications/index/_read_notifications.html.erb new file mode 100644 index 000000000..c5e282237 --- /dev/null +++ b/app/views/notifications/index/_read_notifications.html.erb @@ -0,0 +1,7 @@ +
+

Previously seen

+ +
+ <%= render partial: "notifications/index/notification", collection: page.records, cached: true %> +
+
diff --git a/app/views/notifications/index/_unread_notifications.html.erb b/app/views/notifications/index/_unread_notifications.html.erb new file mode 100644 index 000000000..9a9898788 --- /dev/null +++ b/app/views/notifications/index/_unread_notifications.html.erb @@ -0,0 +1,25 @@ +
+ <% if unread.any? %> +
+

New for you

+ <%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %> +
+ <% else %> +
+ Nothing new for you. +
+ <% end %> + +
+ <%= render partial: "notifications/index/notification", collection: unread, cached: true %> +
+ + <% if unread.any? %> + <% total_unread_count = Current.user.notifications.unread.count %> + <% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %> +
+ Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden) +
+ <% end %> + <% end %> +
diff --git a/app/views/public/boards/show/_closed.html.erb b/app/views/public/boards/show/_closed.html.erb index 9f1e1c930..ec75aed00 100644 --- a/app/views/public/boards/show/_closed.html.erb +++ b/app/views/public/boards/show/_closed.html.erb @@ -1,4 +1,4 @@ -