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
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)
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/chats",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
data/docs/PRD.md CHANGED
@@ -18,7 +18,7 @@ Direct messages, group chats, reactions, attachments, read receipts — Hotwire-
18
18
 
19
19
  A gem you add to a Rails app to get **Instagram/X-DM-class** user-to-user messaging without building it again. The happy path is one generator + one `acts_as_messager` line + a mounted engine; the result is a working, real-time, polished inbox. Power users override views, policies, and adapters.
20
20
 
21
- It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool. It is peer-to-peer (and group) human messaging.
21
+ It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool (that is `support_desk`, a separate product gem built on this one — see README). It is peer-to-peer (and group) human messaging.
22
22
 
23
23
  **Why it exists:** every consumer app eventually needs DMs, and everyone rebuilds the same Conversation/Message/Participant/Receipt model, the same Action Cable + Turbo plumbing, and the same "report this message / block this user / filter this text" surface. We already built the moderation half for CarHey; `chats` is the messaging half, and the two snap together.
24
24
 
@@ -11,7 +11,7 @@ with the same facts.
11
11
 
12
12
  | Campfire pattern | Where it landed here | Notes |
13
13
  | --- | --- | --- |
14
- | **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()` | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. |
14
+ | **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()`; **and the inbox** via `refresh_inbox_controller.js` (same reconnect/visibility triggers, but the recovery action is a Turbo 8 page `refresh` since inbox broadcasts already are page refreshes) | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. The inbox got the same doctrine (missed inbox refreshes had no recovery before). |
15
15
  | **DOM cap** (`message_paginator.js` `maxMessages: 300`) | Thread controller `trimExcessMessages()` (300 + 20 leeway) | Only trims while the viewer is parked at the bottom. Our pagination is a server-rendered lazy-frame chain (theirs is JS-driven), so trimming also re-plants the keyset anchor frame (`rebuildPaginationAnchor()`) — trimmed history stays reachable on scroll-up with no gaps. |
16
16
  | **Out-of-order arrival handling** (their `messages_controller.js` re-sorts on insert) | Thread controller `ensureChronological()` | Broadcast appends from concurrent host job workers can land out of order. ISO8601 lexicographic compare; equal timestamps keep arrival order. |
17
17
  | **First-unread anchoring** (they page around the first unread; membership unread marker) | The «new messages» divider: `@first_unread_id` computed in `#show` *before* `read!` advances the horizon | We render a divider rather than re-anchoring the page — the thread still opens at the bottom (coordination chats are short; jumping deep into history on open would feel broken at our scale). Backlogs deeper than a page pin the divider to the top of the page. |
Binary file
Binary file
Binary file
Binary file
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -25,6 +26,8 @@ group :test do
25
26
  gem "mysql2"
26
27
  gem "pg"
27
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
28
31
  gem "bootsnap", require: false
29
32
  gem "importmap-rails"
30
33
  gem "propshaft"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -25,6 +26,8 @@ group :test do
25
26
  gem "mysql2"
26
27
  gem "pg"
27
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
28
31
  gem "bootsnap", require: false
29
32
  gem "importmap-rails"
30
33
  gem "propshaft"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -25,6 +26,8 @@ group :test do
25
26
  gem "mysql2"
26
27
  gem "pg"
27
28
  gem "sqlite3"
29
+ gem "capybara", require: false
30
+ gem "selenium-webdriver", require: false
28
31
  gem "bootsnap", require: false
29
32
  gem "importmap-rails"
30
33
  gem "propshaft"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # The one featured button a message can carry: a label and the place it
