diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 05bc28d35..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [ main ] - -env: - BUNDLE_GITHUB__COM: x-access-token:${{ secrets.GH_TOKEN }} - -jobs: - scan_ruby: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for security vulnerabilities in Ruby dependencies - run: bin/brakeman --no-pager - - scan_js: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for security vulnerabilities in JavaScript dependencies - run: bin/importmap audit - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Lint code for consistent style - run: bin/rubocop -f github - - test: - runs-on: ubuntu-latest - - # services: - # redis: - # image: redis - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libsqlite3-0 libvips - - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Run tests - env: - RAILS_ENV: test - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:setup test test:system - - - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: ${{ github.workspace }}/tmp/screenshots - if-no-files-found: ignore diff --git a/Gemfile b/Gemfile index 19e7f2d5e..edb02dcf8 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,5 @@ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -ruby file: ".ruby-version" gem "rails", github: "rails/rails", branch: "main" gem "active_record-tenanted", bc: "active_record-tenanted" @@ -36,14 +35,11 @@ gem "rails_structured_logging", bc: "rails-structured-logging" group :development, :test do gem "debug" + gem "bundler-audit", require: false gem "brakeman", require: false gem "rubocop-rails-omakase", require: false end -group :development do - gem "hotwire-spark" -end - group :test do gem "capybara" gem "selenium-webdriver" diff --git a/Gemfile.lock b/Gemfile.lock index 0e1eddfb2..8fbd0f756 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/active_record-tenanted - revision: 1bb339a1ca045a240aefacc2bb0b88607935d401 + revision: 8758240be33a6f6f7e7f56486a2e78008c193030 specs: active_record-tenanted (0.1.0) activerecord (>= 8.1.alpha) @@ -18,7 +18,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: e0452e80137c396c93465cf98f8eda43026346c7 + revision: 32d96235810e268eb5335a7837b13cda3f8b653d branch: main specs: actioncable (8.1.0.alpha) @@ -128,9 +128,12 @@ GEM bigdecimal (3.1.9) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (7.0.0) + brakeman (7.0.2) racc builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) capybara (3.40.0) addressable matrix @@ -154,16 +157,6 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - ffi (1.17.1-aarch64-linux-gnu) - ffi (1.17.1-aarch64-linux-musl) - ffi (1.17.1-arm-linux-gnu) - ffi (1.17.1-arm-linux-musl) - ffi (1.17.1-arm64-darwin) - ffi (1.17.1-x86-linux-gnu) - ffi (1.17.1-x86-linux-musl) - ffi (1.17.1-x86_64-darwin) - ffi (1.17.1-x86_64-linux-gnu) - ffi (1.17.1-x86_64-linux-musl) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -172,10 +165,6 @@ GEM addressable (>= 2.5.0) globalid (1.2.1) activesupport (>= 6.1) - hotwire-spark (0.1.13) - listen - rails (>= 7.0.0) - zeitwerk i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) @@ -204,9 +193,6 @@ GEM zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.4) lint_roller (1.1.0) - listen (3.9.0) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) loofah (2.24.0) crass (~> 1.0.2) @@ -297,14 +283,11 @@ GEM 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.2.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) rdoc (6.13.1) psych (>= 4.0.0) redcarpet (3.6.1) regexp_parser (2.10.0) - reline (0.6.0) + reline (0.6.1) io-console (~> 0.5) rexml (3.4.1) rouge (4.5.1) @@ -343,7 +326,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) - selenium-webdriver (4.30.1) + selenium-webdriver (4.31.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -438,10 +421,10 @@ DEPENDENCIES bcrypt (~> 3.1.7) bootsnap brakeman + bundler-audit capybara debug geared_pagination (~> 1.2) - hotwire-spark importmap-rails jbuilder kamal @@ -464,8 +447,5 @@ DEPENDENCIES thruster turbo-rails -RUBY VERSION - ruby 3.4.2p28 - BUNDLED WITH 2.5.21 diff --git a/app/assets/images/picture-add.svg b/app/assets/images/picture-add.svg index 610d32901..a64cf38d6 100644 --- a/app/assets/images/picture-add.svg +++ b/app/assets/images/picture-add.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/images/star.svg b/app/assets/images/star.svg new file mode 100644 index 000000000..281a18051 --- /dev/null +++ b/app/assets/images/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 758b9246f..cd5bd830a 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -33,10 +33,31 @@ } } - &:where(.avatar) { - --btn-border-radius: var(--avatar-border-radius); - --btn-padding: 0; - --btn-border-size: 0; + /* Circle buttons */ + &.btn--circle, + &[aria-label]:where(:has(.icon)), + &:where(:has(.for-screen-reader):has(.icon)) { + --btn-border-radius: 50%; + --btn-padding: 0; + + aspect-ratio: 1; + block-size: var(--btn-size); + display: grid; + inline-size: var(--btn-size); + place-items: center; + + > * { + grid-area: 1/1; + } + } + + /* With radios and checkboxes */ + &:has(input[type=radio]), + &:has(input[type=checkbox]) { + position: relative; + + :is(input[type=radio]), + :is(input[type=checkbox]) { --hover-size: 0; aspect-ratio: 1; diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 558b3d9fd..1b9231cae 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -73,7 +73,9 @@ } .card { + --border-color: transparent; --border-radius: 0.2em; + --border-size: 0; aspect-ratio: 2/0.95; background-color: color-mix(in srgb, var(--bubble-color) 4%, var(--color-bg)); @@ -84,6 +86,15 @@ .card__container & { inline-size: calc(100dvw - var(--actions-inline-inset) * 2 - var(--btn-size) * 2); } + + .local-time-value { + font-weight: bold; + } + + @media (prefers-color-scheme: dark) { + --border-color: var(--color-subtle-dark); + --border-size: 1px; + } } .card__actions { @@ -127,6 +138,21 @@ } } + .card__actions-container--top { + margin: var(--block-space-half) auto calc(var(--block-space) * -2); + padding: var(--block-space) 3ch; + + #header:has(&) { + position: relative; + z-index: 1; + } + + .switch__input:checked + .switch__btn { + background-color: var(--bubble-color) !important; + } + } + + .cards .card__assignees:not(:has(.avatar)) { .txt-uppercase { display: none; @@ -206,11 +232,17 @@ --padding-inline: calc(var(--block-space-double) + var(--block-space)); --padding-block: var(--block-space-double) calc(var(--block-space-double) + var(--block-space-half)); - background-color: var(--color-container); + background-color: color-mix(in srgb, var(--bubble-color) 33%, var(--color-bg)); border-radius: 0.2em; display: inline-flex; padding-block: var(--padding-block); padding-inline: var(--padding-inline); + + &:has(.card__star-input:checked) { + .card { + outline: 4px solid var(--color-negative); + } + } } .card__container--pointing { @@ -346,12 +378,12 @@ .card__meta { --border-color: var(--bubble-color); + --border-size: 1px; --row-gap: 0.25em; margin-inline-start: var(--inline-space-double); strong { - font-size: 1.1em; font-weight: 900; } @@ -462,9 +494,9 @@ font-size: var(--text-rel-large); font-weight: 900; text-shadow: - -2px -2px 0 var(--bubble-color), + -2px -2px 0 var(--bubble-color), 2px -2px 0 var(--bubble-color), - -2px 2px 0 var(--bubble-color), + -2px 2px 0 var(--bubble-color), 2px 2px 0 var(--bubble-color); } } diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 1ba711061..2db3016db 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -11,7 +11,7 @@ place-items: center; text-align: center; - @media (min-width: 150ch) { + @media (min-width: 160ch) { padding-inline: calc(var(--tray-size) + var(--inline-space) * 1.5); } } diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 0506fc38a..7937952f3 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -76,6 +76,7 @@ .icon--share { --svg: url("share.svg "); } .icon--sort-ascending { --svg: url("sort-ascending.svg "); } .icon--sort-descending { --svg: url("sort-descending.svg "); } + .icon--star { --svg: url("star.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--temperature { --svg: url("temperature.svg "); } .icon--thumb-up { --svg: url("thumb-up.svg "); } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index 34290ffc3..c38100393 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -37,7 +37,6 @@ view-transition-name: header; > nav { - font-size: var(--text-small); view-transition-name: nav; header { diff --git a/app/controllers/assignments/toggles_controller.rb b/app/controllers/assignments/toggles_controller.rb deleted file mode 100644 index 96d510baa..000000000 --- a/app/controllers/assignments/toggles_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -class Assignments::TogglesController < ApplicationController - include BubbleScoped, BucketScoped - - def new - render partial: "bubbles/assignment", locals: { bubble: @bubble } - end - - def create - new_assignee_ids = Array(params[:assignee_id]) - current_assignees = @bubble.assignees - - current_assignees.each do |assignee| - @bubble.toggle_assignment(assignee) unless new_assignee_ids.include?(assignee.id.to_s) - end - - new_assignee_ids.each do |id| - assignee = @bucket.users.active.find(id) - @bubble.toggle_assignment(assignee) unless current_assignees.include?(assignee) - end - - @bubble.assignees.reload - - respond_to do |format| - format.turbo_stream do - render turbo_stream: turbo_stream.replace([ @bubble, :assignees ], partial: "bubbles/cards/perma/assignees", locals: { bubble: @bubble }) - end - end - end - - private - def assignee - @bucket.users.active.find params[:assignee_id] - end -end diff --git a/app/controllers/bubbles/assignments_controller.rb b/app/controllers/bubbles/assignments_controller.rb new file mode 100644 index 000000000..7fd9186aa --- /dev/null +++ b/app/controllers/bubbles/assignments_controller.rb @@ -0,0 +1,10 @@ +class Bubbles::AssignmentsController < ApplicationController + include BubbleScoped + + def new + end + + def create + @bubble.toggle_assignment @bucket.users.active.find(params[:assignee_id]) + end +end diff --git a/app/controllers/boosts_controller.rb b/app/controllers/bubbles/boosts_controller.rb similarity index 57% rename from app/controllers/boosts_controller.rb rename to app/controllers/bubbles/boosts_controller.rb index 0a6f34f78..38970c40f 100644 --- a/app/controllers/boosts_controller.rb +++ b/app/controllers/bubbles/boosts_controller.rb @@ -1,5 +1,5 @@ -class BoostsController < ApplicationController - include BubbleScoped, BucketScoped +class Bubbles::BoostsController < ApplicationController + include BubbleScoped def create count = if params[:boost_count].to_i == @bubble.boosts_count @@ -8,9 +8,5 @@ class BoostsController < ApplicationController params[:boost_count].to_i end @bubble.boost!(count) - - respond_to do |format| - format.turbo_stream - end end end diff --git a/app/controllers/bubbles/engagements_controller.rb b/app/controllers/bubbles/engagements_controller.rb index e13d6679c..5599b7381 100644 --- a/app/controllers/bubbles/engagements_controller.rb +++ b/app/controllers/bubbles/engagements_controller.rb @@ -1,5 +1,5 @@ class Bubbles::EngagementsController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create @bubble.engage diff --git a/app/controllers/bubbles/images_controller.rb b/app/controllers/bubbles/images_controller.rb index 005eb967d..b7dc7da97 100644 --- a/app/controllers/bubbles/images_controller.rb +++ b/app/controllers/bubbles/images_controller.rb @@ -1,5 +1,5 @@ class Bubbles::ImagesController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def destroy @bubble.image.purge_later diff --git a/app/controllers/bubbles/pins_controller.rb b/app/controllers/bubbles/pins_controller.rb index a28526dea..d75d2b10d 100644 --- a/app/controllers/bubbles/pins_controller.rb +++ b/app/controllers/bubbles/pins_controller.rb @@ -1,16 +1,29 @@ class Bubbles::PinsController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def show end def create - @bubble.set_pinned(Current.user, true) - redirect_to bucket_bubble_pin_path(@bucket, @bubble) + pin = @bubble.pin_by Current.user + + broadcast_my_new pin + redirect_to bubble_pin_path(@bubble) end def destroy - @bubble.set_pinned(Current.user, false) - redirect_to bucket_bubble_pin_path(@bucket, @bubble) + pin = @bubble.unpin_by Current.user + + broadcast_my_removed pin + redirect_to bubble_pin_path(@bubble) end + + private + def broadcast_my_new(pin) + pin.broadcast_prepend_later_to [ Current.user, :pins ], target: "pins", partial: "my/pins/pin" + end + + def broadcast_my_removed(pin) + pin.broadcast_remove_to [ Current.user, :pins ] + end end diff --git a/app/controllers/bubbles/pops_controller.rb b/app/controllers/bubbles/pops_controller.rb index 395f241e2..5421428ca 100644 --- a/app/controllers/bubbles/pops_controller.rb +++ b/app/controllers/bubbles/pops_controller.rb @@ -1,5 +1,5 @@ class Bubbles::PopsController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create @bubble.pop!(user: Current.user, reason: params[:reason]) diff --git a/app/controllers/bubbles/publishes_controller.rb b/app/controllers/bubbles/publishes_controller.rb index 8064e7c68..bcc75eb27 100644 --- a/app/controllers/bubbles/publishes_controller.rb +++ b/app/controllers/bubbles/publishes_controller.rb @@ -1,5 +1,5 @@ class Bubbles::PublishesController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create @bubble.publish diff --git a/app/controllers/readings_controller.rb b/app/controllers/bubbles/readings_controller.rb similarity index 75% rename from app/controllers/readings_controller.rb rename to app/controllers/bubbles/readings_controller.rb index 6482cd7c9..9f99c456d 100644 --- a/app/controllers/readings_controller.rb +++ b/app/controllers/bubbles/readings_controller.rb @@ -1,5 +1,5 @@ -class ReadingsController < ApplicationController - include BubbleScoped, BucketScoped +class Bubbles::ReadingsController < ApplicationController + include BubbleScoped def create mark_bubble_notifications_read diff --git a/app/controllers/bubbles/recovers_controller.rb b/app/controllers/bubbles/recovers_controller.rb index 530592762..c94fd6ccf 100644 --- a/app/controllers/bubbles/recovers_controller.rb +++ b/app/controllers/bubbles/recovers_controller.rb @@ -1,5 +1,5 @@ class Bubbles::RecoversController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create redirect_to @bubble.recover_abandoned_creation diff --git a/app/controllers/bubbles/stagings_controller.rb b/app/controllers/bubbles/stagings_controller.rb index 2f2ea68cc..28f7af26e 100644 --- a/app/controllers/bubbles/stagings_controller.rb +++ b/app/controllers/bubbles/stagings_controller.rb @@ -1,5 +1,5 @@ class Bubbles::StagingsController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create if params[:stage_id].present? diff --git a/app/controllers/bubbles/taggings_controller.rb b/app/controllers/bubbles/taggings_controller.rb new file mode 100644 index 000000000..156705a98 --- /dev/null +++ b/app/controllers/bubbles/taggings_controller.rb @@ -0,0 +1,16 @@ +class Bubbles::TaggingsController < ApplicationController + include BubbleScoped + + def new + @tags = Current.account.tags.alphabetically + end + + def create + @bubble.toggle_tag_with sanitized_tag_title_param + end + + private + def sanitized_tag_title_param + params.required(:tag_title).strip.gsub(/\A#/, "") + end +end diff --git a/app/controllers/bubbles/watches_controller.rb b/app/controllers/bubbles/watches_controller.rb index cecd292b5..3b21acfa4 100644 --- a/app/controllers/bubbles/watches_controller.rb +++ b/app/controllers/bubbles/watches_controller.rb @@ -1,17 +1,13 @@ class Bubbles::WatchesController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped def create - set_watching_and_redirect(true) + @bubble.watch_by Current.user + redirect_to bubble_watch_path(@bubble) end def destroy - set_watching_and_redirect(false) + @bubble.unwatch_by Current.user + redirect_to bubble_watch_path(@bubble) end - - private - def set_watching_and_redirect(watching) - @bubble.set_watching(Current.user, watching) - redirect_to bucket_bubble_watch_path(@bucket, @bubble) - end end diff --git a/app/controllers/buckets_controller.rb b/app/controllers/buckets_controller.rb index 63357bb92..f4f5a2ff4 100644 --- a/app/controllers/buckets_controller.rb +++ b/app/controllers/buckets_controller.rb @@ -33,7 +33,7 @@ class BucketsController < ApplicationController end def bucket_params - params.expect(bucket: [ :name, :all_access ]).with_defaults(all_access: false) + params.expect(bucket: [ :name, :all_access ]).with_defaults(all_access: true) end def grantees diff --git a/app/controllers/comments/reactions_controller.rb b/app/controllers/comments/reactions_controller.rb index c362f4a2b..36f51f6f6 100644 --- a/app/controllers/comments/reactions_controller.rb +++ b/app/controllers/comments/reactions_controller.rb @@ -24,7 +24,7 @@ class Comments::ReactionsController < ApplicationController private def set_comment - @comment = Comment.find(params[:comment_id]) + @comment = Current.account.comments.find(params[:comment_id]) end def reaction_params diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index c0a9c9d34..4708af0e1 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,5 +1,5 @@ class CommentsController < ApplicationController - include BubbleScoped, BucketScoped + include BubbleScoped before_action :set_comment, only: [ :show, :edit, :update, :destroy ] before_action :require_own_comment, only: [ :edit, :update, :destroy ] @@ -38,6 +38,6 @@ class CommentsController < ApplicationController end def require_own_comment - head :forbidden unless @comment.creator.current? + head :forbidden unless Current.user == @comment.creator end end diff --git a/app/controllers/concerns/bubble_scoped.rb b/app/controllers/concerns/bubble_scoped.rb index bf123910c..a3783d9c5 100644 --- a/app/controllers/concerns/bubble_scoped.rb +++ b/app/controllers/concerns/bubble_scoped.rb @@ -2,11 +2,15 @@ module BubbleScoped extend ActiveSupport::Concern included do - before_action :set_bubble + before_action :set_bubble, :set_bucket end private def set_bubble - @bubble = @bucket.bubbles.find(params[:bubble_id]) + @bubble = Current.user.accessible_bubbles.find(params[:bubble_id]) + end + + def set_bucket + @bucket = Current.user.buckets.find(@bubble.bucket_id) end end diff --git a/app/controllers/bubbles/pins/trays_controller.rb b/app/controllers/my/pins_controller.rb similarity index 51% rename from app/controllers/bubbles/pins/trays_controller.rb rename to app/controllers/my/pins_controller.rb index ef79beae2..c7f222d33 100644 --- a/app/controllers/bubbles/pins/trays_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -1,5 +1,5 @@ -class Bubbles::Pins::TraysController < ApplicationController - def show +class My::PinsController < ApplicationController + def index @pins = Current.user.pins.includes(:bubble).ordered.limit(20) end end diff --git a/app/controllers/taggings/toggles_controller.rb b/app/controllers/taggings/toggles_controller.rb deleted file mode 100644 index d768c4cc7..000000000 --- a/app/controllers/taggings/toggles_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Taggings::TogglesController < ApplicationController - include BubbleScoped, BucketScoped - - def new - render partial: "bubbles/tagging", locals: { bubble: @bubble, tags: Current.account.tags } - end - - def create - if params[:tag_title].present? - sanitized_title = params[:tag_title].strip.gsub(/\A#/, "") - tag = Current.account.tags.find_or_create_by!(title: sanitized_title) - @bubble.toggle_tag(tag) - else - new_tag_ids = Array(params[:tag_id]) - current_tags = @bubble.tags - - current_tags.each do |tag| - @bubble.toggle_tag(tag) unless new_tag_ids.include?(tag.id.to_s) - end - - new_tag_ids.each do |id| - tag = Current.account.tags.find(id) - @bubble.toggle_tag(tag) unless current_tags.include?(tag) - end - end - - @bubble.tags.reload - - respond_to do |format| - format.turbo_stream do - render turbo_stream: turbo_stream.replace([ @bubble, :tags ], partial: "bubbles/cards/perma/tags", locals: { bubble: @bubble }) - end - end - end -end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index f700a6825..e48a1f7f2 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -4,7 +4,7 @@ class Users::AvatarsController < ApplicationController before_action :set_user def show - if stale? @user, cache_control: { max_age: 30.minutes, stale_while_revalidate: 1.week } + if stale? @user, cache_control: { max_age: cache_max_age, stale_while_revalidate: 1.week } render_avatar_or_initials end end @@ -15,6 +15,14 @@ class Users::AvatarsController < ApplicationController end private + def cache_max_age + if Current.user == @user + 0 + else + 30.minutes + end + end + def set_user @user = Current.account.users.find(params[:user_id]) end diff --git a/app/helpers/navigation_helper.rb b/app/helpers/navigation_helper.rb index d7c99ec86..d1bb85a81 100644 --- a/app/helpers/navigation_helper.rb +++ b/app/helpers/navigation_helper.rb @@ -6,4 +6,11 @@ module NavigationHelper icon_tag(icon) + tag.span(label, class: "for-screen-reader") end end + + def link_to_back(fallback_path: root_path) + link_to fallback_path, class: "btn flex-item-justify-start", aria: { label: "Go back" }, + data: { controller: "back-navigation hotkey", action: "keydown.esc@document->hotkey#click", back_navigation_fallback_destination_value: fallback_path } do + icon_tag("arrow-left") + end + end end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb new file mode 100644 index 000000000..58b3e12d6 --- /dev/null +++ b/app/helpers/users_helper.rb @@ -0,0 +1,5 @@ +module UsersHelper + def prepend_current_user_to(users_scope) + users_scope.to_a.prepend(Current.user).uniq + end +end diff --git a/app/helpers/workflows_helper.rb b/app/helpers/workflows_helper.rb index dfcd317f4..f78241aa9 100644 --- a/app/helpers/workflows_helper.rb +++ b/app/helpers/workflows_helper.rb @@ -1,12 +1,12 @@ module WorkflowsHelper def button_to_set_stage(bubble, stage) - button_to bucket_bubble_stagings_path(bubble.bucket, bubble, stage_id: stage), - method: :post, class: [ "btn full-width justify-start workflow-stage txt-uppercase", { "workflow-stage--current": stage == bubble.stage } ], - form_class: "flex align-center gap-half", - data: { turbo_frame: "_top" } do - tag.span class: "overflow-ellipsis" do - stage.name - end + button_to bubble_stagings_path(bubble, stage_id: stage), + method: :post, class: [ "btn full-width justify-start workflow-stage txt-uppercase", { "workflow-stage--current": stage == bubble.stage } ], + form_class: "flex align-center gap-half", + data: { turbo_frame: "_top" } do + tag.span class: "overflow-ellipsis" do + stage.name end + end end end diff --git a/app/javascript/controllers/local_time_controller.js b/app/javascript/controllers/local_time_controller.js index 512169949..bdfbe677a 100644 --- a/app/javascript/controllers/local_time_controller.js +++ b/app/javascript/controllers/local_time_controller.js @@ -1,7 +1,8 @@ import { Controller } from "@hotwired/stimulus" +import { differenceInDays } from "helpers/date_helpers" export default class extends Controller { - static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays" ] + static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago" ] #timer @@ -11,7 +12,7 @@ export default class extends Controller { this.shortDateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }) this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" }) this.agoFormatter = new AgoFormatter() - this.daysAgoFormatter = new DaysAgoFormatter() + this.daysagoFormatter = new DaysAgoFormatter() this.indaysFormatter = new InDaysFormatter() } @@ -43,14 +44,14 @@ export default class extends Controller { this.#formatTime(this.agoFormatter, target) } - daysAgoTargetConnected(target) { - this.#formatTime(this.daysAgoFormatter, target) - } - indaysTargetConnected(target) { this.#formatTime(this.indaysFormatter, target) } + daysagoTargetConnected(target) { + this.#formatTime(this.daysagoFormatter, target) + } + #refreshRelativeTimes() { this.agoTargets.forEach(target => { this.#formatTime(this.agoFormatter, target) @@ -59,7 +60,7 @@ export default class extends Controller { #formatTime(formatter, target) { const dt = new Date(target.getAttribute("datetime")) - target.textContent = formatter.format(dt) + target.innerHTML = formatter.format(dt) target.title = this.dateTimeFormatter.format(dt) } } @@ -93,38 +94,25 @@ class AgoFormatter { } class DaysAgoFormatter { - format(dt) { - const now = new Date() + format(date) { + const days = differenceInDays(date, new Date()) - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const startOfGivenDay = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) - - const msPerDay = 1000 * 60 * 60 * 24 - const dayDiff = Math.floor((startOfToday - startOfGivenDay) / msPerDay) - - if (dayDiff === 0) return "Today" - if (dayDiff === 1) return "Yesterday" - return `in ${dayDiff} days` + if (days <= 0) return styleableValue("today") + if (days === 1) return styleableValue("yesterday") + return `${styleableValue(days)} days ago` } } class InDaysFormatter { - format(dt) { - const target = this.#beginningOfDay(dt) - const today = this.#beginningOfDay(new Date()) - const days = Math.round((target - today) / (1000 * 60 * 60 * 24)) + format(date) { + const days = differenceInDays(new Date(), date) - if (days <= 0) { - return "today" - } - if (days === 1) { - return "tomorrow" - } - - return `in ${Math.round(days)} days` - } - - #beginningOfDay(dt) { - return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) + if (days <= 0) return styleableValue("today") + if (days === 1) return styleableValue("tomorrow") + return `in ${styleableValue(days)} days` } } + +function styleableValue(value) { + return `${value}` +} diff --git a/app/javascript/helpers/date_helpers.js b/app/javascript/helpers/date_helpers.js new file mode 100644 index 000000000..9f3dffc4d --- /dev/null +++ b/app/javascript/helpers/date_helpers.js @@ -0,0 +1,7 @@ +export function differenceInDays(fromDate, toDate) { + return Math.round(Math.abs((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24))) +} + +export function beginningOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} diff --git a/app/models/account.rb b/app/models/account.rb index 1a6e45fde..a2b3c7e34 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -10,6 +10,8 @@ class Account < ApplicationRecord end end + has_many :comments, through: :users + has_many :workflows, dependent: :destroy has_many :stages, through: :workflows, class_name: "Workflow::Stage" diff --git a/app/models/bubble/assignable.rb b/app/models/bubble/assignable.rb index 98ea3d8ea..c0dd36ff4 100644 --- a/app/models/bubble/assignable.rb +++ b/app/models/bubble/assignable.rb @@ -21,7 +21,7 @@ module Bubble::Assignable private def assign(user) assignments.create! assignee: user, assigner: Current.user - set_watching(user, true) + watch_by user track_event :assigned, assignee_ids: [ user.id ] rescue ActiveRecord::RecordNotUnique diff --git a/app/models/bubble/commentable.rb b/app/models/bubble/commentable.rb index 5b8f630f5..ab1c72a74 100644 --- a/app/models/bubble/commentable.rb +++ b/app/models/bubble/commentable.rb @@ -7,7 +7,7 @@ module Bubble::Commentable def comment_created(comment) increment! :comments_count - set_watching comment.creator, true + watch_by comment.creator track_event :commented, comment_id: comment.id rescore diff --git a/app/models/bubble/pinnable.rb b/app/models/bubble/pinnable.rb index 2452fadfe..044125eae 100644 --- a/app/models/bubble/pinnable.rb +++ b/app/models/bubble/pinnable.rb @@ -9,11 +9,11 @@ module Bubble::Pinnable pins.exists?(user: user) end - def set_pinned(user, pinned) - if pinned - pins.find_or_create_by!(user: user) - else - pins.find_by(user: user)&.destroy - end + def pin_by(user) + pins.find_or_create_by!(user: user) + end + + def unpin_by(user) + pins.find_by(user: user).tap { it.destroy } end end diff --git a/app/models/bubble/taggable.rb b/app/models/bubble/taggable.rb index 6b6462e9b..7fa4dee07 100644 --- a/app/models/bubble/taggable.rb +++ b/app/models/bubble/taggable.rb @@ -8,22 +8,19 @@ module Bubble::Taggable scope :tagged_with, ->(tags) { joins(:taggings).where(taggings: { tag: tags }) } end - def toggle_tag(tag) - tagged_with?(tag) ? untag(tag) : tag(tag) + def toggle_tag_with(title) + tag = bucket.account.tags.find_or_create_by!(title: title) + + transaction do + if tagged_with?(tag) + taggings.destroy_by tag: tag + else + taggings.create tag: tag + end + end end def tagged_with?(tag) tags.include? tag end - - private - def tag(tag) - taggings.create! tag: tag - rescue ActiveRecord::RecordNotUnique - # Already tagged - end - - def untag(tag) - taggings.destroy_by tag: tag - end end diff --git a/app/models/bubble/watchable.rb b/app/models/bubble/watchable.rb index 8ad47554d..bc4a82ccf 100644 --- a/app/models/bubble/watchable.rb +++ b/app/models/bubble/watchable.rb @@ -5,15 +5,19 @@ module Bubble::Watchable has_many :watches, dependent: :destroy has_many :watchers, -> { merge(Watch.watching) }, through: :watches, source: :user - after_create :set_watching_for_creator + after_create -> { watch_by creator } end def watched_by?(user) watchers_and_subscribers(include_only_watching: true).include?(user) end - def set_watching(user, watching) - watches.where(user: user).first_or_create.update!(watching: watching) + def watch_by(user) + watches.where(user: user).first_or_create.update!(watching: true) + end + + def unwatch_by(user) + watches.where(user: user).first_or_create.update!(watching: false) end def watchers_and_subscribers(include_only_watching: false) @@ -23,9 +27,4 @@ module Bubble::Watchable User.where(id: subscribers.pluck(:id) + watches.watching.pluck(:user_id) - watches.not_watching.pluck(:user_id)) end - - private - def set_watching_for_creator - set_watching(creator, true) - end end diff --git a/app/models/bucket.rb b/app/models/bucket.rb index 20a7f0336..ce6ec4154 100644 --- a/app/models/bucket.rb +++ b/app/models/bucket.rb @@ -12,7 +12,7 @@ class Bucket < ApplicationRecord after_save :update_bubbles_workflow, if: :saved_change_to_workflow_id? - scope :alphabetically, -> { order(name: :asc) } + scope :alphabetically, -> { order("lower(name)") } private def update_bubbles_workflow diff --git a/app/models/event/particulars.rb b/app/models/event/particulars.rb index 9584bd6d5..a9cec0452 100644 --- a/app/models/event/particulars.rb +++ b/app/models/event/particulars.rb @@ -10,6 +10,6 @@ module Event::Particulars end def comment - @comment ||= Comment.find_by(id: comment_id) + @comment ||= account.comments.find_by(id: comment_id) end end diff --git a/app/models/pin.rb b/app/models/pin.rb index 5bf4635c8..86913023c 100644 --- a/app/models/pin.rb +++ b/app/models/pin.rb @@ -3,10 +3,4 @@ class Pin < ApplicationRecord belongs_to :user scope :ordered, -> { order(created_at: :desc) } - - after_create_commit -> { broadcast_prepend_later_to [ user, :pins ], - target: "pins", - partial: "bubbles/pins/pin" - } - after_destroy_commit -> { broadcast_remove_to [ user, :pins ] } end diff --git a/app/models/tag.rb b/app/models/tag.rb index 51e27c30f..376e21bc0 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -7,6 +7,9 @@ class Tag < ApplicationRecord has_many :bubbles, through: :taggings validates :title, format: { without: /\A#/ } + normalizes :title, with: -> { it.downcase } + + scope :alphabetically, -> { order("lower(title)") } def hashtag "#" + title diff --git a/app/models/user.rb b/app/models/user.rb index 2eb87913f..623111447 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,36 +1,23 @@ class User < ApplicationRecord - include Avatar, Role, Transferable + include Accessor, Assignee, Avatar, Role, Transferable belongs_to :account has_many :sessions, dependent: :destroy has_secure_password validations: false - has_many :accesses, dependent: :destroy - has_many :buckets, through: :accesses - has_many :accessible_bubbles, through: :buckets, source: :bubbles - - has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy - - has_many :pops, dependent: :nullify - - has_many :assignments, foreign_key: :assignee_id, dependent: :destroy - has_many :assignings, foreign_key: :assigner_id, class_name: "Assignment" - has_many :assigned_bubbles, through: :assignments, source: :bubble + has_many :comments, inverse_of: :creator, dependent: :destroy has_many :notifications, dependent: :destroy - has_one_attached :avatar - + has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy + has_many :pops, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_bubbles, through: :pins, source: :bubble normalizes :email_address, with: ->(value) { value.strip.downcase } - after_create_commit :grant_access_to_buckets - scope :alphabetically, -> { order("lower(name)") } - scope :sorted_with_user_first, ->(user) { order(Arel.sql("users.id != ?, lower(name)", user.id)) } def initials name.to_s.scan(/\b\p{L}/).join.upcase @@ -38,22 +25,14 @@ class User < ApplicationRecord def deactivate transaction do - sessions.destroy_all + sessions.delete_all accesses.destroy_all update! active: false, email_address: deactived_email_address end end - def current? - Current.user == self - end - private def deactived_email_address email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@") end - - def grant_access_to_buckets - Access.insert_all account.buckets.all_access.pluck(:id).collect { |bucket_id| { bucket_id: bucket_id, user_id: id } } - end end diff --git a/app/models/user/accessor.rb b/app/models/user/accessor.rb new file mode 100644 index 000000000..5615f9976 --- /dev/null +++ b/app/models/user/accessor.rb @@ -0,0 +1,16 @@ +module User::Accessor + extend ActiveSupport::Concern + + included do + has_many :accesses, dependent: :destroy + has_many :buckets, through: :accesses + has_many :accessible_bubbles, through: :buckets, source: :bubbles + + after_create_commit :grant_access_to_buckets + end + + private + def grant_access_to_buckets + Access.insert_all account.buckets.all_access.pluck(:id).collect { |bucket_id| { bucket_id: bucket_id, user_id: id } } + end +end diff --git a/app/models/user/assignee.rb b/app/models/user/assignee.rb new file mode 100644 index 000000000..730158164 --- /dev/null +++ b/app/models/user/assignee.rb @@ -0,0 +1,9 @@ +module User::Assignee + extend ActiveSupport::Concern + + included do + has_many :assignments, foreign_key: :assignee_id, dependent: :destroy + has_many :assignings, foreign_key: :assigner_id, class_name: "Assignment" + has_many :assigned_bubbles, through: :assignments, source: :bubble + end +end diff --git a/app/views/accounts/users/_user.html.erb b/app/views/accounts/users/_user.html.erb index ebfec287a..e8e57b096 100644 --- a/app/views/accounts/users/_user.html.erb +++ b/app/views/accounts/users/_user.html.erb @@ -1,5 +1,5 @@
<%= pluralize(@bubble.watchers.count, "person") %> will be notified when someone comments on this.
+ ++ <%= pluralize(@bubble.watchers.count, "person") %> will be notified when someone comments on this. +
+