chats 0.1.1 → 0.6.2

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -0,0 +1,75 @@
1
+ <%# A dock window's header. Rendered with the thread (window.html.erb) and,
2
+ with only a name, as the shell a window shows while it loads or after it
3
+ failed (chats/dock/_window_shell) — so minimize and close always work.
4
+
5
+ Locals: conversation (nil in the shell), title, counterpart (or nil). %>
6
+ <% conversation = local_assigns[:conversation] %>
7
+ <% counterpart = local_assigns[:counterpart] %>
8
+ <header class="chats-window__header">
9
+ <%# Phones show one full-screen window: back returns to the list. %>
10
+ <button type="button" class="chats-window__icon chats-window__back"
11
+ aria-label="<%= t("chats.dock.back") %>" title="<%= t("chats.dock.back") %>"
12
+ data-action="chats--dock#back">
13
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
14
+ stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 12H5m6-6-6 6 6 6"/></svg>
15
+ </button>
16
+
17
+ <button type="button" class="chats-window__title" aria-expanded="true" title="<%= title %>"
18
+ data-chats-dock-minimize data-action="chats--dock#minimize">
19
+ <% if counterpart %>
20
+ <%= chats_messager_avatar(counterpart) %>
21
+ <% elsif conversation %>
22
+ <%= chats_conversation_avatar(conversation, chats_current_messager) %>
23
+ <% end %>
24
+ <span class="chats-window__identity">
25
+ <span class="chats-window__name-row">
26
+ <span class="chats-window__name" data-chats-dock-name><%= title %></span>
27
+ <%= chats_verified_badge(counterpart) %>
28
+ </span>
29
+ <% if conversation&.subject_label %>
30
+ <span class="chats-window__subject"><%= conversation.subject_label %></span>
31
+ <% end %>
32
+ </span>
33
+ <%# The conversation's unread count while the window waits to be seen
34
+ (filled in by the dock's controller). %>
35
+ <span class="chats-badge chats-window__unread" data-chats-dock-window-unread hidden></span>
36
+ </button>
37
+
38
+ <%# The host's own thread actions (block, report, view profile), as on
39
+ the page's ⋯ menu, with the same locals. %>
40
+ <% if conversation && chats_slot?(:conversation_header_actions) %>
41
+ <details class="chats-menu chats-window__menu">
42
+ <summary class="chats-window__icon" aria-label="<%= t("chats.thread.options") %>"
43
+ title="<%= t("chats.thread.options") %>">⋯</summary>
44
+ <div class="chats-menu__panel">
45
+ <%= chats_slot :conversation_header_actions,
46
+ conversation: conversation,
47
+ viewer: chats_current_messager,
48
+ counterpart: counterpart,
49
+ blockable: chats_blockable?(counterpart) %>
50
+ </div>
51
+ </details>
52
+ <% end %>
53
+
54
+ <% if conversation %>
55
+ <%= link_to chats_routes.conversation_path(conversation),
56
+ class: "chats-window__icon chats-window__expand",
57
+ title: t("chats.dock.open_full"), "aria-label": t("chats.dock.open_full"),
58
+ data: { turbo_frame: "_top" } do %>
59
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
60
+ stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
61
+ <% end %>
62
+ <% end %>
63
+ <button type="button" class="chats-window__icon chats-window__minimize"
64
+ aria-label="<%= t("chats.dock.minimize") %>" title="<%= t("chats.dock.minimize") %>"
65
+ data-action="chats--dock#minimize">
66
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
67
+ aria-hidden="true" focusable="false"><path d="M5 12h14"/></svg>
68
+ </button>
69
+ <button type="button" class="chats-window__icon chats-window__close"
70
+ aria-label="<%= t("chats.dock.close") %>" title="<%= t("chats.dock.close") %>"
71
+ data-action="chats--dock#close">
72
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
73
+ aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>
74
+ </button>
75
+ </header>
@@ -0,0 +1,17 @@
1
+ <%# What a window shows before its thread arrives, and if it never does: the
2
+ header (so minimize and close work) and a status line. The dock renders
3
+ it once, as <template>s its controller clones; `chats/dock/_window`
4
+ renders the loading one inside the frame it sends.
5
+
6
+ Locals: state (:loading or :failed), title (the name when known). %>
7
+ <section class="chats-window__card" role="region" aria-label="<%= local_assigns[:title] || t("chats.dock.title") %>">
8
+ <%= render "chats/dock/window_header", title: local_assigns[:title] || t("chats.thread.loading") %>
9
+ <% if local_assigns[:state] == :failed %>
10
+ <div class="chats-window__status" role="alert">
11
+ <p><%= t("chats.dock.load_error") %></p>
12
+ <button type="button" class="chats-window__retry" data-action="chats--dock#retry"><%= t("chats.dock.retry") %></button>
13
+ </div>
14
+ <% else %>
15
+ <div class="chats-window__status"><%= t("chats.thread.loading") %></div>
16
+ <% end %>
17
+ </section>
@@ -0,0 +1,15 @@
1
+ <%# A message's featured button (Chats::Action): the one place it takes you,
2
+ under the words it came with. Rendered inside the bubble, so it is part
3
+ of the single viewer-agnostic broadcast render AND of the clone the
4
+ long-press popup lifts — which is why it is a plain link and never a
5
+ form: a form would need a per-viewer CSRF token baked into a render
6
+ every subscriber shares. An action is a destination, not a mutation.
7
+
8
+ data-turbo-frame="_top": older pages of a thread arrive inside lazy
9
+ `chats_page_…` turbo-frames, and a link inside a frame navigates the
10
+ FRAME. _top makes it navigate the page, wherever the bubble landed.
11
+
12
+ The url was validated at the write (Chats::Message#
13
+ action_must_be_well_formed): http(s) or a path on this site, never
14
+ `javascript:` or protocol-relative. %>
15
+ <%= link_to action.label, action.url, class: "chats-message__action", data: { turbo_frame: "_top" } %>
@@ -1,67 +1,95 @@
1
1
  <%# The composer. Submits as a Turbo Stream (the sender's bubble appends from
2
2
  the response — instant, no cable round-trip needed); the chats--composer