5
+ # takes you.
6
+ #
7
+ # alice.message!(bob, "Let's play!", action: { label: "Join my room", url: "/play/friend/abcd" })
8
+ #
9
+ # message.action # => #<data Chats::Action label="Join my room", url="/play/friend/abcd">
10
+ # message.action.label # => "Join my room"
11
+ # message.action.to_h # => { label: "Join my room", url: "/play/friend/abcd" }
12
+ #
13
+ # An immutable value, compared by value: two actions with the same label
14
+ # and url are the same action. It knows nothing about what makes one safe
15
+ # to render — Chats::Message validates that at the write, where a bad one
16
+ # can fail loudly instead of rendering a dead or dangerous link.
17
+ Action = Data.define(:label, :url) do
18
+ # Whatever a host hands `action:` — nil, a Hash (string or symbol keys),
19
+ # or an Action — as an Action, or nil.
20
+ #
21
+ # Unknown keys raise rather than vanish: `{ label:, url:, method: :post }`
22
+ # silently becoming a GET link is exactly the surprise this refuses.
23
+ def self.wrap(value)
24
+ case value
25
+ when nil, self then value
26
+ when Hash
27
+ attributes = value.to_h { |key, attribute| [key.to_s, attribute] }
28
+ unknown = attributes.keys - members.map(&:to_s)
29
+ if unknown.any?
30
+ raise ArgumentError, "unknown action keys: #{unknown.join(", ")} (an action is a label and a url)"
31
+ end
32
+
33
+ new(label: attributes["label"], url: attributes["url"])
34
+ else
35
+ raise ArgumentError, "an action is a Hash with a label and a url, or a Chats::Action — got #{value.inspect}"
36
+ end
37
+ end
38
+
39
+ # Both are text by the time they're stored: a path helper's String, a
40
+ # URI, a symbol label — all read back as the String the column holds.
41
+ # nil stays nil so a missing one is a validation error, not a "" that
42
+ # looks present.
43
+ #
44
+ # Each is a frozen, PLAIN String copy. `Data` freezes the action, not the
45
+ # strings it was handed, so without the copy `action.url.replace(
46
+ # "javascript:…")` would rewrite a validated action in place — and so
47
+ # would the caller mutating the string they passed in. Plain, because
48
+ # `String.new` drops an html_safe mark: a label that arrived as a
49
+ # SafeBuffer (a host's `t("…_html")`) would otherwise render unescaped
50
+ # in the button. An action is a value: what the validator saw is what
51
+ # the bubble renders, and the bubble escapes it like any other content.
52
+ def initialize(label:, url:)
53
+ super(label: plain(label), url: plain(url))
54
+ end
55
+
56
+ private
57
+
58
+ def plain(value)
59
+ String.new(value.to_s).freeze unless value.nil?
60
+ end
61
+ end
62
+
63
+ class Action
64
+ # How `chats_messages.action` (json/jsonb) reads and writes: an Action
65
+ # or nil on the Ruby side, `{"label":…,"url":…}` in the column. Every
66
+ # assignment goes through Action.wrap, so a Hash, an Action and nil all
67
+ # work, and anything else raises right where it was assigned.
68
+ class Type < ActiveRecord::Type::Json
69
+ def cast(value)
70
+ Action.wrap(value)
71
+ end
72
+
73
+ # Reads only the two keys an action has, so a row written by a later
74
+ # version with more of them still loads instead of raising.
75
+ def deserialize(value)
76
+ stored = super
77
+ Action.wrap(stored.slice("label", "url")) if stored.is_a?(Hash)
78
+ end
79
+
80
+ def serialize(value)
81
+ super(Action.wrap(value)&.to_h)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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
@@ -20,9 +20,8 @@ module Chats
20
20
  # broadcast would force per-viewer renders of per-viewer partials;
21
21
  # a refresh makes each client re-request the page and get a correct,
22
22
  # personalized render with morphing + scroll preservation for free.
23
- # Turbo debounces refreshes per stream and tags them with the
24
- # originating request id so the tab that caused the change skips its
25
- # own refresh. https://turbo.hotwired.dev/handbook/streams
23
+ # At most one is queued per messager at a time (see
24
+ # refresh_inbox_of). https://turbo.hotwired.dev/handbook/streams
26
25
  #
27
26
  # 3. The per-messager BADGE stream (`[messager, :chats_badge]`,
28
27
  # subscribable from ANY page via the `chats_unread_badge` helper —
@@ -30,15 +29,46 @@ module Chats
30
29
  # Deliberately separate from the inbox stream — if badges rode on it,
31
30
  # every page embedding a badge would full-refresh on every message.
32
31
  #
