diff --git a/Gemfile b/Gemfile index 5c3b0ff65..fdeb0ea2b 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "thruster", require: false # Features gem "bcrypt", "~> 3.1.7" +gem "geared_pagination", "~> 1.2" gem "rqrcode" gem "redcarpet" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index 26b1c521b..cff4ceb78 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -157,6 +157,9 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + geared_pagination (1.2.0) + activesupport (>= 5.0) + addressable (>= 2.5.0) globalid (1.2.1) activesupport (>= 6.1) hotwire-spark (0.1.12) @@ -397,6 +400,7 @@ DEPENDENCIES brakeman capybara debug + geared_pagination (~> 1.2) hotwire-spark hotwire_combobox! importmap-rails diff --git a/app/assets/images/bell.svg b/app/assets/images/bell.svg new file mode 100644 index 000000000..52a5611fb --- /dev/null +++ b/app/assets/images/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index 4b58106a9..a41f36635 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -1,84 +1,136 @@ .notification-tray { - height: 4rem; - inset: auto auto var(--block-space); + --size: 38ch; + + block-size: calc(4ch + var(--block-space-double)); + inline-size: var(--size); + inset: auto var(--inline-space) var(--block-space-half) auto; + padding: var(--block-space-half) var(--inline-space); position: fixed; + z-index: 1; + + .notification { + --outline-color: var(--color-link); + --offset: calc((var(--position) - 1) * (var(--block-space) * -1)); + --position: 1; + --scale: calc(1 - var(--position) / 30); + --z: calc(6 - var(--position)); + + inset: auto auto 0; + inline-size: var(--size); + pointer-events: none; + position: absolute; + scale: var(--scale); + transform: translateY(var(--offset)); + transform-origin: center; + transition: scale 0.2s ease-out, transform 0.2s ease-out; + z-index: var(--z); + + &:nth-child(1) { --position: 1; } + &:nth-child(2) { --position: 2; } + &:nth-child(3) { --position: 3; } + &:nth-child(4) { --position: 4; } + &:nth-child(5) { --position: 5; } + &:nth-child(6) { --position: 6; } + + &:nth-child(1n + 7) { display: none; } + + .notification-tray[open] & { + --scale: 1; + --offset: calc((100% + var(--block-space-half)) * (var(--position) * -1)); + + margin-block-end: calc(var(--block-space) * -1); + pointer-events: unset; + } + } } -.notification { - inset: auto auto 0; +.notification-tray__actions { + opacity: 0; + scale: calc(1 - 1 / 30); + transform: translateY(100%); + transition: opacity 0.2s ease-out, scale 0.2s ease-out, transform 0.2s ease-out; + + .notification-tray[open] & { + opacity: 1; + scale: 1; + transform: translateY(0); + } + + .notification-tray:has(.notification) & { + .btn img { + display: none; + } + } + + .notification-tray:not(:has(.notification)) & { + opacity: 1; + scale: 1; + transform: none; + transition: none; + + .notification-tray__all_action { + --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; + } + + span { + display: none; + } + } + + .notification-tray__read_action { + display: none; + } + } +} + +.notification__content { + color: var(--color-ink); + + .notificiations-list--read & { + border: 1px solid var(--color-subtle); + box-shadow: none; + } +} + +.notification-tray__expander { + --hover-size: 0; + --outline-size: 0; + + cursor: pointer; + inset: 0; position: absolute; - transform: translate(var(--offsetX), var(--offsetY)); - transform-origin: center center; - transition: transform 0.2s ease-in-out; - z-index: var(--z-index); + z-index: 6; - .notification__content { - color: var(--color-ink); - inline-size: var(--width); - } - - &:nth-child(1) { - --offsetX: 0; - --offsetY: 0; - --width: 40ch; - --z-index: 0; - } - - &:nth-child(2) { - --offsetX: 0.5ch; - --offsetY: calc(var(--block-space-half) * -1); - --width: 39ch; - --z-index: -1; - } - - &:nth-child(3) { - --offsetX: 1ch; - --offsetY: calc(var(--block-space) * -1); - --width: 38ch; - --z-index: -2; - } - - &:nth-child(4) { - --offsetX: 1.5ch; - --offsetY: calc(var(--block-space) * -1.5); - --width: 37ch; - --z-index: -3; - } - - &:nth-child(5) { - --offsetX: 2ch; - --offsetY: calc(var(--block-space) * -2); - --width: 36ch; - --z-index: -4; - } - - &:nth-child(1n + 6) { + .notification-tray[open] &, + .notification-tray:not(:has(.notification)) & { display: none; } +} - .notification-tray:hover & { - &:nth-child(2) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -1); - --width: 40ch; - } +.notification__unread_indicator { + --size: 0.8em; - &:nth-child(3) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -2); - --width: 40ch; - } + padding-block-start: var(--size); - &:nth-child(4) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -3); - --width: 40ch; - } + .dot { + aspect-ratio: 1; + background-color: var(--color-marker); + border-radius: 50%; + block-size: var(--size); + inline-size: var(--size); + } - &:nth-child(5) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -4); - --width: 40ch; - } + .notificiations-list--read & { + display: none; } } diff --git a/app/controllers/notifications/mark_all_reads_controller.rb b/app/controllers/notifications/mark_all_reads_controller.rb new file mode 100644 index 000000000..2aad42028 --- /dev/null +++ b/app/controllers/notifications/mark_all_reads_controller.rb @@ -0,0 +1,5 @@ +class Notifications::MarkAllReadsController < ApplicationController + def create + Current.user.notifications.unread.read_all + end +end diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb new file mode 100644 index 000000000..4a01a1ee9 --- /dev/null +++ b/app/controllers/notifications/trays_controller.rb @@ -0,0 +1,5 @@ +class Notifications::TraysController < ApplicationController + def show + @notifications = Current.user.notifications.unread.ordered.limit(20) + end +end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 22e40167e..83e5db488 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,5 +1,9 @@ class NotificationsController < ApplicationController def index - @notifications = Current.user.notifications.unread.ordered.limit(20) + set_page_and_extract_portion_from Current.user.notifications.read.ordered + + if @page.first? + @unread = Current.user.notifications.unread.ordered + end end end diff --git a/app/controllers/readings_controller.rb b/app/controllers/readings_controller.rb index 9c5798ed3..fb7112b76 100644 --- a/app/controllers/readings_controller.rb +++ b/app/controllers/readings_controller.rb @@ -8,6 +8,6 @@ class ReadingsController < ApplicationController private def mark_bubble_notifications_read - Current.user.notifications.where(bubble: @bubble).update(read: true) + Current.user.notifications.where(bubble: @bubble).read_all end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index ddc142efb..bb0dade2a 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -25,6 +25,12 @@ module NotificationsHelper data: { turbo_frame: "_top" }, & end + def notifications_next_page_link(page) + unless @page.last? + tag.div id: "next_page", data: { controller: "fetch-on-visible", fetch_on_visible_url_value: notifications_path(page: @page.next_param) } + end + end + private def notification_event_action(notification) if notification_is_for_initial_assignement?(notification) diff --git a/app/javascript/controllers/beacon_controller.js b/app/javascript/controllers/beacon_controller.js index 07182bb36..794565f78 100644 --- a/app/javascript/controllers/beacon_controller.js +++ b/app/javascript/controllers/beacon_controller.js @@ -7,4 +7,8 @@ export default class extends Controller { connect() { post(this.urlValue, { responseKind: "turbo-stream" }) } + + disconnect() { + post(this.urlValue, { responseKind: "turbo-stream" }) + } } diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index dcea9bdf9..fd4d122e0 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -26,6 +26,7 @@ export default class extends Controller { close() { this.dialogTarget.close() + this.dialogTarget.blur() } closeOnClickOutside({ target }) { diff --git a/app/javascript/controllers/fetch_on_visible_controller.js b/app/javascript/controllers/fetch_on_visible_controller.js new file mode 100644 index 000000000..42f1a36da --- /dev/null +++ b/app/javascript/controllers/fetch_on_visible_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" +import { get } from "@rails/request.js" + +export default class extends Controller { + static values = { url: String } + + connect() { + this.#observe() + } + + #observe() { + const observer = new IntersectionObserver((entries) => { + const visible = !!entries.find(entry => entry.isIntersecting) + if (visible) { + this.#fetch() + } + }) + + observer.observe(this.element) + } + + #fetch() { + get(this.urlValue, { responseKind: "turbo-stream" }) + } +} diff --git a/app/models/notification.rb b/app/models/notification.rb index 531be12bb..05d38c940 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,10 +4,21 @@ class Notification < ApplicationRecord belongs_to :bubble belongs_to :resource, polymorphic: true - scope :unread, -> { where(read: false) } - scope :ordered, -> { order(read: :desc, created_at: :desc) } + scope :unread, -> { where(read_at: nil) } + scope :read, -> { where.not(read_at: nil) } + scope :ordered, -> { order(read_at: :desc, created_at: :desc) } delegate :creator, to: :event broadcasts_to ->(notification) { [ notification.user, :notifications ] }, inserts_by: :prepend + + class << self + def read_all + update!(read_at: Time.current) + end + end + + def read? + read_at.present? + end end diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb index cba5a252f..d309015b3 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -1,13 +1,17 @@ <% cache notification do %> <%= notification_tag notification do %>
-
+
<%= avatar_image_tag(notification.creator) %>
-
- <%= notification_title(notification) %> -
<%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
+
+ <%= notification_title(notification) %> +
<%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
+
+ +
+
<% end %> diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb index 71ada65cb..476f0bc78 100644 --- a/app/views/notifications/_tray.html.erb +++ b/app/views/notifications/_tray.html.erb @@ -1,5 +1,19 @@ <%= turbo_stream_from Current.user, :notifications %> -<%= tag.div id: "notification-tray", class: "notification-tray flex flex-column gap", data: { turbo_permanent: true } do %> - <%= turbo_frame_tag("notifications", src: notifications_path) %> +<%= tag.dialog id: "notification-tray", class: "notification-tray flex flex-column gap unpad borderless justify-end fill-transparent", + data: { controller: "dialog", turbo_permanent: true, dialog_modal_value: false, dialog_target: "dialog", action: "keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside" } do %> + <%= turbo_frame_tag("notifications", src: notifications_tray_path) -%> +
+ <%= button_to notifications_mark_all_read_path, class: "notification-tray__read_action btn flex-item-justify-start", data: { turbo_frame: "notifications" } do %> + Mark all as read + <% end %> + + <%= link_to notifications_path, class: "notification-tray__all_action btn flex-item-justify-end" do %> + <%= image_tag "bell.svg", aria: { hidden: true }, size: 24 %> + See all notifications + <% end %> +
+ + <% end %> diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index ee91b07e4..35942d06a 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -1,3 +1,36 @@ -<%= turbo_frame_tag "notifications" do %> - <%= render @notifications %> +<% @page_title = "Notifications" %> + +<% content_for :header do %> + +<% end %> + +
+ <% if @unread.any? %> +