3
3
  controller adds autosize, desktop Enter-to-send, throttled typing pings,
4
- and reset-on-success. Plain form POST still works with JS disabled. %>
5
- <%= form_with model: Chats::Message.new,
6
- url: conversation_messages_path(conversation),
7
- class: "chats-composer",
8
- data: {
9
- controller: "chats--composer",
10
- "chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
11
- action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
12
- } do |form| %>
4
+ and reset-on-success. Plain form POST still works with JS disabled.
13
5
 
14
- <div id="<%= dom_id(conversation, :composer_errors) %>" class="chats-composer__errors"></div>
6
+ The outer div is the DOCK — the form plus the host's composer_actions.
7
+ It carries the swap id: a conversation that locks while the composer is
8
+ open gets the locked notice rendered straight over the whole dock
9
+ (chats/messages/locked.turbo_stream.erb), so the host's buttons go with
10
+ it. `.chats-composer` styles the dock; the form is `.chats-composer__form`. %>
11
+ <div id="<%= dom_id(conversation, :composer) %>" class="chats-composer">
12
+ <%# Slot: the host's own buttons by the composer ("Create a game", "Send my
13
+ location"). OUTSIDE the form on purpose: host buttons are usually
14
+ button_to forms, and a form inside a form is invalid HTML — the browser
15
+ drops the inner one and its button submits THIS form instead. %>
16
+ <% if chats_slot?(:composer_actions) %>
17
+ <div class="chats-composer__actions">
18
+ <%= chats_slot :composer_actions, conversation: conversation %>
19
+ </div>
20
+ <% end %>
15
21
 
16
- <%# The edit cue (Telegram's flow): long-press → Editar loads the message
17
- into this same form; this quote-style bar (left border, bold label,
18
- the original trimmed to one line) is the signal that send = save.
19
- The ✕ cancels back to compose mode. %>
20
- <div class="chats-composer__edit" data-chats--composer-target="editBar" hidden>
21
- <div class="chats-composer__edit-quote">
22
- <span class="chats-composer__edit-label"><%= t("chats.composer.editing") %></span>
23
- <span class="chats-composer__edit-preview" data-chats--composer-target="editPreview"></span>
24
- </div>
25
- <button type="button"
26
- class="chats-composer__edit-cancel"
27
- aria-label="<%= t("chats.composer.cancel_edit") %>"
28
- data-action="chats--composer#cancelEdit">×</button>
29
- </div>
22
+ <%= form_with model: Chats::Message.new,
23
+ url: conversation_messages_path(conversation),
24
+ class: "chats-composer__form",
25
+ data: {
26
+ controller: "chats--composer",
27
+ "chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
28
+ action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
29
+ } do |form| %>
30
30
 