32
+ # 4. The per-messager DOCK stream (`[messager, :chats_dock]`, subscribed
33
+ # by the messaging dock — `chats_dock`). One render per participant
34
+ # per event (chats/dock/_update), carrying up to two things:
35
+ # * the dock's BADGES, with the same count as stream 3, whenever the
36
+ # badge moves (a message for them, a read of theirs);
37
+ # * the SIGNAL (chats/dock/_signal), replaced so the dock's
38
+ # controller sees it connect: reload the recent list, and — for a
39
+ # new message the participant should hear about — which
40
+ # conversation it was, from whom, how much is unread there, and
41
+ # that its window may pop open. Senders and edits/deletes get the
42
+ # signal too (their lists reorder), never the pop-up.
43
+ # The hint names a conversation only to a participant who can SEE it
44
+ # (`messager.chats`: blocks and `inbox_scope` applied) — the dock's
45
+ # own list and window endpoints refuse anything else, and the stream
46
+ # must not tell them what those hide. No polling, no Action Cable
47
+ # channel of our own.
48
+ #
33
49
  # Everything uses the `_later` variants (rendering happens in background
34
50
  # jobs, never inline in the request) except `message_destroyed`, where the
35
- # record is gone and can't be serialized for a job.
51
+ # record is gone and can't be serialized for a job. Every job is enqueued
52
+ # from an after_commit callback, so a broadcast never announces a write
53
+ # that could still roll back, and never waits in memory: once the write
54
+ # has committed, its broadcast is in the job queue.
55
+ #
56
+ # A broadcast can still be missed — Action Cable has no replay, and a
57
+ # client whose socket is down when it is sent never gets it. The client
58
+ # side catches up (chats/recovery.js: on reconnect, on return to a hidden
59
+ # page, on network-online, on a back/forward-cache restore), so every
60
+ # surface fed by these streams heals without polling.
36
61
  module Broadcasts
37
62
  extend ActionView::RecordIdentifier # dom_id
38
63
 
39
64
  MESSAGE_PARTIAL = "chats/messages/message"
40
65
  BADGE_PARTIAL = "chats/shared/unread_badge"
66
+ DOCK_UPDATE_PARTIAL = "chats/dock/update"
41
67
  READ_STATE_PARTIAL = "chats/conversations/read_state"
68
+ # The inbox stream's whole payload. Built once, with no request id.
69
+ INBOX_REFRESH = %(<turbo-stream action="refresh" method="morph" scroll="preserve"></turbo-stream>)
70
+ # How long a queued inbox refresh stands for the writes after it.
71
+ INBOX_REFRESH_PENDING = 2.seconds
42
72
 
43
73
  class << self
44
74
  def message_created(message)
@@ -51,7 +81,7 @@ module Chats
51
81
  locals: { message: message }
52
82
  )
53
83
 
54
- fan_out_inboxes(conversation, skip_badge_for: message.sender)
84
+ fan_out_inboxes(conversation, skip_badge_for: message.sender, message: message)
55
85
  end
56
86
 
57
87
  def message_updated(message)
@@ -107,11 +137,73 @@ module Chats
107
137
  )
108
138
  end
109
139
 
140
+ # Straight into the job queue, NOT `broadcast_refresh_later_to`: that
141
+ # one first parks the refresh for half a second in an in-process
142
+ # timer (Turbo::Debouncer, a Concurrent::ScheduledTask) and only then
143
+ # enqueues the job, so a deploy or a crash inside that half second
144
+ # drops the refresh with nothing left to retry it.
145
+ #
146
+ # One job per messager at a time. A refresh carries no data — the
147
+ # client re-requests its inbox — so while one is queued and hasn't
148
+ # started, it covers every write that commits before it runs: a burst
149
+ # (five messages in a 100-person group) is one job per participant,
150
+ # not five. A marker in Rails.cache says one is queued; the job removes
151
+ # it as it STARTS, before it broadcasts, so a write that still finds
152
+ # the marker has committed before that broadcast, and a write after
153
+ # it queues the next job. Every way the marker can go wrong costs an
154
+ # extra job, never a missed refresh: a cache without it (null store,
155
+ # a per-process memory store, an eviction) just doesn't coalesce, and
156
+ # the marker expires after INBOX_REFRESH_PENDING, so a job lost from
157
+ # the queue holds nothing back for longer than that.
158
+ # Refreshes that still arrive close together merge on the client:
159
+ # Turbo runs one page refresh for refreshes within 150 ms of each
160
+ # other (Session#pageRefreshDebouncePeriod), and chats--refresh-inbox
161
+ # reruns one that landed mid-visit, which Turbo would ignore.
162
+ #
163
+ # The marker goes up BEFORE the enqueue, so another write can find it
164
+ # while the enqueue is still in flight and count on that refresh. An
165
+ # enqueue that fails is therefore tried once more, and if the queue
166
+ # refuses again the refresh is broadcast right here: the writes that
167
+ # counted on it are never left without one. The failure is reported to
168
+ # Rails.error, not raised: the write has committed, and the refresh is
169
+ # out.
170
+ #
171
+ # Only a marker that is really there means a refresh is queued. A store
172
+ # that can't hold it (RedisCacheStore answers nil when the write
173
+ # fails, any store may raise) means nothing about the queue, so this
174
+ # write queues its own refresh.
110
175
  def refresh_inbox_of(messager)
