Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ gem 'rack-cors'

gem 'icalendar'

# for web push notifications
gem 'web-push'
gem 'serviceworker-rails'

# for signups as requested by email service
gem 'recaptcha'

Expand Down
10 changes: 10 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ GEM
concurrent-ruby
railties (>= 4.1)
jsonapi-swagger (0.8.1)
jwt (3.1.2)
base64
kgio (2.11.4)
kramdown (2.4.0)
rexml
Expand Down Expand Up @@ -449,6 +451,7 @@ GEM
oauth
omniauth (~> 1.0)
open-uri (0.1.0)
openssl (3.3.0)
orm_adapter (0.5.0)
ostruct (0.6.3)
parallel (1.27.0)
Expand Down Expand Up @@ -667,6 +670,8 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
serviceworker-rails (0.6.0)
railties (>= 3.1)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
Expand Down Expand Up @@ -717,6 +722,9 @@ GEM
descendants_tracker (~> 0.0, >= 0.0.3)
warden (1.2.9)
rack (>= 2.0.9)
web-push (3.0.2)
jwt (~> 3.0)
openssl (~> 3.0)
webrat (0.7.3)
nokogiri (>= 1.2.0)
rack (>= 1.0)
Expand Down Expand Up @@ -837,13 +845,15 @@ DEPENDENCIES
scout_apm
searchkick
selenium-webdriver
serviceworker-rails
sidekiq
sprockets (< 4)
terser
timecop
unicorn
validate_url
vcr
web-push
webrat
will_paginate
will_paginate-bootstrap-style
Expand Down
1 change: 1 addition & 0 deletions app/assets/config/manifest.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// = link_tree ../images
// = link serviceworker.js
// = link_directory ../javascripts .js
// = link_directory ../stylesheets .css
59 changes: 59 additions & 0 deletions app/assets/javascripts/push_notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's
// vendor/assets/javascripts directory can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file. JavaScript code in this file should be added after the last require_* statement.
//
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require rails-ujs
//= require activestorage
//= require_tree .

function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');

const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);

for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

document.addEventListener('turbolinks:load', () => {
const pushButton = document.getElementById('enable-push-notifications');
if (pushButton) {
pushButton.addEventListener('click', () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
const vapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content;
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
}).then(subscription => {
fetch('/push_subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ subscription: subscription.toJSON() })
});
});
});
}
});
}
});
13 changes: 13 additions & 0 deletions app/assets/javascripts/serviceworker.js.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
self.addEventListener('push', function(event) {
const data = event.data.json();
const title = data.title || 'Growstuff';
const options = {
body: data.body,
icon: '/assets/growstuff-apple-touch-icon-precomposed.png',
badge: '/assets/growstuff-apple-touch-icon-precomposed.png'
};

event.waitUntil(
self.registration.showNotification(title, options)
);
});
20 changes: 20 additions & 0 deletions app/controllers/push_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

class PushSubscriptionsController < ApplicationController
before_action :authenticate_member!

def create
subscription = current_member.push_subscriptions.find_or_initialize_by(endpoint: params[:subscription][:endpoint])
subscription.update(
p256dh: params[:subscription][:keys][:p256dh],
auth: params[:subscription][:keys][:auth]
)
head :ok
end

def destroy
subscription = current_member.push_subscriptions.find_by(endpoint: params[:endpoint])
subscription&.destroy
head :ok
end
end
35 changes: 35 additions & 0 deletions app/jobs/push_notification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

class PushNotificationJob < ApplicationJob
queue_as :default

def perform(*args)
Member.where.not(timezone: nil).pluck(:timezone).uniq.each do |timezone|
Time.use_zone(timezone) do
if Time.zone.now.hour == 8
Member.where(timezone: timezone).each do |member|
send_planting_notifications(member)
send_activity_notifications(member)
end
end
end
end
end

private

def send_planting_notifications(member)
member.plantings.active.annual.each do |planting|
if planting.finish_is_predicatable? && (planting.late? || planting.super_late?)
PushNotificationService.new(member, "Your #{planting.crop_name} planting is ready to be marked as finished.").send
end
end
end

def send_activity_notifications(member)
due_activities = member.activities.where(due_date: Date.today, finished: false)
due_activities.each do |activity|
PushNotificationService.new(member, "Activity due: #{activity.name}").send
end
end
end
5 changes: 5 additions & 0 deletions app/models/push_subscription.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class PushSubscription < ApplicationRecord
belongs_to :member
end
31 changes: 31 additions & 0 deletions app/services/push_notification_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

class PushNotificationService
def initialize(member, message)
@member = member
@message = message
end

