message_train 0.7.5 → 1.0.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 (171) hide show
  1. checksums.yaml +5 -5
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/ci.yml +86 -0
  4. data/.gitignore +64 -0
  5. data/.rubocop.yml +9 -0
  6. data/.rubocop_todo.yml +854 -2
  7. data/.ruby-version +1 -1
  8. data/.simplecov +2 -4
  9. data/CHANGELOG.md +128 -0
  10. data/CLAUDE.md +5 -0
  11. data/Gemfile +30 -32
  12. data/LICENSE.txt +1 -1
  13. data/README.md +106 -35
  14. data/Rakefile +4 -18
  15. data/app/assets/stylesheets/message_train.scss +405 -67
  16. data/app/controllers/concerns/message_train_support.rb +2 -2
  17. data/app/controllers/message_train/boxes_controller.rb +4 -2
  18. data/app/controllers/message_train/conversations_controller.rb +1 -0
  19. data/app/helpers/message_train/application_helper.rb +68 -0
  20. data/app/helpers/message_train/attachments_helper.rb +7 -4
  21. data/app/helpers/message_train/conversations_helper.rb +16 -16
  22. data/app/helpers/message_train/messages_helper.rb +23 -10
  23. data/app/javascript/controllers/message_train/bulk_actions_controller.js +36 -0
  24. data/app/javascript/controllers/message_train/lightbox_controller.js +25 -0
  25. data/app/javascript/controllers/message_train/nested_form_controller.js +36 -0
  26. data/app/javascript/controllers/message_train/recipient_autocomplete_controller.js +31 -0
  27. data/app/mailers/message_train/previews/receipt_mailer_preview.rb +7 -5
  28. data/app/models/message_train/attachment.rb +54 -42
  29. data/app/models/message_train/box.rb +2 -2
  30. data/app/models/message_train/conversation.rb +28 -2
  31. data/app/models/message_train/ignore.rb +2 -2
  32. data/app/models/message_train/message.rb +10 -4
  33. data/app/models/message_train/receipt.rb +27 -4
  34. data/app/models/message_train/unsubscribe.rb +1 -1
  35. data/app/views/application/404.html.haml +1 -3
  36. data/app/views/kaminari/_first_page.html.haml +1 -1
  37. data/app/views/kaminari/_last_page.html.haml +1 -1
  38. data/app/views/kaminari/_next_page.html.haml +1 -1
  39. data/app/views/kaminari/_page.html.haml +3 -3
  40. data/app/views/kaminari/_paginator.html.haml +12 -11
  41. data/app/views/kaminari/_prev_page.html.haml +1 -1
  42. data/app/views/message_train/application/_attachment_fields.html.haml +10 -7
  43. data/app/views/message_train/application/_attachment_link.html.haml +2 -2
  44. data/app/views/message_train/application/_widget.html.haml +2 -4
  45. data/app/views/message_train/boxes/_widget.html.haml +5 -5
  46. data/app/views/message_train/boxes/show.html.haml +28 -45
  47. data/app/views/message_train/collectives/_widget.html.haml +4 -4
  48. data/app/views/message_train/conversations/_conversation.html.haml +11 -11
  49. data/app/views/message_train/conversations/_deleted_toggle.html.haml +1 -1
  50. data/app/views/message_train/conversations/_ignored_toggle.html.haml +2 -2
  51. data/app/views/message_train/conversations/_read_toggle.html.haml +2 -2
  52. data/app/views/message_train/conversations/_toggle.html.haml +6 -4
  53. data/app/views/message_train/conversations/_trashed_toggle.html.haml +2 -2
  54. data/app/views/message_train/conversations/show.html.haml +8 -6
  55. data/app/views/message_train/messages/_deleted_toggle.html.haml +1 -1
  56. data/app/views/message_train/messages/_form.html.haml +22 -30
  57. data/app/views/message_train/messages/_message.html.haml +27 -45
  58. data/app/views/message_train/messages/_read_toggle.html.haml +2 -2
  59. data/app/views/message_train/messages/_toggle.html.haml +5 -1
  60. data/app/views/message_train/messages/_trashed_toggle.html.haml +2 -2
  61. data/app/views/message_train/unsubscribes/index.html.haml +12 -15
  62. data/badges/coverage.json +1 -0
  63. data/config/importmap.rb +2 -0
  64. data/config/locales/en.yml +6 -3
  65. data/db/migrate/20150721145319_create_message_train_conversations.rb +1 -1
  66. data/db/migrate/20150721160322_create_message_train_messages.rb +1 -1
  67. data/db/migrate/20150721161144_create_message_train_attachments.rb +5 -2
  68. data/db/migrate/20150721161940_create_message_train_receipts.rb +1 -1
  69. data/db/migrate/20150721163838_create_message_train_ignores.rb +1 -1
  70. data/db/migrate/20150901183458_add_received_through_to_message_train_receipts.rb +1 -1
  71. data/db/migrate/20151004184347_add_unique_index_to_receipts.rb +1 -1
  72. data/db/migrate/20151124000820_create_message_train_unsubscribes.rb +1 -1
  73. data/db/migrate/20260827095938_remove_paperclip_columns_from_message_train_attachments.rb +8 -0
  74. data/db/migrate/20260827171549_remove_body_from_message_train_messages.rb +5 -0
  75. data/db/migrate/20260827180000_add_marking_indexes.rb +9 -0
  76. data/db/migrate/20260827200000_add_body_back_to_message_train_messages.rb +5 -0
  77. data/docs/screenshots/inbox-desktop.png +0 -0
  78. data/docs/screenshots/inbox-mobile.png +0 -0
  79. data/lib/generators/message_train/install/install_generator.rb +1 -1
  80. data/lib/message_train/engine.rb +11 -0
  81. data/lib/message_train/instance_methods.rb +13 -7
  82. data/lib/message_train/localization.rb +4 -3
  83. data/lib/message_train/version.rb +1 -1
  84. data/lib/message_train.rb +0 -2
  85. data/message_train.gemspec +57 -389
  86. data/spec/controllers/message_train/boxes_controller_spec.rb +13 -8
  87. data/spec/controllers/message_train/concerns_spec.rb +2 -2
  88. data/spec/controllers/message_train/conversations_controller_spec.rb +36 -7
  89. data/spec/controllers/message_train/messages_controller_spec.rb +4 -4
  90. data/spec/dummy/app/assets/stylesheets/application.scss +141 -12
  91. data/spec/dummy/app/controllers/application_controller.rb +1 -1
  92. data/spec/dummy/app/javascript/application.js +4 -0
  93. data/spec/dummy/app/javascript/controllers/application.js +9 -0
  94. data/spec/dummy/app/javascript/controllers/index.js +4 -0
  95. data/spec/dummy/app/models/role.rb +1 -1
  96. data/spec/dummy/app/views/active_storage/blobs/_blob.html.erb +14 -0
  97. data/spec/dummy/app/views/layouts/_top_navigation.html.haml +18 -7
  98. data/spec/dummy/app/views/layouts/application.html.haml +22 -26
  99. data/spec/dummy/bin/importmap +4 -0
  100. data/spec/dummy/config/application.rb +7 -20
  101. data/spec/dummy/config/environments/development.rb +17 -21
  102. data/spec/dummy/config/environments/production.rb +11 -63
  103. data/spec/dummy/config/environments/test.rb +21 -16
  104. data/spec/dummy/config/importmap.rb +7 -0
  105. data/spec/dummy/config/initializers/assets.rb +1 -6
  106. data/spec/dummy/config/routes.rb +1 -1
  107. data/spec/dummy/config/storage.yml +7 -0
  108. data/spec/dummy/db/migrate/20150721140013_devise_create_users.rb +1 -1
  109. data/spec/dummy/db/migrate/20150721141009_rolify_create_roles.rb +1 -1
  110. data/spec/dummy/db/migrate/20150721141128_create_groups.rb +1 -1
  111. data/spec/dummy/db/migrate/20150721150307_add_display_name_column_to_users.rb +1 -1
  112. data/spec/dummy/db/migrate/20160207190402_create_message_train_conversations.message_train.rb +1 -1
  113. data/spec/dummy/db/migrate/20160207190403_create_message_train_messages.message_train.rb +1 -1
  114. data/spec/dummy/db/migrate/20160207190404_create_message_train_attachments.message_train.rb +5 -2
  115. data/spec/dummy/db/migrate/20160207190405_create_message_train_receipts.message_train.rb +1 -1
  116. data/spec/dummy/db/migrate/20160207190406_create_message_train_ignores.message_train.rb +1 -1
  117. data/spec/dummy/db/migrate/20160207190407_add_received_through_to_message_train_receipts.message_train.rb +1 -1
  118. data/spec/dummy/db/migrate/20160207190408_add_unique_index_to_receipts.message_train.rb +1 -1
  119. data/spec/dummy/db/migrate/20160207190409_create_message_train_unsubscribes.message_train.rb +1 -1
  120. data/spec/dummy/db/migrate/20260827095939_remove_paperclip_columns_from_message_train_attachments.message_train.rb +9 -0
  121. data/spec/dummy/db/migrate/20260827161141_create_active_storage_tables.active_storage.rb +57 -0
  122. data/spec/dummy/db/migrate/20260827171550_remove_body_from_message_train_messages.message_train.rb +6 -0
  123. data/spec/dummy/db/migrate/20260827225116_add_marking_indexes.message_train.rb +10 -0
  124. data/spec/dummy/db/migrate/20260828034830_add_body_back_to_message_train_messages.message_train.rb +6 -0
  125. data/spec/dummy/db/migrate/20260828040000_drop_action_text_tables.rb +5 -0
  126. data/spec/dummy/db/schema.rb +106 -72
  127. data/spec/dummy/db/seeds/conversations.seeds.rb +22 -22
  128. data/spec/dummy/db/seeds/development/conversations.seeds.rb +2 -2
  129. data/spec/dummy/db/seeds/groups.seeds.rb +4 -4
  130. data/spec/dummy/db/seeds/test/attachments.seeds.rb +4 -4
  131. data/spec/dummy/db/seeds/test/conversations.seeds.rb +2 -2
  132. data/spec/dummy/db/seeds/users.seeds.rb +8 -8
  133. data/spec/dummy/vendor/javascript/.keep +0 -0
  134. data/spec/factories/attachment.rb +5 -6
  135. data/spec/factories/group.rb +2 -2
  136. data/spec/factories/message.rb +7 -7
  137. data/spec/factories/user.rb +3 -3
  138. data/spec/features/boxes_spec.rb +38 -34
  139. data/spec/features/conversations_spec.rb +13 -6
  140. data/spec/features/messages_spec.rb +20 -20
  141. data/spec/features/unsubscribes_spec.rb +9 -9
  142. data/spec/helpers/message_train/application_helper_spec.rb +16 -18
  143. data/spec/helpers/message_train/attachment_helper_spec.rb +8 -18
  144. data/spec/helpers/message_train/boxes_helper_spec.rb +6 -6
  145. data/spec/helpers/message_train/collectives_helper_spec.rb +10 -10
  146. data/spec/helpers/message_train/conversations_helper_spec.rb +43 -85
  147. data/spec/helpers/message_train/messages_helper_spec.rb +41 -61
  148. data/spec/models/message_train/attachment_spec.rb +26 -36
  149. data/spec/models/message_train/box_spec.rb +2 -2
  150. data/spec/models/message_train/conversation_spec.rb +15 -0
  151. data/spec/models/message_train/message_spec.rb +5 -1
  152. data/spec/models/message_train/receipt_spec.rb +1 -1
  153. data/spec/models/message_train/unsubscribe_spec.rb +1 -1
  154. data/spec/rails_helper.rb +14 -13
  155. data/spec/support/controller_behaviors.rb +13 -13
  156. data/spec/support/factory_bot.rb +3 -0
  157. data/spec/support/feature_behaviors.rb +14 -14
  158. data/spec/support/loaded_site/attachments.rb +6 -2
  159. data/spec/support/utilities.rb +49 -20
  160. metadata +260 -189
  161. data/.travis.yml +0 -12
  162. data/VERSION +0 -1
  163. data/app/assets/javascripts/ckeditor/config.js +0 -49
  164. data/app/assets/javascripts/message_train.js +0 -149
  165. data/spec/dummy/app/assets/javascripts/application.js +0 -17
  166. data/spec/dummy/app/assets/stylesheets/bootstrap-everything.scss +0 -54
  167. data/spec/dummy/config/initializers/bootstrap_leather.rb +0 -7
  168. data/spec/dummy/config/initializers/paperclip.rb +0 -5
  169. data/spec/dummy/config/secrets.yml +0 -22
  170. data/spec/dummy/db/test.sqlite3 +0 -0
  171. data/spec/support/factory_girl.rb +0 -3
