Merge pull request #199 from basecamp/notifs

Notifications
This commit is contained in:
Kevin McConnell
2025-01-14 15:25:47 +00:00
committed by GitHub
49 changed files with 490 additions and 31 deletions
+84
View File
@@ -0,0 +1,84 @@
.notification-tray {
height: 4rem;
inset: auto auto var(--block-space);
position: fixed;
}
.notification {
inset: auto auto 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);
.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) {
display: none;
}
.notification-tray:hover & {
&:nth-child(2) {
--offsetX: 0;
--offsetY: calc((100% + var(--block-space-half)) * -1);
--width: 40ch;
}
&:nth-child(3) {
--offsetX: 0;
--offsetY: calc((100% + var(--block-space-half)) * -2);
--width: 40ch;
}
&:nth-child(4) {
--offsetX: 0;
--offsetY: calc((100% + var(--block-space-half)) * -3);
--width: 40ch;
}
&:nth-child(5) {
--offsetX: 0;
--offsetY: calc((100% + var(--block-space-half)) * -4);
--width: 40ch;
}
}
}
@@ -0,0 +1,5 @@
class NotificationsController < ApplicationController
def index
@notifications = Current.user.notifications.unread.ordered.limit(20)
end
end
+13
View File
@@ -0,0 +1,13 @@
class ReadingsController < ApplicationController
include BubbleScoped, BucketScoped
def create
mark_bubble_notifications_read
@notifications = Current.user.notifications.unread.ordered.limit(20)
end
private
def mark_bubble_notifications_read
Current.user.notifications.where(bubble: @bubble).update(read: true)
end
end
+27
View File
@@ -0,0 +1,27 @@
module NotificationsHelper
def notification_title(notification)
title = notification.bubble.title
if notification.resource.is_a? Comment
"RE: " + title
else
title
end
end
def notification_body(notification)
name = notification.creator.name
case notification.event.action
when "assigned" then "#{name} assigned to you"
when "created" then "Added by #{name}"
when "popped" then "Popped by by #{name}"
else name
end
end
def notification_tag(notification, &)
link_to notification.resource, id: dom_id(notification), class: "notification border-radius",
data: { turbo_frame: "_top" }, &
end
end
@@ -0,0 +1,10 @@
import { post } from "@rails/request.js"
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
connect() {
post(this.urlValue, { responseKind: "turbo-stream" })
}
}
+7
View File
@@ -0,0 +1,7 @@
class GenerateNotificationsJob < ApplicationJob
queue_as :default
def perform(event)
event.generate_notifications
end
end
+3 -1
View File
@@ -1,9 +1,11 @@
class Bubble < ApplicationRecord
include Assignable, Boostable, Colored, Commentable, Eventable, Messages, Poppable, Searchable, Staged, Taggable
include Assignable, Boostable, Colored, Commentable, Eventable, Messages, Notifiable, Poppable, Searchable, Staged, Taggable
belongs_to :bucket, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :notifications, dependent: :destroy
has_one_attached :image, dependent: :purge_later
before_save :set_default_title
+2 -1
View File
@@ -5,8 +5,9 @@ module Bubble::Commentable
scope :ordered_by_comments, -> { order comments_count: :desc }
end
def comment_created
def comment_created(comment)
increment! :comments_count
track_event :commented, comment_id: comment.id
rescore
end
+2 -1
View File
@@ -7,7 +7,8 @@ module Bubble::Eventable
private
def track_event(action, creator: Current.user, **particulars)
find_or_capture_event_summary.events.create! action: action, creator: creator, particulars: particulars
event = find_or_capture_event_summary.events.create! action: action, creator: creator, particulars: particulars
event.generate_notifications_later
end
def find_or_capture_event_summary
+4 -1
View File
@@ -13,7 +13,10 @@ module Bubble::Poppable
end
def pop!(user: Current.user)
create_pop!(user: user) unless popped?
unless popped?
create_pop!(user: user)
track_event :popped
end
end
def unpop
+1 -1
View File
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord
include Searchable, Messageable
include Messageable, Notifiable, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
+7
View File
@@ -0,0 +1,7 @@
module Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, as: :resource, dependent: :destroy
end
end
+9 -1
View File
@@ -1,5 +1,5 @@
class Event < ApplicationRecord
include Assignments, Stages
include Particulars
belongs_to :creator, class_name: "User"
belongs_to :summary, touch: true, class_name: "EventSummary"
@@ -9,4 +9,12 @@ class Event < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :non_boosts, -> { where.not action: :boosted }
scope :boosts, -> { where action: :boosted }
def generate_notifications
Notifier.for(self)&.generate
end
def generate_notifications_later
GenerateNotificationsJob.perform_later self
end
end
-11
View File
@@ -1,11 +0,0 @@
module Event::Assignments
extend ActiveSupport::Concern
included do
store_accessor :particulars, :assignee_ids
end
def assignees
@assignees ||= account.users.where id: assignee_ids
end
end
+15
View File
@@ -0,0 +1,15 @@
module Event::Particulars
extend ActiveSupport::Concern
included do
store_accessor :particulars, :assignee_ids, :comment_id, :stage_id, :stage_name
end
def assignees
@assignees ||= account.users.where id: assignee_ids
end
def comment
@comment ||= Comment.find(comment_id)
end
end
-7
View File
@@ -1,7 +0,0 @@
module Event::Stages
extend ActiveSupport::Concern
included do
store_accessor :particulars, :stage_id, :stage_name
end
end
+1 -1
View File
@@ -10,7 +10,7 @@ class Message < ApplicationRecord
private
def created
bubble.comment_created if comment?
bubble.comment_created(comment) if comment?
end
def destroyed
+13
View File
@@ -0,0 +1,13 @@
class Notification < ApplicationRecord
belongs_to :user
belongs_to :event
belongs_to :bubble
belongs_to :resource, polymorphic: true
scope :unread, -> { where(read: false) }
scope :ordered, -> { order(read: :desc, created_at: :desc) }
delegate :creator, to: :event
broadcasts_to ->(notification) { [ notification.user, :notifications ] }, inserts_by: :prepend
end
+34
View File
@@ -0,0 +1,34 @@
class Notifier
attr_reader :event
delegate :creator, to: :event
class << self
def for(event)
"Notifier::#{event.action.classify}".safe_constantize&.new(event)
end
end
def generate
recipients.map do |recipient|
Notification.create! user: recipient, event: event, bubble: bubble, resource: resource
end
end
private
def initialize(event)
@event = event
end
def recipients
bubble.bucket.users.without(creator)
end
def resource
bubble
end
def bubble
event.summary.message.bubble
end
end
+6
View File
@@ -0,0 +1,6 @@
class Notifier::Assigned < Notifier
private
def recipients
event.assignees.without(creator)
end
end
+6
View File
@@ -0,0 +1,6 @@
class Notifier::Commented < Notifier
private
def resource
event.comment
end
end
+2
View File
@@ -0,0 +1,2 @@
class Notifier::Created < Notifier
end
+2
View File
@@ -0,0 +1,2 @@
class Notifier::Popped < Notifier
end
+2
View File
@@ -18,6 +18,8 @@ class User < ApplicationRecord
has_many :assignings, foreign_key: :assigner_id, class_name: "Assignment"
has_many :assigned_bubbles, through: :assignments, source: :bubble
has_many :notifications, dependent: :destroy
has_one_attached :avatar
validates_presence_of :email_address
+3 -1
View File
@@ -2,7 +2,9 @@
<div class="bubble"
style="view-transition-name: bubble-<%= bubble.id -%>; --bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>"
data-controller="animation upload-preview"
data-animation-play-class="bubble--wobble" data-animation-play-on-load-value="true" data-action="mouseover->animation#play">
data-animation-play-class="bubble--wobble"
data-animation-play-on-load-value="true"
data-action="mouseover->animation#play">
<div>
<h1 class="bubble__title">
+2
View File
@@ -50,3 +50,5 @@
<%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %>
</ul>
</section>
<%= render "notifications/tray" %>
+3 -1
View File
@@ -65,7 +65,7 @@
</div>
</aside>
<div class="bubble__container">
<div class="bubble__container" data-controller="beacon" data-beacon-url-value="<%= bucket_bubble_readings_url(@bubble.bucket, @bubble) %>">
<div class="bubble__perma flex justify-center center margin-block-end-double">
<%= render "bubbles/bubble", bubble: @bubble, editing: params[:editing] %>
</div>
@@ -76,3 +76,5 @@
<div class="flex align-start justify-start">
<%= turbo_frame_tag dom_id(@bubble, :stage_picker), src: new_bucket_bubble_stage_picker_path(@bubble.bucket, @bubble) %>
</div>
<%= render "notifications/tray" %>
+2
View File
@@ -20,3 +20,5 @@
<div class="buckets margin-block-double unpad align-start justify-center flex flex-wrap gap">
<%= render @filters %>
</div>
<%= render "notifications/tray" %>
@@ -0,0 +1,12 @@
<%= 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">
<%= avatar_image_tag(notification.creator) %>
</div>
<div class="flex flex-column txt-tight-lines">
<strong><%= notification_title(notification) %></strong>
<%= notification_body(notification) %> · <%= time_ago_in_words(notification.created_at) %> ago
</div>
</div>
<% end %>
+5
View File
@@ -0,0 +1,5 @@
<%= 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) %>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= turbo_frame_tag "notifications" do %>
<%= render @notifications %>
<% end %>
@@ -0,0 +1,3 @@
<%= turbo_stream.update :notifications do %>
<%= render @notifications %>
<% end %>
+1
View File
@@ -4,6 +4,7 @@ pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.11
pin "house", to: "house.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
+7
View File
@@ -12,12 +12,19 @@ Rails.application.routes.draw do
route_for :bucket_bubble, bubble.bucket, bubble, options
end
resolve "Comment" do |comment, options|
options[:anchor] = ActionView::RecordIdentifier.dom_id(comment)
route_for :bucket_bubble, comment.bubble.bucket, comment.bubble, options
end
resources :bubbles
resources :notifications
resources :buckets do
resources :bubbles do
resources :boosts
resources :comments
resource :readings, only: :create
scope module: :bubbles do
resource :image
@@ -0,0 +1,15 @@
class CreateNotifications < ActiveRecord::Migration[8.1]
def change
create_table :notifications do |t|
t.references :user, null: false, foreign_key: true
t.references :event, null: false, foreign_key: true
t.references :bubble, null: false, foreign_key: true
t.references :resource, null: false, polymorphic: true, index: true
t.boolean :read, default: false, null: false
t.timestamps
t.index %i[ user_id read created_at ], order: { read: :desc, created_at: :desc }
end
end
end
Generated
+20 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do
ActiveRecord::Schema[8.1].define(version: 2025_01_09_153649) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.integer "user_id", null: false
@@ -176,6 +176,22 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do
t.index ["messageable_type", "messageable_id"], name: "index_messages_on_messageable", unique: true
end
create_table "notifications", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "event_id", null: false
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.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"], name: "index_notifications_on_user_id"
end
create_table "pops", force: :cascade do |t|
t.integer "bubble_id", null: false
t.integer "user_id"
@@ -244,6 +260,9 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do
add_foreign_key "bubbles", "workflow_stages", column: "stage_id"
add_foreign_key "events", "event_summaries", column: "summary_id"
add_foreign_key "messages", "bubbles"
add_foreign_key "notifications", "bubbles"
add_foreign_key "notifications", "events"
add_foreign_key "notifications", "users"
add_foreign_key "pops", "bubbles"
add_foreign_key "pops", "users"
add_foreign_key "sessions", "users"
@@ -0,0 +1,14 @@
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
end
@@ -0,0 +1,15 @@
require "test_helper"
class ReadingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index" do
assert_changes -> { notifications(:logo_created_kevin).reload.read? }, from: false, to: true do
post bucket_bubble_readings_url(bubbles(:logo).bucket, bubbles(:logo)), as: :turbo_stream
end
assert_response :success
end
end
+13
View File
@@ -54,6 +54,13 @@ layout_created:
summary: layout_initial_activity
created_at: <%= 1.week.ago %>
layout_commented:
creator: david
action: commented
summary: layout_initial_activity
particulars: <%= { comment_id: ActiveRecord::FixtureSet.identify(:layout_overflowing_david) }.to_json %>
created_at: <%= 1.week.ago %>
layout_assignment_jz:
creator: david
action: assigned
@@ -72,3 +79,9 @@ shipping_created:
action: created
summary: shipping_initial_activity
created_at: <%= 1.week.ago %>
shipping_popped:
creator: kevin
action: popped
summary: shipping_initial_activity
created_at: <%= 2.days.ago %>
+15
View File
@@ -0,0 +1,15 @@
logo_created_kevin:
user: kevin
event: logo_created
bubble: logo
resource: logo (Bubble)
read: false
created_at: <%= 1.week.ago %>
layout_commented_kevin:
user: kevin
event: layout_commented
bubble: layout
resource: layout_overflowing_david (Comment)
read: false
created_at: <%= 1.week.ago %>
+3 -1
View File
@@ -9,7 +9,9 @@ class Bubble::PoppableTest < ActiveSupport::TestCase
test "popping" do
assert_not bubbles(:logo).popped?
bubbles(:logo).pop!
with_current_user(:kevin) do
bubbles(:logo).pop!
end
assert bubbles(:logo).popped?
end
+2 -2
View File
@@ -6,11 +6,11 @@ class BubbleTest < ActiveSupport::TestCase
end
test "capturing messages" do
assert_difference "bubbles(:logo).messages.count", +1 do
assert_difference "bubbles(:logo).messages.comments.count", +1 do
bubbles(:logo).capture Comment.new(body: "Agreed.")
end
assert_equal "Agreed.", bubbles(:logo).messages.last.messageable.body.to_plain_text.chomp
assert_equal "Agreed.", bubbles(:logo).messages.comments.last.messageable.body.to_plain_text.chomp
end
test "boosting" do
+25
View File
@@ -0,0 +1,25 @@
require "test_helper"
class Notifier::AssignedTest < ActiveSupport::TestCase
test "creates a notification for the assignee" do
notifications = Notifier.for(events(:logo_assignment_km)).generate
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "does not notify for self-assignments" do
event = EventSummary.last.events.create! action: :assigned,
creator: users(:kevin),
particulars: { assignee_ids: [ users(:kevin).id ] }
assert_no_difference -> { Notification.count } do
Notifier.for(event).generate
end
end
test "links to the bubble" do
Notifier.for(events(:logo_assignment_km)).generate
assert_equal bubbles(:logo), Notification.last.resource
end
end
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
class Notifier::CommentedTest < ActiveSupport::TestCase
test "creates a notification for each recipient" do
notifications = Notifier.for(events(:layout_commented)).generate
assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort
end
test "links to the bubble" do
Notifier.for(events(:layout_commented)).generate
assert_equal comments(:layout_overflowing_david), Notification.last.resource
end
end
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
class Notifier::CreatedTest < ActiveSupport::TestCase
test "creates a notification for each recipient" do
notifications = Notifier.for(events(:logo_created)).generate
assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort
end
test "links to the bubble" do
Notifier.for(events(:logo_created)).generate
assert_equal bubbles(:logo), Notification.last.resource
end
end
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
class Notifier::PoppedTest < ActiveSupport::TestCase
test "creates a notification for each recipient" do
notifications = Notifier.for(events(:shipping_popped)).generate
assert_equal users(:david, :jz).sort, notifications.map(&:user).sort
end
test "links to the bubble" do
Notifier.for(events(:shipping_popped)).generate
assert_equal bubbles(:shipping), Notification.last.resource
end
end
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
class NotifierTest < ActiveSupport::TestCase
test "for returns the matching notifier class for the event" do
assert_kind_of Notifier::Created, Notifier.for(events(:logo_created))
end
test "for does not raise an error when the event is not notifiable" do
assert_nothing_raised do
assert_no_difference -> { Notification.count } do
Notifier.for(events(:logo_boost_dhh))
end
end
end
end
+8
View File
@@ -13,4 +13,12 @@ module SessionTestHelper
delete session_url
assert_not cookies[:session_token].present?
end
def with_current_user(user)
user = users(user) unless user.is_a? User
Current.session = Session.new(user: user)
yield
ensure
Current.reset_all
end
end
File diff suppressed because one or more lines are too long