chats 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +276 -0
  3. data/README.md +96 -9
  4. data/app/assets/stylesheets/chats.css +596 -0
  5. data/app/controllers/chats/conversations_controller.rb +71 -5
  6. data/app/helpers/chats/engine_helper.rb +75 -2
  7. data/app/javascript/chats/composer_controller.js +14 -1
  8. data/app/javascript/chats/dock_controller.js +1070 -0
  9. data/app/javascript/chats/thread_controller.js +72 -6
  10. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  11. data/app/views/chats/conversations/_group.html.erb +6 -2
  12. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  13. data/app/views/chats/conversations/_thread.html.erb +172 -0
  14. data/app/views/chats/conversations/dock.html.erb +40 -0
  15. data/app/views/chats/conversations/index.html.erb +2 -1
  16. data/app/views/chats/conversations/show.html.erb +11 -118
  17. data/app/views/chats/conversations/window.html.erb +26 -0
  18. data/app/views/chats/dock/_badge.html.erb +12 -0
  19. data/app/views/chats/dock/_dock.html.erb +130 -0
  20. data/app/views/chats/dock/_signal.html.erb +26 -0
  21. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  22. data/app/views/chats/dock/_window.html.erb +23 -0
  23. data/app/views/chats/dock/_window_header.html.erb +75 -0
  24. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  25. data/app/views/chats/messages/_composer.html.erb +11 -1
  26. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  27. data/config/importmap.rb +1 -0
  28. data/config/locales/en.yml +18 -0
  29. data/config/locales/es.yml +18 -0
  30. data/config/routes.rb +5 -0
  31. data/docs/screenshots/dock-bar.png +0 -0
  32. data/docs/screenshots/dock-desktop.png +0 -0
  33. data/docs/screenshots/dock-phone-button.png +0 -0
  34. data/docs/screenshots/dock-phone-panel.png +0 -0
  35. data/docs/screenshots/dock-phone-window.png +0 -0
  36. data/gemfiles/rails_7.1.gemfile +2 -0
  37. data/gemfiles/rails_7.2.gemfile +2 -0
  38. data/gemfiles/rails_8.1.gemfile +2 -0
  39. data/lib/chats/broadcasts.rb +76 -10
  40. data/lib/chats/inbox.rb +68 -30
  41. data/lib/chats/models/concerns/messager.rb +10 -2
  42. data/lib/chats/models/conversation.rb +19 -0
  43. data/lib/chats/models/message.rb +32 -8
  44. data/lib/chats/version.rb +1 -1
  45. data/lib/generators/chats/templates/initializer.rb +1 -0
  46. data/lib/generators/chats/views_generator.rb +3 -2
  47. metadata +18 -2
@@ -0,0 +1,130 @@
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-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
36
+ # Plural forms uninterpolated: the controller fills %{count} in.
37
+ "chats--dock-window-unread-value": I18n.t("chats.dock.window_unread").to_json,
38
+ action: "keydown->chats--dock#keydown " \
39
+ "focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
40
+ "touchstart->chats--dock#engage " \
41
+ "turbo:before-fetch-response->chats--dock#frameResponded " \
42
+ "turbo:frame-missing->chats--dock#frameMissing " \
43
+ "turbo:frame-load->chats--dock#frameLoaded " \
44
+ "turbo:fetch-request-error->chats--dock#frameFailed " \
45
+ "turbo:submit-end->chats--dock#sent " \
46
+ "input->chats--dock#typed " \
47
+ "click@document->chats--dock#delegate " \
48
+ "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
49
+ "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
50
+ } do %>
51
+ <%= turbo_stream_from viewer, :chats_dock %>
52
+ <%= render "chats/dock/signal" %>
53
+
54
+ <div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
55
+
56
+ <%# What a window shows before its thread arrives, and if it can't. %>
57
+ <template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
58
+ <template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
59
+
60
+ <details class="chats-dock__panel"
61
+ data-chats--dock-target="panel"
62
+ data-action="toggle->chats--dock#panelToggled">
63
+ <%# The summary reads "Messages, 3 unread" from its own text; on a phone
64
+ the closed one is the round launcher (chats.css). %>
65
+ <summary class="chats-dock__bar chats-dock__toggle">
66
+ <%= chats_messager_avatar(viewer) %>
67
+ <svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
68
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
69
+ <path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
70
+ </svg>
71
+ <span class="chats-dock__title"><%= t("chats.dock.title") %></span>
72
+ <%= render "chats/dock/badge", count: viewer.unread_chats_count %>
73
+ <span class="chats-dock__chevron" aria-hidden="true">
74
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
75
+ stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
76
+ </span>
77
+ <span class="chats-dock__close-glyph" aria-hidden="true">
78
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
79
+ focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
80
+ </span>
81
+ </summary>
82
+
83
+ <div class="chats-dock__body">
84
+ <% if Chats.config.search %>
85
+ <%# The inbox's own search: with JavaScript the results land in the
86
+ dock's frame (the inbox answers that frame with the dock's list);
87
+ without it, on the full inbox. %>
88
+ <%= form_with url: chats_routes.conversations_path,
89
+ method: :get,
90
+ class: "chats-search chats-dock__search",
91
+ role: "search",
92
+ data: {
93
+ controller: "chats--debounced-submit",
94
+ action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
95
+ "chats--debounced-submit-delay-value": 250,
96
+ turbo_frame: "chats_dock_list"
97
+ } do %>
98
+ <%= hidden_field_tag :recent, recent, id: nil %>
99
+ <%= search_field_tag :q, nil,
100
+ id: "chats_dock_q",
101
+ placeholder: t("chats.inbox.search_placeholder"),
102
+ "aria-label": t("chats.inbox.search_placeholder"),
103
+ class: "chats-search__input",
104
+ autocomplete: "off" %>
105
+ <% end %>
106
+ <% end %>
107
+
108
+ <%# Lazy: nothing is fetched until the panel first opens; until then,
109
+ four still rows. The controller reloads it when the signal says. %>
110
+ <%= turbo_frame_tag "chats_dock_list",
111
+ src: chats_routes.dock_conversations_path(recent: recent),
112
+ loading: :lazy,
113
+ target: "_top",
114
+ class: "chats-dock__results",
115
+ # A click here opens a window, never the page: hovering a row
116
+ # must not prefetch it.
117
+ data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
118
+ <div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
119
+ <% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
120
+ </div>
121
+ <% end %>
122
+
123
+ <%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
124
+ <%= chats_slot :dock_panel_footer, viewer: viewer %>
125
+ </div>
126
+ </details>
127
+
128
+ <%# "New message from …": polite, one at a time. The badge is not live. %>
129
+ <div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
130
+ <% 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
@@ -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,4 @@ 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"
@@ -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:
@@ -82,6 +99,7 @@ en:
82
99
  base:
