bible270 0.15.3 → 0.16.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: 3bac102109d922307b87047b9151aa16354f5aacbfa1829a1419ca3bd02dc251
4
- data.tar.gz: 825c3e6d6da35d724ea0cb7ece96244ac8555251c4c8b2165492139f49c7c4d1
3
+ metadata.gz: 3190cb926559301bfff8450c5e4f3d32867d84528c795782f33786c8f49b5a9f
4
+ data.tar.gz: df04e7e7a97b995c86c3440a81bea7db90360617cb990bc60975aca5d11e1669
5
5
  SHA512:
6
- metadata.gz: 54bb44c5d582ad4f4795a68a09d1facd54a0c4b8a77890ed0fb900c7d357ece4cb4e0656570c4bb15241fbb6cf9c6ef161f4a3d11962dd706aa41f03241b1f98
7
- data.tar.gz: 961462928a89a8d3b060a4101996851115f2b8d479567a3741c3613f83823e7a78225113af1c82f1fc125d04101b93a5ee2ac2b2c554909fef1e6d859151e050
6
+ metadata.gz: a535dc114386b817d3e501ebb363d52f5898e8d99b10212a414c0bb760fb1f707cc09ed24298ffbeccc7e648f87ee9afadedd2bdcf0951253a3e8aa6d3348bda
7
+ data.tar.gz: 66b8697402e10bfd1ac99b6aa3b08728a1116521d93dbfbb41ecd2e979b7f34682f185786f27d5a23c154d892470ec679b3e596d01c220da99764fc545e361e6
data/CHANGELOG.md CHANGED
@@ -6,11 +6,20 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
6
6
 
7
7
  See the changes since the last release:
8
8
 
9
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.3...main)**
9
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.16.0...main)**
10
+
11
+ ## [0.16.0] - 2026-08-05
12
+
13
+ ### Added
14
+
15
+ - threaded replies to reflections
16
+ - 'at' mention replies email notify writer of original reflection
17
+
18
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.3...v0.16.0)**
10
19
 
11
20
  ## [0.15.3] - 2026-08-04
12
21
 
13
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.2)**
22
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.3)**
14
23
 
15
24
  ### Added
16
25
 
@@ -250,7 +259,6 @@ Nothing consequential.
250
259
  - Posting a reflection about the whole day failed with "Track is not included in the list": the
251
260
  form's blank option submits an empty string, which `allow_nil` rejects. Blank tracks are now
252
261
  normalised to NULL before validation.
253
-
254
262
  - `config.email_sign_in_log_link` — tri-state: nil logs the sign-in link in development and test
255
263
  only, true forces it on (for smoke-testing a production build locally before mail is wired up),
256
264
  false disables it. The log also distinguishes sent, rate-limited and failed delivery, which the
@@ -268,7 +276,7 @@ Nothing consequential.
268
276
 
269
277
  ### Changed
270
278
 
271
- * **The New Testament now has a reading every day — no days off.** 260 chapters over 270 days is reconciled by dividing the 10 longest chapters in two: Luke 1, Matthew 26 and 27, Mark 14, Luke 9, 12 and 22, John 6 and 8, and Acts 7. Luke 1 reads as 1–40 then 41–80. This drops the longest NT reading from 80 verses to 58 (stdev 14.6 → 11.4) and removes the 10 rest days from 0.8.0, so all three tracks are present on all 270 days and every day needs three check-offs.
279
+ * The New Testament now has a reading every day — no days off.
272
280
  * The daily total is now 76–175 verses (was 76–203), averaging ~119.
273
281
 
274
282
  ### Added
@@ -294,11 +302,7 @@ This is the initial public release.
294
302
  * A pure, deterministic Ruby schedule; the database stores readers, check-offs, and comments rather than plan rows.
295
303
  * `Bible270::Versification`, providing per-chapter verse counts for all 66 books, generated from a public-domain KJV text and validated against canonical anchors.
296
304
  * Verse-balanced daily portions rather than chapter-count balancing, so daily reading time is approximately even.
297
- * Old Testament days grouped to be even in verses, averaging approximately 73 verses per day.
298
305
  * Per-track progress bars based on days read rather than chapter totals.
299
- * The New Testament read twice, with each pass occupying half of the 270-day plan. Whole chapters are verse-balanced within each pass.
300
- * A Psalms/Proverbs companion plan resolving to exactly 135 portions, making two complete passes across the 270-day plan.
301
- * Chapters are generally kept whole; Psalm 119 is divided into two readings, while shorter chapters may be combined with a neighboring psalm.
302
306
  * Configurable start dates through `config.start_date` and `config.allow_reader_start_date`, including per-reader date management.
