bible270 1.4.1 → 1.5.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -3
  3. data/README.md +11 -3
  4. data/app/controllers/bible270/admin_controller.rb +19 -2
  5. data/app/controllers/bible270/checkoffs_controller.rb +20 -16
  6. data/app/controllers/bible270/comments_controller.rb +11 -1
  7. data/app/controllers/bible270/days_controller.rb +3 -10
  8. data/app/helpers/bible270/plan_helper.rb +33 -27
  9. data/app/mailers/bible270/application_mailer.rb +1 -0
  10. data/app/models/bible270/comment.rb +20 -0
  11. data/app/models/bible270/reader.rb +29 -0
  12. data/app/views/bible270/admin/comments.html.erb +1 -1
  13. data/app/views/bible270/admin/show.html.erb +23 -0
  14. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +1 -1
  15. data/app/views/bible270/comments/_body.html.erb +6 -0
  16. data/app/views/bible270/comments/_comment.html.erb +1 -1
  17. data/app/views/bible270/comments/_edit_fields.html.erb +2 -1
  18. data/app/views/bible270/comments/_editor.html.erb +57 -0
  19. data/app/views/bible270/comments/_form.html.erb +3 -12
  20. data/app/views/bible270/comments/create.turbo_stream.erb +2 -1
  21. data/app/views/bible270/days/_completers.html.erb +2 -3
  22. data/app/views/bible270/days/_day_progress.html.erb +4 -2
  23. data/app/views/bible270/days/show.html.erb +4 -3
  24. data/app/views/bible270/notice_mailer/comment_notice.html.erb +1 -1
  25. data/app/views/bible270/notice_mailer/comment_notice.text.erb +1 -1
  26. data/app/views/bible270/shared/_comment_drafts.html.erb +9 -1
  27. data/app/views/bible270/shared/_day_completion_dove.html.erb +7 -5
  28. data/app/views/bible270/shared/_interaction_ui.html.erb +62 -1
  29. data/app/views/bible270/shared/_markdown_toolbar.html.erb +239 -0
  30. data/app/views/bible270/shared/_reflections.html.erb +1 -1
  31. data/app/views/bible270/shared/_styles.html.erb +40 -7
  32. data/config/routes.rb +4 -0
  33. data/db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb +7 -0
  34. data/db/migrate/20260101000021_add_body_format_to_bible270_comments.rb +11 -0
  35. data/lib/bible270/comment_formatter.rb +135 -0
  36. data/lib/bible270/migration_reconciler.rb +17 -0
  37. data/lib/bible270/version.rb +1 -1
  38. data/lib/bible270.rb +1 -0
  39. metadata +41 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcb59bf731c08118644e9303ed749397492c6c7fea523a20cf4eb3c17b84281f
4
- data.tar.gz: e1f6b1be1b245e859370ae745eb05f8adf47df953f89ad7cb28c700ad705830c
3
+ metadata.gz: 7867bb4de49fe177447a5f7b1cfa9fa031e1926cdb8c4c73f2071eb8a7620c82
4
+ data.tar.gz: '0499e0931abdd3890f07945f7fc82f6de8dc5da1a156fbb8147e1b5b2be79596'
5
5
  SHA512:
6
- metadata.gz: 312efc5cd9bfcb503430d1adc601b21a93658c9d378060182d3453b747d43674a736a83b753accaf0f0d738b0da005656c87490f17a51814800b3798632c09ab
7
- data.tar.gz: c45129d6950f6b40f8bb0477b9de95ed9e98bb5ad3c1f9fa36bfb1b44eb5c1b26e337fc9d31e7dc9554115f2c4ecde14427127333bf2287925a0c0a33457b470
6
+ metadata.gz: 422848afb4e96f1a94cbfc26078f7c343afdb519e431c25aa6f2de10be0578d51fa65aa6d04683490b792685d5888474b8f711f0e1839ccd6ccaeb45c52c26d0
7
+ data.tar.gz: 81cd1f5df1028fa4064bc77ec5ee20866406d21c1ae5d4d992e8137bde59e5c6d6c505c7fc92b85111c931b7450da60b125692cb0bacbe63652e63012f9257da
data/CHANGELOG.md CHANGED
@@ -2,13 +2,52 @@
2
2
 
