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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Vendored
-2
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user