303
307
  * Calendar mapping functions on `Bible270::Plan`: `date_for`, `day_for`, `end_date_for`, `before_start?`, `after_end?`, and `to_date`.
304
308
  * Calendar-aware day pages, including a **Today** badge, a **Go to today** link, and a pace indicator.
@@ -321,17 +325,13 @@ This is the initial public release.
321
325
  * Configuration-driven engine mounting and OmniAuth path prefixes.
322
326
  * `spec.email` in the gemspec.
323
327
  * Gem metadata pointing to the actual repository for `source_code_uri`, `bug_tracker_uri`, and `changelog_uri`.
324
- * RubyGems MFA requirement through `rubygems_mfa_required`.
325
- * This changelog, packaged with the gem.
326
328
  * Compatibility notes and tests covering Ruby 4.0 changes, including removal of the `cgi` dependency.
327
329
 
328
330
  ### Changed
329
331
 
330
332
  * The project was renamed from `bible_reading_plan` to `bible270`, including the `Bible270` module, database tables, migrations, CSS and helper prefixes, install task, and engine name.
331
333
  * The install generator defaults to `/daily-bread`, writes the mount path into the engine initializer, and emits configuration-driven `mount` and `path_prefix` lines.
332
- * The README documents the mount point, initializer load-order requirements, OAuth callback URLs, and CMS links that may need updating when the mount path changes.
333
334
  * The engine's sign-in paths follow the mount point at runtime through `request.script_name`.
334
- * Author and copyright information now read “Andrew vonderLuft”.
335
335
  * The display-name-from-email example uses a neutral address.
336
336
  * `sign_out` is `DELETE` only.
337
337
  * Guarded actions redirect to the sign-in page.
@@ -348,17 +348,11 @@ This is the initial public release.
348
348
  * Ruby 4.0 compatibility issues caused by the removed `cgi` default gem.
349
349
  * Progress bars that could not reach the correct totals because they compared checked days with chapter totals.
350
350
 
351
- ### Breaking Changes
352
-
353
- * The project was renamed from `bible_reading_plan` to `bible270`; this is a fresh install rather than an in-place upgrade because database table names changed.
354
- * The New Testament schedule changed from a single pass to two verse-balanced passes.
355
- * The Psalms/Proverbs schedule changed to two complete 135-portion passes.
356
- * Calendar and reading-plan behavior is based on day numbers rather than stored plan rows.
357
-
358
351
  ### Requirements
359
352
 
360
353
  * 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.
361
354
 
355
+ [0.16.0]: https://github.com/avonderluft/bible270/releases/tag/v0.16.0
362
356
  [0.15.3]: https://github.com/avonderluft/bible270/releases/tag/v0.15.3
363
357
  [0.15.2]: https://github.com/avonderluft/bible270/releases/tag/v0.15.2
364
358
  [0.15.1]: https://github.com/avonderluft/bible270/releases/tag/v0.15.1
@@ -44,7 +44,7 @@ module Bible270
44
44
  # Everything is visible by default; this lists it all so anything unsuitable
45
45
  # can be taken down.
46
46
  def comments
47
- scope = Comment.includes(:reader).order(created_at: :desc)
47
+ scope = Comment.includes(:reader, parent: :reader).order(created_at: :desc)
48
48
  @filter = params[:filter].to_s
49
49
  scope = scope.hidden if @filter == 'hidden'
50
50
  scope = scope.approved if @filter == 'visible'
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Bible270
4
4
  class ApplicationController < Bible270.config.parent_controller.constantize
5
+ # The long-lived sign-in cookie. private_constant rather than sitting below
6
+ # `private`, which does not apply to constants — rubocop is right that the
7
+ # placement said something it could not do.
8
+ REMEMBER_COOKIE = :bible270_remember
9
+ private_constant :REMEMBER_COOKIE
10
+
5
11
  # Rails only auto-includes an isolated engine's helpers when the controller's
6
12
  # superclass is *exactly* ActionController::Base:
7
13
  #
@@ -22,8 +28,6 @@ module Bible270
22
28
 
23
29
  helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
24
30
 
25
- REMEMBER_COOKIE = :bible270_remember
26
-
27
31
  private
28
32
 
29
33
  def bible270_layout
@@ -67,6 +71,12 @@ module Bible270
67
71
  # Re-establish the session so the rest of the request behaves as normal.
68
72
  session[:bible270_reader_id] = reader.id
69
73
  reader
