Merge pull request #2511 from basecamp/native-push-notifications

Support native push notifications
This commit is contained in:
Rosa Gutierrez
2026-02-25 19:55:04 +01:00
committed by GitHub
52 changed files with 1477 additions and 221 deletions
+1 -8
View File
@@ -1,13 +1,6 @@
require "zlib"
module AvatarsHelper
AVATAR_COLORS = %w[
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
]
def avatar_background_color(user)
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
user.avatar_background_color
end
def avatar_tag(user, hidden_for_screen_reader: false, **options)
+5
View File
@@ -0,0 +1,5 @@
class Notification::PushJob < ApplicationJob
def perform(notification)
notification.push
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notification)
NotificationPusher.new(notification).push
notification.push
end
end
-12
View File
@@ -1,12 +0,0 @@
module PushNotifiable
extend ActiveSupport::Concern
included do
after_save_commit :push_notification_later, if: :source_id_previously_changed?
end
private
def push_notification_later
PushNotificationJob.perform_later(self)
end
end
+2 -1
View File
@@ -1,5 +1,5 @@
class Notification < ApplicationRecord
include PushNotifiable
include Notification::Pushable
belongs_to :account, default: -> { user.account }
belongs_to :user
@@ -27,6 +27,7 @@ class Notification < ApplicationRecord
after_destroy_commit -> { broadcast_remove_to user, :notifications }
delegate :notifiable_target, to: :source
delegate :identity, to: :user
class << self
def read_all
@@ -0,0 +1,57 @@
class Notification::DefaultPayload
attr_reader :notification
delegate :card, to: :notification
def initialize(notification)
@notification = notification
end
def to_h
{ title: title, body: body, url: url }
end
def title
"New notification"
end
def body
"You have a new notification"
end
def url
notifications_url
end
def category
"default"
end
def high_priority?
false
end
def base_url
Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/")
end
def avatar_url
Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options)
end
private
def card_url(card)
Rails.application.routes.url_helpers.card_url(card, **url_options)
end
def notifications_url
Rails.application.routes.url_helpers.notifications_url(**url_options)
end
def url_options
base_options = Rails.application.routes.default_url_options.presence ||
Rails.application.config.action_mailer.default_url_options ||
{}
base_options.merge(script_name: notification.account.slug)
end
end
+67
View File
@@ -0,0 +1,67 @@
class Notification::EventPayload < Notification::DefaultPayload
include ExcerptHelper
def title
case event.action
when "comment_created"
"RE: #{card_title}"
else
card_title
end
end
def body
case event.action
when "comment_created"
format_excerpt(event.eventable.body, length: 200)
when "card_assigned"
"Assigned to you by #{event.creator.name}"
when "card_published"
"Added by #{event.creator.name}"
when "card_closed"
card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
when "card_reopened"
"Reopened by #{event.creator.name}"
else
event.creator.name
end
end
def url
case event.action
when "comment_created"
card_url_with_comment_anchor(event.eventable)
else
card_url(card)
end
end
def category
case event.action
when "card_assigned" then "assignment"
when "comment_created" then "comment"
else "card"
end
end
def high_priority?
event.action.card_assigned?
end
private
def event
notification.source
end
def card_title
card.title.presence || "Card #{card.number}"
end
def card_url_with_comment_anchor(comment)
Rails.application.routes.url_helpers.card_url(
comment.card,
anchor: ActionView::RecordIdentifier.dom_id(comment),
**url_options
)
end
end
@@ -0,0 +1,28 @@
class Notification::MentionPayload < Notification::DefaultPayload
include ExcerptHelper
def title
"#{mention.mentioner.first_name} mentioned you"
end
def body
format_excerpt(mention.source.mentionable_content, length: 200)
end
def url
card_url(card)
end
def category
"mention"
end
def high_priority?
true
end
private
def mention
notification.source
end
end
+17
View File
@@ -0,0 +1,17 @@
class Notification::PushTarget
attr_reader :notification
delegate :card, to: :notification
def self.process(notification)
new(notification).process
end
def initialize(notification)
@notification = notification
end
def process
raise NotImplementedError
end
end
@@ -0,0 +1,12 @@
class Notification::PushTarget::Web < Notification::PushTarget
def process
if subscriptions.any?
Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions)
end
end
private
def subscriptions
@subscriptions ||= notification.user.push_subscriptions
end
end
+52
View File
@@ -0,0 +1,52 @@
module Notification::Pushable
extend ActiveSupport::Concern
included do
class_attribute :push_targets, default: []
after_save_commit :push_later, if: :source_id_previously_changed?
end
class_methods do
def register_push_target(target)
target = resolve_push_target(target)
push_targets << target unless push_targets.include?(target)
end
private
def resolve_push_target(target)
if target.is_a?(Symbol)
"Notification::PushTarget::#{target.to_s.classify}".constantize
else
target
end
end
end
def push_later
Notification::PushJob.perform_later(self)
end
def push
return unless pushable?
self.class.push_targets.each { |target| push_to(target) }
end
def payload
"Notification::#{payload_type}Payload".constantize.new(self)
end
private
def pushable?
!creator.system? && user.active? && account.active?
end
def push_to(target)
target.process(self)
end
def payload_type
source_type.presence_in(%w[ Event Mention ]) || "Default"
end
end
-124
View File
@@ -1,124 +0,0 @@
class NotificationPusher
include Rails.application.routes.url_helpers
include ExcerptHelper
attr_reader :notification
def initialize(notification)
@notification = notification
end
def push
return unless should_push?
build_payload.tap do |payload|
push_to_user(payload)
end
end
private
def should_push?
notification.user.push_subscriptions.any? &&
!notification.creator.system? &&
notification.user.active? &&
notification.account.active?
end
def build_payload
case notification.source_type
when "Event"
build_event_payload
when "Mention"
build_mention_payload
else
build_default_payload
end
end
def build_event_payload
event = notification.source
card = event.card
base_payload = {
title: card_notification_title(card),
path: card_path(card)
}
case event.action
when "comment_created"
base_payload.merge(
title: "RE: #{base_payload[:title]}",
body: comment_notification_body(event),
path: card_path_with_comment_anchor(event.eventable)
)
when "card_assigned"
base_payload.merge(
body: "Assigned to you by #{event.creator.name}"
)
when "card_published"
base_payload.merge(
body: "Added by #{event.creator.name}"
)
when "card_closed"
base_payload.merge(
body: card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
)
when "card_reopened"
base_payload.merge(
body: "Reopened by #{event.creator.name}"
)
else
base_payload.merge(
body: event.creator.name
)
end
end
def build_mention_payload
mention = notification.source
card = mention.card
{
title: "#{mention.mentioner.first_name} mentioned you",
body: format_excerpt(mention.source.mentionable_content, length: 200),
path: card_path(card)
}
end
def build_default_payload
{
title: "New notification",
body: "You have a new notification",
path: notifications_path(script_name: notification.account.slug)
}
end
def push_to_user(payload)
subscriptions = notification.user.push_subscriptions
enqueue_payload_for_delivery(payload, subscriptions)
end
def enqueue_payload_for_delivery(payload, subscriptions)
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
end
def card_notification_title(card)
card.title.presence || "Card #{card.number}"
end
def comment_notification_body(event)
format_excerpt(event.eventable.body, length: 200)
end
def card_path(card)
Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug)
end
def card_path_with_comment_anchor(comment)
Rails.application.routes.url_helpers.card_path(
comment.card,
anchor: ActionView::RecordIdentifier.dom_id(comment),
script_name: notification.account.slug
)
end
end
+10
View File
@@ -1,8 +1,14 @@
require "zlib"
module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
AVATAR_COLORS = %w[
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
].freeze
included do
has_one_attached :avatar do |attachable|
@@ -22,6 +28,10 @@ module User::Avatar
avatar.variable? ? avatar.variant(:thumb) : avatar
end
def avatar_background_color
AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]
end
# Avatars are always publicly accessible
def publicly_accessible?
true
@@ -16,6 +16,7 @@
<div class="settings__panel panel shadow">
<%= render "notifications/settings/push_notifications" %>
<%= render "notifications/settings/native_devices" if Fizzy.saas? %>
<%= render "notifications/settings/email", settings: @settings %>
</div>
</section>
+1 -1
View File
@@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) {
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.path, self.location.origin).href
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})