3
3
  All notable changes to bible270. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), treating pre-1.0 minor bumps as the place breaking changes may land.
4
4
 
5
- ## Unreleased Changes
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+
9
+ - add an on-demand Markdown formatting drawer and separate server-rendered Preview control for reflections and replies, with safe rendering, formatted notification emails, mention and browser-draft compatibility, and literal display of existing plain-text reflections; upgrading hosts must copy and run the new `body_format` comment migration
10
+
11
+ ### Fixed
12
+
13
+ - keep `json` below 3.0 while supported Rails releases still pass positional parse options, preventing session-cookie decoding from crashing every request under `json` 3.0
14
+
15
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
16
+
17
+ ## [1.4.2] - 2026-09-03
18
+
19
+ ### Added
20
+
21
+ - let administrators disable the day-completion dove animation for individual readers without affecting progress tracking; upgrading hosts must copy and run the new `completion_dove_disabled` reader migration before changing this setting
6
22
 
7
23
  ### Changed
8
24
 
9
- - simplify day completion to one continuous server-confirmed dove flight from the lower center, eliminating the stagger between a pre-submit animation and the Turbo or HTML response
25
+ - keep the completion dove bright in dark mode, grow it to an 80%-of-viewport fit with a smoother rounded silhouette, and finish with a centered fade instead of veering offscreen
26
+ - show every reader who finished a day after the Reflections section, using compact 56px avatars ordered from earliest to latest completion
27
+
28
+ ### Fixed
29
+
30
+ - show the dove only once for each persisted day completion, including during concurrent final checkoffs, Turbo response replays, and stale-session retries
31
+
32
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.1...v1.4.2)**
33
+
34
+ ## [1.4.1] - 2026-09-02
35
+
36
+ ### Changed
37
+
38
+ - run the day-completion dove as one continuous, server-confirmed animation from the lower center, avoiding a stagger between submission and the Turbo or HTML response
39
+ - improve installer guidance for OmniAuth providers, deferred bundling, Active Storage, pending migrations, and OAuth callback setup
40
+ - detect existing engine mounts more reliably and move them above catch-all routes when necessary
41
+
42
+ ### Fixed
43
+
44
+ - reject invalid enrollment states and malformed or out-of-range admin day values instead of silently interpreting them
45
+ - apply admin profile name and avatar changes atomically, preventing partial updates when validation fails
46
+ - report broadcast queueing, delivery failures, and partial success accurately without recording an entirely failed broadcast as sent
47
+ - restrict reflection return redirects to safe application URLs
48
+ - honor the install generator's `--mailer-from` option, reject unsafe provider identifiers, and safely quote generated configuration values
10
49
 
11
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...main)**
50
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...v1.4.1)**
12
51
 
13
52
  ## [1.4.0] - 2026-09-02
14
53
 
@@ -622,6 +661,9 @@ This is the initial public release.
622
661
 
623
662
  * 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.
624
663
 