74
+ rescue StandardError => e
75
+ # A cookie is attacker-supplied input: a damaged or forged one must sign
76
+ # nobody in, and must never take the site down. Anything unexpected here is
77
+ # treated as "not remembered", and the cookie is dropped.
78
+ Rails.logger.warn("[bible270] ignoring an unusable remember cookie: #{e.class}: #{e.message}")
79
+ forget_reader!
70
80
  end
71
81
 
72
82
  # Encrypted, so the payload can be neither read nor forged by the client.
@@ -44,7 +44,7 @@ module Bible270
44
44
  private
45
45
 
46
46
  def comment_params
47
- params.require(:comment).permit(:body, :track)
47
+ params.require(:comment).permit(:body, :track, :parent_id)
48
48
  end
49
49
  end
50
50
  end
@@ -24,8 +24,11 @@ module Bible270
24
24
  redirect_to(root_path, alert: 'That day is outside the plan.') and return unless Plan.valid_day?(@day)
25
25
 
26
26
  @readings = Plan.readings_for(@day)
27
- @comments = Comment.for_day(@day).includes(:reader)
28
- @new_comment = Comment.new(day: @day)
27
+ @comments = Comment.threads_for_day(@day)
28
+ # Replying prefills the form with the handle rather than needing JavaScript,
29
+ # so it works with Turbo and without an asset pipeline.
30
+ parent = reply_parent
31
+ @new_comment = Comment.new(day: @day, parent_id: parent&.id, body: reply_prefix(parent))
29
32
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
30
33
 
31
34
  # Every box on the day, not every track: an Old Testament reading of three
@@ -41,5 +44,24 @@ module Bible270
41
44
  @prev_day = @day > 1 ? @day - 1 : nil
42
45
  @next_day = @day < Plan::DAYS ? @day + 1 : nil
43
46
  end
47
+
48
+ private
49
+
50
+ # "@handle " for the reflection being replied to, or nothing. Private, but
51
+ # declared with the keyword rather than trailing the file, so it is obvious
52
+ # this is not an action.
53
+ # The reflection being replied to, if the link carried one and it is a
54
+ # top-level reflection: a reply to a reply is not allowed.
55
+ def reply_parent
56
+ parent = Comment.approved.find_by(id: params[:reply_to])
57
+ parent if parent && parent.day == @day && parent.parent_id.nil?
58
+ end
59
+
60
+ # "@handle " so the author is mentioned — and so gets the email — without the
61
+ # reader having to type it.
62
+ def reply_prefix(parent)
63
+ handle = parent&.reader&.mention_handle
64
+ handle ? "@#{handle} " : nil
65
+ end
44
66
  end
45
67
  end
@@ -12,6 +12,10 @@ module Bible270
12
12
 
13
13
  def update
14
14
  problems = []
15
+ # A checkbox sends nothing when unticked, so absence means off. Set before
16
+ # the name check, so the preference is saved even if a name is rejected.
17
+ current_reader.update_column(:notify_on_mention, params[:notify_on_mention].present?)
18
+
15
19
  problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
16
20
  params[:last_name])
17
21
  if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
@@ -53,6 +53,38 @@ module Bible270
53
53
 
54
54
  # The mark beside the title in the header. Larger than the favicon and inlined
55
55
  # as SVG, so it stays crisp at any size and needs no asset pipeline.
56
+ # Renders "@handle" as a link to that reader. An unresolved or ambiguous handle
57
+ # is left as plain text, which is how a writer discovers it found nobody.
58
+ #
59
+ # Built by walking the matches and escaping everything between them, rather
60
+ # than escaping afterwards: the body is reader-supplied, so the only safe
61
+ # construction is one where every non-link fragment is escaped by hand.
62
+ def b270_with_mentions(body)
63
+ text = body.to_s
64
+ readers = Bible270::Reader.mentioned_in(text)
65
+
66
+ pieces = []
67
+ cursor = 0
68
+
69
+ text.to_enum(:scan, Bible270::Mentions::PATTERN).each do
70
+ match = Regexp.last_match
71
+ pieces << ERB::Util.html_escape(text[cursor...match.begin(0)])
72
+
73
+ handle = Bible270::Mentions.normalize(match[1])
74
+ reader = readers.find { |candidate| candidate.answers_to?(handle) }
75
+ pieces << if reader
76
+ link_to(match[0], reader_path(reader), class: 'b270-mention')
77
+ else
78
+ ERB::Util.html_escape(match[0])
79
+ end
80
+
81
+ cursor = match.end(0)
82
+ end
83
+
84
+ pieces << ERB::Util.html_escape(text[cursor..].to_s)
85
+ safe_join(pieces)
86
+ end
87
+
56
88
  def b270_mark(size: 38)
