bible270 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -2
- data/README.md +13 -4
- data/app/controllers/bible270/admin_controller.rb +28 -1
- data/app/controllers/bible270/application_controller.rb +27 -0
- data/app/controllers/bible270/profiles_controller.rb +18 -8
- data/app/controllers/bible270/readers_controller.rb +7 -0
- data/app/controllers/bible270/sessions_controller.rb +10 -0
- data/app/mailers/bible270/notice_mailer.rb +12 -0
- data/app/models/bible270/comment.rb +13 -4
- data/app/models/bible270/reader.rb +71 -3
- data/app/views/bible270/admin/show.html.erb +24 -0
- data/app/views/bible270/comments/_form.html.erb +12 -1
- data/app/views/bible270/days/_reading.html.erb +2 -2
- data/app/views/bible270/days/show.html.erb +1 -0
- data/app/views/bible270/profiles/edit.html.erb +7 -8
- data/app/views/bible270/shared/_comment_notification_choices.html.erb +17 -0
- data/app/views/bible270/shared/_interaction_ui.html.erb +96 -2
- data/app/views/bible270/shared/_mention_typeahead.html.erb +177 -0
- data/app/views/bible270/shared/_styles.html.erb +14 -0
- data/config/routes.rb +5 -2
- data/db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb +7 -0
- data/lib/bible270/migration_reconciler.rb +5 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/generators/bible270/install/install_generator.rb +1 -1
- data/lib/generators/bible270/install/templates/bible270.rb.tt +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9f698d3dbbc6afbb9130e3dc81767302792e8f25e23db7d702401eb290b747e
|
|
4
|
+
data.tar.gz: e109e61cc7a4e39098124cb7e1996c980d248b54c3e7649702901a6b5e65e392
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9742929c763b48681e352e95510a8944eb087529453ed10af579fd737db35a75492ef55c25c9a6416d925728072b69868a02f2e8f3300882f14711a3f71c338
|
|
7
|
+
data.tar.gz: d5b50e44afd429885e2e90af7b36df10fa0aaff363d39c03309f4d1885560da0f73d020ba7de53571f0ae0be129b2e55bccba9e1bd02dc45d82984718d82134d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,32 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
4
4
|
|
|
5
5
|
## Unreleased Changes
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- let readers choose reflection emails for every new reflection and reply, only replies to their reflections and mentions of them, or none; administrators can manage the same setting, pending migrations are explained instead of silently hiding it, and existing preferences retain their previous behavior
|
|
10
|
+
- suggest up to five unambiguous reader handles only while a signed-in writer types `@`, with keyboard and touch selection and no change to server-authoritative mention resolution
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- refresh stale `Reader` column information after an in-process database migration so newly available reflection email controls do not remain hidden until the web process restarts
|
|
15
|
+
|
|
16
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.7...main)**
|
|
17
|
+
|
|
18
|
+
## [1.1.7] - 2026-08-27
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- silently recover checkoffs from expired or rotated sessions: refresh remembered sessions and CSRF tokens before resumed interactions, handle remaining stale-token races without a flash or Rails 422 page, and retry the intended checkoff once with a fresh token
|
|
23
|
+
|
|
24
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.6...v1.1.7)**
|
|
25
|
+
|
|
26
|
+
## [1.1.6] - 2026-08-25
|
|
27
|
+
|
|
7
28
|
### Changed
|
|
8
29
|
|
|
9
|
-
- show administrators a quiet, linked completed-day count on Community reader rows while keeping progress hidden—and its aggregate query skipped—for ordinary readers and visitors
|
|
30
|
+
- show administrators a quiet, linked completed-day count on Community reader rows while keeping progress hidden—and its aggregate query skipped—for ordinary readers and visitors; the link stays unadorned at rest and uses the site’s gold hover and keyboard-focus treatment
|
|
10
31
|
|
|
11
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.5...
|
|
32
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.5...v1.1.6)**
|
|
12
33
|
|
|
13
34
|
## [1.1.5] - 2026-08-25
|
|
14
35
|
|
|
@@ -552,6 +573,8 @@ This is the initial public release.
|
|
|
552
573
|
|
|
553
574
|
* Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
|
|
554
575
|
|
|
576
|
+
[1.1.7]: https://github.com/avonderluft/bible270/releases/tag/v1.1.7
|
|
577
|
+
[1.1.6]: https://github.com/avonderluft/bible270/releases/tag/v1.1.6
|
|
555
578
|
[1.1.5]: https://github.com/avonderluft/bible270/releases/tag/v1.1.5
|
|
556
579
|
[1.1.4]: https://github.com/avonderluft/bible270/releases/tag/v1.1.4
|
|
557
580
|
[1.1.3]: https://github.com/avonderluft/bible270/releases/tag/v1.1.3
|
data/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible re
|
|
|
6
6
|
|
|
7
7
|
Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school working on a 9 month schedule.
|
|
8
8
|
|
|
9
|
+
For a guided tour of the implementation, see the [architecture walkthrough](docs/architecture.md).
|
|
10
|
+
|
|
9
11
|
## The plan
|
|
10
12
|
|
|
11
13
|
Three readings a day, sized by **verse count** so each day takes roughly the same time.
|
|
@@ -280,9 +282,15 @@ The Reflections page lists conversations by their latest approved reply, support
|
|
|
280
282
|
filters, and paginates older threads. Signed-in readers see which conversations became active since
|
|
281
283
|
their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
|
|
282
284
|
cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
|
|
285
|
+
Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
|
|
286
|
+
work without JavaScript.
|
|
283
287
|
|
|
284
|
-
|
|
285
|
-
|
|
288
|
+
When `config.mention_notifications` is enabled, each reader can request email for every new reflection
|
|
289
|
+
and reply, only replies to their reflections and mentions of them, or none. Administrators can change
|
|
290
|
+
the same preference from the reader's Admin page. Migration
|
|
291
|
+
`20260101000018` adds the broad opt-in without changing any existing reader's choice. Migration
|
|
292
|
+
`20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
|
|
293
|
+
run the current migrations:
|
|
286
294
|
|
|
287
295
|
```bash
|
|
288
296
|
bin/rails bible270:install:migrations
|
|
@@ -396,7 +404,7 @@ Bible270.configure do |config|
|
|
|
396
404
|
config.allow_reader_start_date = true
|
|
397
405
|
|
|
398
406
|
config.daily_reminders = false # schedule reminders:send every 15 minutes
|
|
399
|
-
config.mention_notifications = true #
|
|
407
|
+
config.mention_notifications = true # reflection/reply emails; readers choose the scope
|
|
400
408
|
config.mailer_host = "example.com" # links in reminder/notice email
|
|
401
409
|
|
|
402
410
|
config.parent_controller = "ActionController::Base" # or "::ApplicationController"
|
|
@@ -485,7 +493,8 @@ GET /day/:day days#show readings, who's finished, refle
|
|
|
485
493
|
POST /day/:day/toggle/:track checkoffs#toggle
|
|
486
494
|
POST /day/:day/comments comments#create
|
|
487
495
|
DELETE /comments/:id comments#destroy
|
|
488
|
-
GET /community readers#index
|
|
496
|
+
GET /community readers#index reader directory
|
|
497
|
+
GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
|
|
489
498
|
GET /readers/:id readers#show
|
|
490
499
|
GET /sign_in sessions#new email form + provider list
|
|
491
500
|
POST /sign_in/email sessions#email_link
|
|
@@ -13,7 +13,8 @@ module Bible270
|
|
|
13
13
|
|
|
14
14
|
before_action :require_admin!
|
|
15
15
|
before_action :load_reader,
|
|
16
|
-
only: %i[show destroy update_start update_profile remove_avatar complete_through
|
|
16
|
+
only: %i[show destroy update_start update_profile update_notifications remove_avatar complete_through
|
|
17
|
+
toggle_day]
|
|
17
18
|
before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
|
|
18
19
|
|
|
19
20
|
# ---- enrolment --------------------------------------------------------
|
|
@@ -69,6 +70,7 @@ module Bible270
|
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
def show
|
|
73
|
+
@comment_notifications_available = comment_notifications_available_for?(@reader)
|
|
72
74
|
@days_completed = @reader.days_completed
|
|
73
75
|
@comments = @reader.comments.order(created_at: :desc).limit(50)
|
|
74
76
|
end
|
|
@@ -194,6 +196,24 @@ module Bible270
|
|
|
194
196
|
end
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
def update_notifications
|
|
200
|
+
unless Bible270.config.mention_notifications
|
|
201
|
+
return redirect_to admin_reader_path(@reader), alert: 'Reflection emails are disabled for this installation.'
|
|
202
|
+
end
|
|
203
|
+
unless comment_notifications_available_for?(@reader)
|
|
204
|
+
return redirect_to admin_reader_path(@reader),
|
|
205
|
+
alert: 'Run the pending Bible270 database migration before changing reflection emails.'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
level = params[:comment_notifications].to_s
|
|
209
|
+
unless Reader::COMMENT_NOTIFICATION_LEVELS.include?(level)
|
|
210
|
+
return redirect_to admin_reader_path(@reader), alert: 'Choose a reflection email setting from the list.'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@reader.update_comment_notification_level!(level)
|
|
214
|
+
redirect_to admin_reader_path(@reader), notice: 'Reflection email setting updated.'
|
|
215
|
+
end
|
|
216
|
+
|
|
197
217
|
def remove_avatar
|
|
198
218
|
@reader.remove_avatar!
|
|
199
219
|
redirect_to admin_reader_path(@reader), notice: "Removed #{@reader.display_name}'s picture."
|
|
@@ -247,6 +267,13 @@ module Bible270
|
|
|
247
267
|
|
|
248
268
|
private
|
|
249
269
|
|
|
270
|
+
def comment_notifications_available_for?(reader)
|
|
271
|
+
return false unless Bible270.config.mention_notifications && Reader.comment_notification_columns?
|
|
272
|
+
|
|
273
|
+
reader.reload unless reader.has_attribute?(:notify_on_all_comments)
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
250
277
|
def require_admin!
|
|
251
278
|
return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
|
|
252
279
|
|
|
@@ -26,6 +26,8 @@ module Bible270
|
|
|
26
26
|
|
|
27
27
|
layout :bible270_layout
|
|
28
28
|
|
|
29
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :recover_from_stale_page
|
|
30
|
+
|
|
29
31
|
helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
|
|
30
32
|
|
|
31
33
|
private
|
|
@@ -101,6 +103,31 @@ module Bible270
|
|
|
101
103
|
current_reader.present?
|
|
102
104
|
end
|
|
103
105
|
|
|
106
|
+
# A suspended mobile tab can outlive its Rails session cookie. The page still
|
|
107
|
+
# contains the old session's CSRF token, so Rails would normally raise before
|
|
108
|
+
# current_reader could restore the session from the remember cookie. Redirect
|
|
109
|
+
# directly from Rails' forgery callback; rescue_from remains a fallback for an
|
|
110
|
+
# authenticity exception raised anywhere else in the request.
|
|
111
|
+
def handle_unverified_request
|
|
112
|
+
recover_from_stale_page
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def recover_from_stale_page(error = nil)
|
|
116
|
+
reason = error ? error.class : 'an unverified request'
|
|
117
|
+
Rails.logger.info("[bible270] silently refreshing a page after #{reason}")
|
|
118
|
+
|
|
119
|
+
if stale_turbo_checkoff?
|
|
120
|
+
response.set_header('X-Bible270-Refresh-Session', 'true')
|
|
121
|
+
head :conflict
|
|
122
|
+
else
|
|
123
|
+
redirect_back fallback_location: root_path, allow_other_host: false, status: :see_other
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def stale_turbo_checkoff?
|
|
128
|
+
controller_name == 'checkoffs' && action_name == 'toggle' && request.format.turbo_stream?
|
|
129
|
+
end
|
|
130
|
+
|
|
104
131
|
# Guard participation (checking off / commenting). Viewing is always open.
|
|
105
132
|
def require_reader!
|
|
106
133
|
return true if signed_in?
|
|
@@ -14,9 +14,9 @@ module Bible270
|
|
|
14
14
|
|
|
15
15
|
def update
|
|
16
16
|
@comment_notifications_available = comment_notifications_available?
|
|
17
|
-
@
|
|
17
|
+
@comment_notification_level = requested_comment_notification_level
|
|
18
18
|
@daily_reminders_available = daily_reminders_available?
|
|
19
|
-
problems = []
|
|
19
|
+
problems = [comment_notification_problem].compact
|
|
20
20
|
problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
|
|
21
21
|
params[:last_name])
|
|
22
22
|
if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
|
|
@@ -35,7 +35,7 @@ module Bible270
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
if problems.empty?
|
|
38
|
-
current_reader.
|
|
38
|
+
current_reader.update_comment_notification_level!(@comment_notification_level) if @comment_notifications_available
|
|
39
39
|
if @daily_reminders_available
|
|
40
40
|
current_reader.update!(daily_reminders: requested_daily_reminders,
|
|
41
41
|
daily_reminder_time: requested_daily_reminder_time)
|
|
@@ -59,17 +59,27 @@ module Bible270
|
|
|
59
59
|
|
|
60
60
|
def load_comment_notification_state
|
|
61
61
|
@comment_notifications_available = comment_notifications_available?
|
|
62
|
-
@
|
|
62
|
+
@comment_notification_level = current_reader.comment_notification_level
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def comment_notifications_available?
|
|
66
|
-
Bible270.config.mention_notifications &&
|
|
66
|
+
return false unless Bible270.config.mention_notifications && Reader.comment_notification_columns?
|
|
67
|
+
|
|
68
|
+
current_reader.reload unless current_reader.has_attribute?(:notify_on_all_comments)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def requested_comment_notification_level
|
|
73
|
+
return current_reader.comment_notification_level unless params.key?(:comment_notifications)
|
|
74
|
+
|
|
75
|
+
params[:comment_notifications].to_s
|
|
67
76
|
end
|
|
68
77
|
|
|
69
|
-
def
|
|
70
|
-
return
|
|
78
|
+
def comment_notification_problem
|
|
79
|
+
return unless @comment_notifications_available
|
|
80
|
+
return if Reader::COMMENT_NOTIFICATION_LEVELS.include?(@comment_notification_level)
|
|
71
81
|
|
|
72
|
-
|
|
82
|
+
'a reflection email preference from the list'
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
def load_daily_reminder_state
|
|
@@ -35,5 +35,12 @@ module Bible270
|
|
|
35
35
|
@days_completed = @reader.days_completed
|
|
36
36
|
@recent_comments = @reader.comments.recent.limit(20)
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
def mention_suggestions
|
|
40
|
+
return head :unauthorized unless signed_in?
|
|
41
|
+
|
|
42
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
43
|
+
render json: { suggestions: Reader.mention_suggestions(params[:q], except: current_reader) }
|
|
44
|
+
end
|
|
38
45
|
end
|
|
39
46
|
end
|
|
@@ -17,6 +17,16 @@ module Bible270
|
|
|
17
17
|
@origin = safe_origin(params[:origin])
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Refresh a resumed page's session and CSRF token without reloading its content.
|
|
21
|
+
# Calling current_reader first lets the long-lived remember cookie rebuild a
|
|
22
|
+
# browser session that the phone may have discarded while the tab was asleep.
|
|
23
|
+
def refresh
|
|
24
|
+
current_reader
|
|
25
|
+
response.headers['X-CSRF-Token'] = form_authenticity_token
|
|
26
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
27
|
+
head :no_content
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def create
|
|
21
31
|
auth = request.env['omniauth.auth']
|
|
22
32
|
redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return unless auth
|
|
@@ -36,6 +36,18 @@ module Bible270
|
|
|
36
36
|
deliver_comment_notice("#{@app_name}: #{@author.display_name} replied on day #{@comment.day}")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def comment_posted(comment_id:, reader_id:)
|
|
40
|
+
return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
|
|
41
|
+
return message.perform_deliveries = false unless @reader.wants_all_comment_notifications?
|
|
42
|
+
return message.perform_deliveries = false if @comment.reader_id == @reader.id
|
|
43
|
+
|
|
44
|
+
kind = @comment.reply? ? 'reply' : 'reflection'
|
|
45
|
+
@heading = "#{@author.display_name} posted a #{kind}"
|
|
46
|
+
@introduction = "#{@author.display_name} posted a #{kind} on day #{@comment.day}."
|
|
47
|
+
@reason = 'You are getting this because you asked to be emailed about every new reflection and reply.'
|
|
48
|
+
deliver_comment_notice("#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}")
|
|
49
|
+
end
|
|
50
|
+
|
|
39
51
|
def daily_reminder(reader_id:, day:, on: Bible270.today)
|
|
40
52
|
@reader = Reader.find_by(id: reader_id)
|
|
41
53
|
return message.perform_deliveries = false unless Bible270.config.daily_reminders?
|
|
@@ -155,11 +155,13 @@ module Bible270
|
|
|
155
155
|
end
|
|
156
156
|
|
|
157
157
|
def notify_comment_readers
|
|
158
|
-
return unless Bible270.config.mention_notifications
|
|
158
|
+
return unless Bible270.config.mention_notifications && approved?
|
|
159
159
|
|
|
160
160
|
replied_to = reply_notification_reader
|
|
161
161
|
deliver_comment_notice(replied_to, :replied) if replied_to
|
|
162
|
-
|
|
162
|
+
directly_notified = [reader_id, replied_to&.id].compact
|
|
163
|
+
directly_notified.concat(notify_mentioned_readers(except: directly_notified))
|
|
164
|
+
notify_all_comment_readers(except: directly_notified)
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
def notify_newly_mentioned_readers
|
|
@@ -171,13 +173,20 @@ module Bible270
|
|
|
171
173
|
end
|
|
172
174
|
|
|
173
175
|
def notify_mentioned_readers(except: [])
|
|
174
|
-
return unless Bible270.config.mention_notifications
|
|
176
|
+
return [] unless Bible270.config.mention_notifications
|
|
175
177
|
|
|
176
|
-
Reader.mentioned_in(body).
|
|
178
|
+
Reader.mentioned_in(body).each_with_object([]) do |mentioned, notified|
|
|
177
179
|
next if mentioned.id == reader_id
|
|
178
180
|
next if except.include?(mentioned.id)
|
|
179
181
|
|
|
180
182
|
deliver_comment_notice(mentioned, :mentioned)
|
|
183
|
+
notified << mentioned.id
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def notify_all_comment_readers(except: [])
|
|
188
|
+
Reader.all_comment_notification_recipients.where.not(id: except).find_each do |recipient|
|
|
189
|
+
deliver_comment_notice(recipient, :comment_posted)
|
|
181
190
|
end
|
|
182
191
|
end
|
|
183
192
|
|
|
@@ -31,6 +31,9 @@ module Bible270
|
|
|
31
31
|
DAILY_REMINDER_MINUTES = %w[00 15 30 45].freeze
|
|
32
32
|
DAILY_REMINDER_COLUMNS = %w[daily_reminders daily_reminder_time last_daily_reminder_sent_on].freeze
|
|
33
33
|
REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
|
|
34
|
+
COMMENT_NOTIFICATION_LEVELS = %w[all personal none].freeze
|
|
35
|
+
COMMENT_NOTIFICATION_COLUMNS = %w[notify_on_mention notify_on_all_comments].freeze
|
|
36
|
+
MENTION_SUGGESTION_LIMIT = 5
|
|
34
37
|
|
|
35
38
|
validates :display_name, presence: true
|
|
36
39
|
validates :uid, uniqueness: { scope: :provider }, allow_nil: true
|
|
@@ -244,11 +247,76 @@ module Bible270
|
|
|
244
247
|
|
|
245
248
|
# ---- mentions ----------------------------------------------------------
|
|
246
249
|
|
|
247
|
-
# Older copied databases may briefly lack
|
|
250
|
+
# Older copied databases may briefly lack preference columns while their
|
|
248
251
|
# migrations are being reconciled. Preserve the historical opted-in behavior
|
|
249
|
-
# until the column is available rather than breaking reflection delivery.
|
|
252
|
+
# until the original column is available rather than breaking reflection delivery.
|
|
250
253
|
def wants_comment_notifications?
|
|
251
|
-
!has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
|
|
254
|
+
wants_all_comment_notifications? || !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def wants_all_comment_notifications?
|
|
258
|
+
has_attribute?(:notify_on_all_comments) && self[:notify_on_all_comments] == true
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def comment_notification_level
|
|
262
|
+
return 'all' if wants_all_comment_notifications?
|
|
263
|
+
|
|
264
|
+
wants_comment_notifications? ? 'personal' : 'none'
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def update_comment_notification_level!(level)
|
|
268
|
+
raise ArgumentError, 'unknown reflection email preference' unless COMMENT_NOTIFICATION_LEVELS.include?(level)
|
|
269
|
+
|
|
270
|
+
update!(notify_on_mention: level != 'none', notify_on_all_comments: level == 'all')
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self.comment_notification_columns?
|
|
274
|
+
return true if (COMMENT_NOTIFICATION_COLUMNS - column_names).empty?
|
|
275
|
+
|
|
276
|
+
# A migration commonly runs in a one-off process while the web process keeps
|
|
277
|
+
# its old schema cache. Refresh once before reporting that the migration is
|
|
278
|
+
# still pending, so the controls appear without requiring an application restart.
|
|
279
|
+
reset_column_information
|
|
280
|
+
(COMMENT_NOTIFICATION_COLUMNS - column_names).empty?
|
|
281
|
+
rescue ActiveRecord::StatementInvalid
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.all_comment_notification_recipients
|
|
286
|
+
return none unless comment_notification_columns?
|
|
287
|
+
|
|
288
|
+
where(notify_on_all_comments: true).where.not(email: [nil, ''])
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Small, deterministic suggestions for the reflection composer. Full-handle
|
|
292
|
+
# collisions are omitted because the server must never guess which reader a
|
|
293
|
+
# mention intended.
|
|
294
|
+
def self.mention_suggestions(query, except: nil)
|
|
295
|
+
needle = Mentions.normalize(query.to_s.first(61))
|
|
296
|
+
candidates = where.not(first_name: [nil, '']).where.not(last_name: [nil, ''])
|
|
297
|
+
candidates = candidates.where.not(id: except.id) if except
|
|
298
|
+
unique = candidates.to_a.group_by { |reader| canonical_mention_handle(reader) }
|
|
299
|
+
.select { |handle, readers| handle.present? && readers.one? }
|
|
300
|
+
|
|
301
|
+
ranked = unique.filter_map do |handle, readers|
|
|
302
|
+
reader = readers.first
|
|
303
|
+
surname = Mentions.normalize(reader.last_name)
|
|
304
|
+
rank = if needle.empty? || handle.start_with?(needle)
|
|
305
|
+
0
|
|
306
|
+
elsif surname.start_with?(needle)
|
|
307
|
+
1
|
|
308
|
+
end
|
|
309
|
+
next unless rank
|
|
310
|
+
|
|
311
|
+
[{ name: reader.display_name, handle: "@#{handle}" }, rank, handle, reader.id]
|
|
312
|
+
end
|
|
313
|
+
ranked.sort_by { |_, rank, handle, id| [rank, handle, id] }
|
|
314
|
+
.first(MENTION_SUGGESTION_LIMIT)
|
|
315
|
+
.map(&:first)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.canonical_mention_handle(reader)
|
|
319
|
+
Mentions.handles_for(reader.first_name, reader.last_name).last
|
|
252
320
|
end
|
|
253
321
|
|
|
254
322
|
# The readers a piece of text mentions. A handle matching more than one reader
|
|
@@ -46,6 +46,30 @@
|
|
|
46
46
|
<% end %>
|
|
47
47
|
</div>
|
|
48
48
|
|
|
49
|
+
<div class="b270-panel">
|
|
50
|
+
<h2 class="b270-subhead">Email notifications</h2>
|
|
51
|
+
<% unless Bible270.config.mention_notifications %>
|
|
52
|
+
<p class="b270-hint">Reflection emails are disabled for this installation.</p>
|
|
53
|
+
<% else %>
|
|
54
|
+
<% unless @comment_notifications_available %>
|
|
55
|
+
<p class="b270-flash alert" style="margin:0">
|
|
56
|
+
Run the pending Bible270 database migration before changing reflection emails.
|
|
57
|
+
</p>
|
|
58
|
+
<% else %>
|
|
59
|
+
<% if @reader.email.blank? %>
|
|
60
|
+
<p class="b270-hint">This reader does not currently have an email address.</p>
|
|
61
|
+
<% end %>
|
|
62
|
+
<%= form_with url: admin_reader_notifications_path(@reader), method: :patch,
|
|
63
|
+
class: "b270-emailform" do %>
|
|
64
|
+
<%= render "bible270/shared/comment_notification_choices",
|
|
65
|
+
level: @reader.comment_notification_level,
|
|
66
|
+
personal_label: "Only replies to their reflections and mentions of them" %>
|
|
67
|
+
<%= submit_tag 'Save', class: 'b270-btn' %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<% end %>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
49
73
|
<div class="b270-panel">
|
|
50
74
|
<h2 class="b270-subhead">Where they are in the plan</h2>
|
|
51
75
|
<%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
|
|
@@ -18,7 +18,18 @@
|
|
|
18
18
|
</p>
|
|
19
19
|
<% end %>
|
|
20
20
|
<%= f.hidden_field :parent_id %>
|
|
21
|
-
|
|
21
|
+
<div class="b270-mention-composer" data-b270-mention-composer
|
|
22
|
+
data-suggestions-url="<%= mention_suggestions_path %>">
|
|
23
|
+
<%= f.label :body, (posting_reply ? "Reply" : "Reflection"), class: "b270-sr-only" %>
|
|
24
|
+
<%= f.text_area :body,
|
|
25
|
+
placeholder: posting_reply ? "Write your reply…" : "Share a reflection on today's reading…",
|
|
26
|
+
role: "combobox", autocomplete: "off",
|
|
27
|
+
aria: { autocomplete: "list", expanded: "false", haspopup: "listbox",
|
|
28
|
+
controls: "b270-mention-options" } %>
|
|
29
|
+
<div class="b270-mention-options" id="b270-mention-options" role="listbox"
|
|
30
|
+
data-b270-mention-options hidden></div>
|
|
31
|
+
<p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
|
|
32
|
+
</div>
|
|
22
33
|
<p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
|
|
23
34
|
<div class="row">
|
|
24
35
|
<%= f.select :track,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<% if signed_in? %>
|
|
23
23
|
<%= button_to toggle_checkoff_part_path(day, track, index), class: "b270-toggle",
|
|
24
24
|
params: { checked: part_done ? "0" : "1" },
|
|
25
|
-
form: { data: { turbo_stream: true, b270_submit: true,
|
|
25
|
+
form: { data: { turbo_stream: true, b270_submit: true, b270_checkoff: true,
|
|
26
26
|
b270_pending: "Saving reading…",
|
|
27
27
|
b270_success: (part_done ? "Reading marked unread." : "Reading marked read."),
|
|
28
28
|
b270_failure: "Couldn’t update that reading. Try again." } },
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<% if signed_in? %>
|
|
47
47
|
<%= button_to toggle_checkoff_part_path(day, track, 0), class: "b270-toggle",
|
|
48
48
|
params: { checked: done ? "0" : "1" },
|
|
49
|
-
form: { data: { turbo_stream: true, b270_submit: true,
|
|
49
|
+
form: { data: { turbo_stream: true, b270_submit: true, b270_checkoff: true,
|
|
50
50
|
b270_pending: "Saving reading…",
|
|
51
51
|
b270_success: (done ? "Reading marked unread." : "Reading marked read."),
|
|
52
52
|
b270_failure: "Couldn’t update that reading. Try again." } },
|
|
@@ -38,14 +38,13 @@
|
|
|
38
38
|
<%= render "bible270/shared/passage_source", reader: current_reader %>
|
|
39
39
|
|
|
40
40
|
<% if @comment_notifications_available %>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
</div>
|
|
41
|
+
<%= render "bible270/shared/comment_notification_choices",
|
|
42
|
+
level: @comment_notification_level,
|
|
43
|
+
personal_label: "Only replies to my reflections and mentions of me" %>
|
|
44
|
+
<% elsif Bible270.config.mention_notifications %>
|
|
45
|
+
<p class="b270-flash alert" style="margin:0">
|
|
46
|
+
Reflection email settings are temporarily unavailable while this installation finishes a database update.
|
|
47
|
+
</p>
|
|
49
48
|
<% end %>
|
|
50
49
|
|
|
51
50
|
<% if @daily_reminders_available %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<fieldset class="b270-field b270-notification-field">
|
|
2
|
+
<legend>Reflection emails</legend>
|
|
3
|
+
<div class="b270-notification-choices">
|
|
4
|
+
<label class="b270-notification-choice">
|
|
5
|
+
<%= radio_button_tag :comment_notifications, "all", level == "all" %>
|
|
6
|
+
<span>Every new reflection and reply</span>
|
|
7
|
+
</label>
|
|
8
|
+
<label class="b270-notification-choice">
|
|
9
|
+
<%= radio_button_tag :comment_notifications, "personal", level == "personal" %>
|
|
10
|
+
<span><%= personal_label %></span>
|
|
11
|
+
</label>
|
|
12
|
+
<label class="b270-notification-choice">
|
|
13
|
+
<%= radio_button_tag :comment_notifications, "none", level == "none" %>
|
|
14
|
+
<span>None</span>
|
|
15
|
+
</label>
|
|
16
|
+
</div>
|
|
17
|
+
</fieldset>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
<script nonce="<%= content_security_policy_nonce %>">
|
|
1
|
+
<script nonce="<%= content_security_policy_nonce %>" data-refresh-path="<%= refresh_session_path %>">
|
|
2
2
|
(() => {
|
|
3
|
+
const refreshSessionPath = document.currentScript.dataset.refreshPath;
|
|
3
4
|
if (window.Bible270InteractionUI) {
|
|
4
5
|
window.Bible270InteractionUI.reset();
|
|
5
6
|
return;
|
|
@@ -30,6 +31,42 @@
|
|
|
30
31
|
document.querySelectorAll("form[data-b270-submit='true']").forEach(resetForm);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const SESSION_REFRESH_AFTER = 5 * 60 * 1000;
|
|
35
|
+
let sessionRefreshedAt = Date.now();
|
|
36
|
+
let sessionRefresh = null;
|
|
37
|
+
let hiddenAt = null;
|
|
38
|
+
|
|
39
|
+
function updateAuthenticityToken(token) {
|
|
40
|
+
const meta = document.querySelector("meta[name='csrf-token']");
|
|
41
|
+
if (meta) meta.content = token;
|
|
42
|
+
document.querySelectorAll("input[name='authenticity_token']").forEach((input) => {
|
|
43
|
+
input.value = token;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function refreshSession() {
|
|
48
|
+
if (sessionRefresh) return sessionRefresh;
|
|
49
|
+
|
|
50
|
+
sessionRefresh = fetch(refreshSessionPath, {
|
|
51
|
+
credentials: "same-origin",
|
|
52
|
+
cache: "no-store",
|
|
53
|
+
headers: { Accept: "text/plain" }
|
|
54
|
+
}).then((response) => {
|
|
55
|
+
const token = response.headers.get("X-CSRF-Token");
|
|
56
|
+
if (!response.ok || !token) throw new Error("Could not refresh the session");
|
|
57
|
+
|
|
58
|
+
updateAuthenticityToken(token);
|
|
59
|
+
sessionRefreshedAt = Date.now();
|
|
60
|
+
}).finally(() => {
|
|
61
|
+
sessionRefresh = null;
|
|
62
|
+
});
|
|
63
|
+
return sessionRefresh;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sessionRefreshNeeded() {
|
|
67
|
+
return Date.now() - sessionRefreshedAt >= SESSION_REFRESH_AFTER;
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
let scriptureWindow = null;
|
|
34
71
|
const replacesBackgroundTabs = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
35
72
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
@@ -62,6 +99,24 @@
|
|
|
62
99
|
document.addEventListener("submit", (event) => {
|
|
63
100
|
const form = event.target.closest("form[data-b270-submit='true']");
|
|
64
101
|
if (!form) return;
|
|
102
|
+
|
|
103
|
+
if (sessionRefresh || sessionRefreshNeeded()) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
if (form.dataset.b270AwaitingSession === "true") return;
|
|
106
|
+
|
|
107
|
+
form.dataset.b270AwaitingSession = "true";
|
|
108
|
+
const submitter = event.submitter;
|
|
109
|
+
refreshSession().then(() => {
|
|
110
|
+
delete form.dataset.b270AwaitingSession;
|
|
111
|
+
form.requestSubmit(submitter || undefined);
|
|
112
|
+
}).catch(() => {
|
|
113
|
+
delete form.dataset.b270AwaitingSession;
|
|
114
|
+
sessionRefreshedAt = Date.now();
|
|
115
|
+
form.requestSubmit(submitter || undefined);
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
if (form.dataset.b270Submitting === "true") {
|
|
66
121
|
event.preventDefault();
|
|
67
122
|
return;
|
|
@@ -77,11 +132,39 @@
|
|
|
77
132
|
announce(form.dataset.b270Pending || "Saving…", "pending");
|
|
78
133
|
});
|
|
79
134
|
|
|
135
|
+
function staleSessionResponse(fetchResponse) {
|
|
136
|
+
if (!fetchResponse) return false;
|
|
137
|
+
if (fetchResponse.statusCode === 422) return true;
|
|
138
|
+
|
|
139
|
+
return fetchResponse.statusCode === 409 &&
|
|
140
|
+
fetchResponse.response.headers.get("X-Bible270-Refresh-Session") === "true";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
document.addEventListener("turbo:before-fetch-response", (event) => {
|
|
144
|
+
const target = event.target;
|
|
145
|
+
const form = target.closest && target.closest("form[data-b270-checkoff='true']");
|
|
146
|
+
const response = event.detail.fetchResponse;
|
|
147
|
+
if (!form || !staleSessionResponse(response)) return;
|
|
148
|
+
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
if (form.dataset.b270RetriedAfterStaleSession === "true") {
|
|
151
|
+
window.location.reload();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
form.dataset.b270RetriedAfterStaleSession = "true";
|
|
156
|
+
resetForm(form);
|
|
157
|
+
refreshSession().then(() => form.requestSubmit()).catch(() => window.location.reload());
|
|
158
|
+
});
|
|
159
|
+
|
|
80
160
|
document.addEventListener("turbo:submit-end", (event) => {
|
|
81
161
|
const form = event.target;
|
|
82
162
|
if (!form.matches("form[data-b270-submit='true']")) return;
|
|
83
163
|
|
|
84
164
|
resetForm(form);
|
|
165
|
+
if (form.dataset.b270RetriedAfterStaleSession === "true" &&
|
|
166
|
+
staleSessionResponse(event.detail.fetchResponse)) return;
|
|
167
|
+
|
|
85
168
|
if (event.detail.success) {
|
|
86
169
|
announce(form.dataset.b270Success || "Saved.", "success");
|
|
87
170
|
} else {
|
|
@@ -89,7 +172,18 @@
|
|
|
89
172
|
}
|
|
90
173
|
});
|
|
91
174
|
|
|
92
|
-
window.addEventListener("pageshow",
|
|
175
|
+
window.addEventListener("pageshow", (event) => {
|
|
176
|
+
reset();
|
|
177
|
+
if (event.persisted) refreshSession().catch(() => {});
|
|
178
|
+
});
|
|
179
|
+
document.addEventListener("visibilitychange", () => {
|
|
180
|
+
if (document.visibilityState === "hidden") {
|
|
181
|
+
hiddenAt = Date.now();
|
|
182
|
+
} else if (hiddenAt && Date.now() - hiddenAt >= SESSION_REFRESH_AFTER) {
|
|
183
|
+
refreshSession().catch(() => {});
|
|
184
|
+
hiddenAt = null;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
93
187
|
document.addEventListener("turbo:load", reset);
|
|
94
188
|
|
|
95
189
|
window.Bible270InteractionUI = { reset };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
2
|
+
(() => {
|
|
3
|
+
if (window.Bible270MentionTypeahead) {
|
|
4
|
+
window.Bible270MentionTypeahead.reset();
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
|
|
10
|
+
function activeToken(textarea) {
|
|
11
|
+
if (textarea.selectionStart !== textarea.selectionEnd) return null;
|
|
12
|
+
|
|
13
|
+
const cursor = textarea.selectionStart;
|
|
14
|
+
const before = textarea.value.slice(0, cursor);
|
|
15
|
+
const match = before.match(/(?:^|[^\p{L}\p{N}_@])@([\p{L}\p{N}.'-]{0,61})$/u);
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
|
|
18
|
+
return { start: cursor - match[1].length - 1, end: cursor, query: match[1] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function close(composer, state) {
|
|
22
|
+
const textarea = composer.querySelector("textarea");
|
|
23
|
+
const options = composer.querySelector("[data-b270-mention-options]");
|
|
24
|
+
const status = composer.querySelector("[data-b270-mention-status]");
|
|
25
|
+
options.hidden = true;
|
|
26
|
+
options.replaceChildren();
|
|
27
|
+
textarea.setAttribute("aria-expanded", "false");
|
|
28
|
+
textarea.removeAttribute("aria-activedescendant");
|
|
29
|
+
status.textContent = "";
|
|
30
|
+
state.items = [];
|
|
31
|
+
state.active = -1;
|
|
32
|
+
state.token = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function selectActive(composer, state, index) {
|
|
36
|
+
if (!state.items.length) return;
|
|
37
|
+
|
|
38
|
+
state.active = (index + state.items.length) % state.items.length;
|
|
39
|
+
const textarea = composer.querySelector("textarea");
|
|
40
|
+
composer.querySelectorAll("[role='option']").forEach((option, optionIndex) => {
|
|
41
|
+
const selected = optionIndex === state.active;
|
|
42
|
+
option.setAttribute("aria-selected", selected ? "true" : "false");
|
|
43
|
+
if (selected) textarea.setAttribute("aria-activedescendant", option.id);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function insert(composer, state, item) {
|
|
48
|
+
const textarea = composer.querySelector("textarea");
|
|
49
|
+
const token = activeToken(textarea) || state.token;
|
|
50
|
+
if (!token) return close(composer, state);
|
|
51
|
+
|
|
52
|
+
const after = textarea.value.slice(token.end);
|
|
53
|
+
const separator = after === "" || /^[\p{L}\p{N}@]/u.test(after) ? " " : "";
|
|
54
|
+
textarea.value = `${textarea.value.slice(0, token.start)}${item.handle}${separator}${after}`;
|
|
55
|
+
const cursor = token.start + item.handle.length + separator.length;
|
|
56
|
+
textarea.setSelectionRange(cursor, cursor);
|
|
57
|
+
close(composer, state);
|
|
58
|
+
textarea.focus();
|
|
59
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function render(composer, state, token, items) {
|
|
63
|
+
const textarea = composer.querySelector("textarea");
|
|
64
|
+
const options = composer.querySelector("[data-b270-mention-options]");
|
|
65
|
+
const status = composer.querySelector("[data-b270-mention-status]");
|
|
66
|
+
const current = activeToken(textarea);
|
|
67
|
+
if (!current || current.start !== token.start || current.query !== token.query) return;
|
|
68
|
+
|
|
69
|
+
const safeItems = items.filter((item) => item && typeof item.name === "string" &&
|
|
70
|
+
typeof item.handle === "string").slice(0, 5);
|
|
71
|
+
if (!safeItems.length) return close(composer, state);
|
|
72
|
+
|
|
73
|
+
options.replaceChildren();
|
|
74
|
+
state.items = safeItems;
|
|
75
|
+
state.token = token;
|
|
76
|
+
safeItems.forEach((item, index) => {
|
|
77
|
+
const option = document.createElement("button");
|
|
78
|
+
option.type = "button";
|
|
79
|
+
option.id = `b270-mention-option-${index}`;
|
|
80
|
+
option.className = "b270-mention-option";
|
|
81
|
+
option.setAttribute("role", "option");
|
|
82
|
+
option.setAttribute("aria-selected", "false");
|
|
83
|
+
option.textContent = `${item.name} ${item.handle}`;
|
|
84
|
+
option.addEventListener("pointerdown", (event) => event.preventDefault());
|
|
85
|
+
option.addEventListener("click", () => insert(composer, state, item));
|
|
86
|
+
options.append(option);
|
|
87
|
+
});
|
|
88
|
+
options.hidden = false;
|
|
89
|
+
textarea.setAttribute("aria-expanded", "true");
|
|
90
|
+
status.textContent = `${safeItems.length} mention suggestion${safeItems.length === 1 ? "" : "s"} available.`;
|
|
91
|
+
selectActive(composer, state, 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function request(composer, state, token) {
|
|
95
|
+
const key = token.query.toLocaleLowerCase();
|
|
96
|
+
if (cache.has(key)) return render(composer, state, token, cache.get(key));
|
|
97
|
+
|
|
98
|
+
if (state.abort) state.abort.abort();
|
|
99
|
+
state.abort = new AbortController();
|
|
100
|
+
const url = new URL(composer.dataset.suggestionsUrl, window.location.href);
|
|
101
|
+
url.searchParams.set("q", token.query);
|
|
102
|
+
fetch(url, {
|
|
103
|
+
credentials: "same-origin",
|
|
104
|
+
cache: "no-store",
|
|
105
|
+
headers: { Accept: "application/json" },
|
|
106
|
+
signal: state.abort.signal
|
|
107
|
+
}).then((response) => {
|
|
108
|
+
if (!response.ok) throw new Error("Mention suggestions unavailable");
|
|
109
|
+
return response.json();
|
|
110
|
+
}).then((body) => {
|
|
111
|
+
const items = Array.isArray(body.suggestions) ? body.suggestions : [];
|
|
112
|
+
cache.set(key, items);
|
|
113
|
+
render(composer, state, token, items);
|
|
114
|
+
}).catch((error) => {
|
|
115
|
+
if (error.name !== "AbortError") close(composer, state);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function update(composer, state) {
|
|
120
|
+
const textarea = composer.querySelector("textarea");
|
|
121
|
+
const token = activeToken(textarea);
|
|
122
|
+
window.clearTimeout(state.timer);
|
|
123
|
+
if (!token) return close(composer, state);
|
|
124
|
+
|
|
125
|
+
state.timer = window.setTimeout(() => request(composer, state, token), 120);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function initialize(composer) {
|
|
129
|
+
if (composer.dataset.b270MentionReady === "true") return;
|
|
130
|
+
composer.dataset.b270MentionReady = "true";
|
|
131
|
+
const textarea = composer.querySelector("textarea");
|
|
132
|
+
const state = { items: [], active: -1, token: null, timer: null, abort: null };
|
|
133
|
+
|
|
134
|
+
textarea.addEventListener("input", () => update(composer, state));
|
|
135
|
+
textarea.addEventListener("click", () => update(composer, state));
|
|
136
|
+
textarea.addEventListener("keydown", (event) => {
|
|
137
|
+
if (!state.items.length) return;
|
|
138
|
+
|
|
139
|
+
if (event.key === "ArrowDown") {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
selectActive(composer, state, state.active + 1);
|
|
142
|
+
} else if (event.key === "ArrowUp") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
selectActive(composer, state, state.active - 1);
|
|
145
|
+
} else if (event.key === "Enter" && !event.shiftKey && state.active >= 0) {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
insert(composer, state, state.items[state.active]);
|
|
148
|
+
} else if (event.key === "Escape") {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
close(composer, state);
|
|
151
|
+
} else if (event.key === "Tab") {
|
|
152
|
+
close(composer, state);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
textarea.addEventListener("blur", () => {
|
|
156
|
+
window.setTimeout(() => {
|
|
157
|
+
if (!composer.contains(document.activeElement)) close(composer, state);
|
|
158
|
+
}, 0);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scan() {
|
|
163
|
+
document.querySelectorAll("[data-b270-mention-composer]").forEach(initialize);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function reset() {
|
|
167
|
+
cache.clear();
|
|
168
|
+
scan();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
scan();
|
|
172
|
+
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
|
|
173
|
+
window.addEventListener("pageshow", scan);
|
|
174
|
+
document.addEventListener("turbo:load", reset);
|
|
175
|
+
window.Bible270MentionTypeahead = { scan, reset };
|
|
176
|
+
})();
|
|
177
|
+
</script>
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
.b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
|
|
41
41
|
.b270-field input{font:inherit;font-size:15px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%}
|
|
42
42
|
.b270-field input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(165,129,44,.13)}
|
|
43
|
+
.b270-notification-field{margin:0;padding:0;border:0}
|
|
44
|
+
.b270-notification-field legend{padding:0;margin:0 0 3px}
|
|
45
|
+
.b270-notification-choices{display:grid;gap:7px}
|
|
46
|
+
.b270-notification-choice{display:flex;align-items:flex-start;gap:7px;line-height:1.35;cursor:pointer}
|
|
47
|
+
.b270-field .b270-notification-choice input[type=radio]{width:auto;margin:1px 0 0;padding:0;flex:none;accent-color:var(--gold)}
|
|
43
48
|
.b270-reminder-row{display:flex;align-items:center;gap:12px;width:100%;flex-wrap:wrap}
|
|
44
49
|
.b270-reminder-optin,.b270-reminder-time{display:inline-flex;align-items:center;gap:7px;color:var(--muted);white-space:nowrap}
|
|
45
50
|
.b270-reminder-optin{position:relative;min-width:0}
|
|
@@ -247,6 +252,15 @@
|
|
|
247
252
|
.b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
|
|
248
253
|
.b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
|
|
249
254
|
.b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
|
|
255
|
+
.b270-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
|
|
256
|
+
.b270-mention-composer{position:relative}
|
|
257
|
+
.b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
|
|
258
|
+
border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:var(--shadow)}
|
|
259
|
+
.b270-mention-options[hidden]{display:none}
|
|
260
|
+
.b270 .b270-mention-option{display:block;width:100%;min-height:44px;padding:10px 12px;border:0;border-radius:0;
|
|
261
|
+
background:var(--card);color:var(--ink);font:inherit;font-size:13px;text-align:left;cursor:pointer}
|
|
262
|
+
.b270-mention-option+ .b270-mention-option{border-top:1px solid var(--line-soft)}
|
|
263
|
+
.b270-mention-option:hover,.b270-mention-option[aria-selected=true]{background:var(--paper);color:var(--gold)}
|
|
250
264
|
.b270-draft-status{min-height:16px;margin:4px 0 -4px;font-size:11.5px;color:var(--faint)}
|
|
251
265
|
.b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
|
|
252
266
|
.b270-form .row select{max-width:100%}
|
data/config/routes.rb
CHANGED
|
@@ -20,8 +20,9 @@ Bible270::Engine.routes.draw do
|
|
|
20
20
|
get 'admin', to: 'admin#index', as: :admin
|
|
21
21
|
get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
|
|
22
22
|
delete 'admin/readers/:id', to: 'admin#destroy'
|
|
23
|
-
patch 'admin/readers/:id/profile', to: 'admin#update_profile',
|
|
24
|
-
|
|
23
|
+
patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
|
|
24
|
+
patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
|
|
25
|
+
delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
|
|
25
26
|
patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
|
|
26
27
|
patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
|
|
27
28
|
patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
|
|
@@ -45,11 +46,13 @@ Bible270::Engine.routes.draw do
|
|
|
45
46
|
get 'progress', to: 'readers#progress', as: :progress
|
|
46
47
|
|
|
47
48
|
get 'community', to: 'readers#index', as: :community
|
|
49
|
+
get 'mention-suggestions', to: 'readers#mention_suggestions', as: :mention_suggestions, defaults: { format: :json }
|
|
48
50
|
get 'readers/:id', to: 'readers#show', as: :reader
|
|
49
51
|
|
|
50
52
|
# Built-in OmniAuth sign-in. The request phase (POST <mount>/auth/:provider)
|
|
51
53
|
# is handled by the OmniAuth middleware in the host app, not by these routes.
|
|
52
54
|
get 'sign_in', to: 'sessions#new', as: :sign_in
|
|
55
|
+
get 'session/refresh', to: 'sessions#refresh', as: :refresh_session
|
|
53
56
|
|
|
54
57
|
# Email (magic link) sign-in, for readers without a social account.
|
|
55
58
|
post 'sign_in/email', to: 'sessions#email_link', as: :email_sign_in_link
|
|
@@ -27,6 +27,7 @@ module Bible270
|
|
|
27
27
|
'AddPassageSourceToBible270Readers' => :check_passage_source,
|
|
28
28
|
'AddDailyRemindersToBible270Readers' => :check_daily_reminders,
|
|
29
29
|
'AddReflectionsSeenAtToBible270Readers' => :check_reflections_seen_at,
|
|
30
|
+
'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
|
|
30
31
|
'CreateActiveStorageTables' => :check_active_storage
|
|
31
32
|
}.freeze
|
|
32
33
|
|
|
@@ -190,6 +191,10 @@ module Bible270
|
|
|
190
191
|
table_missing(:bible270_readers, columns: %i[reflections_seen_at])
|
|
191
192
|
end
|
|
192
193
|
|
|
194
|
+
def check_all_comment_notices
|
|
195
|
+
table_missing(:bible270_readers, columns: %i[notify_on_all_comments])
|
|
196
|
+
end
|
|
197
|
+
|
|
193
198
|
# The Bible270 installer can install Active Storage for reader avatars. A
|
|
194
199
|
# copied database can therefore have the same timestamp mismatch for Rails'
|
|
195
200
|
# generated Active Storage migration as it has for the engine migrations.
|
data/lib/bible270/version.rb
CHANGED
|
@@ -517,7 +517,7 @@ module Bible270
|
|
|
517
517
|
# Readers choose an opt-in time only when enabled. This application owns
|
|
518
518
|
# scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
|
|
519
519
|
config.daily_reminders = false
|
|
520
|
-
config.mention_notifications = true #
|
|
520
|
+
config.mention_notifications = true # reflection/reply emails; readers choose the scope
|
|
521
521
|
# config.mailer_host = 'example.com' # links back to the day and profile
|
|
522
522
|
|
|
523
523
|
# --- Reading links --------------------------------------------------------
|
|
@@ -60,7 +60,7 @@ Bible270.configure do |config|
|
|
|
60
60
|
# Readers choose an opt-in time on their profile only when this is enabled.
|
|
61
61
|
# The host app owns scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
|
|
62
62
|
config.daily_reminders = false
|
|
63
|
-
config.mention_notifications = true #
|
|
63
|
+
config.mention_notifications = true # reflection/reply emails; readers choose the scope
|
|
64
64
|
# config.mailer_host = "example.com" # required for day/profile links in email
|
|
65
65
|
|
|
66
66
|
# --- Reading links ---------------------------------------------------------
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bible270
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -137,12 +137,14 @@ files:
|
|
|
137
137
|
- app/views/bible270/readers/show.html.erb
|
|
138
138
|
- app/views/bible270/sessions/new.html.erb
|
|
139
139
|
- app/views/bible270/shared/_comment_drafts.html.erb
|
|
140
|
+
- app/views/bible270/shared/_comment_notification_choices.html.erb
|
|
140
141
|
- app/views/bible270/shared/_day_index.html.erb
|
|
141
142
|
- app/views/bible270/shared/_footer.html.erb
|
|
142
143
|
- app/views/bible270/shared/_grid_legend.html.erb
|
|
143
144
|
- app/views/bible270/shared/_header.html.erb
|
|
144
145
|
- app/views/bible270/shared/_interaction_status.html.erb
|
|
145
146
|
- app/views/bible270/shared/_interaction_ui.html.erb
|
|
147
|
+
- app/views/bible270/shared/_mention_typeahead.html.erb
|
|
146
148
|
- app/views/bible270/shared/_nav_links.html.erb
|
|
147
149
|
- app/views/bible270/shared/_passage_source.html.erb
|
|
148
150
|
- app/views/bible270/shared/_progress.html.erb
|
|
@@ -170,6 +172,7 @@ files:
|
|
|
170
172
|
- db/migrate/20260101000015_add_passage_source_to_bible270_readers.rb
|
|
171
173
|
- db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb
|
|
172
174
|
- db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb
|
|
175
|
+
- db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
|
|
173
176
|
- lib/bible270.rb
|
|
174
177
|
- lib/bible270/avatars.rb
|
|
175
178
|
- lib/bible270/configuration.rb
|