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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -0
  3. data/README.md +90 -28
  4. data/app/assets/stylesheets/chats.css +225 -15
  5. data/app/helpers/chats/engine_helper.rb +101 -7
  6. data/app/javascript/chats/dock_controller.js +54 -33
  7. data/app/javascript/chats/sheet.js +69 -0
  8. data/app/javascript/chats/thread_controller.js +199 -5
  9. data/app/javascript/chats/tray_controller.js +506 -0
  10. data/app/views/chats/conversations/_thread.html.erb +4 -0
  11. data/app/views/chats/dock/_dock.html.erb +9 -1
  12. data/app/views/chats/messages/_message.html.erb +30 -3
  13. data/app/views/chats/tray/_badge.html.erb +12 -0
  14. data/app/views/chats/tray/_corner.html.erb +16 -0
  15. data/app/views/chats/tray/_item.html.erb +44 -0
  16. data/app/views/chats/tray/_tray.html.erb +27 -0
  17. data/config/importmap.rb +4 -0
  18. data/config/locales/en.yml +1 -0
  19. data/config/locales/es.yml +1 -0
  20. data/lib/chats/after_commit.rb +87 -0
  21. data/lib/chats/broadcasts.rb +8 -0
  22. data/lib/chats/models/participant.rb +14 -1
  23. data/lib/chats/subscribers.rb +14 -1
  24. data/lib/chats/version.rb +1 -1
  25. data/lib/generators/chats/templates/initializer.rb +1 -0
  26. metadata +8 -16
  27. data/docs/PRD.md +0 -254
  28. data/docs/campfire_review.md +0 -46
  29. data/docs/screenshots/composer-actions.png +0 -0
  30. data/docs/screenshots/dock-bar.png +0 -0
  31. data/docs/screenshots/dock-desktop.png +0 -0
  32. data/docs/screenshots/dock-phone-button.png +0 -0
  33. data/docs/screenshots/dock-phone-panel.png +0 -0
  34. data/docs/screenshots/dock-phone-window.png +0 -0
  35. data/docs/screenshots/inbox.png +0 -0
  36. data/docs/screenshots/split-inbox-empty.png +0 -0
  37. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  38. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  39. data/docs/screenshots/split-inbox.png +0 -0
  40. data/docs/screenshots/system-note-action.png +0 -0
  41. data/docs/screenshots/thread-actions-phone.png +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa521d419a2dc6b1d672e2919c4db795674c744fd97df02027ce33b81f582675
4
- data.tar.gz: c64cf6cfad1d3f41b9eaf9938936017ecb3af6840feb74f342699c7e0d5d6a01
3
+ metadata.gz: a7bc51c7dab52fde58726ff5ab0a756f26bd02c057f99cacb331e99de7cb231d
4
+ data.tar.gz: db4b00f65dc731c1cf3bd73334e9dd988585461ab83c1af1c89aaba1c7b45cf6
5
5
  SHA512:
6
- metadata.gz: 99f45e5ce16f754d2e32a39bffa6664c4205fd401882b2794152a339eacfc6bb155514303c3e483e68f48f0e7dc52028ea0a7ce9180d776c3083df1b6ff3dd29
7
- data.tar.gz: 660e92ea36bc25cea9ad374479495c32f5fcbd1f04f661cfff561eb9f6ce300e8afecd0b5a8294d30630e7577cd35a1f81395ae2848a806be60046b729f25f46
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 on every bubble **someone else** sent. 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** instead (viewer-independent), and hide it on own bubbles with the same client-side mechanism the gem uses for edit/delete:
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 link on foreign bubbles (signed target, broadcast-safe)
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
- ![Two panes](docs/screenshots/split-inbox.png)
600
+ ![Two panes](https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/split-inbox.png)
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
- ![The messaging dock](docs/screenshots/dock-desktop.png)
634
+ ![The messaging dock](https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/dock-desktop.png)
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 button on phones: your nav opens the sheet (chats_dock_toggle) %>
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
+ ![The tray: a launcher beside the bar](https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/tray-bar.png)
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
+ ![The tray: an item open beside a window](https://raw.githubusercontent.com/rameerez/chats/main/docs/screenshots/tray-desktop.png)
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/split_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
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