@@ -47,26 +47,26 @@ module MessageTrain
47
47
 
48
48
  private
49
49
 
50
- # rubocop:disable Metrics/ParameterLists
51
50
  def conversation_toggle(conv, icon, mark, method, title, options = {})
52
- render(
53
- partial: 'message_train/conversations/toggle',
54
- locals: {
55
- conversation: conv,
56
- icon: icon,
57
- mark_to_set: mark,
58
- options: conversation_toggle_options(mark, method, title, options)
59
- }
60
- )
51
+ kind = options.delete(:kind) || mark
52
+ collective = options.delete(:collective)
53
+ show_text = options.delete(:show_text)
54
+ locals = {
55
+ conversation: conv, icon: icon, title: title, show_text: show_text,
56
+ mark_to_set: mark, kind: kind, collective: collective,
57
+ options: conversation_toggle_options(mark, method, title, options, small: !show_text)
58
+ }
59
+ render(partial: 'message_train/conversations/toggle', locals: locals)
61
60
  end
62
- # rubocop:enable Metrics/ParameterLists
63
61
 
64
- def conversation_toggle_options(mark, method, title, options = {})
65
- options[:remote] = true
66
- options[:method] = method
62
+ def conversation_toggle_options(mark, method, title, options = {}, small: true)
67
63
  options[:title] = title
