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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +139 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/macros.rb +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- 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
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
|
|
data/docs/campfire_review.md
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/gemfiles/rails_7.1.gemfile
CHANGED
|
@@ -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"
|
data/gemfiles/rails_7.2.gemfile
CHANGED
|
@@ -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"
|
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -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"
|
data/lib/chats/action.rb
ADDED
|
@@ -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
|
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/chats/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|