Module: Chats::Broadcasts
- Extended by:
- ActionView::RecordIdentifier
- Defined in:
- lib/chats/broadcasts.rb
Overview
ALL of the gem’s real-time fan-out, in one file — read this and you know the entire live behavior. Three streams exist:
1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
thread page): surgical message append/replace/remove, the read-state
payload, and the typing custom action. One render per event, shared
by every subscriber — bubbles are rendered VIEWER-AGNOSTIC and the
`chats--thread` Stimulus controller aligns own-vs-other client-side
(comparing each bubble's sender key against its own `me` value),
which is what makes a single broadcast render possible at all.
2. The per-messager INBOX stream (`[messager, :chats_inbox]`, subscribed
only on the inbox page): we broadcast Turbo 8 page REFRESHES instead
of surgically patching rows. An inbox row is intensely per-viewer
(unread badge, bold state, ordering), so patching it from a shared
broadcast would force per-viewer renders of per-viewer partials;
a refresh makes each client re-request the page and get a correct,
personalized render with morphing + scroll preservation for free.
Turbo debounces refreshes per stream and tags them with the
originating request id so the tab that caused the change skips its
own refresh. https://turbo.hotwired.dev/handbook/streams
3. The per-messager BADGE stream (`[messager, :chats_badge]`,
subscribable from ANY page via the `chats_unread_badge` helper —
e.g. a bottom-nav dock): a tiny `update` of the badge element.
Deliberately separate from the inbox stream — if badges rode on it,
every page embedding a badge would full-refresh on every message.
Everything uses the ‘_later` variants (rendering happens in background jobs, never inline in the request) except `message_destroyed`, where the record is gone and can’t be serialized for a job.
Constant Summary collapse
- MESSAGE_PARTIAL =
"chats/messages/message"- BADGE_PARTIAL =
"chats/shared/unread_badge"- READ_STATE_PARTIAL =
"chats/conversations/read_state"
Class Method Summary collapse
- .message_created(message) ⇒ Object
- .message_destroyed(message) ⇒ Object
- .message_updated(message) ⇒ Object
-
.read_state(conversation) ⇒ Object
The viewer-agnostic read-state payload (a hidden element carrying every participant’s read horizon as JSON).
- .refresh_inbox_of(messager) ⇒ Object
-
.typing(conversation, messager) ⇒ Object
Ephemeral “X is typing…” — a Turbo Stream CUSTOM ACTION (registered in chats/thread_controller.js as Turbo.StreamActions.chats_typing), carrying the typist in attributes.
- .update_badge_of(messager) ⇒ Object
Class Method Details
.message_created(message) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/chats/broadcasts.rb', line 44 def () conversation = .conversation Turbo::StreamsChannel.broadcast_append_later_to( conversation, target: dom_id(conversation, :messages), partial: MESSAGE_PARTIAL, locals: { message: } ) fan_out_inboxes(conversation, skip_badge_for: .sender) end |
.message_destroyed(message) ⇒ Object
70 71 72 73 74 75 76 77 78 79 |
# File 'lib/chats/broadcasts.rb', line 70 def () # Hard delete: the row is gone — broadcast synchronously (nothing to # serialize into a job) and let inboxes re-render. Turbo::StreamsChannel.broadcast_remove_to( .conversation, target: dom_id() ) fan_out_inboxes(.conversation, badges: false) end |
.message_updated(message) ⇒ Object
57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/chats/broadcasts.rb', line 57 def () Turbo::StreamsChannel.broadcast_replace_later_to( .conversation, target: dom_id(), partial: MESSAGE_PARTIAL, locals: { message: } ) # Edits/deletes change the inbox preview when they touch the latest # message; a refresh is debounced and cheap, so just fan out. fan_out_inboxes(.conversation, badges: false) end |
.read_state(conversation) ⇒ Object
The viewer-agnostic read-state payload (a hidden element carrying every participant’s read horizon as JSON). The thread controller turns it into a per-viewer “Seen” indicator client-side.
84 85 86 87 88 89 90 91 |
# File 'lib/chats/broadcasts.rb', line 84 def read_state(conversation) Turbo::StreamsChannel.broadcast_replace_later_to( conversation, target: dom_id(conversation, :read_state), partial: READ_STATE_PARTIAL, locals: { conversation: conversation } ) end |
.refresh_inbox_of(messager) ⇒ Object
110 111 112 |
# File 'lib/chats/broadcasts.rb', line 110 def refresh_inbox_of() Turbo::StreamsChannel.broadcast_refresh_later_to(, :chats_inbox) end |
.typing(conversation, messager) ⇒ Object
Ephemeral “X is typing…” — a Turbo Stream CUSTOM ACTION (registered in chats/thread_controller.js as Turbo.StreamActions.chats_typing), carrying the typist in attributes. Nothing is persisted; if nobody’s subscribed it evaporates, which is exactly right for presence-ish signals. No ‘_later`: typing is only meaningful NOW.
98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/chats/broadcasts.rb', line 98 def typing(conversation, ) Turbo::StreamsChannel.broadcast_action_to( conversation, action: :chats_typing, target: dom_id(conversation, :typing), attributes: { "data-name" => Chats.display_name_for(), "data-key" => Chats.() } ) end |
.update_badge_of(messager) ⇒ Object
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/chats/broadcasts.rb', line 114 def update_badge_of() # REPLACE, not update: the partial renders the whole badge element # (its id included) — an `update` would nest it inside the existing # element and duplicate the id. Replace keeps it idempotent. Turbo::StreamsChannel.broadcast_replace_later_to( , :chats_badge, target: "chats_unread_badge", partial: BADGE_PARTIAL, # Counted at broadcast-enqueue time: the badge is advisory UI; a # count that's seconds stale self-heals on the next event. # reorder(nil): COUNT(DISTINCT) + the inbox's COALESCE order would # error on PostgreSQL. locals: { count: Chats::Conversation.inbox_for().unread_by().reorder(nil).distinct.count } ) end |