68
- options[:class] ||= ''
69
- options[:class] += " #{mark}-toggle"
64
+ options[:class] = [
65
+ 'message-train-button',
66
+ (small ? 'message-train-button--small' : nil),
67
+ "#{mark}-toggle"
68
+ ].compact.join(' ')
69
+ options[:method] = method
70
70
  options
71
71
  end
72
72
 
@@ -1,6 +1,16 @@
1
1
  module MessageTrain
2
2
  # Messages helper
3
3
  module MessagesHelper
4
+ MARKDOWN_RENDERER = Redcarpet::Markdown.new(
5
+ Redcarpet::Render::HTML.new(safe_links_only: true),
6
+ autolink: true, tables: true, fenced_code_blocks: true,
7
+ no_intra_emphasis: true, strikethrough: true
8
+ ).freeze
9
+
10
+ def message_body(message)
11
+ sanitize(MARKDOWN_RENDERER.render(message.body.to_s))
12
+ end
13
+
4
14
  def message_class(box, message)
5
15
  css_classes = []
6
16
  css_classes << message_css_for_read_state(message)
@@ -9,24 +19,24 @@ module MessageTrain
9
19
  css_classes.join(' ')
10
20
  end
11
21
 
12
- def message_trashed_toggle(message)
22
+ def message_trashed_toggle(message, collective = nil)
13
23
  render(
14
24
  partial: 'message_train/messages/trashed_toggle',
15
- locals: { message: message }
25
+ locals: { message: message, collective: collective }
16
26
  )