New for you

+ <%= render @unread %> + <% else %> +
+ Nothing new for you. +
+ <% end %> +
+ +<% if @page.records.any? %> +
+

Previously seen

+ <%= render @page.records %> +
+ + <%= notifications_next_page_link(@page) %> <% end %> diff --git a/app/views/notifications/index.turbo_stream.erb b/app/views/notifications/index.turbo_stream.erb new file mode 100644 index 000000000..00d744dcd --- /dev/null +++ b/app/views/notifications/index.turbo_stream.erb @@ -0,0 +1,7 @@ +<%= turbo_stream.append :notifications_list_read do %> + <%= render @page.records %> +<% end %> + +<%= turbo_stream.replace :next_page do %> + <%= notifications_next_page_link(@page) %> +<% end %> diff --git a/app/views/notifications/mark_all_reads/create.html.erb b/app/views/notifications/mark_all_reads/create.html.erb new file mode 100644 index 000000000..52d6bcd10 --- /dev/null +++ b/app/views/notifications/mark_all_reads/create.html.erb @@ -0,0 +1 @@ +<%= turbo_frame_tag "notifications" %> diff --git a/app/views/notifications/trays/show.html.erb b/app/views/notifications/trays/show.html.erb new file mode 100644 index 000000000..051055670 --- /dev/null +++ b/app/views/notifications/trays/show.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "notifications" do %> + <%= render collection: @notifications, partial: "notifications/notification" %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index f122bb209..bea4b1c2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,11 @@ Rails.application.routes.draw do end resources :bubbles - resources :notifications + resources :notifications, only: :index + namespace :notifications do + resource :tray, only: :show + resource :mark_all_read, only: :create + end resources :buckets do resources :bubbles do diff --git a/db/migrate/20250116163344_change_notification_read_to_read_at.rb b/db/migrate/20250116163344_change_notification_read_to_read_at.rb new file mode 100644 index 000000000..c9bd6bf64 --- /dev/null +++ b/db/migrate/20250116163344_change_notification_read_to_read_at.rb @@ -0,0 +1,12 @@ +class ChangeNotificationReadToReadAt < ActiveRecord::Migration[8.1] + def change + remove_index :notifications, %i[ user_id read created_at ], order: { read: :desc, created_at: :desc } + + change_table :notifications do |t| + t.remove :read + t.datetime :read_at + + t.index %i[ user_id read_at created_at ], order: { read_at: :desc, created_at: :desc } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 688750b25..749e157d5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -190,13 +190,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_174109) do t.integer "bubble_id", null: false t.string "resource_type", null: false t.integer "resource_id", null: false - t.boolean "read", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "read_at" t.index ["bubble_id"], name: "index_notifications_on_bubble_id" t.index ["event_id"], name: "index_notifications_on_event_id" t.index ["resource_type", "resource_id"], name: "index_notifications_on_resource" - t.index ["user_id", "read", "created_at"], name: "index_notifications_on_user_id_and_read_and_created_at", order: { read: :desc, created_at: :desc } + t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end @@ -284,4 +284,4 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_174109) do # Note that virtual tables may not work with other database engines. Be careful if changing database. create_virtual_table "bubbles_search_index", "fts5", ["title"] create_virtual_table "comments_search_index", "fts5", ["body"] -end +end \ No newline at end of file diff --git a/test/controllers/notifications/mark_all_reads_controller_test.rb b/test/controllers/notifications/mark_all_reads_controller_test.rb new file mode 100644 index 000000000..ea43e1be6 --- /dev/null +++ b/test/controllers/notifications/mark_all_reads_controller_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class Notifications::MarkAllReadsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do + post notifications_mark_all_read_url + end + end + + assert_response :success + end +end diff --git a/test/controllers/notifications/trays_controller_test.rb b/test/controllers/notifications/trays_controller_test.rb new file mode 100644 index 000000000..7d44a3e00 --- /dev/null +++ b/test/controllers/notifications/trays_controller_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show" do + get notifications_tray_url + + assert_response :success + assert_select "div", text: /Layout is broken/ + end +end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index fc4c2bfee..20b28201d 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -1,14 +1,7 @@ require "test_helper" class NotificationsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in_as :kevin - end - - test "index" do - get notifications_url - - assert_response :success - assert_select "div", text: /Layout is broken/ - end + # test "the truth" do + # assert true + # end end diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index 89a8256ee..7bff2665c 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -3,7 +3,6 @@ logo_published_kevin: event: logo_published bubble: logo resource: logo (Bubble) - read: false created_at: <%= 1.week.ago %> layout_commented_kevin: @@ -11,5 +10,4 @@ layout_commented_kevin: event: layout_commented bubble: layout resource: layout_overflowing_david (Comment) - read: false created_at: <%= 1.week.ago %>