111
- Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
176
+ return if inbox_refresh_queued?(inbox_refresh_marker(messager))
177
+ return if 2.times.any? { enqueue_inbox_refresh(messager) } # once more if the first fails
178
+
179
+ refresh_inbox_now(messager)
180
+ end
181
+
182
+ # The job's half (Chats::InboxRefreshJob): take the marker down, then
183
+ # broadcast. `method: morph, scroll: preserve` ride on the tag, so the
184
+ # inbox morphs in place whatever the host layout's refresh meta says.
185
+ # No request id: one refresh may answer writes from several requests,
186
+ # and a tab that skipped it as "its own" would miss the others'.
187
+ def refresh_inbox_now(messager)
188
+ begin
189
+ Rails.cache.delete(inbox_refresh_marker(messager))
190
+ rescue StandardError => e
191
+ Rails.error.report(e, handled: true) # the marker expires on its own; the refresh goes out regardless
192
+ end
193
+ Turbo::StreamsChannel.broadcast_stream_to(messager, :chats_inbox, content: INBOX_REFRESH)
112
194
  end
113
195
 
114
- def update_badge_of(messager)
196
+ # The unread badges of +messager+: the nav badge (stream 3) and the
197
+ # dock's (stream 4), with the signal that tells the dock what changed.
198
+ # +conversation+ and +message+ say what happened (both nil for a
199
+ # read); +participant+ is the messager's seat there.
200
+ def update_badge_of(messager, conversation: nil, message: nil, participant: nil)
201
+ # Counted at broadcast-enqueue time: the badge is advisory UI; a
202
+ # count that's seconds stale self-heals on the next event. The SAME
203
+ # count the page renders (`chats_unread_badge`), so what the inbox
204
+ # scope hides never lights the badge. Counted once for both.
205
+ count = messager.unread_chats_count
206
+
115
207
  # REPLACE, not update: the partial renders the whole badge element
116
208
  # (its id included) — an `update` would nest it inside the existing
117
209
  # element and duplicate the id. Replace keeps it idempotent.
@@ -119,29 +211,98 @@ module Chats
119
211
  messager, :chats_badge,
120
212
  target: "chats_unread_badge",
121
213
  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.
124
- # reorder(nil): COUNT(DISTINCT) + the inbox's COALESCE order would
125
- # error on PostgreSQL.
126
- locals: { count: Chats::Conversation.inbox_for(messager).unread_by(messager).reorder(nil).distinct.count }
214
+ locals: { count: count }
215
+ )
216
+
217
+ update_dock_of(messager, count: count, conversation: conversation, message: message, participant: participant)
218
+ end
219
+
220
+ # The dock's update (stream 4): the badges when +count+ is given, and
221
+ # the signal. A conversation the messager cannot see is never named:
222
+ # its badge count is still truthful, the signal is simply not sent.
223
+ def update_dock_of(messager, count: nil, conversation: nil, message: nil, participant: nil)
224
+ signal = dock_signal(messager, conversation, message, participant)
225
+ return if count.nil? && signal.nil?
226
+
227
+ Turbo::StreamsChannel.broadcast_render_later_to(
228
+ messager, :chats_dock,
229
+ partial: DOCK_UPDATE_PARTIAL,
230
+ locals: { count: count, signal: signal }
127
231
  )
