chats 0.6.2 → 0.7.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 +147 -0
- data/README.md +90 -28
- data/app/assets/stylesheets/chats.css +225 -15
- data/app/helpers/chats/engine_helper.rb +101 -7
- data/app/javascript/chats/dock_controller.js +54 -33
- data/app/javascript/chats/sheet.js +69 -0
- data/app/javascript/chats/thread_controller.js +199 -5
- data/app/javascript/chats/tray_controller.js +506 -0
- data/app/views/chats/conversations/_thread.html.erb +4 -0
- data/app/views/chats/dock/_dock.html.erb +9 -1
- data/app/views/chats/messages/_message.html.erb +30 -3
- data/app/views/chats/tray/_badge.html.erb +12 -0
- data/app/views/chats/tray/_corner.html.erb +16 -0
- data/app/views/chats/tray/_item.html.erb +44 -0
- data/app/views/chats/tray/_tray.html.erb +27 -0
- data/config/importmap.rb +4 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/chats/after_commit.rb +87 -0
- data/lib/chats/broadcasts.rb +8 -0
- data/lib/chats/models/participant.rb +14 -1
- data/lib/chats/subscribers.rb +14 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- metadata +8 -16
- data/docs/PRD.md +0 -254
- data/docs/campfire_review.md +0 -46
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7bc51c7dab52fde58726ff5ab0a756f26bd02c057f99cacb331e99de7cb231d
|
|
4
|
+
data.tar.gz: db4b00f65dc731c1cf3bd73334e9dd988585461ab83c1af1c89aaba1c7b45cf6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a734cf98def8441491a899b5180d53415fe5379d0337ee88b695f791f13934d9cfbf8fc9f3d741af8b1d02234d171854aa69488d22b66dc6661f440bd5bd2077
|
|
7
|
+
data.tar.gz: 2a840060e9f69965ea85839533af64cfe8e09634e9a4e024f3d6727f1e606302bbe685b9b3eea64452971bf79cf3cc678dab94c1a124875deae2ee12ad1f288d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,153 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.7.2] - 2026-10-19
|
|
8
|
+
|
|
9
|
+
**0.7.2**: **No migration, nothing to configure.**
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **The `message_menu` slot**: `chats/slots/_message_menu.html.erb` renders
|
|
14
|
+
inside each message's menu, after Copy, Edit and Delete, so a Report needs
|
|
15
|
+
no ejected partial and no link under every bubble. It gets `message:`
|
|
16
|
+
only (the bubble is one render for everybody). Items are
|
|
17
|
+
`chats-popup__item` links or `button_to` forms, which survive the popup's
|
|
18
|
+
clone of the menu's `<template>`; `data-chats-other-only` keeps one off the
|
|
19
|
+
viewer's own messages, `data-chats-own-only` off everybody else's (both
|
|
20
|
+
already honoured, now documented). The Trust & Safety recipe uses it.
|
|
21
|
+
- **"⋯" beside each bubble on a desktop**, on its inner edge, opening the
|
|
22
|
+
same popup as a right-click. Only under `(hover: hover) and (pointer:
|
|
23
|
+
fine)`: a touch screen never draws it and long-presses. Keyboard: the
|
|
24
|
+
thread's "⋯" buttons are one tab stop (roving tabindex: the newest
|
|
25
|
+
message's), ArrowUp / ArrowDown move along them, Enter opens the menu with
|
|
26
|
+
the focus on its first item, Escape closes it and returns the focus. A
|
|
27
|
+
stream replacing the focused "⋯"'s bubble (an edit, a reaction) leaves the
|
|
28
|
+
focus on the replacement's, a removal on the nearest message's. The
|
|
29
|
+
markup stays viewer-agnostic; the name (`chats.message.menu`) is stamped
|
|
30
|
+
in the reader's language by `chats--thread` (`menuLabel`). A touch laptop
|
|
31
|
+
whose main pointer is a trackpad shows it too; its screen long-presses.
|
|
32
|
+
- **The popup keeps the focus.** It was already `role="dialog"
|
|
33
|
+
aria-modal="true"` (its backdrop covers the thread, on touch as on a
|
|
34
|
+
desk), but Tab walked out to the page behind it. Tab / Shift+Tab now cycle
|
|
35
|
+
its reactions and items, ArrowUp / ArrowDown / Home / End move along the
|
|
36
|
+
items (`role="menu"`), and the dialog is named (`chats.message.menu`).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The package leaves all of `docs/` behind, the design notes with the
|
|
41
|
+
screenshots: nothing inside the gem reads them.
|
|
42
|
+
- **Choosing a link in the menu closes it**, as a form button did: the dock
|
|
43
|
+
is carried from page to page, and its popup stayed open over the next one.
|
|
44
|
+
- **A menu the visibility gates leave empty is not drawn** (no empty card).
|
|
45
|
+
- **A cloned form carries the page's CSRF token.** The menu's forms are
|
|
46
|
+
rendered once, often by a broadcast outside any session; the popup now puts
|
|
47
|
+
the page's `csrf-token` in each cloned form's hidden input (adding one if
|
|
48
|
+
there was none), so a native submit, with Turbo off around the thread,
|
|
49
|
+
passes forgery protection too.
|
|
50
|
+
- **The popup's keys go with it**, however it goes: a dock window closed with
|
|
51
|
+
its menu open left a document listener swallowing the page's Tab.
|
|
52
|
+
|
|
53
|
+
If you ejected `chats/messages/_message.html.erb`, it keeps working; copy
|
|
54
|
+
the new `button.chats-message__menu-trigger` (after `.chats-message__bubble`)
|
|
55
|
+
and the `chats_slot :message_menu` line (end of `.chats-popup__menu`) to get
|
|
56
|
+
them. `.chats-message__bubble-row` is now `position: relative`.
|
|
57
|
+
|
|
58
|
+
## [0.7.1] - 2026-10-19
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **The package no longer carries the README's screenshots.** They are
|
|
63
|
+
repository furniture: GitHub serves them, nothing inside the gem reads
|
|
64
|
+
them, and they were 900 KB of a 1.1 MB package. The README now loads them
|
|
65
|
+
from the repository, so they render on rubygems.org too. The gem is a
|
|
66
|
+
fifth of its former size. No code change; `0.7.0` was never published.
|
|
67
|
+
- The tray layout system test settles by a bounded synchronous poll: on
|
|
68
|
+
CI's Linux Chrome the async callback armed after the launcher's click
|
|
69
|
+
never came back and every job died as a script timeout, which is how
|
|
70
|
+
`0.7.0` was tagged on a red run.
|
|
71
|
+
|
|
72
|
+
## [0.7.0] - 2026-10-20
|
|
73
|
+
|
|
74
|
+
Intended as **0.7.0**: the tray. **No migration, nothing changes until you
|
|
75
|
+
use it.**
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- **The tray: other launchers beside the dock's bar.** `chats_dock { … }`
|
|
79
|
+
now takes a block, and what it renders sits in the dock's own row, between
|
|
80
|
+
the windows and the Messages bar: support_desk's "Need help?", a feedback
|
|
81
|
+
box, anything built with the new `chats_tray_item(key, label:, title:,
|
|
82
|
+
icon:, badge:, badge_url:) { panel }`. Closed, an item is a tab on the
|
|
83
|
+
bottom edge as tall as the bar; open, a panel with the dock's skin (the
|
|
84
|
+
window's width, the panel's height). One owner for the corner: the
|
|
85
|
+
windows get the width the tray leaves (measured with a ResizeObserver, so
|
|
86
|
+
an item opening reflows them like a narrower screen: idle windows close,
|
|
87
|
+
ones in use fold, drafts are kept), one menu is open at a time (an item
|
|
88
|
+
opening closes the Messages panel, and the other way round), and nothing
|
|
89
|
+
ever overlaps. On a phone the items are 48px round buttons stacked above
|
|
90
|
+
the dock's, an open one is the dock's full-screen sheet, and
|
|
91
|
+
`launcher: false` takes them away with the dock's button. Escape closes
|
|
92
|
+
the open item, focus back on its tab; the open item is remembered on a
|
|
93
|
+
desk (`chats:tray:v1:<key>`). Run by the new `chats--tray` controller.
|
|
94
|
+
- **`chats_tray { … }`: the tray on its own**, for a host with no messaging
|
|
95
|
+
dock, and what `chats_dock { … }` renders when nobody is signed in: the
|
|
96
|
+
same corner (`.chats-dock.chats-dock--tray-only`, `data-turbo-permanent`),
|
|
97
|
+
the same variables, holding only the items. Inside a `chats_dock` block it
|
|
98
|
+
renders its items alone, so a launcher helper can always wrap itself in it.
|
|
99
|
+
It hides on pages carrying `data-chats-dock-hide`, as the dock does.
|
|
100
|
+
- **The tray follows the page it is carried to.** The dock and the tray-only
|
|
101
|
+
corner are `data-turbo-permanent`, so Turbo keeps the old tray on a visit;
|
|
102
|
+
`chats--tray` reconciles it with the items the new page rendered, by key: one
|
|
103
|
+
the page no longer renders goes, a new one comes in, and one still there
|
|
104
|
+
takes the page's words, badge, badge URL, stream and panel, keeping only
|
|
105
|
+
its open state and, while open, a frame of its panel the page renders the
|
|
106
|
+
same way (the same id and `src`, resolved, query included; where it went
|
|
107
|
+
since is the viewer's): what is being read or written. Its class and style
|
|
108
|
+
are the page's too, but for the sheet and the room the controller set. A
|
|
109
|
+
resize measures once a frame.
|
|
110
|
+
- **Room.** An open item is as wide as the corner leaves it (the screen less
|
|
111
|
+
the insets, the Messages panel and every other item, measured as it opens:
|
|
112
|
+
`--chats-tray-room`); with less than `--chats-tray-min-width` (280px) it
|
|
113
|
+
opens as the full-screen sheet, as on a phone (`.chats-tray__item--sheet`,
|
|
114
|
+
which now carries the tray's sheet layout on phones too).
|
|
115
|
+
- **Badge URLs are same-origin.** The catch-up applies what a badge URL
|
|
116
|
+
answers to the page, so `chats--tray` resolves it against the page and
|
|
117
|
+
refuses (with a console error) one on another origin.
|
|
118
|
+
- **What an item's content can count on**, declared in markup: the read
|
|
119
|
+
gate a dock window has (`data-chats-active`, `data-chats-minimized` while
|
|
120
|
+
closed, `chats:activated` on open: a chats thread inside a closed item
|
|
121
|
+
never marks anything read); `chats_tray_badge(count, id:)`, the badge an
|
|
122
|
+
item's own stream replaces, asked again from `badge_url:` when that stream
|
|
123
|
+
may have missed a broadcast (chats/recovery); `data-chats-tray-refresh`
|
|
124
|
+
(its frame reloads when the badge's count moves); a frame's
|
|
125
|
+
`data-chats-tray-home` (where it goes back to rather than show a
|
|
126
|
+
conversation the page or a dock window already shows); and the
|
|
127
|
+
`chats--tray:open` / `chats--tray:close` events.
|
|
128
|
+
- **`:unread_changed` (`messager:, conversation:`)**: a messager's unread
|
|
129
|
+
count in a conversation may have moved: every message, system ones
|
|
130
|
+
included (which `:message_created` skips on purpose), for every active
|
|
131
|
+
seat but the sender's, and a read that consumed unread content. Product
|
|
132
|
+
gems keep their own badges live from it (support_desk's launcher does). It
|
|
133
|
+
fires after the write commits (a read's is owned by the transaction it
|
|
134
|
+
wrote in, `Chats::AfterCommit`, so a host's rollback or a rolled-back
|
|
135
|
+
savepoint never loses or invents one; once per seat per transaction), once per eligible seat,
|
|
136
|
+
synchronously in the write's `after_commit`
|
|
137
|
+
(a message in a 100-person group: 99 calls), so subscribers filter cheaply
|
|
138
|
+
and enqueue or coalesce.
|
|
139
|
+
- `--chats-tray-panel-width`, `--chats-tray-panel-height`,
|
|
140
|
+
`--chats-tray-launcher-size`, declared with the dock's on `.chats-dock`.
|
|
141
|
+
|
|
142
|
+
### Changed
|
|
143
|
+
- The dock's phone sheet and the tray's share `chats/sheet` (pinned outside
|
|
144
|
+
`controllers/`): the page's `inert` and its scroll lock are held by owner,
|
|
145
|
+
so one sheet closing never frees what another still holds. Same behaviour
|
|
146
|
+
for the dock alone; a node the host made inert itself is still never
|
|
147
|
+
touched. Both release it before Turbo caches the page (`turbo:before-cache`)
|
|
148
|
+
and hold it again after the visit renders, so a cached snapshot never comes
|
|
149
|
+
back inert.
|
|
150
|
+
- The engine test that checked the asset paths by directory name now checks
|
|
151
|
+
the engine's own directories, so it passes from a checkout not named
|
|
152
|
+
`chats`.
|
|
153
|
+
|
|
7
154
|
## [0.6.2] - 2026-10-18
|
|
8
155
|
|
|
9
156
|
### Fixed
|
data/README.md
CHANGED
|
@@ -259,24 +259,7 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
|
|
|
259
259
|
|
|
260
260
|
### 3. The report affordance — mind the broadcast
|
|
261
261
|
|
|
262
|
-
Put a report link
|
|
263
|
-
|
|
264
|
-
```erb
|
|
265
|
-
<%# in your ejected chats/messages/_message.html.erb, inside .chats-popup__menu
|
|
266
|
-
in the <template data-chats-message-menu> (the long-press menu) %>
|
|
267
|
-
<% if message.sender %>
|
|
268
|
-
<%= link_to "Report",
|
|
269
|
-
main_app.new_abuse_report_path(
|
|
270
|
-
target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE),
|
|
271
|
-
field: "body"
|
|
272
|
-
),
|
|
273
|
-
class: "chats-popup__item", role: "menuitem",
|
|
274
|
-
data: { chats_other_only: true } %>
|
|
275
|
-
<%# data-chats-other-only: the popup drops it from your own bubbles;
|
|
276
|
-
moderate's controller re-checks report_visible_to? server-side,
|
|
277
|
-
so hiding is cosmetic %>
|
|
278
|
-
<% end %>
|
|
279
|
-
```
|
|
262
|
+
Put a report link in the menu of every message **someone else** sent: the [`message_menu` slot](#items-in-a-messages-menu-the-message_menu-slot)'s example is exactly that. One nuance matters: `chats` renders each bubble **once per broadcast, viewer-agnostically** (that's what makes real-time fan-out cheap), so anything depending on `current_user` at render time — like moderate's `report_link` helper, which checks `report_visible_to?(viewer)` — would silently vanish from live-appended bubbles. Use the **signed-target URL** (viewer-independent) and `data-chats-other-only` (the popup drops the item from your own messages); moderate's controller re-checks `report_visible_to?` server-side, so hiding is cosmetic.
|
|
280
263
|
|
|
281
264
|
And give direct threads a **block** action in the thread menu (your ejected `show.html.erb`) pointing at your moderate-backed blocks endpoint. One UX trap: blocking hides the very thread the user is standing in — redirect to the inbox, not back.
|
|
282
265
|
|
|
@@ -298,7 +281,7 @@ Moderate::Flag.flag!(
|
|
|
298
281
|
- [ ] `blocked_messager_ids` → `Moderate.blocked_ids_for`
|
|
299
282
|
- [ ] `Chats::Message` reportable (`:body`, and `:files` if attachments are on)
|
|
300
283
|
- [ ] body + files filter policies in `:flag` mode
|
|
301
|
-
- [ ] report
|
|
284
|
+
- [ ] report item in the menu of foreign messages (`message_menu` slot, signed target, broadcast-safe)
|
|
302
285
|
- [ ] block action on direct threads (redirecting away from the hidden thread)
|
|
303
286
|
- [ ] admin queue handles chat flags/reports (it does, automatically — verify with one test)
|
|
304
287
|
- [ ] a test that a block placed mid-conversation stops the next send
|
|
@@ -313,6 +296,8 @@ Chats.on(:message_created) { |message| NewMessageNotifier.with(record: mess
|
|
|
313
296
|
Chats.on(:conversation_created) { |conversation| Analytics.track("chat_started", conversation) }
|
|
314
297
|
Chats.on(:participant_left) { |participant| AuditLog.log("chat_left", participant) }
|
|
315
298
|
Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant.messager, conversation) }
|
|
299
|
+
Chats.on(:unread_changed) { |messager:, conversation:| MyBadge.refresh(messager) }
|
|
300
|
+
|
|
316
301
|
```
|
|
317
302
|
|
|
318
303
|
| event | block arguments | when |
|
|
@@ -321,7 +306,7 @@ Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(p
|
|
|
321
306
|
| `:conversation_created` | `conversation` | once per conversation, never when an existing one is resumed |
|
|
322
307
|
| `:participant_left` | `participant` | someone left a group |
|
|
323
308
|
| `:conversation_read` | `conversation:, participant:` | a read advanced the horizon past unread content |
|
|
324
|
-
|
|
309
|
+
| `:unread_changed` | `messager:, conversation:` | a messager's unread count there may have moved: any message (system ones included) for every seat but the sender's, and a read that consumed some. What a badge chats doesn't draw (a product gem's, a native app's) listens to. Always after the write commits, owned by the transaction it wrote in (never for one that rolls back, a host's transaction or a savepoint included; once per seat per transaction). **Once per eligible seat**: a message in a 100-person group calls it 99 times, synchronously, in the write's `after_commit`, so a subscriber filters on the conversation first (no query) and enqueues or coalesces its work (support_desk's: one small job, for the case's one requester) |
|
|
325
310
|
|
|
326
311
|
Four properties, all of which matter the first time something goes wrong at 3am:
|
|
327
312
|
|
|
@@ -503,7 +488,7 @@ desk.message!(user, "Your invoice is ready", author: lucia, action: { label: "Pa
|
|
|
503
488
|
conversation.post_system_message!("Your ride was cancelled", action: { label: "Book another", url: new_ride_path })
|
|
504
489
|
```
|
|
505
490
|
|
|
506
|
-
<img src="docs/screenshots/thread-actions-phone.png" alt="A thread on a phone: a received message with a dark Join my room button, and your own dark bubble with a white Open my lobby button" width="320">
|
|
491
|
+
<img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/thread-actions-phone.png" alt="A thread on a phone: a received message with a dark Join my room button, and your own dark bubble with a white Open my lobby button" width="320">
|
|
507
492
|
|
|
508
493
|
*One action per message: accent-filled under someone else's message, reversed in your own bubble.*
|
|
509
494
|
|
|
@@ -575,6 +560,7 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
575
560
|
| `composer_actions` | a row of your own buttons above the composer's input, outside its form (gets `conversation:`; gone when the thread locks) |
|
|
576
561
|
| `locked_composer` | the locked composer's body |
|
|
577
562
|
| `message_meta` | after each bubble's timestamp |
|
|
563
|
+
| `message_menu` | inside each message's menu, after Copy, Edit and Delete (gets `message:`; see [below](#items-in-a-messages-menu-the-message_menu-slot)) |
|
|
578
564
|
| `dock_panel_footer` | beside "See all messages" in the [messaging dock](#-messaging-dock)'s panel (gets `viewer:`) |
|
|
579
565
|
|
|
580
566
|
```erb
|
|
@@ -584,17 +570,40 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
584
570
|
|
|
585
571
|
An engine mounted on top of chats ships its own `app/views/chats/slots/…`; the host's file wins by view-path order. `rails generate chats:views` is still there for wholesale restyling.
|
|
586
572
|
|
|
573
|
+
### Items in a message's menu: the `message_menu` slot
|
|
574
|
+
|
|
575
|
+
Every message has a menu (Telegram's): a long-press on a touch screen, a right-click, or the **"⋯"** beside the bubble while the pointer is on it (a fine pointer that hovers only; a touch screen never shows it). A touch laptop whose main pointer is a trackpad shows the "⋯" too, and its screen still long-presses. The keyboard reaches it: a thread's "⋯" buttons are one tab stop (the newest message's), ArrowUp / ArrowDown move along them, and a stream replacing the focused one's bubble (an edit, a reaction) leaves the focus on its replacement. Enter opens the menu, a modal dialog: the focus is on its first item, Tab cycles the reactions and the items without leaving it, the arrows (and Home / End) move along the items, and Escape closes it and gives the focus back. The `message_menu` slot puts your items in that menu, after the gem's:
|
|
576
|
+
|
|
577
|
+
```erb
|
|
578
|
+
<%# app/views/chats/slots/_message_menu.html.erb %>
|
|
579
|
+
<% if message.sender %>
|
|
580
|
+
<%= link_to "Report",
|
|
581
|
+
main_app.new_abuse_report_path(target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE), field: "body"),
|
|
582
|
+
class: "chats-popup__item", role: "menuitem",
|
|
583
|
+
data: { chats_other_only: true, turbo_frame: "_top" } %>
|
|
584
|
+
<% end %>
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
<img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/message-menu-desktop.png" alt="A desktop thread: a received message's menu, with Copy and a host's Report, opened from the ⋯ beside the bubble" width="480">
|
|
588
|
+
|
|
589
|
+
- **A link or a `button_to` form** with the `chats-popup__item` class (`chats-popup__item--danger` for a destructive one). The slot renders inside an inert `<template>` the thread controller **clones** into the popup on open: a link survives that, and so does a form: the clone carries the page's own CSRF token (the `csrf-param` / `csrf-token` metas, put in its hidden input), so it passes forgery protection even from a bubble a broadcast rendered outside the viewer's session, with Turbo or without it. The item is in the page only while the menu is open, so script that looks for it looks in the popup. Choosing one closes the menu.
|
|
590
|
+
- **The same for every viewer.** The bubble is rendered once per broadcast for everybody, so the partial gets `message:` and no `current_user`. **`data-chats-other-only`** keeps an item off the viewer's own messages, **`data-chats-own-only`** off everybody else's (Edit and Delete use it). Both are cosmetic: the endpoint still decides.
|
|
591
|
+
- **`data-turbo-frame="_top"`** on a link that leaves the thread: a thread can sit in a frame (the two-pane page's pane, a dock window), and a link in a frame navigates the frame.
|
|
592
|
+
- **The broadcast's language.** A live bubble is rendered in the locale of the request that sent it, so a multilingual app re-labels its item in the browser from the reader's thread page, as the gem does for the "⋯" button's name.
|
|
593
|
+
|
|
594
|
+
A deleted message and a system note have no menu. In a [locked conversation](#-locked-conversations) it keeps Copy and your items; the endpoint decides what a lock refuses.
|
|
595
|
+
|
|
587
596
|
## 🪟 Two panes
|
|
588
597
|
|
|
589
598
|
`/messages` is LinkedIn's messaging page: the conversation list on the left, the open conversation on the right. `/messages/:id` is that same page with its conversation open and its row highlighted, so a link to a conversation, a reload, back and forward all land where they should.
|
|
590
599
|
|
|
591
|
-

|
|
600
|
+

|
|
592
601
|
|
|
593
602
|
Each row is a real link to `/messages/:id` (keeping the list's search or stack in its query, so the page's URL always names the list it shows). On a wide screen it opens the conversation in the right pane, a `turbo_frame_tag "chats_conversation"` whose `data-turbo-action="advance"` moves the address bar and history with it, and the server answers that frame with the pane alone. The pane's thread sits in a `data-turbo-permanent` element keyed by the conversation (and the viewer), so the inbox's live refreshes (Turbo 8 morphs of the whole page) re-render the list and leave the conversation alone: its draft, scroll position, read state and stream subscription. Opening one takes you into it, as on LinkedIn: the focus goes to its composer (on a touch screen, to its title, so the keyboard waits until you ask for it), inside a pane a screen reader names after the conversation.
|
|
594
603
|
|
|
595
604
|
**On a phone** (under 700px wide, where the dock turns into a full-screen sheet) it is one column, exactly the separate pages as before: `/messages` is the list, and a row opens the conversation's page, with its back link to the list. One media query decides, so the layout holds before JavaScript runs and without it; with JavaScript off a row always opens the page.
|
|
596
605
|
|
|
597
|
-
<img src="docs/screenshots/split-inbox-phone-list.png" alt="A phone: the messaging page is the conversation list" width="240"> <img src="docs/screenshots/split-inbox-phone-thread.png" alt="A phone: a conversation fills the screen, with its back link to the list" width="240">
|
|
606
|
+
<img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/split-inbox-phone-list.png" alt="A phone: the messaging page is the conversation list" width="240"> <img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/split-inbox-phone-thread.png" alt="A phone: a conversation fills the screen, with its back link to the list" width="240">
|
|
598
607
|
|
|
599
608
|
**Reads** happen when you open a conversation: a row clicked, a link, a reload. The page's own refreshes never read (one may arrive in a background tab), and a conversation the page renders unread reads itself as soon as it's on screen.
|
|
600
609
|
|
|
@@ -622,7 +631,7 @@ LinkedIn and Steam don't send you to another page to answer a message: chats liv
|
|
|
622
631
|
<%= chats_dock %>
|
|
623
632
|
```
|
|
624
633
|
|
|
625
|
-

|
|
634
|
+

|
|
626
635
|
|
|
627
636
|
*Two conversations in floating windows (one with an action button) and the panel of recent conversations. The rows open in a window carry the accent bar.*
|
|
628
637
|
|
|
@@ -636,7 +645,7 @@ LinkedIn and Steam don't send you to another page to answer a message: chats liv
|
|
|
636
645
|
|
|
637
646
|
On a phone (under 700px wide or 500px tall) the bar becomes a round button with the badge, the panel a full-screen sheet, and a conversation **one full-screen window**, with ← back to the list. While a window is up, the list behind it is out of the tab order; Back returns the focus to it. The sheet holds the focus while it's open (everything around it is made `inert`, siblings of the dock and of each of its ancestors, so a layout that wraps the dock works too), the page behind it doesn't scroll, and the composer sits on the keyboard. This layout is one media query, so it applies before JavaScript runs and without it.
|
|
638
647
|
|
|
639
|
-
<img src="docs/screenshots/dock-phone-button.png" alt="A phone: the dock is a round button with an unread badge in the corner" width="240"> <img src="docs/screenshots/dock-phone-panel.png" alt="A phone: the dock's panel fills the screen with the recent conversations" width="240"> <img src="docs/screenshots/dock-phone-window.png" alt="A phone: one conversation fills the screen, with the composer at the bottom" width="240">
|
|
648
|
+
<img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/dock-phone-button.png" alt="A phone: the dock is a round button with an unread badge in the corner" width="240"> <img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/dock-phone-panel.png" alt="A phone: the dock's panel fills the screen with the recent conversations" width="240"> <img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/dock-phone-window.png" alt="A phone: one conversation fills the screen, with the composer at the bottom" width="240">
|
|
640
649
|
|
|
641
650
|
**Open a window from your own pages.** `dock: true` on the Message button opens the conversation in the dock instead of navigating to it (a conversation already open in a window is focused, with its draft intact):
|
|
642
651
|
|
|
@@ -655,7 +664,8 @@ It needs `chats_dock` on the same page: without the dock, the button's response
|
|
|
655
664
|
|
|
656
665
|
```erb
|
|
657
666
|
<%= chats_dock windows: 2, recent: 8 %> <%# at most this many windows (3), conversations in the panel (10) %>
|
|
658
|
-
<%= chats_dock launcher: false %> <%# no round
|
|
667
|
+
<%= chats_dock launcher: false %> <%# no round buttons on phones (the tray's either): your nav opens the sheet (chats_dock_toggle) %>
|
|
668
|
+
|
|
659
669
|
<%= chats_dock everywhere: true %> <%# also on the engine's own pages (see below) %>
|
|
660
670
|
```
|
|
661
671
|
|
|
@@ -684,6 +694,56 @@ It needs `chats_dock` on the same page: without the dock, the button's response
|
|
|
684
694
|
|
|
685
695
|
The `dock_panel_footer` [slot](#-view-slots) puts your own link next to "See all messages". `rails generate chats:views` ejects the dock's partials (`chats/dock/`) along with everything else. All labels are localized under `chats.dock.*`. The window's unread count (`chats.dock.window_unread`) is pluralized in the browser with `Intl.PluralRules` in the page's `<html lang>`: the gem ships `one`/`other`, so add the forms your languages need (`zero`, `two`, `few`, `many`) in your own locale file, e.g. Polish `few: "%{count} nowe wiadomości"`, `many: "%{count} nowych wiadomości"`.
|
|
686
696
|
|
|
697
|
+
### The tray: other launchers beside the bar
|
|
698
|
+
|
|
699
|
+
A product often has more than one thing living in that corner: a "Need help?" launcher, a feedback box, a changelog. Two floating buttons that ignore each other overlap, fight over the z-index and cover the windows. The **tray** gives them one owner: whatever the block of `chats_dock` renders sits in the dock's own row, between the windows and the Messages bar.
|
|
700
|
+
|
|
701
|
+
```erb
|
|
702
|
+
<%= chats_dock do %>
|
|
703
|
+
<%= support_launcher %> <%# support_desk's "Need help?" is one; anything built with chats_tray_item is %>
|
|
704
|
+
<% end %>
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+

|
|
708
|
+
|
|
709
|
+
- **Closed**, an item is a tab on the bottom edge, as tall as the bar: its icon, its words, its badge. **Open**, it is a panel with the dock's skin (the window's width, the panel's height) and its title on the bar.
|
|
710
|
+
- **Nothing overlaps.** The windows get the width the tray leaves, measured: an item opening takes its room the way a narrower screen would (an idle window closes, one in use folds, unsent text is kept). **One menu at a time**: opening an item closes the Messages panel and the other way round. Windows stay.
|
|
711
|
+
- **On a phone** the items are 48px round buttons stacked above the dock's, and an open one is the same full-screen sheet as the dock's: everything around it `inert`, the page still, the composer on the keyboard. `launcher: false` takes the tray's buttons away with the dock's.
|
|
712
|
+
- **Room.** An open item is as wide as the corner leaves it: the screen less the insets, the Messages panel and every other item, measured as it opens (`--chats-tray-room`). When that is under `--chats-tray-min-width` (280px), it opens as the full-screen sheet, as on a phone.
|
|
713
|
+
- **Escape** closes the open item and gives the focus back to its tab. Which item was open is remembered on a desk (`chats:tray:v1:<key>`), never on a phone (nor when it opened as a desk's sheet).
|
|
714
|
+
- **Visits.** The tray rides along with the dock from page to page, and takes each new page's items: one the page no longer renders goes, a new one comes in, and one still there takes the page's words, badge, badge URL, stream and panel. Only its open state stays, and, while it is open, a frame of its panel the new page renders the same way (the same id and the same `src`, resolved): what is being read or written there. A frame with the same id and another `src` is another thread, and replaces it.
|
|
715
|
+
- **Signed out**, or with no dock at all, the items still render, in a corner of their own: `chats_dock { … }` with nobody signed in renders its block alone, and `chats_tray { … }` is that corner for a host with no messaging dock. Same element class, so the same variables and the same place. On the engine's own pages, and on any page with `data-chats-dock-hide`, the tray goes with the dock.
|
|
716
|
+
|
|
717
|
+

|
|
718
|
+
|
|
719
|
+
<img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/tray-phone-buttons.png" alt="A phone: the tray's round button above the dock's" width="240"> <img src="https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/tray-phone-sheet.png" alt="A phone: an open item fills the screen" width="240">
|
|
720
|
+
|
|
721
|
+
**Build an item** with `chats_tray_item`; the block is the panel:
|
|
722
|
+
|
|
723
|
+
```erb
|
|
724
|
+
<%= chats_tray_item :help,
|
|
725
|
+
label: "Need help?", # the closed tab
|
|
726
|
+
title: "Help centre", # the open panel's bar (HTML allowed)
|
|
727
|
+
icon: help_icon, # HTML: the tab's and the round button's
|
|
728
|
+
badge: safe_join([turbo_stream_from(current_user, :help), chats_tray_badge(count, id: "help_badge")]),
|
|
729
|
+
badge_url: help_badge_path do %> # a GET answering the badge's replace, for what the stream missed
|
|
730
|
+
<%= turbo_frame_tag "help_panel", src: help_path, loading: :lazy, data: { chats_tray_home: help_path } %>
|
|
731
|
+
<% end %>
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
What an item's content can count on, all declared in markup:
|
|
735
|
+
|
|
736
|
+
| | |
|
|
737
|
+
|---|---|
|
|
738
|
+
| `data-chats-active` / `data-chats-minimized` on the item | the read gate a dock window has: a chats thread inside a closed item never marks anything read, and a read that waited happens when it opens |
|
|
739
|
+
| `chats_tray_badge(count, id:)` | hidden at zero; your stream replaces it by `id` |
|
|
740
|
+
| `badge_url:` | asked again (a Turbo Stream) when the item's stream may have missed a broadcast: a reconnect, a return to the tab. Same origin only: the answer is applied to the page, so a URL on another origin is refused (and said in the console) |
|
|
741
|
+
| `data-chats-tray-refresh` on content | its frame reloads when the badge's count moves (now if open, when it opens if closed) |
|
|
742
|
+
| `turbo-frame[data-chats-tray-home]` | goes back to that URL rather than show a conversation the page (or a dock window) already shows: a thread's ids must exist once |
|
|
743
|
+
| `chats--tray:open` / `chats--tray:close` | dispatched on the item (`detail: { key }`), for your own code |
|
|
744
|
+
|
|
745
|
+
The tray's variables sit with the dock's on `.chats-dock`: `--chats-tray-panel-width` (a window's, 336px), `--chats-tray-panel-height` (the panel's, 560px), `--chats-tray-min-width` (280px: less room, and an item opens as the sheet) and `--chats-tray-launcher-size` (48px, the round buttons). The classes are its own (`.chats-tray__launcher`, `.chats-tray__title`…), so a host's selectors for the dock's bar still find exactly one.
|
|
746
|
+
|
|
687
747
|
## 🔗 Profile links
|
|
688
748
|
|
|
689
749
|
`chats` never assumes your app has a `user_path`. Tell it where a messager lives and names become links; leave it alone and they render as plain text:
|
|
@@ -718,7 +778,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
718
778
|
rails generate chats:views
|
|
719
779
|
```
|
|
720
780
|
|
|
721
|
-
Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, `controllers/chats/dock_controller`, and `controllers/chats/
|
|
781
|
+
Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, `controllers/chats/dock_controller`, `controllers/chats/split_controller`, and `controllers/chats/tray_controller`. What the inbox, thread, badge, dock and tray controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too. The dock's sheet and the tray's share `chats/sheet` (`around`, `holdInert`, `releaseInert`, `lockScroll`, `fitViewport`).
|
|
722
782
|
|
|
723
783
|
## Configuration reference
|
|
724
784
|
|
|
@@ -898,6 +958,8 @@ All available in the bundled views **and** in your own templates:
|
|
|
898
958
|
| `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream; the viewer's own also [catches up](#what-happens-when-a-broadcast-is-missed) on updates it missed |
|
|
899
959
|
| `chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)` | the [messaging dock](#-messaging-dock): bar, panel and floating windows |
|
|
900
960
|
| `chats_dock_opener(conversation)` / `chats_dock_toggle` | data for your own links: open that conversation's dock window, or open the dock's panel |
|
|
961
|
+
| `chats_dock { … }` / `chats_tray(launcher: true) { … }` | the [tray](#the-tray-other-launchers-beside-the-bar): the block's items beside the bar, or in a corner of their own |
|
|
962
|
+
| `chats_tray_item(key, label:, title:, icon:, badge:, badge_url:) { … }` / `chats_tray_badge(count, id:)` | one item of the tray, and its badge |
|
|
901
963
|
| `chats_muted?(conversation, viewer)` | whether the viewer muted it (read from the preloaded seats) |
|
|
902
964
|
| `chats_messager_name(messager, css_class:)` | the name, linked through `messager_url`, badged when verified |
|
|
903
965
|
| `chats_messager_avatar(messager)` / `chats_conversation_avatar(conversation, viewer)` | avatar (URL, attachment, variant or initials) |
|
|
@@ -935,7 +997,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
|
|
|
935
997
|
rails db:migrate
|
|
936
998
|
```
|
|
937
999
|
|
|
938
|
-
0.6.0 needs no migration: the messaging page becomes [two panes](#-two-panes) on wide screens by itself; `config.inbox_layout = :stacked` keeps the separate pages. Views you ejected before 0.6 keep rendering as they are (the separate pages); the [CHANGELOG](CHANGELOG.md) lists the templates that changed. 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
|
|
1000
|
+
0.7.0 needs no migration and changes nothing by itself: the [tray](#the-tray-other-launchers-beside-the-bar) is the block you may now give `chats_dock`, and `:unread_changed` is a new event. If you ejected `chats/dock/_dock.html.erb`, re-eject it (or copy the tray's line and the `chats--tray:open` action) to use the tray. 0.6.0 needs no migration: the messaging page becomes [two panes](#-two-panes) on wide screens by itself; `config.inbox_layout = :stacked` keeps the separate pages. Views you ejected before 0.6 keep rendering as they are (the separate pages); the [CHANGELOG](CHANGELOG.md) lists the templates that changed. 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
|
|
939
1001
|
|
|
940
1002
|
## Database support
|
|
941
1003
|
|