chats 0.4.2 → 0.5.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. metadata +23 -2
@@ -0,0 +1,138 @@
1
+ <%# The messaging dock (`chats_dock` in the host's layout): fixed to the
2
+ bottom-right corner, LinkedIn / Steam style. Left to right, the floating
3
+ conversation WINDOWS (newest nearest the panel), then the PANEL: its
4
+ header is the collapsed bar (avatar, "Messages", unread badge), and open
5
+ it holds search, the recent list and the way to the full inbox. On a
6
+ phone (chats.css: under 700px wide or 500px tall) the closed bar is a
7
+ round button, the open panel a full-screen sheet.
8
+
9
+ data-turbo-permanent: Turbo Drive carries this element from page to page
10
+ as it is — open windows, scroll positions, drafts and cable
11
+ subscriptions included — as long as the next page renders a dock with
12
+ the same id. The id names the VIEWER, so a visit that lands on another
13
+ account's page (sign out and in, an account switcher) never matches and
14
+ never carries one person's windows into another's page.
15
+
16
+ The panel is a <details>: it opens and closes with no JavaScript at all,
17
+ and its rows, search and links are real links and forms. The chats--dock
18
+ controller (dock_controller.js) does the rest. Its live signal is the
19
+ template on the viewer's own `[messager, :chats_dock]` stream (see
20
+ chats/dock/_signal and Chats::Broadcasts). %>
21
+ <% key = Chats.messager_key(viewer) %>
22
+ <%= chats_styles %>
23
+ <%= tag.aside id: "chats_dock_#{key}",
24
+ class: ["chats-dock", ("chats-dock--no-launcher" unless launcher)],
25
+ "aria-label": t("chats.dock.title"),
26
+ data: {
27
+ turbo_permanent: true,
28
+ chats_messager: key,
29
+ controller: "chats--dock",
30
+ "chats--dock-windows-value": windows,
31
+ "chats--dock-messager-value": key,
32
+ "chats--dock-everywhere-value": everywhere,
33
+ "chats--dock-window-url-value": chats_routes.conversation_path("__id__", window: 1),
34
+ "chats--dock-record-key-value": Chats::Conversation.model_name.param_key,
35
+ "chats--dock-badge-url-value": chats_routes.badge_conversations_path,
36
+ "chats--dock-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
37
+ # Every plural form the locale has (CLDR: zero, one, two, few, many,
38
+ # other), uninterpolated: the controller picks one with
39
+ # Intl.PluralRules and fills %{count} in.
40
+ "chats--dock-window-unread-value": chats_plural_forms("chats.dock.window_unread").to_json,
41
+ action: "keydown->chats--dock#keydown " \
42
+ "focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
43
+ "touchstart->chats--dock#engage " \
44
+ "turbo:before-fetch-response->chats--dock#frameResponded " \
45
+ "turbo:frame-missing->chats--dock#frameMissing " \
46
+ "turbo:frame-load->chats--dock#frameLoaded " \
47
+ "turbo:fetch-request-error->chats--dock#frameFailed " \
48
+ "turbo:submit-end->chats--dock#sent " \
49
+ "input->chats--dock#typed " \
50
+ "click@document->chats--dock#delegate " \
51
+ "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
52
+ "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
53
+ } do %>
54
+ <%= turbo_stream_from viewer, :chats_dock %>
55
+ <%= render "chats/dock/signal" %>
56
+
57
+ <div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
58
+
59
+ <%# What a window shows before its thread arrives, and if it can't. %>
60
+ <template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
61
+ <template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
62
+
63
+ <details class="chats-dock__panel"
64
+ data-chats--dock-target="panel"
65
+ data-action="toggle->chats--dock#panelToggled">
66
+ <%# The summary reads "Messages, 3 unread" from its own text; on a phone
67
+ the closed one is the round launcher (chats.css). %>
68
+ <summary class="chats-dock__bar chats-dock__toggle">
69
+ <%= chats_messager_avatar(viewer) %>
70
+ <svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
71
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
72
+ <path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
73
+ </svg>
74
+ <span class="chats-dock__title"><%= t("chats.dock.title") %></span>
75
+ <%= render "chats/dock/badge", count: viewer.unread_chats_count %>
76
+ <span class="chats-dock__chevron" aria-hidden="true">
77
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
78
+ stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
79
+ </span>
80
+ <span class="chats-dock__close-glyph" aria-hidden="true">
81
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
82
+ focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
83
+ </span>
84
+ </summary>
85
+
86
+ <div class="chats-dock__body">
87
+ <% if Chats.config.search %>
88
+ <%# The inbox's own search: with JavaScript the results land in the
89
+ dock's frame (the inbox answers that frame with the dock's list);
90
+ without it, on the full inbox. %>
91
+ <%= form_with url: chats_routes.conversations_path,
92
+ method: :get,
93
+ class: "chats-search chats-dock__search",
94
+ # `html:`: form_with drops attributes it doesn't know (role among them).
95
+ html: { role: "search" },
96
+ data: {
97
+ controller: "chats--debounced-submit",
98
+ action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
99
+ "chats--debounced-submit-delay-value": 250,
100
+ turbo_frame: "chats_dock_list"
101
+ } do %>
102
+ <%= hidden_field_tag :recent, recent, id: nil %>
103
+ <%= search_field_tag :q, nil,
104
+ id: "chats_dock_q",
105
+ placeholder: t("chats.inbox.search_placeholder"),
106
+ "aria-label": t("chats.inbox.search_placeholder"),
107
+ class: "chats-search__input",
108
+ autocomplete: "off" %>
109
+ <% end %>
110
+ <% end %>
111
+
112
+ <%# Lazy: nothing is fetched until the panel first opens; until then,
113
+ four still rows. The controller reloads it when the signal says,
114
+ and when its stream may have missed a signal. `refresh: :morph`:
115
+ a reload morphs the rows in place, so the list keeps its scroll
116
+ position, and it reloads its own src, so a search stays. %>
117
+ <%= turbo_frame_tag "chats_dock_list",
118
+ src: chats_routes.dock_conversations_path(recent: recent),
119
+ loading: :lazy,
120
+ refresh: :morph,
121
+ target: "_top",
122
+ class: "chats-dock__results",
123
+ # A click here opens a window, never the page: hovering a row
124
+ # must not prefetch it.
125
+ data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
126
+ <div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
127
+ <% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
128
+ </div>
129
+ <% end %>
130
+
131
+ <%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
132
+ <%= chats_slot :dock_panel_footer, viewer: viewer %>
133
+ </div>
134
+ </details>
135
+
136
+ <%# "New message from …": polite, one at a time. The badge is not live. %>
137
+ <div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
138
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%# What just changed, for the dock's controller (it acts when the replaced
2
+ element connects, and consumes data-changed so a Turbo Drive visit that
3
+ reconnects the same node never acts twice). An inert <template>: it
4
+ renders nothing. The dock renders it bare; Chats::Broadcasts sends it
5
+ with the signal:
6
+
7
+ data-changed reload the recent list
8
+ data-conversation-id the conversation it was about, when the
9
+ viewer can see it
10
+ data-notify a message they'd be notified about: its
11
+ window may pop open, and it is announced
12
+ data-sender-name who it is from, for the announcement
13
+ data-unread-count unread there now, for the window's header
14
+
15
+ Strings for people are rendered by the dock in the VIEWER's locale
16
+ (Stimulus values), never here: a broadcast renders in no one's. %>
17
+ <% signal = local_assigns[:signal] %>
18
+ <%= tag.template id: "chats_dock_signal",
19
+ data: {
20
+ "chats--dock-target": "signal",
21
+ changed: (true if signal),
22
+ conversation_id: signal&.dig(:conversation_id),
23
+ notify: (true if signal&.dig(:notify)),
24
+ sender_name: signal&.dig(:sender_name),
25
+ unread_count: signal&.dig(:unread_count)
26
+ } %>
@@ -0,0 +1,10 @@
1
+ <%# One dock update (Chats::Broadcasts.update_dock_of), rendered per
2
+ participant and sent on their own `[messager, :chats_dock]` stream:
3
+ the badges when the unread count moved, and the signal. %>
4
+ <% if count %>
5
+ <%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]",
6
+ partial: "chats/dock/badge", locals: { count: count } %>
7
+ <% end %>
8
+ <% if signal %>
9
+ <%= turbo_stream.replace "chats_dock_signal", partial: "chats/dock/signal", locals: { signal: signal } %>
10
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%# A floating window's FRAME, loading its thread from `?window=1`
2
+ (conversations/window.html.erb). Appended to #chats_dock_windows by
3
+ ConversationsController#create (`chat_button_to …, dock: true`); the
4
+ dock's controller builds the identical element itself when a row or a
5
+ live message opens one (dock_controller.js, buildWindow) — keep the two
6
+ in step. A conversation already open in a window is focused instead of
7
+ reloaded (the controller intercepts the append).
8
+
9
+ `target: "_top"`: links inside a window open the page, never the frame.
10
+ `data-chats-active`: the viewer opened it, so it may mark messages read.
11
+ `data-focus`: and the composer takes the focus once the thread arrives. %>
12
+ <%= turbo_frame_tag dom_id(conversation, :window),
13
+ src: chats_routes.conversation_path(conversation, window: 1),
14
+ target: "_top",
15
+ class: "chats-window",
16
+ data: {
17
+ "chats--dock-target": "window",
18
+ conversation_id: conversation.id,
19
+ chats_active: true,
20
+ focus: (true if local_assigns[:focus])
21
+ } do %>
22
+ <%= render "chats/dock/window_shell", state: :loading, title: conversation.title_for(chats_current_messager) %>
23
+ <% end %>
@@ -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>
@@ -57,6 +57,7 @@
57
57
  <% if Chats.config.attachments %>