128
232
  end
129
233
 
130
234
  private
131
235
 
236
+ # Whether the queue took the job. A refusal is reported to Rails.error
237
+ # however the adapter says it: perform_later raises, or returns false
238
+ # (a callback aborted it), or a job that wasn't enqueued.
239
+ def enqueue_inbox_refresh(messager)
240
+ job = Chats::InboxRefreshJob.perform_later(messager)
241
+ job = nil unless job.is_a?(ActiveJob::Base) # false: a callback aborted it
242
+ return true if job&.successfully_enqueued?
243
+
244
+ Rails.error.report(job&.enqueue_error || ActiveJob::EnqueueError.new("Chats::InboxRefreshJob was not enqueued"),
245
+ handled: true)
246
+ false
247
+ rescue StandardError => e
248
+ Rails.error.report(e, handled: true)
249
+ false
250
+ end
251
+
252
+ # Whether a refresh is already queued for this marker's messager: the
253
+ # write was refused (false) AND the marker can be read back. Anything
254
+ # else — this write put it up, the store answered nil or raised, the
255
+ # marker vanished in between — is "not that we know of".
256
+ def inbox_refresh_queued?(marker)
257
+ written = Rails.cache.write(marker, true, unless_exist: true, expires_in: INBOX_REFRESH_PENDING)
258
+ written == false && Rails.cache.read(marker).present?
259
+ rescue StandardError => e
260
+ Rails.error.report(e, handled: true)
261
+ false
262
+ end
263
+
264
+ def inbox_refresh_marker(messager)
265
+ "chats:inbox-refresh:#{Chats.messager_key(messager)}"
266
+ end
267
+
132
268
  # New activity: every active participant's inbox refreshes (including
133
- # the sender's — their OTHER tabs/devices need the new ordering; the
134
- # originating tab skips its own refresh via Turbo's request-id dedup),
269
+ # the sender's — their other tabs and devices need the new ordering),
135
270
  # and every participant except the sender gets a fresh unread badge.
136
- def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true)
271
+ # Everyone else — the sender, and every seat on an edit or a delete —
272
+ # gets the dock's list refresh alone.
273
+ def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true, message: nil)
137
274
  conversation.participants.active.includes(:messager).each do |participant|
138
275
  messager = participant.messager
139
276
  next if messager.nil?
140
277
 
141
278
  refresh_inbox_of(messager)
142
- update_badge_of(messager) if badges && messager != skip_badge_for
279
+ if badges && messager != skip_badge_for
280
+ update_badge_of(messager, conversation: conversation, message: message, participant: participant)
281
+ else
282
+ update_dock_of(messager, conversation: conversation)
283
+ end
143
284
  end
144
285
  end
286
+
287
+ # What the dock is told, or nil for nothing at all. A read (no
288
+ # conversation) only reloads the list. A conversation the messager
289
+ # can't see (blocked, scoped out) is never named. A new message the
290
+ # participant would be notified about (Participant#notifiable_for?:
291
+ # not theirs, not muted, not a headless seat) may pop its window and
292
+ # be announced, with who it is from and how much is unread there.
293
+ def dock_signal(messager, conversation, message, participant)
294
+ return {} if conversation.nil?
295
+ return nil unless messager.chats.exists?(conversation.id)
296
+
297
+ signal = { conversation_id: conversation.id }
298
+ return signal unless message && participant&.notifiable_for?(message)
299
+
300
+ signal.merge(
301
+ notify: true,
302
+ sender_name: message.sender ? Chats.display_name_for(message.sender) : conversation.title_for(messager),
303
+ unread_count: participant.unread_count
304
+ )
305
+ end
145
306
  end
146
307
  end
147
308
  end
@@ -15,6 +15,7 @@ module Chats
15
15
  class Configuration
16
16
  ATTACHMENT_MODES = [false, :images, :any].freeze
17
17
  DELETION_MODES = [false, :soft, :hard].freeze