31
- <%# Selected-attachment previews: thumbnails with per-file remove, rendered
32
- client-side from the picker selection (object URLs — nothing uploads
33
- until send). This is THE feedback that files are about to go out; a
34
- counter chip alone reads as noise. %>
35
- <% if Chats.config.attachments %>
36
- <div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
37
- <% end %>
31
+ <div id="<%= dom_id(conversation, :composer_errors) %>" class="chats-composer__errors"></div>
32
+
33
+ <%# The edit cue (Telegram's flow): long-press → Editar loads the message
34
+ into this same form; this quote-style bar (left border, bold label,
35
+ the original trimmed to one line) is the signal that send = save.
36
+ The ✕ cancels back to compose mode. %>
37
+ <div class="chats-composer__edit" data-chats--composer-target="editBar" hidden>
38
+ <div class="chats-composer__edit-quote">
39
+ <span class="chats-composer__edit-label"><%= t("chats.composer.editing") %></span>
40
+ <span class="chats-composer__edit-preview" data-chats--composer-target="editPreview"></span>
41
+ </div>
42
+ <button type="button"
43
+ class="chats-composer__edit-cancel"
44
+ aria-label="<%= t("chats.composer.cancel_edit") %>"
45
+ data-action="chats--composer#cancelEdit">×</button>
46
+ </div>
38
47
 
39
- <div class="chats-composer__bar">
48
+ <%# Selected-attachment previews: thumbnails with per-file remove, rendered
49
+ client-side from the picker selection (object URLs — nothing uploads
50
+ until send). This is THE feedback that files are about to go out; a
51
+ counter chip alone reads as noise. %>
40
52
  <% if Chats.config.attachments %>
41
- <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
42
- <%= form.file_field :files, multiple: true, hidden: true,
43
- accept: (Chats.config.attachments == :images ? "image/*" : nil),
44
- data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
45
- <span aria-hidden="true">📎</span>
46
- <span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
47
- </label>
53
+ <div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
48
54
  <% end %>
49
55
 
50
- <%= form.text_area :body,
51
- rows: 1,
52
- placeholder: t("chats.composer.placeholder"),
53
- class: "chats-composer__input",
54
- autocomplete: "off",
55
- data: {
56
- "chats--composer-target": "input",
57
- action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend"
58
- } %>
56
+ <div class="chats-composer__bar">
57
+ <% if Chats.config.attachments %>
58
+ <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
59
+ <%= form.file_field :files, multiple: true, hidden: true,
60
+ id: dom_id(conversation, :message_files),
61
+ accept: (Chats.config.attachments == :images ? "image/*" : nil),
62
+ data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
63
+ <span aria-hidden="true">📎</span>
64
+ <span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
65
+ </label>
66
+ <% end %>
59
67
 
60
- <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
61
- mobile keyboard up) through the send tap — see composer controller. %>
62
- <%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
63
- data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
64
- <span aria-hidden="true">➤</span>
65
- <% end %>
66
- </div>
67
- <% end %>
68
+ <%# Ids per conversation, not form_with's `message_body`: a page can
69
+ hold several composers (the dock's windows), and Turbo restores
70
+ focus BY ID after rendering a stream — a shared id sent the cursor
71
+ into another window's composer. %>
72
+ <%= form.text_area :body,
73
+ id: dom_id(conversation, :message_body),
74
+ rows: 1,
75
+ placeholder: t("chats.composer.placeholder"),
76
+ class: "chats-composer__input",
77
+ autocomplete: "off",
78
+ data: {
79
+ "chats--composer-target": "input",
80
+ # In a dock window, the dock puts a saved draft back the moment
81
+ # this field appears (dock_controller.js, composerTargetConnected).
82
+ "chats--dock-target": "composer",
83
+ action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend " \
84
+ "keydown.esc->chats--composer#escape"
85
+ } %>
86
+
87
+ <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
88
+ mobile keyboard up) through the send tap — see composer controller. %>
89
+ <%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
90
+ data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
91
+ <span aria-hidden="true">➤</span>
92
+ <% end %>
93
+ </div>
94
+ <% end %>
95
+ </div>
@@ -15,19 +15,24 @@
15
15
  data-chats--thread-target="message"