83
100
  locked: "This conversation is closed."
84
101
  blocked: "You can't message this person."
102
+ not_allowed: "You can't message this person right now."
85
103
  action:
86
104
  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 /"
87
105
  author:
@@ -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:
@@ -82,6 +99,7 @@ es:
82
99
  base:
83
100
  locked: "Esta conversación está cerrada."
84
101
  blocked: "No puedes enviar mensajes a esta persona."
102
+ not_allowed: "Ahora mismo no puedes enviar mensajes a esta persona."
85
103
  action:
86
104
  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 /"
87
105
  author:
data/config/routes.rb CHANGED
@@ -5,6 +5,11 @@ 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
+
8
13
  member do
9
14
  post :read # advance the viewer's read horizon (mark as read)
10
15
  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"
@@ -4,7 +4,7 @@ require "action_view/record_identifier"
4
4
 
5
5
  module Chats
6
6
  # ALL of the gem's real-time fan-out, in one file — read this and you know
7
- # the entire live behavior. Three streams exist:
7
+ # the entire live behavior. Four streams exist:
8
8
  #
9
9
  # 1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
10
10
  # thread page): surgical message append/replace/remove, the read-state
@@ -30,6 +30,23 @@ module Chats
30
30
  # Deliberately separate from the inbox stream — if badges rode on it,
31
31
  # every page embedding a badge would full-refresh on every message.
32
32
  #
33
+ # 4. The per-messager DOCK stream (`[messager, :chats_dock]`, subscribed
34
+ # by the messaging dock — `chats_dock`). One render per participant
35
+ # per event (chats/dock/_update), carrying up to two things:
36
+ # * the dock's BADGES, with the same count as stream 3, whenever the
37
+ # badge moves (a message for them, a read of theirs);
38
+ # * the SIGNAL (chats/dock/_signal), replaced so the dock's
39
+ # controller sees it connect: reload the recent list, and — for a
40
+ # new message the participant should hear about — which
41
+ # conversation it was, from whom, how much is unread there, and
42
+ # that its window may pop open. Senders and edits/deletes get the
43
+ # signal too (their lists reorder), never the pop-up.
44
+ # The hint names a conversation only to a participant who can SEE it
45
+ # (`messager.chats`: blocks and `inbox_scope` applied) — the dock's
46
+ # own list and window endpoints refuse anything else, and the stream
47
+ # must not tell them what those hide. No polling, no Action Cable
48
+ # channel of our own.
49
+ #
33
50
  # Everything uses the `_later` variants (rendering happens in background
34
51
  # jobs, never inline in the request) except `message_destroyed`, where the
35
52
  # record is gone and can't be serialized for a job.
@@ -38,6 +55,7 @@ module Chats
38
55
 
39
56
  MESSAGE_PARTIAL = "chats/messages/message"
40
57
  BADGE_PARTIAL = "chats/shared/unread_badge"