def send
@member.push_subscriptions.each do |subscription|
begin
WebPush.payload_send(
message: JSON.generate(title: 'Growstuff', body: @message),
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
vapid: {
subject: "mailto:#{ENV.fetch('GROWSTUFF_EMAIL', '[email protected]')}",
public_key: ENV['GROWSTUFF_VAPID_PUBLIC_KEY'],
private_key: ENV['GROWSTUFF_VAPID_PRIVATE_KEY']
}
)
rescue WebPush::InvalidSubscription => e
# A subscription can become invalid if the user revokes the permission.
# In this case, we should delete the subscription.
subscription.destroy
Rails.logger.info "Subscription deleted because it was invalid: #{e.message}"
end
end
end
end
2 changes: 2 additions & 0 deletions app/views/layouts/_head.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
%title
= content_for?(:title) ? yield(:title) + " - #{ENV['GROWSTUFF_SITE_NAME']} " : ENV['GROWSTUFF_SITE_NAME']
= csrf_meta_tags
%meta{name: "vapid-public-key", content: ENV['GROWSTUFF_VAPID_PUBLIC_KEY']}
= stylesheet_link_tag "application", media: "all"

%link{ href: path_to_image("growstuff-apple-touch-icon-precomposed.png"), rel: "apple-touch-icon-precomposed" }
%link{ href: "https://fonts.googleapis.com/css?family=Modak|Raleway&display=swap", rel: "stylesheet" }
= favicon_link_tag 'favicon.ico'
= serviceworker_js_tag
8 changes: 8 additions & 0 deletions app/views/members/_notifications.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.card.mt-3
.card-body
%h5.card-title Notifications
%p
Install Growstuff as a Progressive Web App (PWA) to get notifications on your device.
Look for the "Add to Home Screen" option in your browser's menu.
%button.btn.btn-primary#enable-push-notifications
Enable Push Notifications
2 changes: 2 additions & 0 deletions app/views/members/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@

= render 'members/follow_buttons', member: @member

= render "notifications", member: @member if can?(:update, @member)

- if can?(:destroy, @member)
%hr/
= link_to admin_member_path(slug: @member.slug), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-block btn-light text-danger' do
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
match '/members/:id/finish_signup' => 'members#finish_signup', via: %i(get patch), as: :finish_signup

resources :authentications, only: %i(create destroy)
resources :push_subscriptions, only: %i(create destroy)

get "home/index"
root to: 'home#index'
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20240929041436_add_timezone_to_members.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddTimezoneToMembers < ActiveRecord::Migration[7.2]
def change
add_column :members, :timezone, :string
end
end
15 changes: 15 additions & 0 deletions db/migrate/20240929041437_create_push_subscriptions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class CreatePushSubscriptions < ActiveRecord::Migration[7.2]
def change
create_table :push_subscriptions do |t|
t.references :member, null: false, foreign_key: true
t.string :endpoint, null: false
t.string :p256dh, null: false
t.string :auth, null: false

t.timestamps
end
add_index :push_subscriptions, :endpoint, unique: true
end
end
14 changes: 14 additions & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@
t.string "facebook_handle"
t.string "bluesky_handle"
t.string "other_url"
t.string "timezone"
t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true
t.index ["discarded_at"], name: "index_members_on_discarded_at"
t.index ["email"], name: "index_members_on_email", unique: true
Expand Down Expand Up @@ -586,6 +587,7 @@
t.integer "harvests_count", default: 0
t.integer "likes_count", default: 0
t.boolean "failed", default: false, null: false
t.boolean "from_other_source"
t.index ["crop_id"], name: "index_plantings_on_crop_id"
t.index ["garden_id"], name: "index_plantings_on_garden_id"
t.index ["owner_id"], name: "index_plantings_on_owner_id"
Expand Down Expand Up @@ -634,6 +636,17 @@
t.index ["slug"], name: "index_problems_on_slug"
end

create_table "push_subscriptions", force: :cascade do |t|
t.bigint "member_id", null: false
t.string "endpoint", null: false
t.string "p256dh", null: false
t.string "auth", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true
t.index ["member_id"], name: "index_push_subscriptions_on_member_id"
end

create_table "roles", id: :serial, force: :cascade do |t|
t.string "name", null: false
t.text "description"
Expand Down Expand Up @@ -699,5 +712,6 @@
add_foreign_key "problem_posts", "problems"
add_foreign_key "problems", "members", column: "creator_id"
add_foreign_key "problems", "members", column: "requester_id"
add_foreign_key "push_subscriptions", "members"
add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify
end
6 changes: 6 additions & 0 deletions env-example
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,9 @@ MAILGUN_SMTP_SERVER=""
# In production, replace them with real ones
RECAPTCHA_SITE_KEY="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
RECAPTCHA_SECRET_KEY="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"

# VAPID keys for web push notifications
# These are insecure and should be replaced with real keys in production
# Generate new keys with `bundle exec rake webpush:generate_keys`
GROWSTUFF_VAPID_PUBLIC_KEY="BFf_pM3_3q0g1hIUiWf_nQdYj524I4E-mp3jW_j_7X-B-xWpW-j_8X_8X_8X_8X_8X_8X_8X_8X_8"
GROWSTUFF_VAPID_PRIVATE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Loading