Class: Chats::Participant

Inherits:
ApplicationRecord show all
Defined in:
lib/chats/models/participant.rb

Overview

A messager’s seat in a conversation. The messager is polymorphic — any ‘acts_as_messager` model can sit here (a User, an Organization, a support Agent) — and ALL per-member state lives on this row:

role        "member" | "owner" (group creator/admin)
last_read_at    the read horizon (see "Read state" below)
muted_at        notifications muted (the gem still delivers messages;
                hosts consult `notifiable?` in their notifier hook)
left_at         soft-left groups (history kept, no new messages seen)
last_notified_at  bookkeeping for "notify once per unread burst"
                  debounced notifications (see #should_notify?)

Read state: a horizon, not per-message receipts

We deliberately store ONE timestamp per participant instead of a per-message receipts table. A message is unread for you iff ‘created_at > your last_read_at`; you “read” a conversation by advancing the horizon. This gives unread counts, unread badges and “Seen” indicators with zero extra writes per message (a receipts table writes N rows per message per N participants — the classic chat-schema scaling trap), and it’s exactly how Basecamp’s Campfire models it. If a future use case truly needs per-message receipts (e.g. per-member “read by 7/9” in large groups), they can be added as a new table without breaking this API.

Constant Summary collapse

ROLES =
%w[member owner].freeze

Instance Method Summary collapse

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


48
# File 'lib/chats/models/participant.rb', line 48

def active? = left_at.nil?

#display_nameObject



51
52
53
# File 'lib/chats/models/participant.rb', line 51

def display_name
  Chats.display_name_for(messager)
end

#leave!Object



106
107
108
# File 'lib/chats/models/participant.rb', line 106

def leave!
  update!(left_at: Time.current)
end

#left?Boolean

Returns:

  • (Boolean)


47
# File 'lib/chats/models/participant.rb', line 47

def left? = left_at.present?

#mark_notified!(at: Time.current) ⇒ Object



132
133
134
# File 'lib/chats/models/participant.rb', line 132

def mark_notified!(at: Time.current)
  update!(last_notified_at: at)
end

#mute!Object



103
# File 'lib/chats/models/participant.rb', line 103

def mute! = update!(muted_at: Time.current)

#muted?Boolean

Returns:

  • (Boolean)


49
# File 'lib/chats/models/participant.rb', line 49

def muted? = muted_at.present?

#notifiable_for?(message) ⇒ Boolean

Should the host notify this participant about message? Encapsulates the etiquette every messaging product implements so each host doesn’t re-derive it: don’t notify yourself, the muted, the departed — and for debounced email digests, don’t notify twice for the same unread burst.

Returns:

  • (Boolean)


116
117
118
119
120
121
# File 'lib/chats/models/participant.rb', line 116

def notifiable_for?(message)
  return false if left? || muted?
  return false if message.sender == messager

  true
end

#owner?Boolean

Returns:

  • (Boolean)


46
# File 'lib/chats/models/participant.rb', line 46

def owner? = role == "owner"

#read!(at: Time.current) ⇒ Object

Advance the read horizon to now and tell everyone who cares:

- the conversation stream gets a fresh read-state payload (powers the
  "Seen" indicator on the other side, when read receipts are on)
- this messager's OWN inbox + badge refresh (their other devices/tabs
  should drop the unread highlight too)
- the HOST gets a `:conversation_read` notifier event — but only when
  the horizon actually swallowed unread content. Hosts use it to keep
  external notification surfaces truthful (e.g. mark this chat's rows
  read in a notification center the moment the thread is read, so a
  bell badge doesn't keep advertising messages the user has already
  seen). Same notify hook as :message_created; error-isolated.


91
92
93
94
95
96
97
98
99
100
101
# File 'lib/chats/models/participant.rb', line 91

def read!(at: Time.current)
  return self if last_read_at && last_read_at >= at

  had_unread = unread?
  update!(last_read_at: at)
  broadcast_read_state if Chats.config.read_receipts
  Chats::Broadcasts.refresh_inbox_of(messager)
  Chats::Broadcasts.update_badge_of(messager)
  Chats.notify(:conversation_read, conversation: conversation, participant: self) if had_unread
  self
end

#should_notify?Boolean

For “email me only once until I come back” digests: true when there’s something unread AND we haven’t already notified since the last read. Pair with ‘mark_notified!` after actually sending.

Returns:

  • (Boolean)


126
127
128
129
130
# File 'lib/chats/models/participant.rb', line 126

def should_notify?
  return false unless unread?

  last_notified_at.nil? || last_notified_at < (last_read_at || Conversation::EPOCH)
end

#unmute!Object



104
# File 'lib/chats/models/participant.rb', line 104

def unmute! = update!(muted_at: nil)

#unread?Boolean

Returns:

  • (Boolean)


76
77
78
# File 'lib/chats/models/participant.rb', line 76

def unread?
  unread_messages.exists?
end

#unread_countObject



72
73
74
# File 'lib/chats/models/participant.rb', line 72

def unread_count
  unread_messages.count
end

#unread_messagesObject

Messages this participant hasn’t seen: anything newer than their read horizon, excluding their own messages and deleted tombstones. The explicit IS NULL leg keeps senderless SYSTEM messages counted — SQL’s three-valued logic would otherwise drop them (NOT(NULL = …) is NULL, not TRUE). Same fix as Conversation.unread_by.



62
63
64
65
66
67
68
69
70
# File 'lib/chats/models/participant.rb', line 62

def unread_messages
  conversation.messages.visible
              .where("chats_messages.created_at > ?", last_read_at || Conversation::EPOCH)
              .where(
                "chats_messages.sender_type IS NULL OR " \
                "NOT (chats_messages.sender_type = ? AND chats_messages.sender_id = ?)",
                messager_type, messager_id.to_s
              )
end