58
58
  <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
59
59
  <%= form.file_field :files, multiple: true, hidden: true,
60
+ id: dom_id(conversation, :message_files),
60
61
  accept: (Chats.config.attachments == :images ? "image/*" : nil),
61
62
  data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
62
63
  <span aria-hidden="true">📎</span>
@@ -64,14 +65,23 @@
64
65
  </label>
65
66
  <% end %>
66
67
 
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. %>
67
72
  <%= form.text_area :body,
73
+ id: dom_id(conversation, :message_body),
68
74
  rows: 1,
69
75
  placeholder: t("chats.composer.placeholder"),
70
76
  class: "chats-composer__input",
71
77
  autocomplete: "off",
72
78
  data: {
73
79
  "chats--composer-target": "input",
74
- action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend"
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"
75
85
  } %>
76
86
 
77
87
  <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
@@ -15,12 +15,14 @@
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
28
  <div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
@@ -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>
data/config/importmap.rb CHANGED
@@ -15,3 +15,10 @@ 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
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
+
21
+ # Not a controller: what the inbox, thread, badge and dock controllers share
22
+ # to catch up on broadcasts they missed. Outside controllers/ so the eager
23
+ # loader never tries to register it.
24
+ pin "chats/recovery", to: "chats/recovery.js"
@@ -54,6 +54,23 @@ en:
54
54
  send: "Send"
