Merge branch 'notification-index'

* notification-index:
  This shouldn't be here
  Paginate the read notifications
  Send beacon request on disconnect as well
  Wire up notifications "Mark all as read" button
  Add unread dot
  Set state for when there are no new notifications
  Move to right side, smooth transition
  Open tray on click, stub mark-as-read button
  Refactor
  `New for you` empty state
  Add a notifications button to the tray for reaching the index when you have no unreads
  Needed when truncating so the avatar isn't squished
  Style notifications index; add truncation for long notifications
  Add an index view for all notifications
  Move notifications to tray controller
This commit is contained in:
Jason Zimdars
2025-01-24 13:41:59 -06:00
26 changed files with 317 additions and 98 deletions
+1
View File
@@ -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"
+4
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m21 17.5a1.5 1.5 0 0 1 -1.5-1.5v-4.862a7.957 7.957 0 0 0 -6.5-8.065v-2.073a1 1 0 0 0 -2 0v2.073a7.957 7.957 0 0 0 -6.5 8.065v4.862a1.5 1.5 0 0 1 -1.5 1.5 1 1 0 0 0 0 2h18a1 1 0 0 0 0-2z"/><path d="m14.236 21h-4.472a.25.25 0 0 0 -.248.222 2.319 2.319 0 0 0 -.016.278 2.5 2.5 0 1 0 5 0 2.319 2.319 0 0 0 -.016-.278.248.248 0 0 0 -.248-.222z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

+122 -70
View File
@@ -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;
}
}
@@ -0,0 +1,5 @@
class Notifications::MarkAllReadsController < ApplicationController
def create
Current.user.notifications.unread.read_all
end
end
@@ -0,0 +1,5 @@
class Notifications::TraysController < ApplicationController
def show
@notifications = Current.user.notifications.unread.ordered.limit(20)
end
end
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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)
@@ -7,4 +7,8 @@ export default class extends Controller {
connect() {
post(this.urlValue, { responseKind: "turbo-stream" })
}
disconnect() {
post(this.urlValue, { responseKind: "turbo-stream" })
}
}
@@ -26,6 +26,7 @@ export default class extends Controller {
close() {
this.dialogTarget.close()
this.dialogTarget.blur()
}
closeOnClickOutside({ target }) {
@@ -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" })
}
}
+13 -2
View File
@@ -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
@@ -1,13 +1,17 @@
<% cache notification do %>
<%= notification_tag notification do %>
<div class="notification__content border-radius borderless pad shadow fill-white flex align-start txt-align-start gap-half">
<div class="avatar txt-small">
<div class="avatar txt-small flex-item-no-shrink">
<%= avatar_image_tag(notification.creator) %>
</div>
<div class="flex flex-column txt-tight-lines">
<strong><%= notification_title(notification) %></strong>
<div><%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
<div class="flex flex-column txt-tight-lines min-width flex-item-grow">
<strong class="overflow-ellipsis"><%= notification_title(notification) %></strong>
<div class="overflow-ellipsis"><%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
</div>
<div class="notification__unread_indicator flex flex-column flex-item-justify-end">
<div class="dot flex-item-no-shrink"></div>
</div>
</div>
<% end %>
+16 -2
View File
@@ -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) -%>
<div class="notification-tray__actions flex align-center gap-half">
<%= 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 %>
<span>See all notifications</span>
<% end %>
</div>
<button class="notification-tray__expander borderless unpad fill-transparent" data-action="dialog#toggle" aria-label="Expand notifications stack">
</button>
<% end %>
+35 -2
View File
@@ -1,3 +1,36 @@
<%= turbo_frame_tag "notifications" do %>
<%= render @notifications %>
<% @page_title = "Notifications" %>
<% content_for :header do %>
<nav>
<%= link_to root_path, class: "btn flex-item-justify-start", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Go back</span>
<% end %>
<header class="txt-align-center">
<h1 class="txt-large flex align-center gap-half"><%= @page_title %></h1>
</header>
<div class="btn btn--placeholder flex-item-justify-end"></div>
</nav>
<% end %>
<section class="panel center borderless unpad flex flex-column gap-half">
<% if @unread.any? %>
<h2 class="txt-small margin-block-start-double margin-block-end-half txt-uppercase">New for you</h2>
<%= render @unread %>
<% else %>
<div class="pad border-radius border translucent" style="--border-style: dashed; --border-color: var(--color-ink);">
<strong>Nothing new for you.</strong>
</div>
<% end %>
</section>
<% if @page.records.any? %>
<section id="notifications_list_read" class="notificiations-list--read panel center borderless unpad flex flex-column gap-half margin-block-start-double">
<h2 class="txt-small margin-block-start-double margin-block-end-half txt-uppercase">Previously seen</h2>
<%= render @page.records %>
</section>
<%= notifications_next_page_link(@page) %>
<% end %>
@@ -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 %>
@@ -0,0 +1 @@
<%= turbo_frame_tag "notifications" %>
@@ -0,0 +1,3 @@
<%= turbo_frame_tag "notifications" do %>
<%= render collection: @notifications, partial: "notifications/notification" %>
<% end %>
+5 -1
View File
@@ -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
@@ -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
Generated
+3 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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
-2
View File
@@ -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 %>