18
+ INBOX_LAYOUTS = %i[split stacked].freeze
18
19
 
19
20
  # --- Host integration -----------------------------------------------------
20
21
 
@@ -86,6 +87,21 @@ module Chats
86
87
  attr_accessor :messages_per_page, :max_message_length, :max_group_size, :max_attachment_size,
87
88
  :max_attachments_per_message
88
89
 
90
+ # How many conversations the inbox loads (and therefore how deep search
91
+ # and grouping see). The inbox is a "recent activity" surface, not an
92
+ # archive — raise it only if your users really keep hundreds of live
93
+ # threads.
94
+ attr_accessor :inbox_limit
95
+
96
+ # How the inbox and a conversation share the screen. `:split` (the
97
+ # default): on screens at least 700px wide, /messages is two panes, the
98
+ # conversation list on the left and the open conversation on the right,
99
+ # and /messages/:id is that same page with the conversation open
100
+ # (LinkedIn's messaging page); narrower, the list and the thread are
101
+ # separate pages. `:stacked`: separate pages at every width, as before
102
+ # 0.6.
103
+ attr_reader :inbox_layout
104
+
89
105
  # Per-sender send throttle, enforced with Rails 8's built-in controller
90
106
  # `rate_limit` when available (feature-detected; on Rails 7.1 it's a
91
107
  # no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
@@ -109,6 +125,12 @@ module Chats
109
125
  # May +creator+ create a group conversation?
110
126
  attr_reader :can_create_group
111
127
 
128
+ # ->(relation, viewer) { relation } — composed into the inbox query
129
+ # before the limit, so a host can hide or re-scope rows without
130
+ # overriding the controller:
131
+ # config.inbox_scope = ->(relation, viewer) { relation.where.not(kind: "group") }
132
+ attr_reader :inbox_scope
133
+
112
134
  # --- Ecosystem seams (procs, no-op defaults) ------------------------------
113
135
 
114
136
  # ->(messager) { ids } — every messager id that can't talk with the given
@@ -130,6 +152,36 @@ module Chats
130
152
  # or variant), or nil to render an initials placeholder.
131
153
  attr_reader :messager_avatar
132
154
 
155
+ # ->(messager) { path_or_url_or_nil } — where a messager's profile lives.
156
+ # The bundled views link names and avatars to it; nil (the default) means
157
+ # no anchor at all, so the gem never assumes a `user_path` exists.
158
+ attr_reader :messager_url
159
+
160
+ # ->(message) { String } — the signature line under a SIGNED message
161
+ # (one written by an author on the sender's behalf — see
162
+ # Chats::Message#signed?). nil (the default) renders the localized
163
+ # "— Author Name".
164
+ attr_reader :message_signature
165
+
166
+ # ->(messager) { markup } — the badge shown next to an OFFICIAL
167
+ # account's name (`acts_as_messager verified: true`). nil (the default)
168
+ # renders the gem's own rosette, `chats/shared/_verified_badge`, coloured
169
+ # by the `--chats-verified` CSS variable.
170
+ #
171
+ # Return an html_safe value (anything `render`, `tag` or `image_tag`
172
+ # gives you) — a bare String is escaped, exactly as it would be anywhere
173
+ # else in a Rails view. Returning nil renders nothing, so a host can
174
+ # suppress the badge on some messagers without touching the models.
175
+ attr_reader :verified_badge
176
+
177
+ # ->(conversation, viewer) { String } — the title of the messaging page:
178
+ # the inbox (conversation nil) or a conversation. The bundled views
179
+ # give it to `content_for :title` (a layout doing `yield(:title)` shows
180
+ # it), and the two-pane page gives the tab the conversation's when a
181
+ # row opens it in the pane. nil (the default) is the localized
182
+ # `chats.page_title.inbox` / `chats.page_title.conversation`.
183
+ attr_reader :page_title
184
+
133
185
  def initialize
134
186
  @messager_class = "User"
135
187
  @parent_controller = "::ApplicationController"
@@ -152,14 +204,21 @@ module Chats
152
204
  @max_attachment_size = 10 * 1024 * 1024 # 10 MB
153
205
  @max_attachments_per_message = 4
154
206
  @send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