17
27
  end
18
28
 
19
- def message_read_toggle(message)
29
+ def message_read_toggle(message, collective = nil)
20
30
  render(
21
31
  partial: 'message_train/messages/read_toggle',
22
- locals: { message: message }
32
+ locals: { message: message, collective: collective }
23
33
  )
24
34
  end
25
35
 
26
- def message_deleted_toggle(message)
36
+ def message_deleted_toggle(message, collective = nil)
27
37
  render(
28
38
  partial: 'message_train/messages/deleted_toggle',
29
- locals: { message: message }
39
+ locals: { message: message, collective: collective }
30
40
  )
31
41
  end
32
42
 
@@ -36,24 +46,27 @@ module MessageTrain
36
46
 
37
47
  private
38
48
 
39
- def message_toggle(message, icon, mark_to_set, title, options = {})
49
+ def message_toggle(message, icon, mark_to_set, title, collective, options = {})
50
+ kind = options.delete(:kind) || mark_to_set
40
51
  render(
41
52
  partial: 'message_train/messages/toggle',
42
53
  locals: {
43
54
  message: message,
44
55
  icon: icon,
56
+ title: title,
45
57
  mark_to_set: mark_to_set,
58
+ kind: kind,
59
+ collective: collective,
46
60
  options: message_toggle_options(message, mark_to_set, title, options)
47
61
  }
48
62
  )
49
63
  end
50
64
 
51
65
  def message_toggle_options(message, mark_to_set, title, options = {})
52
- options[:remote] = true
53
66
  options[:id] = "mark_#{mark_to_set}_#{message.id}"
54
- options[:class] = 'mark-link'
55
- options[:method] = :put
67
+ options[:class] = 'message-train-button message-train-button--small'
56
68
  options[:title] = title
69
+ options[:method] = :put
57
70
  options
58
71
  end
59
72
 