16
16
  data-sender-key="<%= message.sender_key %>"
17
17
  data-timestamp="<%= message.created_at.iso8601 %>"
18
- <%# updated-at-ms: the thread controller tracks the max across bubbles as
19
- its `?since=` cursor for the stale-thread refresh — updated_at (not
20
- created_at) so edits/tombstones that happened while asleep are caught
21
- too. `.ceil` biases the cursor PAST this row's sub-millisecond tail,
22
- so the strictly-greater server query can't re-fetch it. %>
23
- data-updated-at-ms="<%= (message.updated_at.to_f * 1000).ceil %>">
18
+ <%# The stale-thread catch-up's cursors, to the microsecond the
19
+ database keeps, with the id that breaks a tie: the newest creation
20
+ on screen decides what is appended (`after`), the newest change
21
+ what is replaced (`changed`), and the oldest bubble's creation is
22
+ the `from` of the list of what still exists (hard deletes). %>
23
+ data-message-id="<%= message.id %>"
24
+ data-created-at-us="<%= (message.created_at.to_i * 1_000_000) + message.created_at.usec %>"
25
+ data-updated-at-us="<%= (message.updated_at.to_i * 1_000_000) + message.updated_at.usec %>">
24
26
 
25
27
  <% if message.system? %>
26
- <div class="chats-message__system"><%= message.body %></div>
28
+ <div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
29
+ <%= message.body %>
30
+ <%= render "chats/messages/action", action: message.action if message.action? %>
31
+ </div>
27
32
  <% else %>
28
33
  <div class="chats-message__content">
29
34
  <% if message.conversation.group? && message.sender %>
30
- <span class="chats-message__sender"><%= Chats.display_name_for(message.sender) %></span>
35
+ <%= chats_messager_name(message.sender, css_class: "chats-message__sender") %>
31
36
  <% end %>
32
37
 
33
38
  <div class="chats-message__bubble-row">
@@ -74,6 +79,16 @@
74
79
  <% end %>
75
80
  </div>
76
81
  <% end %>
82
+
83
+ <%= render "chats/messages/action", action: message.action if message.action? %>
84
+ <% end %>
85
+
86
+ <%# Signature: who WROTE this, when that isn't who it was sent
87
+ from — an agent answering from a shared desk seat. The sender
88
+ stays the conversation identity; this line keeps the human
89
+ visible. See Chats::Message#signed?. %>
90
+ <% if (signature = chats_message_signature(message)) %>
91
+ <div class="chats-message__signature" data-chats-message-signature><%= signature %></div>
77
92
  <% end %>
78
93
 
79
94
  <span class="chats-message__meta">
@@ -81,6 +96,7 @@
81
96
  <span class="chats-message__edited"><%= t("chats.message.edited") %></span>
82
97
  <% end %>
83
98
  <time datetime="<%= message.created_at.iso8601 %>"><%= message.created_at.in_time_zone.strftime("%H:%M") %></time>
99
+ <%= chats_slot :message_meta, message: message %>
84
100
  <% if Chats.config.read_receipts %>
85
101
  <span class="chats-message__receipt"
86
102
  data-chats-message-receipt
@@ -95,6 +111,12 @@
95
111
  </div>
96
112
 
97
113
  <% unless message.deleted? %>
114
+ <%# A locked conversation (its SUBJECT says so) refuses every write —
115
+ new messages, edits, deletes and reactions alike. The server
116
+ enforces it; here we stop OFFERING what would only 422: existing
117
+ reactions still render, as plain counts instead of toggles, and
118
+ the long-press menu keeps only Copy. %>
119
+ <% locked = message.conversation.locked? %>
98
120
  <% reactions = Chats.config.reactions ? Chats::Reaction.summary_for(message) : [] %>
99
121
  <% if reactions.any? %>
100
122
  <div class="chats-message__reactions">
@@ -103,10 +125,16 @@
103
125
  through the HOST's renderer where engine helpers don't exist
104
126
  unqualified. See EngineHelper#chats_routes. %>
105
127
  <% reactions.each do |emoji, count| %>