55
55
  attach: "Attach images"
56
56
  locked: "This conversation is closed."
57
+ dock:
58
+ title: "Messages"
59
+ unread_suffix: "unread"
60
+ see_all: "See all messages"
61
+ window_label: "Conversation with %{name}"
62
+ open_full: "Open full conversation"
63
+ minimize: "Minimize"
64
+ close: "Close conversation"
65
+ back: "Back to messages"
66
+ hello: "Say hello to %{name}."
67
+ load_error: "Couldn't load this conversation."
68
+ retry: "Try again"
69
+ new_message_from: "New message from %{name}"
70
+ window_unread:
71
+ one: "%{count} new message"
72
+ other: "%{count} new messages"
73
+ muted: "Muted"
57
74
  buttons:
58
75
  chat: "Message"
59
76
  flashes:
@@ -54,6 +54,23 @@ es:
54
54
  send: "Enviar"
55
55
  attach: "Adjuntar imágenes"
56
56
  locked: "Esta conversación está cerrada."
57
+ dock:
58
+ title: "Mensajes"
59
+ unread_suffix: "sin leer"
60
+ see_all: "Ver todos los mensajes"
61
+ window_label: "Conversación con %{name}"
62
+ open_full: "Abrir la conversación completa"
63
+ minimize: "Minimizar"
64
+ close: "Cerrar conversación"
65
+ back: "Volver a los mensajes"
66
+ hello: "Saluda a %{name}."
67
+ load_error: "No se ha podido cargar esta conversación."
68
+ retry: "Reintentar"
69
+ new_message_from: "Nuevo mensaje de %{name}"
70
+ window_unread:
71
+ one: "%{count} mensaje nuevo"
72
+ other: "%{count} mensajes nuevos"
73
+ muted: "Silenciada"
57
74
  buttons:
58
75
  chat: "Mensaje"
59
76
  flashes:
data/config/routes.rb CHANGED
@@ -5,6 +5,14 @@ Chats::Engine.routes.draw do
5
5
  # `mount Chats::Engine => "/messages"` the inbox is /messages, a thread is
6
6
  # /messages/:id, sending is POST /messages/:conversation_id/messages.
7
7
  resources :conversations, path: "", only: %i[index show create] do
8
+ # The messaging dock's recent list (`chats_dock`), served into its turbo
9
+ # frame. A collection route, so it is drawn before /:id and never read as
10
+ # a conversation id.
11
+ get :dock, on: :collection
12
+ # Every unread badge on the page, fresh (`chats_unread_badge` and the
13
+ # dock's), for a page that may have missed the badge streams.
14
+ get :badge, on: :collection
15
+
8
16
  member do
9
17
  post :read # advance the viewer's read horizon (mark as read)
10
18
  post :typing # ephemeral "X is typing…" ping (see Chats::Broadcasts.typing)
Binary file
Binary file
@@ -26,6 +26,8 @@ group :test do
26
26
  gem "mysql2"
27
27
  gem "pg"
28
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
29
31
  gem "bootsnap", require: false
30
32
  gem "importmap-rails"
31
33
  gem "propshaft"
@@ -26,6 +26,8 @@ group :test do
26
26
  gem "mysql2"
27
27
  gem "pg"
28
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
29
31
  gem "bootsnap", require: false
30
32
  gem "importmap-rails"
31
33
  gem "propshaft"
@@ -26,6 +26,8 @@ group :test do
26
26
  gem "mysql2"
27
27
  gem "pg"
28
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
29
31
  gem "bootsnap", require: false
30
32
  gem "importmap-rails"
31
33
  gem "propshaft"