Class: Chats::ConversationsController

Inherits:
ApplicationController show all
Defined in:
app/controllers/chats/conversations_controller.rb

Overview

The inbox (index), the thread (show), starting conversations from host pages (create), and the per-member actions (read/typing/leave/mute).

Instance Method Summary collapse

Instance Method Details

#createObject

Start (or resume) a direct conversation from a host page. The recipient/subject arrive as SIGNED GlobalIDs minted by the ‘chat_button_to` helper — unforgeable and purpose-scoped, so raw polymorphic params never reach `GlobalID::Locator`. Policy and block checks still run inside `chat_with` (defense in depth).



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'app/controllers/chats/conversations_controller.rb', line 96

def create
  recipient = locate_signed!(params.require(:recipient_sgid), purpose: :chats_recipient)
  raise ActiveRecord::RecordNotFound unless Chats.messager_class?(recipient.class)

  subject = params[:subject_sgid].presence &&
            locate_signed!(params[:subject_sgid], purpose: :chats_subject)

  conversation = chats_current_messager.chat_with(recipient, about: subject)
  redirect_to conversation_path(conversation)
rescue Chats::BlockedError
  # Fallback is the INBOX (engine root) — inside the engine, `root_path`
  # already resolves there, never to the host root.
  redirect_back fallback_location: conversations_path, alert: t("chats.flashes.blocked")
rescue Chats::NotAllowedError
  redirect_back fallback_location: conversations_path, alert: t("chats.flashes.not_allowed")
end

#indexObject

The inbox. Everything is preloaded/batched so rendering N rows costs a constant number of queries (conversations + last messages + participants + one grouped unread-count query — see Conversation.unread_counts_for).



12
13
14
15
16
17
18
# File 'app/controllers/chats/conversations_controller.rb', line 12

def index
  @conversations = chats_current_messager.chats
                                         .includes(:last_message, :subject, participants: :messager)
                                         .limit(200)
  @conversations = apply_search(@conversations)
  @unread_counts = Chats::Conversation.unread_counts_for(chats_current_messager, @conversations)
end

#leaveObject

Raises:

  • (ActiveRecord::RecordNotFound)


129
130
131
132
133
134
135
136
137
# File 'app/controllers/chats/conversations_controller.rb', line 129

def leave
  # Direct threads can't be left (mute or block instead) — leaving would
  # strand a 1:1 thread in a weird half-state.
  raise ActiveRecord::RecordNotFound if @conversation.direct?

  title = @conversation.title_for(chats_current_messager)
  @conversation.participant_for(chats_current_messager)&.leave!
  redirect_to conversations_path, notice: t("chats.flashes.left", title: title)
end

#muteObject



139
140
141
142
# File 'app/controllers/chats/conversations_controller.rb', line 139

def mute
  @conversation.participant_for(chats_current_messager)&.mute!
  redirect_to conversation_path(@conversation), notice: t("chats.flashes.muted")
end

#readObject

Advance the viewer’s read horizon. Called by the thread Stimulus controller (debounced) when new messages arrive while the thread is visible. Side effects (read-state broadcast, badge refresh) live in Participant#read!.



117
118
119
120
# File 'app/controllers/chats/conversations_controller.rb', line 117

def read
  @conversation.participant_for(chats_current_messager)&.read!
  head :no_content
end

#refreshObject

Stale-thread catch-up: appends messages created — and replaces ones edited/tombstoned — since the newest ‘updated_at` the client has rendered (`?since=` in ms). The thread controller calls this when the tab wakes from a long sleep or its Turbo Stream subscription reconnects, i.e. whenever broadcasts may have been missed. Mobile WebViews suspend WebSockets aggressively, so without this a backgrounded chat silently loses messages until a manual reload. Pattern from Basecamp’s Campfire (Rooms::RefreshesController): github.com/basecamp/once-campfire



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'app/controllers/chats/conversations_controller.rb', line 67

def refresh
  head :no_content and return if params[:since].blank?

  since = Time.zone.at(0, params[:since].to_i, :millisecond)
  scope = @conversation.messages.includes(:sender, :reactions)
  scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)

  @new_messages = scope.created_since(since).oldest_first.limit(Chats.config.messages_per_page + 1).to_a

  # A backlog deeper than one page would mean splicing an arbitrary
  # amount of history through surgical appends; a Turbo 8 page refresh
  # (morph + scroll preservation) re-renders the latest page + frame
  # chain correctly instead. Raw tag rather than `turbo_stream.refresh`
  # so we don't depend on turbo-rails ≥ 2.0 helpers.
  if @new_messages.size > Chats.config.messages_per_page
    render html: '<turbo-stream action="refresh"></turbo-stream>'.html_safe,
           content_type: "text/vnd.turbo-stream.html"
    return
  end

  @updated_messages = scope.updated_since(since)
  render "chats/conversations/refresh", formats: :turbo_stream
end

#showObject

The thread. Renders the LATEST page of messages; older pages stream in through a lazy Turbo Frame chain (keyset-paginated — see Message.before_message and _messages_page.html.erb).



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'app/controllers/chats/conversations_controller.rb', line 23

def show
  @participant = @conversation.participant_for(chats_current_messager)

  anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
  scope = @conversation.messages.includes(:sender, :reactions)
  scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
  scope = scope.before_message(anchor) if anchor

  # Fetch newest-first + reverse so "the last N messages" render in
  # chronological order. One extra record peeks whether older pages exist.
  page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
  @more_messages = page.size > Chats.config.messages_per_page
  @messages = page.first(Chats.config.messages_per_page).reverse

  if anchor
    # Older-page request from the pagination frame: render just the page.
    render partial: "chats/conversations/messages_page",
           locals: { conversation: @conversation, messages: @messages, more: @more_messages }
  else
    # The «new messages» divider: computed BEFORE read! advances the
    # horizon (after it, nothing is unread anymore). Anchored to the
    # oldest unread bubble on the rendered page; when the backlog runs
    # deeper than one page it pins to the top of the page instead —
    # the scroll-up frame chain holds the rest.
    if @participant&.unread?
      @first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
                         @messages.first&.id
    end

    # Opening the thread reads it. (Live appends while the thread stays
    # open are read via the thread controller's POST to #read.)
    @participant&.read!
  end
end

#typingObject

Ephemeral typing ping (client throttles to ~1 every 3s while typing). Nothing is persisted; see Chats::Broadcasts.typing.



124
125
126
127
# File 'app/controllers/chats/conversations_controller.rb', line 124

def typing
  Chats::Broadcasts.typing(@conversation, chats_current_messager) if Chats.config.typing_indicators
  head :no_content
end

#unmuteObject



144
145
146
147
# File 'app/controllers/chats/conversations_controller.rb', line 144

def unmute
  @conversation.participant_for(chats_current_messager)&.unmute!
  redirect_to conversation_path(@conversation), notice: t("chats.flashes.unmuted")
end