@@ -0,0 +1,36 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replaces message_train.js's jQuery-driven checkbox/mark-all wiring for
4
+ // the box listing's bulk actions (select rows, then mark them all at once).
5
+ export default class extends Controller {
6
+ static targets = ["checkbox", "checkAll", "markToSet"]
7
+
8
+ toggleAll(event) {
9
+ this.checkboxTargets.forEach((box) => { box.checked = event.target.checked })
10
+ }
11
+
12
+ selectMatching(event) {
13
+ event.preventDefault()
14
+ event.stopPropagation()
15
+ const selector = event.currentTarget.dataset.selector
16
+ this.checkboxTargets.forEach((box) => {
17
+ box.checked = selector ? box.closest(".message_train_conversation")?.matches(selector) : false
18
+ })
19
+ if (this.hasCheckAllTarget) {
20
+ this.checkAllTarget.checked = selector === ".message_train_conversation"
21
+ }
22
+ }
23
+
24
+ markAll(event) {
25
+ event.preventDefault()
26
+ event.stopPropagation()
27
+ const mark = event.currentTarget.dataset.mark
28
+ const form = document.getElementById("box")
29
+ const methodField = form.querySelector('input[name="_method"]')
30
+ if (methodField) {
31
+ methodField.value = ["ignore", "unignore"].includes(mark) ? "delete" : "put"
32
+ }
33
+ this.markToSetTarget.value = mark
34
+ form.requestSubmit()
35
+ }
36
+ }
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Opens a <dialog> and points its image at the src/original carried on the
4
+ // clicked link's data attributes, replacing the old Bootstrap modal.
5
+ export default class extends Controller {
6
+ static targets = ["image"]
7
+
8
+ open(event) {
9
+ event.preventDefault()
10
+ const dialog = document.getElementById(event.currentTarget.dataset.lightboxDialogId)
11
+ if (!dialog) return
12
+
13
+ const image = dialog.querySelector("[data-lightbox-target~='image']") || dialog.querySelector("img")
14
+ if (image) {
15
+ image.src = event.currentTarget.dataset.lightboxSrc
16
+ image.alt = event.currentTarget.dataset.lightboxAlt || ""
17
+ }
18
+
19
+ dialog.showModal()
20
+ }
21
+
22
+ close(event) {
23
+ event.target.closest("dialog")?.close()
24
+ }
25
+ }
@@ -0,0 +1,36 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replaces cocoon's add/remove-nested-fields helpers. Expects a <template>
4
+ // target holding one blank fields_for row (child_index: "NEW_RECORD"), and
5
+ // wraps each row (new or persisted) in an element carrying the
6
+ // "destroyField" target so removal can either drop the DOM node (new,
7
+ // unsaved rows) or flag it for destruction on submit (persisted rows).
8
+ export default class extends Controller {
9
+ static targets = ["template", "rows"]
10
+
11
+ add(event) {
12
+ event.preventDefault()
13
+ const content = this.templateTarget.innerHTML.replace(
14
+ /NEW_RECORD/g,
15
+ new Date().getTime()
16
+ )
17
+ this.rowsTarget.insertAdjacentHTML("beforeend", content)
18
+ }
19
+
20
+ remove(event) {
21
+ event.preventDefault()
22
+ const message = event.currentTarget.dataset.confirm
23
+ if (message && !window.confirm(message)) return
24
+
25
+ const row = event.currentTarget.closest("[data-message-train--nested-form-target='row']")
26
+ if (!row) return
27
+
28
+ const destroyField = row.querySelector("[data-message-train--nested-form-target='destroyField']")
29
+ if (destroyField) {
30
+ destroyField.value = "1"
31
+ row.hidden = true
32
+ } else {
33
+ row.remove()
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replaces the old Bloodhound/typeahead.js recipient picker with a plain
4
+ // <input> + <datalist>, backed by the same participants JSON endpoint.
5
+ export default class extends Controller {
6
+ static targets = ["input", "list"]
7
+ static values = { url: String }
8
+
9
+ connect() {
10
+ this.timeout = null
11
+ }
12
+
13
+ search() {
14
+ clearTimeout(this.timeout)
15
+ const query = this.inputTarget.value.trim()
16
+ if (query.length < 1) return
17
+
18
+ this.timeout = setTimeout(() => this.fetchSuggestions(query), 150)
19
+ }
20
+
21
+ async fetchSuggestions(query) {
22
+ const url = `${this.urlValue}?query=${encodeURIComponent(query)}`
23
+ const response = await fetch(url, { headers: { Accept: "application/json" } })
24
+ if (!response.ok) return
25
+
26
+ const data = await response.json()
27
+ this.listTarget.innerHTML = (data.participants || [])
28
+ .map((participant) => `<option value="${participant.slug}"></option>`)
29
+ .join("")
30
+ }
31
+ }
@@ -1,9 +1,11 @@
1
1
  module MessageTrain
2
- # Receipt mailer preview
3
- class ReceiptMailerPreview < ActionMailer::Preview
4
- def notification_email
5
- receipt = MessageTrain::Receipt.last
6
- ReceiptMailer.notification_email(receipt)
2
+ module Previews
3
+ # Receipt mailer preview
4
+ class ReceiptMailerPreview < ActionMailer::Preview
5
+ def notification_email
6
+ receipt = MessageTrain::Receipt.last
7
+ MessageTrain::ReceiptMailer.notification_email(receipt)
8
+ end
7
9
  end
8
10
  end
9
11
  end
@@ -1,51 +1,63 @@
1
1
  module MessageTrain
2
2
  # Attachment model
3
3
  class Attachment < ActiveRecord::Base
4
- belongs_to :message, foreign_key: :message_train_message_id, touch: true
5
- has_attached_file(
6
- :attachment,
7
- styles: lambda do |attachment|
8
- if attachment.instance.image?
9
- {
10
- thumb: '235x',
11
- large: '800x'
12
- }
13
- else
14
- {}
15
- end
16
- end,
17
- path: ':rails_root/public/system/:rails_env/:class/:attachment/'\
18
- ':id_partition/:style_prefix:filename',
19
- url: '/system/:rails_env/:class/:attachment/'\
20
- ':id_partition/:style_prefix:filename',
21
- convert_options: {
22
- large: '-quality 75 -interlace Plane -strip',
23
- thumb: '-quality 75 -strip'
24
- }
25
- )
26
- validates_attachment_presence :attachment
27
- validates_attachment_content_type(
28
- :attachment,
29
- content_type: [
30
- 'application/pdf',
31
- 'application/vnd.ms-excel',
32
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
33
- 'application/msword',
34
- 'application/'\
35
- 'vnd.openxmlformats-officedocument.wordprocessingml.document',
36
- 'application/rtf',
37
- 'text/plain',
38
- %r{^(image|(x-)?application)/(bmp|gif|jpeg|jpg|pjpeg|png|x-png)$}
39
- ]
40
- )
4
+ ALLOWED_CONTENT_TYPES = [
5
+ 'application/pdf',
6
+ 'application/vnd.ms-excel',
7
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
8
+ 'application/msword',
9
+ 'application/'\
10
+ 'vnd.openxmlformats-officedocument.wordprocessingml.document',
11
+ 'application/rtf',
12
+ 'text/plain',
13
+ %r{^(image|(x-)?application)/(bmp|gif|jpeg|jpg|pjpeg|png|x-png)$}
14
+ ].freeze
15
+
16
+ belongs_to :message,
17
+ foreign_key: :message_train_message_id,
18
+ touch: true,
19
+ inverse_of: :attachments
20
+ has_one_attached :attachment
21
+
22
+ validate :attachment_must_be_present
23
+ validate :attachment_content_type_must_be_allowed
24
+
41
25
  def image?
42
- # rubocop:disable Metrics/LineLength
43
- !(attachment_content_type =~ %r{^(image|(x-)?application)/(bmp|gif|jpeg|jpg|pjpeg|png|x-png)$}).nil?
44
- # rubocop:enable Metrics/LineLength
26
+ attached_content_type = attachment.content_type
27
+ !(attached_content_type =~ %r{^(image|(x-)?application)/(bmp|gif|jpeg|jpg|pjpeg|png|x-png)$}).nil?
45
28
  end
46
29
 
47
- Paperclip.interpolates :style_prefix do |attachment, style|
48
- attachment.instance.image? ? "#{style}/" : ''
30
+ def thumb
31
+ attachment.variant(
32
+ resize_to_limit: [235, 235],
33
+ quality: 75,
34
+ strip: true
35
+ )
36
+ end
37
+
38
+ def large
39
+ attachment.variant(
40
+ resize_to_limit: [800, 800],
41
+ quality: 75,
42
+ strip: true,
43
+ interlace: 'Plane'
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def attachment_must_be_present
50
+ errors.add(:attachment, :blank) unless attachment.attached?
51
+ end
52
+
53
+ def attachment_content_type_must_be_allowed
54
+ return unless attachment.attached?
55
+
56
+ content_type = attachment.content_type
57
+ allowed = ALLOWED_CONTENT_TYPES.any? do |allowed_type|
58
+ allowed_type.is_a?(Regexp) ? allowed_type.match?(content_type) : allowed_type == content_type
59
+ end
60
+ errors.add(:attachment, :content_type_invalid) unless allowed
49
61
  end
50
62
  end
51
63
  end
@@ -5,7 +5,7 @@ module MessageTrain
5
5
  'Hash' => :mark_hash,
6
6
  'Array' => :mark_array,
7
7
  'String' => :mark_id,
8
- 'Fixnum' => :mark_id,
8
+ 'Integer' => :mark_id,
9
9
  'MessageTrain::Conversation' => :mark_communication
10
10
  }.freeze
11
11
 
@@ -31,7 +31,7 @@ module MessageTrain
31
31
  end
32
32
 
33
33
  def unread_count
34
- conversations(read: false).count
34
+ @unread_count ||= conversations(read: false).count
35
35
  end
36
36
 
37
37
  def conversations(options = {})
@@ -89,7 +89,7 @@ module MessageTrain
89
89
  def new_reply(args)
90
90
  args[:reply_recipients] = default_recipients_for(args.delete(:box).parent)
91
91
  args[:subject] = "Re: #{subject}"
92
- args[:body] = "<blockquote>#{messages.last.body}</blockquote><p></p>"
92
+ args[:body] = quote_as_markdown(messages.last.body)
93
93
  messages.build(args)
94
94
  end
95
95
 
@@ -102,7 +102,13 @@ module MessageTrain
102
102
  end
103
103
 
104
104
  def participant_ignored?(participant)
105
- ignores.empty? ? false : ignores.find_all_by_participant(participant).any?
105
+ # Filters the (often already-preloaded) in-memory ignores rather
106
+ # than issuing a fresh scoped query every call - this gets called
107
+ # several times per conversation row in a box listing.
108
+ ignores.any? do |ignore|
109
+ ignore.participant_type == participant.class.name &&
110
+ ignore.participant_id == participant.id
111
+ end
106
112
  end
107
113
 
108
114
  def mark(mark, participant)
@@ -125,6 +131,21 @@ module MessageTrain
125
131
  includes_matching_messages_with_prep?(flag, prep, *args)
126
132
  end
127
133
 
134
+ # PERF: this is the box listing's remaining N+1. `conversation_class`
135
+ # in ConversationsHelper calls the various includes_*_for? methods
136
+ # (read/trashed/untrashed/deleted/undeleted/drafts_by) several times
137
+ # per conversation row via this method, and each call is a fresh
138
+ # `receipts.send(...)` scoped query - none of it benefits from
139
+ # preloading `messages: :receipts`, because a *scope* call on an
140
+ # association always re-queries rather than filtering the already-
141
+ # loaded records. Fixing this for real means replicating the
142
+ # flag/preposition scope-combination DSL (see Receipt's own
143
+ # method_missing) as in-memory filtering over preloaded receipts,
144
+ # for every flag/preposition combination actually used in the box
145
+ # listing - judged too easy to get subtly wrong for the time
146
+ # available during the Phase 9 performance pass (see the modernization
147
+ # plan). `participant_ignored?` got the equivalent fix already; this
148
+ # one is next if this becomes a real bottleneck.
128
149
  def includes_matching_receipts?(match_method_sym, *args)
129
150
  receipts.send(match_method_sym, *args).any?
130
151
  end
@@ -153,5 +174,10 @@ module MessageTrain
153
174
  def includes_matching_messages_with_prep?(flag, preposition, *args)
154
175
  messages.send(flag).receipts.flagged(preposition, *args).any?
155
176
  end
177
+
178
+ def quote_as_markdown(body)
179
+ quoted = body.to_s.each_line.map { |line| "> #{line.chomp}" }.join("\n")
180
+ "#{quoted}\n\n"
181
+ end
156
182
  end
157
183
  end
@@ -5,7 +5,7 @@ module MessageTrain
5
5
  'Hash' => :ignore_hash,
6
6
  'Array' => :ignore_array,
7
7
  'String' => :ignore_id,
8
- 'Fixnum' => :ignore_id,
8
+ 'Integer' => :ignore_id,
9
9
  'MessageTrain::Conversation' => :ignore_conversation
10
10
  }.freeze
11
11
 
@@ -13,7 +13,7 @@ module MessageTrain
13
13
  'Hash' => :unignore_hash,
14
14
  'Array' => :unignore_array,
15
15
  'String' => :unignore_id,
16
- 'Fixnum' => :unignore_id,
16
+ 'Integer' => :unignore_id,
17
17
  'MessageTrain::Conversation' => :unignore_conversation
18
18
  }.freeze
19
19
 
@@ -2,7 +2,7 @@ module MessageTrain
2
2
  # Message model
3
3
  class Message < ActiveRecord::Base
4
4
  # Serializations
5
- serialize :recipients_to_save, Hash
5
+ serialize :recipients_to_save, coder: JSON, type: Hash
6
6
 
7
7
  attr_accessor :box
8
8
 
@@ -14,7 +14,9 @@ module MessageTrain
14
14
  touch: true
15
15
  )