106
- <%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
107
- method: :post, params: { emoji: emoji },
108
- class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
109
- <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
128
+ <% if locked %>
129
+ <span class="chats-reaction chats-reaction--locked">
130
+ <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
131
+ </span>
132
+ <% else %>
133
+ <%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
134
+ method: :post, params: { emoji: emoji },
135
+ class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
136
+ <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
137
+ <% end %>
110
138
  <% end %>
111
139
  <% end %>
112
140
  </div>
@@ -122,7 +150,7 @@
122
150
  button_to forms keep working when cloned: the CSRF token is
123
151
  baked in at render time. %>
124
152
  <template data-chats-message-menu>
125
- <% if Chats.config.reactions %>
153
+ <% if Chats.config.reactions && !locked %>
126
154
  <div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
127
155
  <% %w[👍 ❤️ 😂 😮 😢 🙏].each do |emoji| %>
128
156
  <%= button_to emoji, chats_routes.conversation_message_reactions_path(message.conversation, message),
@@ -137,12 +165,12 @@
137
165
  <%= t("chats.message.copy") %>
138
166
  </button>
139
167
  <% end %>
140
- <% if Chats.config.editing && message.body.present? %>
168
+ <% if Chats.config.editing && message.body.present? && !locked %>
141
169
  <button type="button" class="chats-popup__item" role="menuitem" data-chats-action="edit" data-chats-own-only>
142
170
  <%= t("chats.message.edit") %>
143
171
  </button>
144
172
  <% end %>
145
- <% if Chats.config.deletion %>
173
+ <% if Chats.config.deletion && !locked %>
146
174
  <%= button_to t("chats.message.delete"),
147
175
  chats_routes.conversation_message_path(message.conversation, message),
148
176
  method: :delete,
@@ -0,0 +1,6 @@
1
+ <%# The send landed on a conversation whose subject has since locked it.
2
+ Replace the composer with the locked notice (422) so the screen stops
3
+ lying to whoever had it open, and clear any stale error text. %>
4
+ <%= turbo_stream.replace dom_id(@conversation, :composer) do %>
5
+ <%= render "chats/conversations/locked_composer", conversation: @conversation %>
6
+ <% end %>
@@ -1,6 +1,9 @@
1
1
  <%# The live unread badge (embed anywhere via the chats_unread_badge helper).
2
2
  Fixed id: it's the target Chats::Broadcasts.update_badge_of replaces.
3
- Always rendered (hidden at zero) so the broadcast has an element to update. %>
3
+ Always rendered (hidden at zero) so the broadcast has an element to update.
4
+ data-chats-unread-badge marks every unread badge, the dock's included, for
5
+ host CSS and JS that want them all. %>
4
6
  <span id="chats_unread_badge"
5
7
  class="chats-badge chats-badge--floating"
8
+ data-chats-unread-badge
6
9
  <%= "hidden" unless count.positive? %>><%= count > 99 ? "99+" : count %></span>
@@ -0,0 +1,32 @@
1
+ <%# The "official account" mark. Rendered next to a verified messager's name
2
+ (`acts_as_messager verified: true`) in every bundled view that shows one:
3
+ the inbox row, the stacked row and the thread header.
4
+
5
+ It is an IMAGE with a name, not decoration: the wrapper carries
6
+ role="img" plus the localized label, so a screen reader announces
7
+ "Cuenta oficial" / "Official account" where a sighted person sees the
8
+ badge, and the glyph itself is aria-hidden so it is never read twice.
9
+
10
+ The glyph is heroicons `check-badge` (24/solid): a scalloped rosette
11
+ with a TICK knocked out of it, which is the mark people already read as
12
+ "verified". It is deliberately not a plain star — a star says "favourite"
13
+ or "rated", and at 14px a solid star and a solid rosette are the same
14
+ blob, so the tick is the whole point. `fill-rule: evenodd` is what knocks
15
+ the tick out; without it the rosette fills solid and the mark is a blob.
16
+
17
+ Colour comes from the --chats-verified CSS variable (see chats.css) via
18
+ `fill: currentColor` — override that variable, or swap this whole partial
19
+ with `config.verified_badge`. %>
20
+ <span class="chats-verified"
21
+ role="img"
22
+ title="<%= t("chats.verified.label") %>"
23
+ aria-label="<%= t("chats.verified.label") %>">
24
+ <svg xmlns="http://www.w3.org/2000/svg"
25
+ viewBox="0 0 24 24"
26
+ fill="currentColor"
27
+ class="chats-verified__glyph"
28
+ aria-hidden="true"
29
+ focusable="false">
30
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" />
31
+ </svg>
32
+ </span>
data/config/importmap.rb CHANGED
@@ -8,9 +8,18 @@
8
8
  # "chats--composer", etc. with ZERO host JavaScript changes. (stimulus-rails,