58
+ DOCK_UPDATE_PARTIAL = "chats/dock/update"
41
59
  READ_STATE_PARTIAL = "chats/conversations/read_state"
42
60
 
43
61
  class << self
@@ -51,7 +69,7 @@ module Chats
51
69
  locals: { message: message }
52
70
  )
53
71
 
54
- fan_out_inboxes(conversation, skip_badge_for: message.sender)
72
+ fan_out_inboxes(conversation, skip_badge_for: message.sender, message: message)
55
73
  end
56
74
 
57
75
  def message_updated(message)
@@ -111,7 +129,17 @@ module Chats
111
129
  Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
112
130
  end
113
131
 
114
- def update_badge_of(messager)
132
+ # The unread badges of +messager+: the nav badge (stream 3) and the
133
+ # dock's (stream 4), with the signal that tells the dock what changed.
134
+ # +conversation+ and +message+ say what happened (both nil for a
135
+ # read); +participant+ is the messager's seat there.
136
+ def update_badge_of(messager, conversation: nil, message: nil, participant: nil)
137
+ # Counted at broadcast-enqueue time: the badge is advisory UI; a
138
+ # count that's seconds stale self-heals on the next event. The SAME
139
+ # count the page renders (`chats_unread_badge`), so what the inbox
140
+ # scope hides never lights the badge. Counted once for both.
141
+ count = messager.unread_chats_count
142
+
115
143
  # REPLACE, not update: the partial renders the whole badge element
116
144
  # (its id included) — an `update` would nest it inside the existing
117
145
  # element and duplicate the id. Replace keeps it idempotent.
@@ -119,11 +147,23 @@ module Chats
119
147
  messager, :chats_badge,
120
148
  target: "chats_unread_badge",
121
149
  partial: BADGE_PARTIAL,
122
- # Counted at broadcast-enqueue time: the badge is advisory UI; a
123
- # count that's seconds stale self-heals on the next event. The
124
- # SAME count the page renders (`chats_unread_badge`), so what the
125
- # inbox scope hides never lights the badge.
126
- locals: { count: messager.unread_chats_count }
150
+ locals: { count: count }
151
+ )
152
+
153
+ update_dock_of(messager, count: count, conversation: conversation, message: message, participant: participant)
154
+ end
155
+
156
+ # The dock's update (stream 4): the badges when +count+ is given, and
157
+ # the signal. A conversation the messager cannot see is never named:
158
+ # its badge count is still truthful, the signal is simply not sent.
159
+ def update_dock_of(messager, count: nil, conversation: nil, message: nil, participant: nil)
160
+ signal = dock_signal(messager, conversation, message, participant)
161
+ return if count.nil? && signal.nil?
162
+
163
+ Turbo::StreamsChannel.broadcast_render_later_to(
164
+ messager, :chats_dock,
165
+ partial: DOCK_UPDATE_PARTIAL,
166
+ locals: { count: count, signal: signal }
127
167
  )
128
168
  end
129
169
 
@@ -133,15 +173,41 @@ module Chats
133
173
  # the sender's — their OTHER tabs/devices need the new ordering; the
134
174
  # originating tab skips its own refresh via Turbo's request-id dedup),
135
175
  # and every participant except the sender gets a fresh unread badge.
136
- def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true)
176
+ # Everyone else — the sender, and every seat on an edit or a delete —
177
+ # gets the dock's list refresh alone.
178
+ def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true, message: nil)
137
179
  conversation.participants.active.includes(:messager).each do |participant|
138
180
  messager = participant.messager
139
181
  next if messager.nil?
140
182
 
141
183
  refresh_inbox_of(messager)
142
- update_badge_of(messager) if badges && messager != skip_badge_for
184
+ if badges && messager != skip_badge_for
185
+ update_badge_of(messager, conversation: conversation, message: message, participant: participant)
186
+ else
187
+ update_dock_of(messager, conversation: conversation)
188
+ end
143
189
  end
144
190
  end
191
+
192
+ # What the dock is told, or nil for nothing at all. A read (no
193
+ # conversation) only reloads the list. A conversation the messager
194
+ # can't see (blocked, scoped out) is never named. A new message the
195
+ # participant would be notified about (Participant#notifiable_for?:
196
+ # not theirs, not muted, not a headless seat) may pop its window and
197
+ # be announced, with who it is from and how much is unread there.
198
+ def dock_signal(messager, conversation, message, participant)
199
+ return {} if conversation.nil?
200
+ return nil unless messager.chats.exists?(conversation.id)
201
+
202
+ signal = { conversation_id: conversation.id }
203
+ return signal unless message && participant&.notifiable_for?(message)
204
+
205
+ signal.merge(
206
+ notify: true,
207
+ sender_name: message.sender ? Chats.display_name_for(message.sender) : conversation.title_for(messager),
208
+ unread_count: participant.unread_count
209
+ )
210
+ end
145
211
  end
146
212
  end
147
213
  end