57
89
  configured = Bible270.config.header_mark
58
90
  return nil if configured == false
@@ -5,19 +5,47 @@ module Bible270
5
5
  class NoticeMailer < ApplicationMailer
6
6
  def new_reader(reader_id:, recipients:)
7
7
  @reader = Reader.find_by(id: reader_id)
8
- return message.perform_deliveries = false if @reader.nil? || recipients.blank?
8
+ # Blanks dropped rather than trusting the caller: [''] is not blank — a
9
+ # one-element array never is — so it would otherwise have been sent to an
10
+ # empty address, which fails at the SMTP server rather than here.
11
+ addresses = Array(recipients).map { |address| address.to_s.strip }.reject(&:empty?)
12
+ return message.perform_deliveries = false if @reader.nil? || addresses.empty?
9
13
 
10
14
  @app_name = Bible270.config.app_name
11
15
  @total = Reader.count
12
16
  @admin_url = admin_url_for(@reader)
13
17
 
14
- mail to: recipients, subject: "#{@app_name}: #{@reader.display_name} has joined"
18
+ mail to: addresses, subject: "#{@app_name}: #{@reader.display_name} has joined"
19
+ end
20
+
21
+ def mentioned(comment_id:, reader_id:)
22
+ @comment = Comment.find_by(id: comment_id)
23
+ @reader = Reader.find_by(id: reader_id)
24
+ return message.perform_deliveries = false if @comment.nil? || @reader.nil? || @reader.email.blank?
25
+
26
+ @author = @comment.reader
27
+ @app_name = Bible270.config.app_name
28
+ @day_url = day_url_for(@comment.day)
29
+
30
+ mail to: @reader.email,
31
+ subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
15
32
  end
16
33
 
17
34
  private
18
35
 
19
36
  # A mailer has no request, so the host is config.mailer_host or nothing. No
20
37
  # link is better than a broken one.
38
+ def day_url_for(day)
39
+ host = Bible270.config.mailer_host
40
+ return nil if host.blank?
41
+
42
+ Bible270::Engine.routes.url_helpers.day_url(
43
+ day, host: host, protocol: host.start_with?('localhost') ? 'http' : 'https'
44
+ )
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
21
49
  def admin_url_for(reader)
22
50
  host = Bible270.config.mailer_host
23
51
  return nil if host.blank?
@@ -7,6 +7,12 @@ module Bible270
7
7
 
8
8
  belongs_to :reader, class_name: 'Bible270::Reader'
9
9
 
10
+ # One level of replies. dependent: :destroy, because a reply to a deleted
11
+ # reflection has nothing left to answer.
12
+ belongs_to :parent, class_name: 'Bible270::Comment', optional: true
13
+ has_many :replies, class_name: 'Bible270::Comment', foreign_key: :parent_id,
14
+ dependent: :destroy, inverse_of: :parent
15
+
10
16
  validates :day, inclusion: { in: 1..Plan::DAYS }
11
17
  validates :body, presence: true, length: { maximum: 4000 }
12
18
  # A reflection can be about one track or about the whole day. The form's
@@ -16,16 +22,29 @@ module Bible270
16
22
  before_validation :normalize_track
17
23
 
18
24
  validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_blank: true
25
+ validate :parent_is_a_top_level_reflection
26
+ validate :parent_is_on_the_same_day
27
+
28
+ # Fires once the reflection is really saved, so nothing is sent for one whose
29
+ # transaction rolls back.
30
+ after_create_commit :notify_mentioned_readers
19
31
 
20
32
  scope :approved, -> { where(approved: true) }
21
33
  scope :hidden, -> { where(approved: false) }
22
- scope :for_day, ->(d) { approved.where(day: d).order(created_at: :asc) }
23
- scope :recent, -> { approved.order(created_at: :desc) }
34
+ # Only the reflections themselves: replies are rendered under their parent, so
35
+ # listing them again at the top level would show each twice.
36
+ scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
37
+ scope :threads_for_day, ->(d) { for_day(d).includes(:reader, replies: :reader) }
38
+ scope :recent, -> { approved.order(created_at: :desc) }
24
39
 
25
40
  # Every reflection is visible when written; these are the moderation actions.
26
41
  def hide! = update!(approved: false, moderated_at: Time.current)
27
42
  def unhide! = update!(approved: true, moderated_at: Time.current)
28
43
  def hidden? = !approved
44
+ def reply? = parent_id.present?
45
+
46
+ # Visible replies, oldest first: a conversation reads forwards.
47
+ def visible_replies = replies.approved.order(created_at: :asc)
29
48
  def moderated? = moderated_at.present?