9
9
  # app/assets/javascripts/stimulus-loading.js, registerControllerFromPath.)
10
10
  #
11
- # Hosts can override either controller by pinning the same key themselves —
11
+ # Hosts can override any controller by pinning the same key themselves —
12
12
  # the engine's importmap is drawn FIRST (unshifted in Chats::Engine), and
13
13
  # importmap-rails resolves duplicate pins last-wins.
14
14
  pin "controllers/chats/thread_controller", to: "chats/thread_controller.js"
15
15
  pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
16
16
  pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
17
+ pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
18
+ pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
19
+ pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
20
+ pin "controllers/chats/split_controller", to: "chats/split_controller.js"
21
+
22
+ # Not a controller: what the inbox, thread, badge and dock controllers share
23
+ # to catch up on broadcasts they missed. Outside controllers/ so the eager
24
+ # loader never tries to register it.
25
+ pin "chats/recovery", to: "chats/recovery.js"
@@ -1,5 +1,8 @@
1
1
  en:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Messages"
5
+ conversation: "%{name} · Messages"
3
6
  inbox:
4
7
  title: "Messages"
5
8
  search_placeholder: "Search conversations"
@@ -8,6 +11,12 @@ en:
8
11
  no_results_title: "No results"
9
12
  no_results_hint: "Nothing matched “%{query}”."
10
13
  no_messages: "No messages yet"
14
+ select_hint: "Select a conversation"
15
+ filtered_by: "Conversations with %{name}"
16
+ clear_filter: "Show all conversations"
17
+ group_count:
18
+ one: "1 conversation"
19
+ other: "%{count} conversations"
11
20
  you_prefix: "You:"
12
21
  thread:
13
22
  back: "Back"
@@ -22,14 +31,18 @@ en:
22
31
  yesterday: "Yesterday"
23
32
  typing_suffix: "is typing…"
24
33
  new_messages: "New messages"
34
+ see_all: "See all"
25
35
  conversation:
26
36
  empty_title: "Conversation"
37
+ verified:
38
+ label: "Official account"
27
39
  message:
28
40
  deleted: "Message deleted"
29
41
  edited: "edited"
30
42
  copy: "Copy"
31
43
  copied: "Copied!"
32
44
  attachment: "Photo"
45
+ signature: "— %{name}"
33
46
  close_attachment: "Close photo"
34
47
  edit: "Edit"
35
48
  delete: "Delete"
@@ -44,6 +57,24 @@ en:
44
57
  placeholder: "Write a message…"
45
58
  send: "Send"
46
59
  attach: "Attach images"
60
+ locked: "This conversation is closed."
61
+ dock:
62
+ title: "Messages"
63
+ unread_suffix: "unread"
64
+ see_all: "See all messages"
65
+ window_label: "Conversation with %{name}"
66
+ open_full: "Open full conversation"
67
+ minimize: "Minimize"
68
+ close: "Close conversation"
69
+ back: "Back to messages"
70
+ hello: "Say hello to %{name}."
71
+ load_error: "Couldn't load this conversation."
72
+ retry: "Try again"
73
+ new_message_from: "New message from %{name}"
74
+ window_unread:
75
+ one: "%{count} new message"
76
+ other: "%{count} new messages"
77
+ muted: "Muted"
47
78
  buttons:
48
79
  chat: "Message"
49
80
  flashes:
@@ -54,6 +85,9 @@ en:
54
85
  unmuted: "Conversation unmuted."
55
86
 
56
87
  activerecord:
88
+ attributes:
89
+ chats/message:
90
+ action: "Action"
57
91
  errors:
58
92
  models:
59
93
  chats/conversation:
@@ -67,7 +101,13 @@ en:
67
101
  chats/message:
