bible270 1.1.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7014361816b071aaacc54d569f247ff116cc2c5c0f49ea4490e46a2e62375f4
4
- data.tar.gz: d61942b56c9409563d03e8b76e2f3dea0ba6a73d0119e4d0dade38bdcb177ec2
3
+ metadata.gz: b9f698d3dbbc6afbb9130e3dc81767302792e8f25e23db7d702401eb290b747e
4
+ data.tar.gz: e109e61cc7a4e39098124cb7e1996c980d248b54c3e7649702901a6b5e65e392
5
5
  SHA512:
6
- metadata.gz: ee0357bfb87e84ab59b359ba11a274c380834587008263b7229e3d8f3edd04c02a1211045ebabec90545c4384c0404d872913c1caa2461609ec09aa4339bce88
7
- data.tar.gz: 2e413b639bdccfbc194a44a7e30051d5c304ed2205625c3b3c79749da20f463178c538b6b9db43233843d783e2bfb91fce2e43121f55b7ff798a55798564a1a7
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
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...main)**
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,7 @@ 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
555
577
  [1.1.6]: https://github.com/avonderluft/bible270/releases/tag/v1.1.6
556
578
  [1.1.5]: https://github.com/avonderluft/bible270/releases/tag/v1.1.5
557
579
  [1.1.4]: https://github.com/avonderluft/bible270/releases/tag/v1.1.4
data/README.md CHANGED
@@ -282,9 +282,15 @@ The Reflections page lists conversations by their latest approved reply, support
282
282
  filters, and paginates older threads. Signed-in readers see which conversations became active since
283
283
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
284
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.
285
287
 
286
- After upgrading, copy and run migration `20260101000017`, which stores each reader's last visit to the
287
- Reflections page:
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:
288
294
 
289
295
  ```bash
290
296
  bin/rails bible270:install:migrations
@@ -398,7 +404,7 @@ Bible270.configure do |config|
398
404
  config.allow_reader_start_date = true
399
405
 
400
406
  config.daily_reminders = false # schedule reminders:send every 15 minutes
401
- config.mention_notifications = true # replies and @mentions; readers may opt out
407
+ config.mention_notifications = true # reflection/reply emails; readers choose the scope
402
408
  config.mailer_host = "example.com" # links in reminder/notice email
403
409
 
404
410
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
@@ -487,7 +493,8 @@ GET /day/:day days#show readings, who's finished, refle
487
493
  POST /day/:day/toggle/:track checkoffs#toggle
488
494
  POST /day/:day/comments comments#create
489
495
  DELETE /comments/:id comments#destroy
490
- GET /community readers#index leaderboard
496
+ GET /community readers#index reader directory
497
+ GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
491
498
  GET /readers/:id readers#show
492
499
  GET /sign_in sessions#new email form + provider list
493
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 toggle_day]
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
 
@@ -104,14 +104,28 @@ module Bible270
104
104
  end
105
105
 
106
106
  # A suspended mobile tab can outlive its Rails session cookie. The page still
107
- # contains the old session's CSRF token, so Rails rejects its next form before
108
- # current_reader has a chance to restore the session from the remember cookie.
109
- # Never execute that unverified request; return to a fresh page for a safe retry.
110
- def recover_from_stale_page(error)
111
- Rails.logger.info("[bible270] refreshing a page after #{error.class}")
112
- redirect_back fallback_location: root_path, allow_other_host: false,
113
- alert: 'This page had been open for a while, so it was refreshed. Please try again.',
114
- status: :see_other
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?
115
129
  end
116
130
 
117
131
  # Guard participation (checking off / commenting). Viewing is always open.
@@ -14,9 +14,9 @@ module Bible270
14
14
 
15
15
  def update
16
16
  @comment_notifications_available = comment_notifications_available?
17
- @notify_on_mention = requested_comment_notifications
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.update!(notify_on_mention: @notify_on_mention) if @comment_notifications_available
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
- @notify_on_mention = current_reader.wants_comment_notifications?
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 && current_reader.has_attribute?(:notify_on_mention)
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 requested_comment_notifications
70
- return current_reader.wants_comment_notifications? unless params.key?(:notify_on_mention)
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
- params[:notify_on_mention] == '1'
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
@@ -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
- notify_mentioned_readers(except: [replied_to&.id].compact)
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).each do |mentioned|
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 the preference column while their
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
- <%= f.text_area :body, placeholder: comment.parent_id.present? ? "Write your reply…" : "Share a reflection on today's reading…" %>
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." } },
@@ -27,6 +27,7 @@
27
27
  <%= render "bible270/comments/form", day: @day, comment: @new_comment %>
28
28
  </div>
29
29
  <%= render "bible270/shared/comment_drafts" %>
30
+ <%= render "bible270/shared/mention_typeahead" %>
30
31
  <% end %>
31
32
  </div>
32
33
  </div>
@@ -38,14 +38,13 @@
38
38
  <%= render "bible270/shared/passage_source", reader: current_reader %>
39
39
 
40
40
  <% if @comment_notifications_available %>
41
- <div class="b270-field">
42
- <span>Email notifications</span>
43
- <label class="b270-reminder-optin">
44
- <%= hidden_field_tag :notify_on_mention, "0", id: nil %>
45
- <%= check_box_tag :notify_on_mention, "1", @notify_on_mention %>
46
- <span>Email me about replies and mentions</span>
47
- </label>
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>
@@ -132,11 +132,39 @@
132
132
  announce(form.dataset.b270Pending || "Saving…", "pending");
133
133
  });
134
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
+
135
160
  document.addEventListener("turbo:submit-end", (event) => {
136
161
  const form = event.target;
137
162
  if (!form.matches("form[data-b270-submit='true']")) return;
138
163
 
139
164
  resetForm(form);
165
+ if (form.dataset.b270RetriedAfterStaleSession === "true" &&
166
+ staleSessionResponse(event.detail.fetchResponse)) return;
167
+
140
168
  if (event.detail.success) {
141
169
  announce(form.dataset.b270Success || "Saved.", "success");
142
170
  } else {
@@ -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', as: :admin_reader_profile
24
- delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
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,6 +46,7 @@ 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)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAllCommentNoticesToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :notify_on_all_comments, :boolean, null: false, default: false
6
+ end
7
+ end
@@ -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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.1.7'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -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 # direct replies and @mentions; readers may opt out
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 # direct replies and @mentions; readers may opt out
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.1.7
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