30
49
 
31
50
  private
@@ -33,5 +52,36 @@ module Bible270
33
52
  def normalize_track
34
53
  self.track = Plan.normalize_track(track)
35
54
  end
55
+
56
+ # A reply to a reply would make a tree, and with it the questions of how deep
57
+ # to indent and what hiding a middle node means. One level avoids all of that.
58
+ def parent_is_a_top_level_reflection
59
+ return if parent.nil?
60
+ return if parent.parent_id.nil?
61
+
62
+ errors.add(:parent, 'is itself a reply')
63
+ end
64
+
65
+ def parent_is_on_the_same_day
66
+ return if parent.nil? || parent.day == day
67
+
68
+ errors.add(:parent, "is on day #{parent.day}, not #{day}")
69
+ end
70
+
71
+ # A mention emails the person named. Failures are logged rather than raised:
72
+ # nobody should lose a reflection because the mail server is down.
73
+ def notify_mentioned_readers
74
+ return unless Bible270.config.mention_notifications
75
+
76
+ Reader.mentioned_in(body).each do |mentioned|
77
+ next if mentioned.id == reader_id
78
+ next unless mentioned.wants_mention_notices?
79
+
80
+ notice = NoticeMailer.mentioned(comment_id: id, reader_id: mentioned.id)
81
+ Bible270.config.registration_notice_deliver_later ? notice.deliver_later : notice.deliver_now
82
+ end
83
+ rescue StandardError => e
84
+ Rails.logger.error("[bible270] could not send mention notices for comment #{id}: #{e.class}: #{e.message}")
85
+ end
36
86
  end
37
87
  end
@@ -144,6 +144,48 @@ module Bible270
144
144
  display_name.to_s.strip.downcase
145
145
  end
146
146
 
147
+ # ---- mentions ----------------------------------------------------------
148
+
149
+ # The handle to write when mentioning this reader: their first name, unless
150
+ # someone else shares it, in which case first.last.
151
+ def mention_handle
152
+ first = first_name.presence || display_name.to_s.split.first
153
+ return nil if first.blank?
154
+
155
+ Mentions.preferred_handle(first, last_name, ambiguous: self.class.shared_first_name?(first, self))
156
+ end
157
+
158
+ def self.shared_first_name?(first, except = nil)
159
+ scope = where('LOWER(first_name) = ?', first.to_s.downcase)
160
+ scope = scope.where.not(id: except.id) if except&.id
161
+ scope.exists?
162
+ end
163
+
164
+ # The readers a piece of text mentions. A handle matching more than one reader
165
+ # resolves to nobody: mailing the wrong person is worse than mailing none, and
166
+ # the writer sees the mention left unlinked.
167
+ def self.mentioned_in(text)
168
+ handles = Mentions.extract(text)
169
+ return [] if handles.empty?
170
+
171
+ candidates = where.not(first_name: [nil, '']).to_a
172
+ handles.filter_map do |handle|
173
+ matches = candidates.select { |reader| reader.answers_to?(handle) }
174
+ matches.first if matches.one?
175
+ end.uniq
176
+ end
177
+
178
+ def answers_to?(handle)
179
+ Mentions.handles_for(first_name, last_name).include?(handle)
180
+ end
181
+
182
+ def wants_mention_notices?
183
+ return false if email.blank?
184
+ return true unless respond_to?(:notify_on_mention)
185
+
186
+ notify_on_mention != false
187
+ end
188
+
147
189
  # ---- staying signed in -------------------------------------------------
148
190
 
149
191
  # Generated on first use rather than at sign-up, so readers who never stay
@@ -19,6 +19,7 @@
19
19
  <strong><%= comment.reader.display_name %></strong>
20
20
  · <%= link_to "day #{comment.day}", day_path(comment.day) %>
21
21
  · <%= comment.created_at.strftime('%b %-d, %Y') %>
22
+ <% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
22
23
  <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
23
24
  </div>
24
25
  <p class="b270-cbody"><%= comment.body %></p>
@@ -1,5 +1,6 @@
1
- <div class="b270-comment" id="comment-<%= comment.id %>">
2
- <%= b270_avatar(comment.reader) %>
1
+ <%# locals: (comment:, reply: false) %>
2
+ <div class="b270-comment <%= 'b270-reply' if reply %>" id="comment-<%= comment.id %>">
3
+ <%= b270_avatar(comment.reader, size: reply ? 28 : 34) %>
3
4
  <div style="flex:1">
4
5
  <div class="b270-cmeta">
5
6
  <strong><%= link_to comment.reader.display_name, reader_path(comment.reader), style: "text-decoration:none" %></strong>