68
102
  attributes:
69
103
  base:
104
+ locked: "This conversation is closed."
70
105
  blocked: "You can't message this person."
106
+ not_allowed: "You can't message this person right now."
107
+ action:
108
+ invalid: "needs a label of up to %{count} characters and a link to an http(s) address or to a path on this site that starts with /"
109
+ author:
110
+ not_a_messager: "must be a messager (acts_as_messager)"
71
111
  sender:
72
112
  blank: "is required"
73
113
  not_a_participant: "is not a participant of this conversation"
@@ -1,5 +1,8 @@
1
1
  es:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Mensajes"
5
+ conversation: "%{name} · Mensajes"
3
6
  inbox:
4
7
  title: "Mensajes"
5
8
  search_placeholder: "Buscar conversaciones"
@@ -8,6 +11,12 @@ es:
8
11
  no_results_title: "Sin resultados"
9
12
  no_results_hint: "No hay nada que coincida con «%{query}»."
10
13
  no_messages: "Sin mensajes todavía"
14
+ select_hint: "Selecciona una conversación"
15
+ filtered_by: "Conversaciones con %{name}"
16
+ clear_filter: "Ver todas las conversaciones"
17
+ group_count:
18
+ one: "1 conversación"
19
+ other: "%{count} conversaciones"
11
20
  you_prefix: "Tú:"
12
21
  thread:
13
22
  back: "Atrás"
@@ -22,14 +31,18 @@ es:
22
31
  yesterday: "Ayer"
23
32
  typing_suffix: "está escribiendo…"
24
33
  new_messages: "Mensajes nuevos"
34
+ see_all: "Ver todas"
25
35
  conversation:
26
36
  empty_title: "Conversación"
37
+ verified:
38
+ label: "Cuenta oficial"
27
39
  message:
28
40
  deleted: "Mensaje eliminado"
29
41
  edited: "editado"
30
42
  copy: "Copiar"
31
43
  copied: "¡Copiado!"
32
44
  attachment: "Foto"
45
+ signature: "— %{name}"
33
46
  close_attachment: "Cerrar foto"
34
47
  edit: "Editar"
35
48
  delete: "Eliminar"
@@ -44,6 +57,24 @@ es:
44
57
  placeholder: "Escribe un mensaje…"
45
58
  send: "Enviar"
46
59
  attach: "Adjuntar imágenes"
60
+ locked: "Esta conversación está cerrada."
61
+ dock:
62
+ title: "Mensajes"
63
+ unread_suffix: "sin leer"
64
+ see_all: "Ver todos los mensajes"
65
+ window_label: "Conversación con %{name}"
66
+ open_full: "Abrir la conversación completa"
67
+ minimize: "Minimizar"
68
+ close: "Cerrar conversación"
69
+ back: "Volver a los mensajes"
70
+ hello: "Saluda a %{name}."
71
+ load_error: "No se ha podido cargar esta conversación."
72
+ retry: "Reintentar"
73
+ new_message_from: "Nuevo mensaje de %{name}"
74
+ window_unread:
75
+ one: "%{count} mensaje nuevo"
76
+ other: "%{count} mensajes nuevos"
77
+ muted: "Silenciada"
47
78
  buttons:
48
79
  chat: "Mensaje"
49
80
  flashes:
@@ -54,6 +85,9 @@ es:
54
85
  unmuted: "Notificaciones activadas."
55
86
 
56
87
  activerecord:
88
+ attributes:
89
+ chats/message:
90
+ action: "Acción"
57
91
  errors:
58
92
  models:
59
93
  chats/conversation:
@@ -67,7 +101,13 @@ es:
67
101
  chats/message:
68
102
  attributes:
69
103
  base:
104
+ locked: "Esta conversación está cerrada."
70
105
  blocked: "No puedes enviar mensajes a esta persona."
106
+ not_allowed: "Ahora mismo no puedes enviar mensajes a esta persona."
107
+ action:
108
+ invalid: "necesita un texto de hasta %{count} caracteres y un enlace a una dirección http(s) o a una ruta de este sitio que empiece por /"
109
+ author:
110
+ not_a_messager: "debe ser un mensajero (acts_as_messager)"
71
111
  sender:
72
112
  blank: "es obligatorio"
73
113
  not_a_participant: "no participa en esta conversación"