16
16
  belongs_to :sender, polymorphic: true
17
- has_many :attachments, foreign_key: :message_train_message_id
17
+ has_many :attachments,
18
+ foreign_key: :message_train_message_id,
19
+ inverse_of: :message
18
20
  has_many :receipts, foreign_key: :message_train_message_id
19
21
  delegate :participant_ignored?, to: :conversation
20
22
  delegate :send_receipts, to: :receipts
@@ -23,7 +25,7 @@ module MessageTrain
23
25
  validates_presence_of :sender, :subject
24
26
 
25
27
  # Callbacks
26
- before_create :create_conversation_if_blank
28
+ before_validation :create_conversation_if_blank
27
29
  after_create :generate_sender_receipt
28
30
  after_save :generate_receipts_or_set_draft
29
31
  after_save :set_conversation_subject_if_alone
@@ -60,7 +62,11 @@ module MessageTrain
60
62
  end
61
63
 
62
64
  def self.mark(mark_to_set, participant)
63
- find_each { |message| message.mark(mark_to_set, participant) }
65
+ # Called both directly on the class and via a scoped relation (e.g.
66
+ # `conversation.messages.mark(...)`); Rails delegates the latter by
67
+ # calling this method on the bare class within a `scoping` block, so
68
+ # `all` (not `self`) is what resolves to the actually-scoped relation.
69
+ MessageTrain::Receipt.for_messages(all).for(participant).mark(mark_to_set)
64
70
  end