@@ -10,6 +11,23 @@
10
11
  form: { style: "display:inline", data: { turbo_stream: true } } %>
11
12
  <% end %>
12
13
  </div>
13
- <p class="b270-cbody"><%= comment.body %></p>
14
+
15
+ <p class="b270-cbody"><%= b270_with_mentions(comment.body) %></p>
16
+
17
+ <%# Only a top-level reflection can be replied to: one level deep, so a reply
18
+ offers no Reply of its own. %>
19
+ <% unless reply %>
20
+ <% if signed_in? && comment.reader_id != current_reader.id %>
21
+ <div class="b270-creply">
22
+ <%= link_to "Reply", day_path(comment.day, reply_to: comment.id, anchor: "new_comment_form"),
23
+ class: "b270-linkbtn" %>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div class="b270-replies" id="replies-<%= comment.id %>">
28
+ <%= render partial: "bible270/comments/comment",
29
+ collection: comment.visible_replies, as: :comment, locals: { reply: true } %>
30
+ </div>
31
+ <% end %>
14
32
  </div>
15
33
  </div>
@@ -3,7 +3,14 @@
3
3
  <% if comment.errors.any? %>
4
4
  <p style="color:var(--oxblood);font-size:13px"><%= comment.errors.full_messages.to_sentence %></p>
5
5
  <% end %>
6
- <%= f.text_area :body, placeholder: "Share a reflection on today's reading…" %>
6
+ <% if comment.parent_id.present? && (parent = Bible270::Comment.find_by(id: comment.parent_id)) %>
7
+ <p class="b270-replying">
8
+ Replying to <strong><%= parent.reader.display_name %></strong>
9
+ · <%= link_to "cancel", day_path(day, anchor: "new_comment_form") %>
10
+ </p>
11
+ <% end %>
12
+ <%= f.hidden_field :parent_id %>
13
+ <%= f.text_area :body, placeholder: comment.parent_id.present? ? "Write your reply…" : "Share a reflection on today's reading…" %>
7
14
  <div class="row">
8
15
  <%= f.select :track,
9
16
  options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] }),
@@ -1,7 +1,16 @@
1
1
  <%= turbo_stream.remove "no_comments" %>
2
- <%= turbo_stream.append "comments" do %>
3
- <%= render partial: "bible270/comments/comment", locals: { comment: @comment } %>
2
+
3
+ <%# A reply belongs under its parent; a new reflection goes at the end. %>
4
+ <% if @comment.reply? %>
5
+ <%= turbo_stream.append "replies-#{@comment.parent_id}" do %>
6
+ <%= render partial: "bible270/comments/comment", locals: { comment: @comment, reply: true } %>
7
+ <% end %>
8
+ <% else %>
9
+ <%= turbo_stream.append "comments" do %>
10
+ <%= render partial: "bible270/comments/comment", locals: { comment: @comment, reply: false } %>
11
+ <% end %>
4
12
  <% end %>
13
+
5
14
  <%= turbo_stream.replace "new_comment_form" do %>
6
15
  <%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day) %>
7
16
  <% end %>
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <h1 style="margin:0 0 6px;font-family:Georgia,serif;font-size:20px;font-weight:600">
6
+ <%= @author.display_name %> mentioned you
7
+ </h1>
8
+ <p style="margin:0 0 18px;font-size:14px;color:#6d6558">
9
+ Day <%= @comment.day %> · <%= @app_name %>
10
+ </p>
11
+
12
+ <blockquote style="margin:0;padding:14px 16px;background:#ece6da;border-left:3px solid #a5812c;border-radius:8px;font-size:15px;line-height:1.55">
13
+ <%= simple_format(@comment.body) %>
14
+ </blockquote>
15
+
16
+ <% if @day_url %>
17
+ <p style="margin:20px 0 0">
18
+ <a href="<%= @day_url %>" style="display:inline-block;background:#231f18;color:#ece6da;text-decoration:none;padding:10px 18px;border-radius:10px;font-weight:600;font-size:14px">Read it and reply</a>
19
+ </p>
20
+ <% end %>
21
+
22
+ <p style="margin:22px 0 0;font-size:12px;color:#9a9082">
23
+ You are getting this because someone wrote your name in a reflection.
24
+ You can turn these off on your profile page.
25
+ </p>
26
+ </div>
27
+ </body>
28
+ </html>
@@ -0,0 +1,12 @@
1
+ <%= @author.display_name %> mentioned you in a reflection on day <%= @comment.day %>.
2
+
3
+ <%= @comment.body %>
4
+
5
+ <% if @day_url %>
6
+ Read it and reply: <%= @day_url %>
7
+ <% else %>
8
+ You can find it on day <%= @comment.day %> of <%= @app_name %>.
9
+ <% end %>
10
+ --
11
+ You are getting this because someone wrote your name in a reflection. You can
12
+ turn these off on your profile page.
@@ -19,6 +19,11 @@
19
19
  <% end %>
