diff --git a/Gemfile.lock b/Gemfile.lock index 24449909b..6efc361eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -127,8 +127,8 @@ GEM specs: action_text-trix (2.1.16) railties - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -337,7 +337,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) diff --git a/Gemfile.saas b/Gemfile.saas index c29eaeb79..39aa49a71 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,6 +11,9 @@ gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" +# Native push notifications (iOS/Android) +gem "action_push_native" + # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" gem "sentry-ruby" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index b18d2fa8b..fc88e7aec 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -195,6 +195,13 @@ PATH GEM remote: https://rubygems.org/ specs: + action_push_native (0.3.1) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) action_text-trix (2.1.16) railties actionpack-xml_parser (2.0.1) @@ -208,8 +215,8 @@ GEM activemodel (>= 7.0) activemodel-serializers-xml (~> 1.0) activesupport (>= 7.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) anyway_config (2.7.2) ruby-next-core (~> 1.0) ast (2.4.3) @@ -279,6 +286,12 @@ GEM tzinfo faker (3.6.0) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -295,8 +308,23 @@ GEM addressable (>= 2.5.0) globalid (1.3.0) activesupport (>= 6.1) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-logging-utils (0.2.0) + googleauth (1.16.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) gvltools (0.4.0) hashdiff (1.2.1) + http-2 (1.1.1) + httpx (1.7.1) + http-2 (>= 1.0.0) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -369,6 +397,9 @@ GEM mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.19.1) + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) net-imap (0.6.3) @@ -403,6 +434,7 @@ GEM nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) + os (1.1.4) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -457,7 +489,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) @@ -546,6 +578,11 @@ GEM sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) sniffer (0.5.0) anyway_config (>= 1.0) dry-initializer (~> 3) @@ -663,6 +700,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + action_push_native actionpack-xml_parser activeresource audits1984! diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index a8284b309..604938deb 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,12 +5,8 @@ class BoardsController < ApplicationController before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index - if request.format.json? - set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed - fresh_when etag: @page.records - else - set_page_and_extract_portion_from Current.user.boards - end + set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity) + fresh_when etag: @page.records end def show diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 54c18704c..7062b85e5 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ class UsersController < ApplicationController before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index - set_page_and_extract_portion_from Current.account.users.active.alphabetically + set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity) end def show diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 8ae4346ac..d80685349 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -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) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 7ddeb1b33..438cf22e1 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -3,7 +3,9 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge" BridgeElement.prototype.getButton = function() { return { title: this.title, - icon: this.getIcon() + icon: this.getIcon(), + displayTitle: this.getDisplayTitle(), + displayAsPrimaryAction: this.getDisplayAsPrimaryAction() } } @@ -16,3 +18,11 @@ BridgeElement.prototype.getIcon = function() { return null } + +BridgeElement.prototype.getDisplayTitle = function() { + return !!this.bridgeAttribute(`display-title`) +} + +BridgeElement.prototype.getDisplayAsPrimaryAction = function() { + return !!this.bridgeAttribute(`display-as-primary-action`) +} diff --git a/app/jobs/notification/push_job.rb b/app/jobs/notification/push_job.rb new file mode 100644 index 000000000..233762b37 --- /dev/null +++ b/app/jobs/notification/push_job.rb @@ -0,0 +1,5 @@ +class Notification::PushJob < ApplicationJob + def perform(notification) + notification.push + end +end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb index c912e141d..862564927 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(notification) - NotificationPusher.new(notification).push + notification.push end end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb deleted file mode 100644 index 4bf9b575d..000000000 --- a/app/models/concerns/push_notifiable.rb +++ /dev/null @@ -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 diff --git a/app/models/notification.rb b/app/models/notification.rb index 417a2f22d..a5eab3ccd 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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 diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb new file mode 100644 index 000000000..98f104856 --- /dev/null +++ b/app/models/notification/default_payload.rb @@ -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 diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb new file mode 100644 index 000000000..fba3a6cb2 --- /dev/null +++ b/app/models/notification/event_payload.rb @@ -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 diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb new file mode 100644 index 000000000..480771c15 --- /dev/null +++ b/app/models/notification/mention_payload.rb @@ -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 diff --git a/app/models/notification/push_target.rb b/app/models/notification/push_target.rb new file mode 100644 index 000000000..1fc250259 --- /dev/null +++ b/app/models/notification/push_target.rb @@ -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 diff --git a/app/models/notification/push_target/web.rb b/app/models/notification/push_target/web.rb new file mode 100644 index 000000000..9bf839906 --- /dev/null +++ b/app/models/notification/push_target/web.rb @@ -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 diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb new file mode 100644 index 000000000..e02f3a8de --- /dev/null +++ b/app/models/notification/pushable.rb @@ -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 diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb deleted file mode 100644 index d6425e561..000000000 --- a/app/models/notification_pusher.rb +++ /dev/null @@ -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 diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c62d87e09..970104d6f 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -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 diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 9a73b287d..c0f5e4453 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index 040fd55d3..ee2f87196 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@
No devices registered. Install the mobile app to receive push notifications.
+<% end %> diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb new file mode 100644 index 000000000..a0ddbed13 --- /dev/null +++ b/saas/app/views/notifications/settings/_native_devices.html.erb @@ -0,0 +1,14 @@ ++ You have <%= pluralize(Current.identity.devices.count, "mobile device") %> registered for push notifications. +
+ <%= link_to "Manage devices", saas.devices_path, class: "btn txt-small" %> + <% else %> ++ No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. +
+ <% end %> +