65
71
 
66
72
  def recipients
@@ -2,7 +2,7 @@ module MessageTrain
2
2
  # Receipt model
3
3
  class Receipt < ActiveRecord::Base
4
4
  belongs_to :recipient, polymorphic: true
5
- belongs_to :received_through, polymorphic: true
5
+ belongs_to :received_through, polymorphic: true, optional: true
6
6
  belongs_to :message, foreign_key: :message_train_message_id, touch: true
7
7
  delegate :conversation, to: :message
8
8
  validates_presence_of :recipient, :message
@@ -65,6 +65,11 @@ module MessageTrain
65
65
  end
66
66
 
67
67
  def mark(mark_to_set)
68
+ setting, column = self.class.setting_and_column_for(mark_to_set)
69
+ update_attribute(column, setting)
70
+ end
71
+
72
+ def self.setting_and_column_for(mark_to_set)
68
73
  if mark_to_set.to_s =~ /^un/
69
74
  setting = false
70
75
  suffix = mark_to_set.to_s.gsub(/^un/, '')
@@ -72,12 +77,30 @@ module MessageTrain
72
77
  setting = true
73
78
  suffix = mark_to_set.to_s
74
79
  end
75
- column = "marked_#{suffix}".to_sym
76
- update_attribute(column, setting)
80
+ [setting, "marked_#{suffix}".to_sym]
77
81
  end
