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 +4 -4
- data/CHANGELOG.md +13 -19
- data/app/controllers/bible270/admin_controller.rb +1 -1
- data/app/controllers/bible270/application_controller.rb +12 -2
- data/app/controllers/bible270/comments_controller.rb +1 -1
- data/app/controllers/bible270/days_controller.rb +24 -2
- data/app/controllers/bible270/profiles_controller.rb +4 -0
- data/app/helpers/bible270/plan_helper.rb +32 -0
- data/app/mailers/bible270/notice_mailer.rb +30 -2
- data/app/models/bible270/comment.rb +52 -2
- data/app/models/bible270/reader.rb +42 -0
- data/app/views/bible270/admin/comments.html.erb +1 -0
- data/app/views/bible270/comments/_comment.html.erb +21 -3
- data/app/views/bible270/comments/_form.html.erb +8 -1
- data/app/views/bible270/comments/create.turbo_stream.erb +11 -2
- data/app/views/bible270/notice_mailer/mentioned.html.erb +28 -0
- data/app/views/bible270/notice_mailer/mentioned.text.erb +12 -0
- data/app/views/bible270/profiles/edit.html.erb +5 -0
- data/app/views/bible270/shared/_styles.html.erb +12 -1
- data/db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb +10 -0
- data/db/migrate/20260101000012_add_parent_to_bible270_comments.rb +11 -0
- data/lib/bible270/configuration.rb +5 -0
- data/lib/bible270/mentions.rb +72 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3190cb926559301bfff8450c5e4f3d32867d84528c795782f33786c8f49b5a9f
|
|
4
|
+
data.tar.gz: df04e7e7a97b995c86c3440a81bea7db90360617cb990bc60975aca5d11e1669
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
@@ -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.
|
|
28
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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.
|
|
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
|
data/lib/bible270/version.rb
CHANGED
data/lib/bible270.rb
CHANGED
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.
|
|
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
|