diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css new file mode 100644 index 000000000..4b58106a9 --- /dev/null +++ b/app/assets/stylesheets/notifications.css @@ -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; + } + } +} diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 000000000..22e40167e --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,5 @@ +class NotificationsController < ApplicationController + def index + @notifications = Current.user.notifications.unread.ordered.limit(20) + end +end diff --git a/app/controllers/readings_controller.rb b/app/controllers/readings_controller.rb new file mode 100644 index 000000000..9c5798ed3 --- /dev/null +++ b/app/controllers/readings_controller.rb @@ -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 diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 000000000..7c35a5080 --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -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 diff --git a/app/javascript/controllers/beacon_controller.js b/app/javascript/controllers/beacon_controller.js new file mode 100644 index 000000000..07182bb36 --- /dev/null +++ b/app/javascript/controllers/beacon_controller.js @@ -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" }) + } +} diff --git a/app/jobs/generate_notifications_job.rb b/app/jobs/generate_notifications_job.rb new file mode 100644 index 000000000..e386b7b37 --- /dev/null +++ b/app/jobs/generate_notifications_job.rb @@ -0,0 +1,7 @@ +class GenerateNotificationsJob < ApplicationJob + queue_as :default + + def perform(event) + event.generate_notifications + end +end diff --git a/app/models/bubble.rb b/app/models/bubble.rb index 5da059a49..5b6a0ec93 100644 --- a/app/models/bubble.rb +++ b/app/models/bubble.rb @@ -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 diff --git a/app/models/bubble/commentable.rb b/app/models/bubble/commentable.rb index f80f34777..152eeb7a0 100644 --- a/app/models/bubble/commentable.rb +++ b/app/models/bubble/commentable.rb @@ -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 diff --git a/app/models/bubble/eventable.rb b/app/models/bubble/eventable.rb index 05883a796..64379e360 100644 --- a/app/models/bubble/eventable.rb +++ b/app/models/bubble/eventable.rb @@ -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 diff --git a/app/models/bubble/poppable.rb b/app/models/bubble/poppable.rb index b2043d5d0..c60474d89 100644 --- a/app/models/bubble/poppable.rb +++ b/app/models/bubble/poppable.rb @@ -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 diff --git a/app/models/comment.rb b/app/models/comment.rb index 3de28081f..b4fd7d0c5 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,5 @@ class Comment < ApplicationRecord - include Searchable, Messageable + include Messageable, Notifiable, Searchable belongs_to :creator, class_name: "User", default: -> { Current.user } diff --git a/app/models/concerns/notifiable.rb b/app/models/concerns/notifiable.rb new file mode 100644 index 000000000..8cfc7b112 --- /dev/null +++ b/app/models/concerns/notifiable.rb @@ -0,0 +1,7 @@ +module Notifiable + extend ActiveSupport::Concern + + included do + has_many :notifications, as: :resource, dependent: :destroy + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 006e25ed0..8dfceeeeb 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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 diff --git a/app/models/event/assignments.rb b/app/models/event/assignments.rb deleted file mode 100644 index 56a6b9e6e..000000000 --- a/app/models/event/assignments.rb +++ /dev/null @@ -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 diff --git a/app/models/event/particulars.rb b/app/models/event/particulars.rb new file mode 100644 index 000000000..e21b20a7c --- /dev/null +++ b/app/models/event/particulars.rb @@ -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 diff --git a/app/models/event/stages.rb b/app/models/event/stages.rb deleted file mode 100644 index c6abe0bfc..000000000 --- a/app/models/event/stages.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Event::Stages - extend ActiveSupport::Concern - - included do - store_accessor :particulars, :stage_id, :stage_name - end -end diff --git a/app/models/message.rb b/app/models/message.rb index f688f27dd..24e658ed3 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -10,7 +10,7 @@ class Message < ApplicationRecord private def created - bubble.comment_created if comment? + bubble.comment_created(comment) if comment? end def destroyed diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 000000000..531be12bb --- /dev/null +++ b/app/models/notification.rb @@ -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 diff --git a/app/models/notifier.rb b/app/models/notifier.rb new file mode 100644 index 000000000..cb64ec77c --- /dev/null +++ b/app/models/notifier.rb @@ -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 diff --git a/app/models/notifier/assigned.rb b/app/models/notifier/assigned.rb new file mode 100644 index 000000000..cabf8452a --- /dev/null +++ b/app/models/notifier/assigned.rb @@ -0,0 +1,6 @@ +class Notifier::Assigned < Notifier + private + def recipients + event.assignees.without(creator) + end +end diff --git a/app/models/notifier/commented.rb b/app/models/notifier/commented.rb new file mode 100644 index 000000000..42ad4e5c8 --- /dev/null +++ b/app/models/notifier/commented.rb @@ -0,0 +1,6 @@ +class Notifier::Commented < Notifier + private + def resource + event.comment + end +end diff --git a/app/models/notifier/created.rb b/app/models/notifier/created.rb new file mode 100644 index 000000000..f2724a931 --- /dev/null +++ b/app/models/notifier/created.rb @@ -0,0 +1,2 @@ +class Notifier::Created < Notifier +end diff --git a/app/models/notifier/popped.rb b/app/models/notifier/popped.rb new file mode 100644 index 000000000..f83e721ce --- /dev/null +++ b/app/models/notifier/popped.rb @@ -0,0 +1,2 @@ +class Notifier::Popped < Notifier +end diff --git a/app/models/user.rb b/app/models/user.rb index 907881cc5..cceadb922 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/bubbles/_bubble.html.erb b/app/views/bubbles/_bubble.html.erb index 033364ed5..f00d502ff 100644 --- a/app/views/bubbles/_bubble.html.erb +++ b/app/views/bubbles/_bubble.html.erb @@ -2,7 +2,9 @@