207
+ @inbox_limit = 200
208
+ @inbox_layout = :split
155
209
 
156
210
  @encrypt_messages = false
157
211
 
158
212
  @can_message = ->(_sender, _recipient) { true }
159
213
  @can_create_group = ->(_creator) { true }
214
+ @inbox_scope = ->(relation, _viewer) { relation }
160
215
 
161
216
  @blocked_messager_ids = ->(_messager) { [] }
162
217
  @notifier = ->(_event, **_payload) {}
218
+ @messager_url = ->(_messager) { nil }
219
+ @message_signature = nil
220
+ @verified_badge = nil
221
+ @page_title = nil
163
222
 
164
223
  @messager_display_name = lambda do |messager|
165
224
  messager.try(:display_name) || messager.try(:name) ||
@@ -205,6 +264,18 @@ module Chats
205
264
  @deletion = normalized
206
265
  end
207
266
 
267
+ def inbox_layout=(value)
268
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
269
+ unless INBOX_LAYOUTS.include?(normalized)
270
+ raise ConfigurationError, "inbox_layout must be one of #{INBOX_LAYOUTS.inspect}, got #{value.inspect}"
271
+ end
272
+
273
+ @inbox_layout = normalized
274
+ end
275
+
276
+ # Whether the inbox and the open conversation are one two-pane page.
277
+ def split_inbox? = inbox_layout == :split
278
+
208
279
  def send_rate_limit=(value)
209
280
  if value.nil?
210
281
  @send_rate_limit = nil
@@ -232,8 +303,49 @@ module Chats
232
303
  @blocked_messager_ids = ensure_callable(value, "blocked_messager_ids")
233
304
  end
234
305
 
306
+ # DEPRECATED (removed in 1.0): sugar that subscribes one `(event,
307
+ # **payload)` proc to the two events that existed in 0.1.1
308
+ # (:message_created, :conversation_read) — and ONLY those, so an old
309
+ # keyword-specific hook can't start raising on events it was never
310
+ # written for. `Chats.on` supersedes it: many subscribers, per-event
311
+ # payloads, reload-safe keys, and every event.
235
312
  def notifier=(value)
236
- @notifier = ensure_callable(value, "notifier")
313
+ hook = ensure_callable(value, "notifier")
314
+ Chats.deprecator.warn(
315
+ "config.notifier is deprecated and will be removed in chats 1.0. " \
316
+ "It receives #{Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.map(&:inspect).join(" and ")} only; " \
317
+ "the events added in 0.2.0 are Chats.on-only. " \
318
+ "Subscribe with Chats.on(:message_created) { |message| … } instead " \
319
+ "(see the README's \"Events\" section)."
320
+ )
321
+
322
+ Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.each do |event|
323
+ Chats::Subscribers.on(event, key: Chats::Subscribers::NOTIFIER_KEY, style: :event) do |fired, **payload|
324
+ hook.call(fired, **payload)
325
+ end
326
+ end
327
+
328
+ @notifier = hook
329
+ end
330
+
331
+ def inbox_scope=(value)
332
+ @inbox_scope = ensure_callable(value, "inbox_scope")
333
+ end
334
+
335
+ def messager_url=(value)
336
+ @messager_url = ensure_callable(value, "messager_url")
337
+ end
338
+
339
+ def message_signature=(value)
340
+ @message_signature = value.nil? ? nil : ensure_callable(value, "message_signature")
341
+ end
342
+
343
+ def verified_badge=(value)
344
+ @verified_badge = value.nil? ? nil : ensure_callable(value, "verified_badge")
345
+ end
346
+
347
+ def page_title=(value)
348
+ @page_title = value.nil? ? nil : ensure_callable(value, "page_title")
237
349
  end
238
350
 
239
351
  def messager_display_name=(value)
@@ -259,6 +371,8 @@ module Chats
259
371
  raise ConfigurationError, "messages_per_page must be positive (got #{messages_per_page.inspect})"
260
372
  end
261
373
 
374
+ raise ConfigurationError, "inbox_limit must be positive (got #{inbox_limit.inspect})" if inbox_limit.to_i < 1
375
+
262
376
  true
263
377
  end
264
378