664
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
665
+ [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
666
+ [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
625
667
  [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
626
668
  [1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
627
669
  [1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Bible 270
2
2
 
3
- [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://coveralls.io/repos/github/avonderluft/bible270/badge.svg?branch=main&cache=20260812-1)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/avonderluft/bible270?branch=main&cacheSeconds=300)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
4
 
5
5
  A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
6
 
@@ -283,7 +283,14 @@ filters, and paginates older threads. Signed-in readers see which conversations
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
285
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
286
- work without JavaScript.
286
+ work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
287
+ quotations, bulleted and numbered lists, and links without requiring readers to know Markdown syntax.
288
+ A separate **Preview** control checks the current draft through the same server-side sanitizer used
289
+ after posting; formatting tools stay out of sight until requested. The source remains
290
+ editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
291
+ set. Existing reflections remain literal plain text until a reader intentionally formats or converts
292
+ them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
293
+ plain text or Markdown; until then, the composer safely falls back to plain text.
287
294
 
288
295
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
289
296
  and reply, only replies to their reflections and mentions of them, or none. Administrators can change
@@ -483,7 +490,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
483
490
  |-------|-------|
484
491
  | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
485
492
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
486
- | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
493
+ | `bible270_comments` | a plain-text or safely rendered Markdown reflection on a day, optionally scoped to a track, public to all |
487
494
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
488
495
 
489
496
  A day is "complete" once all three tracks are ticked. Progress and comments are **public** by
@@ -496,6 +503,7 @@ GET / days#index overview + community + calendar
496
503
  GET /day/:day days#show readings, who's finished, reflections
497
504
  POST /day/:day/toggle/:track checkoffs#toggle
498
505
  POST /day/:day/comments comments#create
506
+ POST /comments/preview comments#preview
499
507
  DELETE /comments/:id comments#destroy
500
508
  GET /community readers#index reader directory
501
509
  GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
@@ -13,8 +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 update_notifications remove_avatar complete_through
17
- toggle_day]
16
+ only: %i[show destroy update_start update_profile update_notifications update_completion_animation
17
+ remove_avatar complete_through toggle_day]
18
18
  before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
19
19
 
20
20
  # ---- enrolment --------------------------------------------------------
@@ -74,6 +74,7 @@ module Bible270
74
74
 
75
75
  def show
76
76
  @comment_notifications_available = comment_notifications_available_for?(@reader)
77
+ @completion_dove_preference_available = completion_dove_available_for(@reader)
77
78
  @days_completed = @reader.days_completed
78
79
  @comments = @reader.comments.order(created_at: :desc).limit(50)
79
80
  end
@@ -236,6 +237,16 @@ module Bible270
236
237
  redirect_to admin_reader_path(@reader), notice: 'Reflection email setting updated.'
237
238
  end
238
239
 
240
+ def update_completion_animation
241
+ unless completion_dove_available_for(@reader)
242
+ alert = 'Run the pending Bible270 database migration before changing the completion animation.'
243
+ return redirect_to(admin_reader_path(@reader), alert: alert)
244
+ end
245
+
246
+ @reader.update!(completion_dove_disabled: params[:completion_dove_disabled] == '1')
247
+ redirect_to admin_reader_path(@reader), notice: 'Completion animation setting updated.'
248
+ end
249
+
239
250
  def remove_avatar
240
251
  @reader.remove_avatar!
241
252
  redirect_to admin_reader_path(@reader), notice: "Removed #{@reader.display_name}'s picture."
@@ -298,6 +309,12 @@ module Bible270
298
309
  true
299
310
  end
300
311
 
312
+ def completion_dove_available_for(reader)
313
+ available = Reader.completion_dove_column?
314
+ reader.reload if available && !reader.has_attribute?('completion_dove_disabled')
315
+ available
316
+ end
317
+
301
318
  def require_admin!
302
319
  return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
303
320
 
@@ -18,25 +18,27 @@ module Bible270
18
18
  requested_state = params[:checked] if params.key?(:checked)
19
19
  head :bad_request and return if params.key?(:checked) && !%w[0 1].include?(requested_state)
20
20
 
21
- marked_read, checkoff_changed = apply_checkoff(requested_state)
22
-
23
- @reader = current_reader.reload.reload_progress
24
- @day_just_completed = marked_read && checkoff_changed && @reader.day_complete?(@day)
21
+ # Serialize this reader's transitions so two different final portions
22
+ # submitted concurrently cannot both announce the same day completion.
23
+ marked_read = nil
24
+ current_reader.with_lock do
25
+ marked_read, @completion_event_id = apply_checkoff(requested_state)
26
+ @reader = current_reader.reload.reload_progress
27
+ @day_just_completed = marked_read && @completion_event_id && @reader.day_complete?(@day)
28
+ end
25
29
  @reader_tracks = @reader.read_tracks_for(@day)
26
30
  @readings = Plan.readings_for(@day)
27
31
 
28
- required = Plan.total_parts(@day)
29
- completer_ids = Checkoff.where(day: @day)
30
- .group(:reader_id)
31
- .having('COUNT(*) >= ?', required)
32
- .pluck(:reader_id)
33
- @completers = Reader.where(id: completer_ids).order(:display_name)
32
+ @completers = Reader.completers_for(@day)
34
33
 
35
34
  respond_to do |format|
36
35
  format.turbo_stream
37
36
  format.html do
38
37
  if @day_just_completed
39
- redirect_to day_path(@day), flash: { b270_day_just_completed: @day }
38
+ redirect_to day_path(@day), flash: {
39
+ b270_day_just_completed: @day,
40
+ b270_completion_event_id: @completion_event_id
41
+ }
40
42
  else
41
43
  message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
42
44
  redirect_to day_path(@day), flash: { b270_interaction_status: message }
@@ -51,9 +53,10 @@ module Bible270
51
53
  existing = find_checkoff
52
54
  if requested_state == '1'
53
55
  current_reader.ensure_started!
54
- [true, existing.nil? && create_checkoff]
56
+ [true, existing.nil? ? create_checkoff : nil]
55
57
  elsif requested_state == '0' || existing
56
- [false, existing ? existing.destroy.present? : false]
58
+ existing&.destroy
59
+ [false, nil]
57
60
  else
58
61
  current_reader.ensure_started!
59
62
  [true, create_checkoff]
@@ -66,15 +69,16 @@ module Bible270
66
69
  end
67
70
  end
68
71
 
72
+ # The created id is stable if Turbo replays this response, while a later
73
+ # re-completion creates a new id and is correctly treated as a new event.
69
74
  def create_checkoff
70
- Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part)
71
- true
75
+ Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part).id
72
76
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
73
77
  # A repeated or concurrent desired-state request is already successful. Do
74
78
  # not hide any other validation failure under the same recovery.
75
79
  raise unless find_checkoff
76
80
 
77
- false
81
+ nil
78
82
  end
79
83
  end
80
84
  end
@@ -11,6 +11,14 @@ module Bible270
11
11
  current_reader&.mark_reflections_seen!(visited_at)
12
12
  end
13
13
 
14
+ def preview
15
+ return unless require_reader!
16
+
17
+ attributes = { body: params.dig(:comment, :body).to_s }
18
+ attributes[:body_format] = CommentFormatter::MARKDOWN if Comment.body_format_column?
19
+ render partial: 'bible270/comments/body', locals: { comment: Comment.new(attributes) }
20
+ end
21
+
14
22
  def create
15
23
  return unless require_reader!
16
24
 
@@ -134,7 +142,9 @@ module Bible270
134
142
  end
135
143
 
136
144
  def comment_params
137
- params.require(:comment).permit(:body, :track, :parent_id)
145
+ permitted = %i[body track parent_id]
146
+ permitted << :body_format if Comment.body_format_column?
147
+ params.require(:comment).permit(*permitted)
138
148
  end
139
149
 
140
150
  # Scoped to the reader, so someone else's reflection is simply not found —
@@ -18,19 +18,12 @@ module Bible270
18
18
  @readings = Plan.readings_for(@day)
19
19
  @comments = Comment.threads_for_day(@day)
20
20
  @editing_comment_id = editing_comment_id
21
- @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
21
+ @new_comment = Comment.new_for_composer(day: @day, parent_id: reply_parent&.id)
22
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
23
23
  @day_just_completed = flash[:b270_day_just_completed].to_i == @day
24
+ @completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
24
25
 
25
- # Every box on the day, not every track: an Old Testament reading of three
26
- # chapters is three rows, so counting tracks would call someone finished
27
- # who had only read the Old Testament.
28
- required = Plan.total_parts(@day)
29
- completer_ids = Checkoff.where(day: @day)
30
- .group(:reader_id)
31
- .having('COUNT(*) >= ?', required)
32
- .pluck(:reader_id)
33
- @completers = Reader.where(id: completer_ids).order(:display_name)
26
+ @completers = Reader.completers_for(@day)
34
27
  end
35
28
 
36
29
  private
@@ -53,40 +53,46 @@ module Bible270
53
53
  end
54
54
  end
55
55
 
56
- # The mark beside the title in the header. Larger than the favicon and inlined
57
- # as SVG, so it stays crisp at any size and needs no asset pipeline.
58
- # Renders "@handle" as a link to that reader. An unresolved or ambiguous handle
59
- # is left as plain text, which is how a writer discovers it found nobody.
60
- #
61
- # Built by walking the matches and escaping everything between them, rather
62
- # than escaping afterwards: the body is reader-supplied, so the only safe
63
- # construction is one where every non-link fragment is escaped by hand.
64
- def b270_with_mentions(body)
65
- text = body.to_s
66
- readers = Bible270::Reader.mentioned_in(text)
56
+ # Formats every reflection surface through the same sanitizer. Web views link
57
+ # resolved mentions; email can opt out because it has no mounted request path.
58
+ def b270_comment_body(comment, link_mentions: true)
59
+ format = comment.rendered_body_format
60
+ html = if link_mentions
61
+ readers = Bible270::Reader.mentioned_in(comment.body)
62
+ Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
63
+ reader = readers.find { |candidate| candidate.answers_to?(handle) }
64
+ reader_path(reader) if reader
65
+ end
66
+ else
67
+ Bible270::CommentFormatter.html(comment.body, format: format)
68
+ end
69
+ html.html_safe
70
+ end
67
71
 
68
- pieces = []
69
- cursor = 0
72
+ def b270_comment_text(comment)
73
+ Bible270::CommentFormatter.text(comment.body, format: comment.rendered_body_format)
74
+ end
70
75
 
71
- text.to_enum(:scan, Bible270::Mentions::PATTERN).each do
72
- match = Regexp.last_match
73
- pieces << ERB::Util.html_escape(text[cursor...match.begin(0)])
76
+ def b270_editor_rows(body, columns: 48, minimum: 4)
77
+ rows = body.to_s.split("\n", -1).sum do |line|
78
+ [1, (line.length.to_f / columns).ceil].max
79
+ end
80
+ [minimum, rows].max
81
+ end
74
82
 
75
- handle = Bible270::Mentions.normalize(match[1])
83
+ # Backward-compatible plain-text entry point for host views that called the old
84
+ # mention helper directly.
85
+ def b270_with_mentions(body)
86
+ readers = Bible270::Reader.mentioned_in(body)
87
+ html = Bible270::CommentFormatter.html(body, format: Bible270::CommentFormatter::PLAIN) do |handle|
76
88
  reader = readers.find { |candidate| candidate.answers_to?(handle) }
77
- pieces << if reader
78
- link_to(match[0], reader_path(reader), class: 'b270-mention')
79
- else
80
- ERB::Util.html_escape(match[0])
81
- end
82
-
83
- cursor = match.end(0)
89
+ reader_path(reader) if reader
84
90
  end
85
-
86
- pieces << ERB::Util.html_escape(text[cursor..].to_s)
87
- safe_join(pieces)
91
+ html.html_safe
88
92
  end
89
93
 
94
+ # The mark beside the title in the header. Larger than the favicon and inlined
95
+ # as SVG, so it stays crisp at any size and needs no asset pipeline.
90
96
  def b270_mark(size: 38)
91
97
  configured = Bible270.config.header_mark
92
98
  return nil if configured == false
@@ -3,6 +3,7 @@
3
3
  module Bible270
4
4
  class ApplicationMailer < ActionMailer::Base
5
5
  default from: -> { Bible270.config.mailer_from }
6
+ helper Bible270::PlanHelper
6
7
  # `layout nil` means "normal lookup", which finds the host app's mailer layout and
7
8
  # nests our complete HTML document inside theirs. Only `false` disables it.
8
9
  layout false
@@ -11,6 +11,8 @@ module Bible270
11
11
  # the paper background; an outline glyph like U+2661 is drawn by the font
12
12
  # rather than the emoji set and needs hand-tuning to match.
13
13
  HEART = '❤️'
14
+ BODY_FORMAT_COLUMN = 'body_format'
15
+ BODY_FORMATS = CommentFormatter::FORMATS
14
16
  ThreadPage = Struct.new(:threads, :activity_by_id, :page, :pages, :total, keyword_init: true)
15
17
 
16
18
  self.table_name = 'bible270_comments'
@@ -26,6 +28,7 @@ module Bible270
26
28
 
27
29
  validates :day, inclusion: { in: 1..Plan::DAYS }
28
30
  validates :body, presence: true, length: { maximum: 4000 }
31
+ validates :body_format, inclusion: { in: BODY_FORMATS }, if: :body_format_column?
29
32
  # A reflection can be about one track or about the whole day. The form's
30
33
  # "The whole day" option submits an empty string, so normalise it to NULL
31
34
  # before validating — allow_nil alone rejects "" with
@@ -54,6 +57,23 @@ module Bible270
54
57
  }
55
58
  scope :recent, -> { approved.order(created_at: :desc) }
56
59
 
60
+ def self.body_format_column?
61
+ column_names.include?(BODY_FORMAT_COLUMN)
62
+ rescue ActiveRecord::StatementInvalid
63
+ false
64
+ end
65
+
66
+ def self.new_for_composer(attributes = {})
67
+ attributes = attributes.merge(BODY_FORMAT_COLUMN => CommentFormatter::MARKDOWN) if body_format_column?
68
+ new(attributes)
69
+ end
70
+
71
+ def body_format_column? = self.class.body_format_column?
72
+
73
+ def rendered_body_format
74
+ body_format_column? ? body_format.presence || CommentFormatter::PLAIN : CommentFormatter::PLAIN
75
+ end
76
+
57
77
  # One page of top-level reflections ordered by the newest activity in each
58
78
  # conversation. Loading only IDs and timestamps keeps older archives cheap to
59
79
  # sort, while the displayed page still eager-loads its readers, replies and likes.
@@ -33,6 +33,7 @@ module Bible270
33
33
  REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
34
34
  COMMENT_NOTIFICATION_LEVELS = %w[all personal none].freeze
35
35
  COMMENT_NOTIFICATION_COLUMNS = %w[notify_on_mention notify_on_all_comments].freeze
36
+ COMPLETION_DOVE_COLUMN = 'completion_dove_disabled'
36
37
  MENTION_SUGGESTION_LIMIT = 5
37
38
 
38
39
  validates :display_name, presence: true
@@ -64,6 +65,18 @@ module Bible270
64
65
  end
65
66
  end
66
67
 
68
+ # A day is completed when its final required checkoff is created, so MAX(created_at)
69
+ # is the completion moment. Keep reader_id as a deterministic tie-breaker.
70
+ def self.completers_for(day)
71
+ reader_ids = Checkoff.where(day: day)
72
+ .group(:reader_id)
73
+ .having('COUNT(*) >= ?', Plan.total_parts(day))
74
+ .order(Checkoff.arel_table[:created_at].maximum.asc, :reader_id)
75
+ .pluck(:reader_id)
76
+ readers_by_id = where(id: reader_ids).index_by(&:id)
77
+ reader_ids.filter_map { |reader_id| readers_by_id[reader_id] }
78
+ end
79
+
67
80
  def self.reflections_seen_column?
68
81
  column_names.include?(REFLECTIONS_SEEN_COLUMN)
69
82
  rescue ActiveRecord::StatementInvalid
@@ -289,6 +302,22 @@ module Bible270
289
302
  where(notify_on_all_comments: true).where.not(email: [nil, ''])
290
303
  end
291
304
 
305
+ def self.completion_dove_column?
306
+ return true if column_names.include?(COMPLETION_DOVE_COLUMN)
307
+
308
+ reset_column_information
309
+ column_names.include?(COMPLETION_DOVE_COLUMN)
310
+ rescue ActiveRecord::StatementInvalid
311
+ false
312
+ end
313
+
314
+ # Keep day completion usable while an upgraded host is waiting to run the
315
+ # preference migration. Historically the dove was enabled, so a missing
316
+ # column preserves that behavior.
317
+ def completion_dove_disabled?
318
+ has_attribute?(COMPLETION_DOVE_COLUMN) && self[COMPLETION_DOVE_COLUMN] == true
319
+ end
320
+
292
321
  # Small, deterministic suggestions for the reflection composer. Full-handle
293
322
  # collisions are omitted because the server must never guess which reader a
294
323
  # mention intended.
@@ -22,7 +22,7 @@
22
22
  <% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
23
23
  <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
- <p class="b270-cbody"><%= comment.body %></p>
25
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
26
26
  <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
27
27
  <% if comment.hidden? %>
28
28
  <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
@@ -70,6 +70,29 @@
70
70
  <% end %>
71
71
  </div>
72
72
 
73
+ <div class="b270-panel">
74
+ <h2 class="b270-subhead">Completion animation</h2>
75
+ <% unless @completion_dove_preference_available %>
76
+ <p class="b270-flash alert" style="margin:0">
77
+ Run the pending Bible270 database migration before changing the completion animation.
78
+ </p>
79
+ <% else %>
80
+ <%= form_with url: admin_reader_completion_animation_path(@reader), method: :patch,
81
+ class: "b270-emailform" do %>
82
+ <div class="b270-field">
83
+ <label class="b270-reminder-optin">
84
+ <%= check_box_tag :completion_dove_disabled, "1", @reader.completion_dove_disabled? %>
85
+ <span>Turn off the dove animation for this reader</span>
86
+ </label>
87
+ </div>
88
+ <p class="b270-hint" style="margin:0">
89
+ Day completion and progress tracking still work normally when the animation is off.
90
+ </p>
91
+ <%= submit_tag 'Save', class: 'b270-btn' %>
92
+ <% end %>
93
+ <% end %>
94
+ </div>
95
+
73
96
  <div class="b270-panel">
74
97
  <h2 class="b270-subhead">Where they are in the plan</h2>
75
98
  <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
@@ -5,7 +5,7 @@
5
5
 
6
6
  <%= turbo_stream.replace "day_progress_#{@day}" do %>
7
7
  <%= render "bible270/days/day_progress", day: @day, reader: @reader,
8
- just_completed: @day_just_completed %>
8
+ just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
9
9
  <% end %>
10
10
 
11
11
  <%= turbo_stream.replace "completers_#{@day}" do %>
@@ -0,0 +1,6 @@
1
+ <%# locals: (comment:) %>
2
+ <% if comment.body.to_s.strip.empty? %>
3
+ <p class="b270-hint">Nothing to preview yet.</p>
4
+ <% else %>
5
+ <%= b270_comment_body(comment) %>
6
+ <% end %>
@@ -32,7 +32,7 @@
32
32
  <% if editing %>
33
33
  <%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
34
34
  <% else %>
35
- <p class="b270-cbody"><%= b270_with_mentions(comment.body) %></p>
35
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
36
36
  <% end %>
37
37
 
38
38
  <%# Only a top-level reflection can be replied to: one level deep, so a reply
@@ -13,7 +13,8 @@
13
13
  <p class="b270-editerror"><%= comment.errors.full_messages.to_sentence %></p>
14
14
  <% end %>
15
15
 
16
- <%= f.text_area :body %>
16
+ <%= render "bible270/comments/editor", form: f, comment: comment,
17
+ label_text: (comment.reply? ? "Reply" : "Reflection") %>
17
18
  <div class="row">
18
19
  <% unless comment.reply? %>
19
20
  <%= f.select :track,
@@ -0,0 +1,57 @@
1
+ <%# locals: (form:, comment:, label_text:, placeholder: nil) %>
2
+ <% editor_key = comment.persisted? ? "edit-#{comment.id}" : "new" %>
3
+ <% textarea_id = "b270-comment-body-#{editor_key}" %>
4
+ <% options_id = "b270-mention-options-#{editor_key}" %>
5
+ <% write_id = "b270-comment-write-#{editor_key}" %>
6
+ <% preview_id = "b270-comment-preview-#{editor_key}" %>
7
+ <% drawer_id = "b270-comment-formatting-#{editor_key}" %>
8
+ <% markdown_available = Bible270::Comment.body_format_column? %>
9
+
10
+ <div class="b270-mention-composer" data-b270-mention-composer
11
+ data-suggestions-url="<%= mention_suggestions_path %>">
12
+ <div class="b270-markdown-editor" data-b270-markdown-editor data-b270-editor-key="<%= editor_key %>"
13
+ data-b270-preview-url="<%= comment_preview_path if markdown_available %>"
14
+ data-b270-formatting-open="false" data-b270-editor-mode="write">
15
+ <% if markdown_available %>
16
+ <div class="b270-editor-utilities" data-b270-editor-utilities hidden>
17
+ <button type="button" class="b270-editor-utility" aria-expanded="false"
18
+ aria-controls="<%= drawer_id %>" data-b270-formatting-toggle>
19
+ Formatting <span class="b270-format-chevron" aria-hidden="true">▾</span>
20
+ </button>
21
+ <button type="button" class="b270-editor-utility" aria-pressed="false"
22
+ aria-controls="<%= write_id %> <%= preview_id %>" data-b270-preview-toggle>Preview</button>
23
+ </div>
24
+ <div id="<%= drawer_id %>" class="b270-formatting-drawer" aria-hidden="true"
25
+ data-b270-formatting-drawer inert>
26
+ <div class="b270-formatting-drawer-inner">
27
+ <div class="b270-markdown-toolbar" role="toolbar" aria-label="Reflection formatting"
28
+ data-b270-markdown-toolbar>
29
+ <button type="button" data-b270-markdown-action="bold" aria-label="Bold selected text" title="Bold (Ctrl+B)"><span class="b270-format-glyph b270-format-bold" aria-hidden="true">B</span></button>
30
+ <button type="button" data-b270-markdown-action="italic" aria-label="Italicize selected text" title="Italic (Ctrl+I)"><span class="b270-format-glyph b270-format-italic" aria-hidden="true">I</span></button>
31
+ <button type="button" data-b270-markdown-action="quote" aria-label="Format as a quotation" title="Quotation">“ ”</button>
32
+ <button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
33
+ <button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
34
+ <button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <%= form.hidden_field :body_format, data: { b270_body_format: true } %>
39
+ <% end %>
40
+
41
+ <div id="<%= write_id %>" data-b270-write-panel>
42
+ <%= form.label :body, label_text, for: textarea_id, class: "b270-sr-only" %>
43
+ <%= form.text_area :body, id: textarea_id, placeholder: placeholder,
44
+ rows: (b270_editor_rows(comment.body) if comment.persisted?),
45
+ role: "combobox", autocomplete: "off", data: { b270_autosize: comment.persisted? },
46
+ aria: { autocomplete: "list", expanded: "false", haspopup: "listbox", controls: options_id } %>
47
+ </div>
48
+ <% if markdown_available %>
49
+ <div id="<%= preview_id %>" class="b270-markdown-preview b270-cbody"
50
+ aria-label="Reflection preview" aria-live="polite" data-b270-preview-panel hidden></div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <div class="b270-mention-options" id="<%= options_id %>" role="listbox"
55
+ data-b270-mention-options hidden></div>
56
+ <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
57
+ </div>
@@ -18,18 +18,9 @@
18
18
  </p>
19
19
  <% end %>
20
20
  <%= f.hidden_field :parent_id %>
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>
21
+ <%= render "bible270/comments/editor", form: f, comment: comment,
22
+ label_text: (posting_reply ? "Reply" : "Reflection"),
23
+ placeholder: (posting_reply ? "Write your reply…" : "Share a reflection on today's reading…") %>
33
24
  <p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
34
25
  <div class="row">
35
26
  <%= f.select :track,
@@ -10,5 +10,6 @@
10
10
  <% end %>
11
11
 
12
12
  <%= turbo_stream.replace "new_comment_form" do %>
13
- <%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day), posted: true %>
13
+ <%= render "bible270/comments/form", day: @day,
14
+ comment: Bible270::Comment.new_for_composer(day: @day), posted: true %>
14
15
  <% end %>