20
20
  </div>
21
21
 
22
+ <label class="b270-field" style="flex-direction:row;align-items:center;gap:10px">
23
+ <%= check_box_tag :notify_on_mention, "1", current_reader.notify_on_mention %>
24
+ <span style="margin:0">Email me when someone mentions me in a reflection</span>
25
+ </label>
26
+
22
27
  <label class="b270-field">
23
28
  <span>First name</span>
24
29
  <%= text_field_tag :first_name, @names[:first_name], required: true, autocomplete: "given-name" %>
@@ -4,7 +4,7 @@
4
4
  --line:#d7cfbf; --line-soft:#e2dbcd; --teal:#2f6a67; --oxblood:#8a2f3b; --gold:#a5812c;
5
5
  --gold-lite:#c6a24a; --shadow:0 1px 0 rgba(35,31,24,.04),0 10px 30px -18px rgba(35,31,24,.35);
6
6
  margin:0; background:radial-gradient(120% 80% at 100% 0%,rgba(165,129,44,.06),transparent 55%),var(--paper);
7
- color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.5; -webkit-font-smoothing:antialiased;
7
+ color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.2; -webkit-font-smoothing:antialiased;
8
8
  }
9
9
  .b270 *{box-sizing:border-box}
10
10
  /* Blanket anchor colour. NB: this is (0,1,1), so any single-class rule
@@ -151,6 +151,17 @@
151
151
  .b270-comment:first-child{border-top:none}
152
152
  .b270-cmeta{font-size:13px;color:var(--muted)}
153
153
  .b270-cmeta strong{color:var(--ink)}
154
+ /* Replies sit indented under their parent, with a rule down the left so a
155
+ thread reads as one block rather than a run of separate reflections. */
156
+ .b270-replies{margin:10px 0 0}
157
+ .b270-replies:empty{margin:0}
158
+ .b270-comment.b270-reply{padding:10px 0 0 16px;border-top:none;border-left:2px solid var(--line-soft);gap:10px}
159
+ .b270-comment.b270-reply .b270-cbody{font-size:14.5px}
160
+ .b270-replying{margin:0 0 8px;font-size:13px;color:var(--muted)}
161
+ .b270-replying strong{color:var(--ink)}
162
+ .b270-mention{color:var(--teal);font-weight:600;text-decoration:none}
163
+ .b270-mention:hover{text-decoration:underline}
164
+ .b270-creply{margin-top:6px}
154
165
  .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
155
166
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
156
167
  .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}
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMentionNoticesToBible270Readers < ActiveRecord::Migration[7.0]
4
+ # Being emailed because someone typed your name is the sort of thing people
5
+ # want to switch off, so it is a per-reader setting rather than a site-wide one.
6
+ # Default true: a mention nobody hears about is not much use.
7
+ def change
8
+ add_column :bible270_readers, :notify_on_mention, :boolean, null: false, default: true
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddParentToBible270Comments < ActiveRecord::Migration[7.0]
4
+ # Replies. One level only: a reply cannot itself be replied to, which keeps the
5
+ # rendering and the moderation story simple — a thread is a reflection and the
6
+ # answers to it, not a tree.
7
+ def change
8
+ add_reference :bible270_comments, :parent, null: true, index: { name: 'idx_b270_comments_parent' },
9
+ foreign_key: { to_table: :bible270_comments }
10
+ end
11
+ end
@@ -329,6 +329,10 @@ module Bible270
329
329
  # config.time_zone = 'America/Los_Angeles' # a named zone
330
330
  attr_accessor :time_zone
331
331
 
332
+ # Email a reader when someone writes "@them" in a reflection. Readers can opt
333
+ # out individually on their profile; this switches the whole feature off.
334
+ attr_accessor :mention_notifications
335
+
332
336
  def remember_signed_in?
333
337
  remember_for.to_i.positive?
334
338
  end
@@ -350,6 +354,7 @@ module Bible270
350
354
  @app_name = 'Daily Bread'
351
355
  @tagline = 'Journeying through Scripture in 9 months'
352
356
  @time_zone = nil
357
+ @mention_notifications = true
353
358
  @header_mark = nil
354
359
  @remember_for = 365 * 24 * 60 * 60