78
82
 
83
+ # Bulk-updates every receipt in the current scope in a single query
84
+ # instead of loading and saving each one. update_attribute's touch:
85
+ # true cascade (Receipt -> Message -> Conversation) is skipped by
86
+ # update_all, so it's replicated explicitly below - the inbox listing
87
+ # orders by, and fragment-caches on, Conversation#updated_at.
79
88
  def self.mark(mark_to_set)
80
- where(nil).each { |receipt| receipt.mark(mark_to_set) }
89
+ setting, column = setting_and_column_for(mark_to_set)
90
+ message_ids = distinct.pluck(:message_train_message_id)
91
+ update_all(column => setting)
92
+ touch_affected_parents(message_ids)
93
+ end
94
+
95
+ def self.touch_affected_parents(message_ids)
96
+ return if message_ids.empty?
97
+ now = Time.current
98
+ MessageTrain::Message.where(id: message_ids).update_all(updated_at: now)
99
+ conversation_ids = MessageTrain::Message.where(id: message_ids)
100
+ .distinct
101
+ .pluck(:message_train_conversation_id)
102
+ MessageTrain::Conversation.where(id: conversation_ids)
103
+ .update_all(updated_at: now)
81
104
  end
82
105
 
83
106
  def self.method_missing(method_sym, *args, &block)
@@ -2,7 +2,7 @@ module MessageTrain
2
2
  # Unsubscribe model
3
3
  class Unsubscribe < ActiveRecord::Base
4
4
  belongs_to :recipient, polymorphic: true
5
- belongs_to :from, polymorphic: true
5
+ belongs_to :from, polymorphic: true, optional: true
6
6
  validates_presence_of :recipient
7
7
  end
8
8
  end
@@ -1,3 +1 @@
1
- - add_hero_unit do
2
- %h1
3
- 404 - Page Not Found
1
+ %h1 404 - Page Not Found
@@ -1,2 +1,2 @@
1
1
  %li
2
- = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, :remote => remote
2
+ = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url
@@ -1,2 +1,2 @@
1
1
  %li
2
- = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {:remote => remote}
2
+ = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url
@@ -1,2 +1,2 @@
1
1
  %li
2
- = link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, :rel => 'next', :remote => remote
2
+ = link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, rel: 'next'
@@ -1,6 +1,6 @@
1
1
  - if page.current?
2
- %li.active
3
- = content_tag :a, page, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil))
2
+ %li.active{ 'aria-current': 'page' }
3
+ = content_tag :a, page, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil))
4
4
  - else
5
5
  %li
6
- = link_to page, url, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil))
6
+ = link_to page, url, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil))
@@ -1,11 +1,12 @@
1
- = paginator.render do
2
- %ul.pagination
3
- = first_page_tag unless current_page.first?
4
- = prev_page_tag unless current_page.first?
5
- - each_page do |page|
6
- - if page.left_outer? || page.right_outer? || page.inside_window?
7
- = page_tag page
8
- - elsif !page.was_truncated?
9
- = gap_tag
10
- = next_page_tag unless current_page.last?
11
- = last_page_tag unless current_page.last?
1
+ %nav{ 'aria-label': 'Pagination' }
2
+ = paginator.render do
3
+ %ul.pagination
4
+ = first_page_tag unless current_page.first?
5
+ = prev_page_tag unless current_page.first?
6
+ - each_page do |page|
7
+ - if page.left_outer? || page.right_outer? || page.inside_window?
8
+ = page_tag page
9
+ - elsif !page.was_truncated?
10
+ = gap_tag
11
+ = next_page_tag unless current_page.last?
12
+ = last_page_tag unless current_page.last?
@@ -1,2 +1,2 @@
1
1
  %li
2
- = link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, :rel => 'prev', :remote => remote
2
+ = link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, rel: 'prev'