message_train 0.7.6 → 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.
- checksums.yaml +5 -5
- data/.gitattributes +1 -0
- data/.github/workflows/ci.yml +86 -0
- data/.gitignore +64 -0
- data/.rubocop.yml +9 -0
- data/.rubocop_todo.yml +854 -2
- data/.ruby-version +1 -1
- data/.simplecov +2 -4
- data/CHANGELOG.md +128 -0
- data/CLAUDE.md +5 -0
- data/Gemfile +29 -31
- data/LICENSE.txt +1 -1
- data/README.md +106 -35
- data/Rakefile +4 -18
- data/app/assets/stylesheets/message_train.scss +405 -67
- data/app/controllers/concerns/message_train_support.rb +2 -2
- data/app/controllers/message_train/boxes_controller.rb +4 -2
- data/app/controllers/message_train/conversations_controller.rb +1 -0
- data/app/helpers/message_train/application_helper.rb +68 -0
- data/app/helpers/message_train/attachments_helper.rb +7 -4
- data/app/helpers/message_train/conversations_helper.rb +16 -16
- data/app/helpers/message_train/messages_helper.rb +23 -10
- data/app/javascript/controllers/message_train/bulk_actions_controller.js +36 -0
- data/app/javascript/controllers/message_train/lightbox_controller.js +25 -0
- data/app/javascript/controllers/message_train/nested_form_controller.js +36 -0
- data/app/javascript/controllers/message_train/recipient_autocomplete_controller.js +31 -0
- data/app/mailers/message_train/previews/receipt_mailer_preview.rb +7 -5
- data/app/models/message_train/attachment.rb +54 -42
- data/app/models/message_train/box.rb +2 -2
- data/app/models/message_train/conversation.rb +28 -2
- data/app/models/message_train/ignore.rb +2 -2
- data/app/models/message_train/message.rb +10 -4
- data/app/models/message_train/receipt.rb +27 -4
- data/app/models/message_train/unsubscribe.rb +1 -1
- data/app/views/application/404.html.haml +1 -3
- data/app/views/kaminari/_first_page.html.haml +1 -1
- data/app/views/kaminari/_last_page.html.haml +1 -1
- data/app/views/kaminari/_next_page.html.haml +1 -1
- data/app/views/kaminari/_page.html.haml +3 -3
- data/app/views/kaminari/_paginator.html.haml +12 -11
- data/app/views/kaminari/_prev_page.html.haml +1 -1
- data/app/views/message_train/application/_attachment_fields.html.haml +10 -7
- data/app/views/message_train/application/_attachment_link.html.haml +2 -2
- data/app/views/message_train/application/_widget.html.haml +2 -4
- data/app/views/message_train/boxes/_widget.html.haml +5 -5
- data/app/views/message_train/boxes/show.html.haml +28 -45
- data/app/views/message_train/collectives/_widget.html.haml +4 -4
- data/app/views/message_train/conversations/_conversation.html.haml +11 -11
- data/app/views/message_train/conversations/_deleted_toggle.html.haml +1 -1
- data/app/views/message_train/conversations/_ignored_toggle.html.haml +2 -2
- data/app/views/message_train/conversations/_read_toggle.html.haml +2 -2
- data/app/views/message_train/conversations/_toggle.html.haml +6 -4
- data/app/views/message_train/conversations/_trashed_toggle.html.haml +2 -2
- data/app/views/message_train/conversations/show.html.haml +8 -6
- data/app/views/message_train/messages/_deleted_toggle.html.haml +1 -1
- data/app/views/message_train/messages/_form.html.haml +22 -30
- data/app/views/message_train/messages/_message.html.haml +27 -45
- data/app/views/message_train/messages/_read_toggle.html.haml +2 -2
- data/app/views/message_train/messages/_toggle.html.haml +5 -1
- data/app/views/message_train/messages/_trashed_toggle.html.haml +2 -2
- data/app/views/message_train/unsubscribes/index.html.haml +12 -15
- data/badges/coverage.json +1 -0
- data/config/importmap.rb +2 -0
- data/config/locales/en.yml +6 -3
- data/db/migrate/20150721145319_create_message_train_conversations.rb +1 -1
- data/db/migrate/20150721160322_create_message_train_messages.rb +1 -1
- data/db/migrate/20150721161144_create_message_train_attachments.rb +5 -2
- data/db/migrate/20150721161940_create_message_train_receipts.rb +1 -1
- data/db/migrate/20150721163838_create_message_train_ignores.rb +1 -1
- data/db/migrate/20150901183458_add_received_through_to_message_train_receipts.rb +1 -1
- data/db/migrate/20151004184347_add_unique_index_to_receipts.rb +1 -1
- data/db/migrate/20151124000820_create_message_train_unsubscribes.rb +1 -1
- data/db/migrate/20260827095938_remove_paperclip_columns_from_message_train_attachments.rb +8 -0
- data/db/migrate/20260827171549_remove_body_from_message_train_messages.rb +5 -0
- data/db/migrate/20260827180000_add_marking_indexes.rb +9 -0
- data/db/migrate/20260827200000_add_body_back_to_message_train_messages.rb +5 -0
- data/docs/screenshots/inbox-desktop.png +0 -0
- data/docs/screenshots/inbox-mobile.png +0 -0
- data/lib/generators/message_train/install/install_generator.rb +1 -1
- data/lib/message_train/engine.rb +11 -0
- data/lib/message_train/instance_methods.rb +13 -7
- data/lib/message_train/localization.rb +4 -3
- data/lib/message_train/version.rb +1 -1
- data/lib/message_train.rb +0 -2
- data/message_train.gemspec +57 -389
- data/spec/controllers/message_train/boxes_controller_spec.rb +13 -8
- data/spec/controllers/message_train/concerns_spec.rb +2 -2
- data/spec/controllers/message_train/conversations_controller_spec.rb +36 -7
- data/spec/controllers/message_train/messages_controller_spec.rb +4 -4
- data/spec/dummy/app/assets/stylesheets/application.scss +141 -12
- data/spec/dummy/app/controllers/application_controller.rb +1 -1
- data/spec/dummy/app/javascript/application.js +4 -0
- data/spec/dummy/app/javascript/controllers/application.js +9 -0
- data/spec/dummy/app/javascript/controllers/index.js +4 -0
- data/spec/dummy/app/models/role.rb +1 -1
- data/spec/dummy/app/views/active_storage/blobs/_blob.html.erb +14 -0
- data/spec/dummy/app/views/layouts/_top_navigation.html.haml +18 -7
- data/spec/dummy/app/views/layouts/application.html.haml +22 -26
- data/spec/dummy/bin/importmap +4 -0
- data/spec/dummy/config/application.rb +7 -20
- data/spec/dummy/config/environments/development.rb +17 -21
- data/spec/dummy/config/environments/production.rb +11 -63
- data/spec/dummy/config/environments/test.rb +21 -16
- data/spec/dummy/config/importmap.rb +7 -0
- data/spec/dummy/config/initializers/assets.rb +1 -6
- data/spec/dummy/config/routes.rb +1 -1
- data/spec/dummy/config/storage.yml +7 -0
- data/spec/dummy/db/migrate/20150721140013_devise_create_users.rb +1 -1
- data/spec/dummy/db/migrate/20150721141009_rolify_create_roles.rb +1 -1
- data/spec/dummy/db/migrate/20150721141128_create_groups.rb +1 -1
- data/spec/dummy/db/migrate/20150721150307_add_display_name_column_to_users.rb +1 -1
- data/spec/dummy/db/migrate/20160207190402_create_message_train_conversations.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190403_create_message_train_messages.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190404_create_message_train_attachments.message_train.rb +5 -2
- data/spec/dummy/db/migrate/20160207190405_create_message_train_receipts.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190406_create_message_train_ignores.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190407_add_received_through_to_message_train_receipts.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190408_add_unique_index_to_receipts.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20160207190409_create_message_train_unsubscribes.message_train.rb +1 -1
- data/spec/dummy/db/migrate/20260827095939_remove_paperclip_columns_from_message_train_attachments.message_train.rb +9 -0
- data/spec/dummy/db/migrate/20260827161141_create_active_storage_tables.active_storage.rb +57 -0
- data/spec/dummy/db/migrate/20260827171550_remove_body_from_message_train_messages.message_train.rb +6 -0
- data/spec/dummy/db/migrate/20260827225116_add_marking_indexes.message_train.rb +10 -0
- data/spec/dummy/db/migrate/20260828034830_add_body_back_to_message_train_messages.message_train.rb +6 -0
- data/spec/dummy/db/migrate/20260828040000_drop_action_text_tables.rb +5 -0
- data/spec/dummy/db/schema.rb +106 -72
- data/spec/dummy/db/seeds/conversations.seeds.rb +22 -22
- data/spec/dummy/db/seeds/development/conversations.seeds.rb +2 -2
- data/spec/dummy/db/seeds/groups.seeds.rb +4 -4
- data/spec/dummy/db/seeds/test/attachments.seeds.rb +4 -4
- data/spec/dummy/db/seeds/test/conversations.seeds.rb +2 -2
- data/spec/dummy/db/seeds/users.seeds.rb +8 -8
- data/spec/dummy/vendor/javascript/.keep +0 -0
- data/spec/factories/attachment.rb +5 -6
- data/spec/factories/group.rb +2 -2
- data/spec/factories/message.rb +7 -7
- data/spec/factories/user.rb +3 -3
- data/spec/features/boxes_spec.rb +38 -34
- data/spec/features/conversations_spec.rb +13 -6
- data/spec/features/messages_spec.rb +20 -20
- data/spec/features/unsubscribes_spec.rb +9 -9
- data/spec/helpers/message_train/application_helper_spec.rb +16 -18
- data/spec/helpers/message_train/attachment_helper_spec.rb +8 -18
- data/spec/helpers/message_train/boxes_helper_spec.rb +6 -6
- data/spec/helpers/message_train/collectives_helper_spec.rb +10 -10
- data/spec/helpers/message_train/conversations_helper_spec.rb +43 -85
- data/spec/helpers/message_train/messages_helper_spec.rb +41 -61
- data/spec/models/message_train/attachment_spec.rb +26 -36
- data/spec/models/message_train/box_spec.rb +2 -2
- data/spec/models/message_train/conversation_spec.rb +15 -0
- data/spec/models/message_train/message_spec.rb +5 -1
- data/spec/models/message_train/receipt_spec.rb +1 -1
- data/spec/models/message_train/unsubscribe_spec.rb +1 -1
- data/spec/rails_helper.rb +14 -13
- data/spec/support/controller_behaviors.rb +13 -13
- data/spec/support/factory_bot.rb +3 -0
- data/spec/support/feature_behaviors.rb +14 -14
- data/spec/support/loaded_site/attachments.rb +6 -2
- data/spec/support/utilities.rb +49 -20
- metadata +260 -213
- data/.travis.yml +0 -12
- data/VERSION +0 -1
- data/app/assets/javascripts/ckeditor/config.js +0 -49
- data/app/assets/javascripts/message_train.js +0 -149
- data/spec/dummy/app/assets/javascripts/application.js +0 -17
- data/spec/dummy/app/assets/stylesheets/bootstrap-everything.scss +0 -54
- data/spec/dummy/config/initializers/bootstrap_leather.rb +0 -7
- data/spec/dummy/config/initializers/paperclip.rb +0 -5
- data/spec/dummy/config/secrets.yml +0 -22
- data/spec/dummy/db/test.sqlite3 +0 -0
- 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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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] = '
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
43
|
-
!(
|
|
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
|
-
|
|
48
|
-
attachment.
|
|
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
|
-
'
|
|
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] =
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
%li
|
|
2
|
-
= link_to_unless current_page.first?, raw(t 'views.pagination.first'), url
|
|
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
|
|
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, :
|
|
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,
|
|
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,
|
|
6
|
+
= link_to page, url, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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, :
|
|
2
|
+
= link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, rel: 'prev'
|