355
360
  @require_sign_in_to_participate = true
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Finding "@someone" in a reflection, and deciding who that is.
5
+ #
6
+ # There is no username field, so mentions match against the names readers
7
+ # already have. Two forms are accepted, both case-insensitively:
8
+ #
9
+ # @andrew a first name
10
+ # @andrew.vonderluft first and last, joined by a dot
11
+ #
12
+ # A mention that matches more than one reader resolves to nobody. Guessing
13
+ # would mean mailing the wrong person, and silence is easier to notice than a
14
+ # misdelivered message: the writer sees their mention unlinked.
15
+ module Mentions
16
+ # Deliberately narrow: letters, digits, dots, hyphens and apostrophes, so
17
+ # "@andrew," and "@andrew." at the end of a sentence still work, and an email
18
+ # address in the body is not mistaken for a mention.
19
+ PATTERN = %r{(?<![\w@])@([\p{L}\p{N}][\p{L}\p{N}.'-]{0,60})}
20
+
21
+ MAX_PER_COMMENT = 10
22
+
23
+ module_function
24
+
25
+ # The handles written in a piece of text, normalised and de-duplicated.
26
+ def extract(text)
27
+ return [] if text.nil?
28
+
29
+ text.to_s.scan(PATTERN).flatten
30
+ .map { |handle| normalize(handle) }
31
+ .reject(&:empty?)
32
+ .uniq
33
+ .first(MAX_PER_COMMENT)
34
+ end
35
+
36
+ # Trailing punctuation is dropped so "@andrew." and "@andrew" agree.
37
+ def normalize(handle)
38
+ handle.to_s.downcase.gsub(%r{[^\p{L}\p{N}.'-]}, '').sub(%r{[.'-]+\z}, '')
39
+ end
40
+
41
+ # Every handle a reader answers to, given their names.
42
+ def handles_for(first_name, last_name)
43
+ first = normalize(first_name)
44
+ last = normalize(last_name)
45
+ return [] if first.empty?
46
+
47
+ handles = [first]
48
+ handles << "#{first}.#{last}" unless last.empty?
49
+ handles.uniq
50
+ end
51
+
52
+ # The form to write when there is more than one reader with the same first
53
+ # name: the longer handle is unambiguous.
54
+ def preferred_handle(first_name, last_name, ambiguous: false)
55
+ handles = handles_for(first_name, last_name)
56
+ return nil if handles.empty?
57
+
58
+ ambiguous ? handles.last : handles.first
59
+ end
60
+
61
+ # Wraps each mention so a view can style it, leaving the text otherwise
62
+ # untouched. The block receives the handle and returns the replacement.
63
+ def highlight(text)
64
+ return text.to_s if text.nil?
65
+
66
+ text.to_s.gsub(PATTERN) do
67
+ handle = Regexp.last_match(1)
68
+ yield(normalize(handle), "@#{handle}")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.15.3'
4
+ VERSION = '0.16.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -6,6 +6,7 @@ require 'bible270/version'
6
6
  require 'bible270/plan'
7
7
  require 'bible270/email_sign_in'
8
8
  require 'bible270/names'
9
+ require 'bible270/mentions'
9
10
  require 'bible270/avatars'
10
11
  require 'bible270/translations'
11
12
  require 'bible270/favicon'
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: 0.15.3
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -110,6 +110,8 @@ files:
110
110
  - app/views/bible270/days/_start_date.html.erb
111
111
  - app/views/bible270/days/index.html.erb
112
112
  - app/views/bible270/days/show.html.erb
113
+ - app/views/bible270/notice_mailer/mentioned.html.erb
114
+ - app/views/bible270/notice_mailer/mentioned.text.erb
113
115
  - app/views/bible270/notice_mailer/new_reader.html.erb
114
116
  - app/views/bible270/notice_mailer/new_reader.text.erb
115
117
  - app/views/bible270/profiles/edit.html.erb
@@ -138,12 +140,15 @@ files:
138
140
  - db/migrate/20260101000008_add_bible_version_to_bible270_readers.rb
139
141
  - db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb
140
142
  - db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
143
+ - db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb
144
+ - db/migrate/20260101000012_add_parent_to_bible270_comments.rb
141
145
  - lib/bible270.rb
142
146
  - lib/bible270/avatars.rb
143
147
  - lib/bible270/configuration.rb
144
148
  - lib/bible270/email_sign_in.rb
145
149
  - lib/bible270/engine.rb
146
150
  - lib/bible270/favicon.rb
151
+ - lib/bible270/mentions.rb
147
152
  - lib/bible270/names.rb
148
153
  - lib/bible270/plan.rb
149
154
  - lib/bible270/translations.rb