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
@@ -345,7 +345,8 @@ turbo-frame[id^="chats_page_"] {
345
345
  .chats-message--continuation { margin-top: 0.15rem; }
346
346
  .chats-message__avatar { width: 1.9rem; height: 1.9rem; font-size: 0.7rem; }
347
347
  .chats-message__content { display: flex; flex-direction: column; min-width: 0; }
348
- .chats-message__bubble-row { display: flex; align-items: flex-end; gap: 0.5rem; }
348
+ /* relative: the anchor of the menu's "⋯" (see the popup section). */
349
+ .chats-message__bubble-row { position: relative; display: flex; align-items: flex-end; gap: 0.5rem; }
349
350
  .chats-message__sender {
350
351
  font-size: 0.72rem;
351
352
  font-weight: 700;
@@ -720,6 +721,43 @@ turbo-frame[id^="chats_page_"] {
720
721
  .chats-popup__item:hover { background: var(--chats-surface); }
721
722
  .chats-popup__item--danger { color: #dc2626; }
722
723
 
724
+ /* The menu's desktop door: "⋯" on the bubble's inner edge, shown while
725
+ the pointer is on the message or the button has keyboard focus. A fine,
726
+ hovering pointer only: a touch screen long-presses. Absolutely placed, so
727
+ no bubble moves; the transparent border is hit area touching the bubble,
728
+ so the pointer never crosses a gap that would un-hover it. */
729
+ .chats-message__menu-trigger { display: none; }
730
+ @media (hover: hover) and (pointer: fine) {
731
+ .chats-message__menu-trigger {
732
+ position: absolute;
733
+ top: 50%;
734
+ left: 100%;
735
+ transform: translateY(-50%);
736
+ display: inline-flex;
737
+ align-items: center;
738
+ justify-content: center;
739
+ width: 2.1rem;
740
+ height: 2.1rem;
741
+ padding: 0;
742
+ border: 0.25rem solid transparent;
743
+ border-radius: 999px;
744
+ background: none;
745
+ background-clip: padding-box;
746
+ color: var(--chats-text-muted);
747
+ font: inherit;
748
+ font-size: 1.1rem;
749
+ line-height: 1;
750
+ cursor: pointer;
751
+ opacity: 0;
752
+ transition: opacity 120ms ease;
753
+ }
754
+ .chats-message--own .chats-message__menu-trigger { left: auto; right: 100%; }
755
+ .chats-message:hover .chats-message__menu-trigger,
756
+ .chats-message__menu-trigger:focus-visible { opacity: 1; }
757
+ .chats-message__menu-trigger:hover { background-color: var(--chats-surface); color: var(--chats-text); }
758
+ .chats-message__menu-trigger:focus-visible { outline: 2px solid var(--chats-accent); outline-offset: -0.25rem; }
759
+ }
760
+
723
761
  /* --- Seen + typing --------------------------------------------------------------------- */
724
762
 
725
763
  .chats-seen {
@@ -1175,6 +1213,11 @@ turbo-frame[id^="chats_page_"] {
1175
1213
  --chats-dock-focus: var(--chats-accent);
1176
1214
  /* --chats-dock-title-font: unset on purpose; titles inherit the host font. */
1177
1215
  --chats-dock-launcher-size: 56px;
1216
+ /* 0.7.0, the tray: an open item is a window's width, the panel's height */
1217
+ --chats-tray-panel-width: var(--chats-dock-window-width);
1218
+ --chats-tray-panel-height: var(--chats-dock-panel-height);
1219
+ --chats-tray-launcher-size: 48px; /* an item's round button on a phone */
1220
+ --chats-tray-min-width: 280px; /* less room than this: an item opens as the sheet */
1178
1221
  --chats-dock-sheet-header-height: 56px;
1179
1222
  --chats-dock-duration: 200ms;
1180
1223
  --chats-dock-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -1207,7 +1250,8 @@ turbo-frame[id^="chats_page_"] {
1207
1250
  gap: var(--chats-dock-gap);
1208
1251
  }
1209
1252
  .chats-window,
1210
- .chats-dock__panel { pointer-events: auto; }
1253
+ .chats-dock__panel,
1254
+ .chats-tray__item { pointer-events: auto; }
1211
1255
 
1212
1256
  /* Inset: every surface clips (overflow: hidden), so an outside ring is cut. */
1213
1257
  .chats-dock :focus-visible {
@@ -1218,7 +1262,8 @@ turbo-frame[id^="chats_page_"] {
1218
1262
  /* --- Surfaces (panel and windows share one skin) -------------------------------- */
1219
1263
 
1220
1264
  .chats-window,
1221
- .chats-dock__panel {
1265
+ .chats-dock__panel,
1266
+ .chats-tray__item {
1222
1267
  background: var(--chats-dock-bg);
1223
1268
  border: 1px solid var(--chats-dock-edge);
1224
1269
  border-bottom: 0;
@@ -1270,6 +1315,7 @@ turbo-frame[id^="chats_page_"] {
1270
1315
  /* --- The bar (the panel's <summary>) and window headers -------------------------- */
1271
1316
 
1272
1317
  .chats-dock__bar,
1318
+ .chats-tray__launcher,
1273
1319
  .chats-window__header {
1274
1320
  flex: 0 0 var(--chats-dock-bar-height);
1275
1321
  height: var(--chats-dock-bar-height);
@@ -1278,7 +1324,8 @@ turbo-frame[id^="chats_page_"] {
1278
1324
  background: var(--chats-dock-header-bg);
1279
1325
  border-bottom: 1px solid var(--chats-dock-edge);
1280
1326
  }
1281
- .chats-dock__toggle {
1327
+ .chats-dock__toggle,
1328
+ .chats-tray__launcher {
1282
1329
  gap: 10px;
1283
1330
  color: inherit;
1284
1331
  padding: 0 8px 0 12px;
@@ -1286,11 +1333,13 @@ turbo-frame[id^="chats_page_"] {
1286
1333
  cursor: pointer;
1287
1334
  user-select: none;
1288
1335
  }
1289
- .chats-dock__toggle::-webkit-details-marker { display: none; }
1290
- .chats-dock__panel:not([open]) .chats-dock__toggle { border-bottom-color: transparent; }
1336
+ :is(.chats-dock__toggle, .chats-tray__launcher)::-webkit-details-marker { display: none; }
1337
+ .chats-dock__panel:not([open]) .chats-dock__toggle,
1338
+ .chats-tray__item:not([open]) .chats-tray__launcher { border-bottom-color: transparent; }
1291
1339
  .chats-dock__toggle .chats-avatar { width: 32px; height: 32px; font-size: 0.8rem; }
1292
1340
  .chats-dock__icon { display: none; width: 26px; height: 26px; flex-shrink: 0; }
1293
- .chats-dock__title {
1341
+ .chats-dock__title,
1342
+ .chats-tray__title {
1294
1343
  flex: 1;
1295
1344
  min-width: 0;
1296
1345
  font-family: var(--chats-dock-title-font);
@@ -1301,7 +1350,9 @@ turbo-frame[id^="chats_page_"] {
1301
1350
  text-overflow: ellipsis;
1302
1351
  }
1303
1352
  .chats-dock__chevron,
1304
- .chats-dock__close-glyph {
1353
+ .chats-dock__close-glyph,
1354
+ .chats-tray__chevron,
1355
+ .chats-tray__close-glyph {
1305
1356
  flex: 0 0 32px;
1306
1357
  width: 32px;
1307
1358
  height: 32px;
@@ -1311,16 +1362,21 @@ turbo-frame[id^="chats_page_"] {
1311
1362
  color: var(--chats-text-muted);
1312
1363
  }
1313
1364
  .chats-dock__chevron svg,
1314
- .chats-dock__close-glyph svg {
1365
+ .chats-dock__close-glyph svg,
1366
+ .chats-tray__chevron svg,
1367
+ .chats-tray__close-glyph svg {
1315
1368
  width: 18px;
1316
1369
  height: 18px;
1317
1370
  transition: transform var(--chats-dock-duration) var(--chats-dock-ease);
1318
1371
  }
1319
1372
  @media (hover: hover) {
1320
- .chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1373
+ .chats-dock__toggle:hover .chats-dock__chevron,
1374
+ .chats-tray__launcher:hover .chats-tray__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1321
1375
  }
1322
- .chats-dock__panel[open] .chats-dock__chevron svg { transform: rotate(180deg); }
1323
- .chats-dock__close-glyph { display: none; }
1376
+ .chats-dock__panel[open] .chats-dock__chevron svg,
1377
+ .chats-tray__item[open] .chats-tray__chevron svg { transform: rotate(180deg); }
1378
+ .chats-dock__close-glyph,
1379
+ .chats-tray__close-glyph { display: none; }
1324
1380
 
1325
1381
  /* Badges in the dock: one size, a hair smaller than the inbox's. */
1326
1382
  .chats-dock .chats-badge {
@@ -1342,9 +1398,10 @@ turbo-frame[id^="chats_page_"] {
1342
1398
  display: flex;
1343
1399
  flex-direction: column;
1344
1400
  }
1345
- .chats-dock__panel[open] .chats-dock__body {
1401
+ :is(.chats-dock__panel, .chats-tray__item)[open] :is(.chats-dock__body, .chats-tray__body) {
1346
1402
  animation: chats-dock-rise var(--chats-dock-duration) var(--chats-dock-ease);
1347
1403
  }
1404
+
1348
1405
  .chats-dock__search { padding: 8px 12px 4px; }
1349
1406
  .chats-dock__search .chats-search__input { padding: 0.45rem 0.8rem; font-size: 0.8125rem; }
1350
1407
  .chats-dock__results {
@@ -1404,6 +1461,71 @@ turbo-frame[id^="chats_page_"] {
1404
1461
  .chats-dock__skeleton span::before { width: 45%; }
1405
1462
  .chats-dock__skeleton span::after { width: 80%; }
1406
1463
 
1464
+ /* --- The tray (0.7.0) --------------------------------------------------------------
1465
+ The items a host puts in the corner (chats_dock { … } or chats_tray { … }),
1466
+ between the windows and the panel. An item is a <details>: closed, a tab on
1467
+ the bottom edge as tall as the bar; open, a panel with the same skin and the
1468
+ bar on top. The windows' row gets what the tray leaves (dock_controller.js,
1469
+ room), so nothing ever overlaps. A corner holding only the tray
1470
+ (.chats-dock--tray-only) is the same element class: same variables, same
1471
+ place. */
1472
+
1473
+ .chats-tray {
1474
+ display: flex;
1475
+ align-items: flex-end;
1476
+ gap: var(--chats-dock-gap);
1477
+ }
1478
+ /* Emptied by a visit to a page that renders no items: no gap left behind. */
1479
+ .chats-tray:not(:has(.chats-tray__item)) { display: none; }
1480
+ .chats-tray__item {
1481
+ position: relative;
1482
+ flex-shrink: 0;
1483
+ padding-bottom: var(--chats-safe-bottom);
1484
+ }
1485
+ /* Never wider than what the corner leaves it (`--chats-tray-room`, measured by
1486
+ chats--tray as it opens: the screen less the insets, the dock's panel and
1487
+ every other item). Under --chats-tray-min-width it opens as the sheet. */
1488
+ .chats-tray__item[open] {
1489
+ width: min(var(--chats-tray-panel-width), var(--chats-tray-room, var(--chats-tray-panel-width)));
1490
+ height: min(var(--chats-tray-panel-height),
1491
+ calc(100dvh - var(--chats-dock-offset) - var(--chats-dock-top-clearance)));
1492
+ }
1493
+ .chats-tray__item:not([open]) .chats-tray__launcher { padding: 0 14px 0 12px; gap: 8px; }
1494
+ .chats-tray__icon {
1495
+ display: inline-grid;
1496
+ place-items: center;
1497
+ width: 20px;
1498
+ height: 20px;
1499
+ flex-shrink: 0;
1500
+ }
1501
+ .chats-tray__icon :is(svg, img) { width: 100%; height: 100%; }
1502
+ .chats-tray__label {
1503
+ font-family: var(--chats-dock-title-font);
1504
+ font-size: 0.875rem;
1505
+ font-weight: 700;
1506
+ white-space: nowrap;
1507
+ }
1508
+ /* Closed, the tab's words; open, the panel's title and the way to close it. */
1509
+ .chats-tray__item[open] .chats-tray__label,
1510
+ .chats-tray__item:not([open]) :is(.chats-tray__title, .chats-tray__chevron) { display: none; }
1511
+ .chats-tray__title { display: flex; align-items: center; gap: 4px; }
1512
+ /* Positioned, as the panel's body is (a <details>' content sits in a UA slot
1513
+ box). The item's content fills it: a frame, a column. */
1514
+ .chats-tray__body {
1515
+ position: absolute;
1516
+ inset: var(--chats-dock-bar-height) 0 var(--chats-safe-bottom) 0;
1517
+ display: flex;
1518
+ flex-direction: column;
1519
+ min-height: 0;
1520
+ overflow: hidden;
1521
+ }
1522
+ .chats-tray__body > turbo-frame {
1523
+ display: flex;
1524
+ flex-direction: column;
1525
+ flex: 1;
1526
+ min-height: 0;
1527
+ }
1528
+
1407
1529
  /* --- Windows --------------------------------------------------------------------- */
1408
1530
 
1409
1531
  .chats-window__header { padding-right: 4px; position: relative; }
@@ -1634,7 +1756,56 @@ turbo-frame[id^="chats_page_"] {
1634
1756
  }
1635
1757
  .chats-dock--no-launcher .chats-dock__panel:not([open]) { display: none; }
1636
1758
  /* Anything full-screen up: the launcher would only cover it. */
1637
- .chats-dock:has(.chats-window--front) .chats-dock__panel:not([open]) { display: none; }
1759
+ .chats-dock:has(.chats-window--front, .chats-tray__item[open]) .chats-dock__panel:not([open]) { display: none; }
1760
+
1761
+ /* The tray: its closed items are smaller round buttons stacked above the
1762
+ dock's (in its place when there is none), the same accent-free surface
1763
+ as a window. `launcher: false` takes them away with the dock's. */
1764
+ .chats-tray {
1765
+ position: fixed;
1766
+ z-index: 3;
1767
+ right: calc(16px + var(--chats-safe-right));
1768
+ bottom: calc(16px + var(--chats-dock-offset) + var(--chats-safe-bottom));
1769
+ flex-direction: column;
1770
+ align-items: flex-end;
1771
+ gap: 12px;
1772
+ }
1773
+ .chats-dock:not(.chats-dock--no-launcher, .chats-dock--tray-only) .chats-tray {
1774
+ bottom: calc(28px + var(--chats-dock-launcher-size) + var(--chats-dock-offset) + var(--chats-safe-bottom));
1775
+ }
1776
+ .chats-tray__item:not([open]) {
1777
+ width: var(--chats-tray-launcher-size);
1778
+ height: var(--chats-tray-launcher-size);
1779
+ padding: 0;
1780
+ border: 1px solid var(--chats-dock-edge);
1781
+ border-radius: 999px;
1782
+ box-shadow: 0 6px 20px rgba(17, 24, 39, 0.28);
1783
+ overflow: visible;
1784
+ }
1785
+ .chats-tray__item:not([open]) .chats-tray__launcher {
1786
+ position: relative;
1787
+ justify-content: center;
1788
+ width: 100%;
1789
+ height: 100%;
1790
+ padding: 0;
1791
+ border: 0;
1792
+ border-radius: 999px;
1793
+ background: none;
1794
+ }
1795
+ .chats-tray__item:not([open]) .chats-tray__icon { width: 22px; height: 22px; }
1796
+ .chats-tray__item:not([open]) .chats-tray__label {
1797
+ /* Still read out ("Need help?, 2 unread"), just not shown. */
1798
+ position: absolute;
1799
+ width: 1px;
1800
+ height: 1px;
1801
+ overflow: hidden;
1802
+ clip: rect(0, 0, 0, 0);
1803
+ }
1804
+ .chats-tray__item:not([open]) .chats-badge { position: absolute; top: -4px; right: -4px; }
1805
+ .chats-dock--no-launcher .chats-tray__item:not([open]) { display: none; }
1806
+ .chats-dock:has(.chats-dock__panel[open], .chats-window--front, .chats-tray__item[open]) .chats-tray__item:not([open]) {
1807
+ display: none;
1808
+ }
1638
1809
 
1639
1810
  /* Open: the full-screen sheet; a window sits on top of the list. */
1640
1811
  .chats-dock__panel[open],
@@ -1673,8 +1844,14 @@ turbo-frame[id^="chats_page_"] {
1673
1844
  .chats-dock__panel[open] .chats-dock__toggle { padding: 0 6px 0 16px; }
1674
1845
  .chats-dock__panel[open] .chats-dock__title { font-size: 1.125rem; font-weight: 800; }
1675
1846
  .chats-dock__panel[open] :is(.chats-dock__toggle .chats-avatar, .chats-dock__chevron) { display: none; }
1676
- .chats-dock__panel[open] .chats-dock__close-glyph { display: inline-grid; flex-basis: 44px; width: 44px; height: 44px; }
1847
+ .chats-dock__panel[open] .chats-dock__close-glyph {
1848
+ display: inline-grid;
1849
+ flex-basis: 44px;
1850
+ width: 44px;
1851
+ height: 44px;
1852
+ }
1677
1853
  .chats-dock__panel[open] .chats-dock__close-glyph svg { width: 22px; height: 22px; }
1854
+
1678
1855
  .chats-dock__search { padding: 8px 16px; }
1679
1856
  /* 16px or iOS Safari zooms the page on focus. */
1680
1857
  .chats-dock__search .chats-search__input { font-size: 16px; padding: 0.55rem 0.9rem; }
@@ -1708,6 +1885,39 @@ turbo-frame[id^="chats_page_"] {
1708
1885
  .chats-dock--keyboard .chats-thread--window .chats-composer { padding-bottom: 8px; }
1709
1886
  }
1710
1887
 
1888
+ /* An item open as the sheet: on a phone, and on a desk whose corner can't fit
1889
+ it (chats--tray sets the class: .chats-tray__item--sheet). The dock's own
1890
+ sheet, for an item. */
1891
+ .chats-tray__item--sheet[open] {
1892
+ position: fixed;
1893
+ z-index: 3;
1894
+ left: 0;
1895
+ right: 0;
1896
+ top: var(--chats-vv-top, 0px);
1897
+ width: 100%;
1898
+ height: var(--chats-vvh, 100dvh);
1899
+ border: 0;
1900
+ border-radius: 0;
1901
+ box-shadow: none;
1902
+ padding: var(--chats-safe-top) var(--chats-safe-right) var(--chats-safe-bottom) var(--chats-safe-left);
1903
+ animation: chats-dock-sheet-up 260ms cubic-bezier(0.32, 0.72, 0, 1);
1904
+ }
1905
+ .chats-tray__item--sheet[open] .chats-tray__body {
1906
+ inset: calc(var(--chats-safe-top) + var(--chats-dock-sheet-header-height)) var(--chats-safe-right)
1907
+ var(--chats-safe-bottom) var(--chats-safe-left);
1908
+ animation: none;
1909
+ }
1910
+ .chats-tray__item--sheet[open] .chats-tray__launcher {
1911
+ flex-basis: var(--chats-dock-sheet-header-height);
1912
+ height: var(--chats-dock-sheet-header-height);
1913
+ padding: 0 6px 0 16px;
1914
+ box-shadow: none;
1915
+ }
1916
+ .chats-tray__item--sheet[open] .chats-tray__title { font-size: 1.125rem; font-weight: 800; }
1917
+ .chats-tray__item--sheet[open] :is(.chats-tray__icon, .chats-tray__chevron) { display: none; }
1918
+ .chats-tray__item--sheet[open] .chats-tray__close-glyph { display: inline-grid; flex-basis: 44px; width: 44px; height: 44px; }
1919
+ .chats-tray__item--sheet[open] .chats-tray__close-glyph svg { width: 22px; height: 22px; }
1920
+
1711
1921
  html.chats-dock-sheet-open { overflow: hidden; }
1712
1922
 
1713
1923
  @media (prefers-reduced-motion: reduce) {
@@ -8,6 +8,16 @@ module Chats
8
8
  # Every slot the bundled views render, and the whole list of them. A
9
9
  # host drops `app/views/chats/slots/_<name>.html.erb` in and it appears;
10
10
  # a name that isn't here renders nothing.
11
+ #
12
+ # `message_menu` renders inside a <template>: a message's menu
13
+ # (long-press, right-click, the desktop "⋯"), which chats--thread
14
+ # CLONES into the popup. Items are `chats-popup__item` links or
15
+ # `button_to` forms (both survive the clone, which gives a form the
16
+ # page's own CSRF token), and the partial gets `message:` only: the
17
+ # bubble is one render per broadcast for every viewer.
18
+ # `data-chats-other-only` keeps an item off the viewer's own messages
19
+ # (a Report), `data-chats-own-only` off the others'. A link leaving the
20
+ # thread wants `data-turbo-frame="_top"`.
11
21
  SLOTS = %w[
12
22
  inbox_top
13
23
  inbox_empty
@@ -15,6 +25,7 @@ module Chats
15
25
  composer_actions
16
26
  locked_composer
17
27
  message_meta
28
+ message_menu
18
29
  dock_panel_footer
19
30
  ].freeze
20
31
 
@@ -95,15 +106,75 @@ module Chats
95
106
  # windows: how many windows may be open at once (3, and never more
96
107
  # than fit beside the panel: 2 at 1280px, 1 at 768px)
97
108
  # recent: how many conversations the panel lists (10)
98
- # launcher: false drops the phone's floating button, for a host that
99
- # opens the sheet from its own nav (see chats_dock_toggle)
100
- def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)
101
- viewer = chats_viewer
102
- return if viewer.nil?
109
+ # launcher: false drops the phone's floating buttons (the dock's and
110
+ # the tray's), for a host that opens the sheet from its own
111
+ # nav (see chats_dock_toggle)
112
+ #
113
+ # The block is the TRAY: items other surfaces put in the corner, beside
114
+ # the bar (`chats_tray_item`; support_desk's `support_launcher` is one):
115
+ #
116
+ # <%= chats_dock do %>
117
+ # <%= support_launcher %>
118
+ # <% end %>
119
+ #
120
+ # With nobody signed in there is no dock, but the block's items still
121
+ # render, in a corner of their own (chats_tray). On the engine's own
122
+ # pages nothing does, tray included: those pages hide the dock anyway.
123
+ def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false, &tray)
103
124
  return if chats_engine_page? && !everywhere
104
125
 
126
+ viewer = chats_viewer
127
+ items = chats_tray_items(&tray)
128
+ return chats_tray_corner(items, launcher: launcher) if viewer.nil?
129
+
105
130
  render(partial: "chats/dock/dock",
106
- locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere })
131
+ locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere,
132
+ tray: items })
133
+ end
134
+
135
+ # The tray on its own, for a host with no messaging dock (or a page with
136
+ # nobody signed in): the dock's corner, same look and same variables,
137
+ # holding only +block+'s items.
138
+ #
139
+ # <%= chats_tray do %>
140
+ # <%= chats_tray_item :help, label: "Help", icon: help_icon do %>…<% end %>
141
+ # <% end %>
142
+ #
143
+ # Inside a `chats_dock` block it renders the items alone, so a launcher
144
+ # helper can always wrap itself in chats_tray and work in both places.
145
+ # Renders nothing when the block renders nothing.
146
+ def chats_tray(launcher: true, &block)
147
+ return capture(&block) if @chats_tray_open
148
+
149
+ items = chats_tray_items(&block)
150
+ chats_tray_corner(items, launcher: launcher) if items
151
+ end
152
+
153
+ # One item of the tray: closed, a tab on the bottom edge beside the bar
154
+ # (a round button on a phone); open, a panel with the dock's skin (the
155
+ # full-screen sheet on a phone). The block is the panel's content.
156
+ #
157
+ # key: its stable name ("help"): its id, its memory
158
+ # label: the closed tab's words ("Need help?")
159
+ # title: the open panel's title (HTML allowed; the label by default)
160
+ # icon: HTML for the tab and the round button
161
+ # badge: HTML: a `chats_tray_badge` and the stream that replaces it
162
+ # badge_url: a GET answering that replace, asked when the stream may
163
+ # have missed one
164
+ #
165
+ # The content may carry `data-chats-tray-refresh` (its frame reloads when
166
+ # the badge's count moves) and a frame `data-chats-tray-home="url"` (where
167
+ # it goes back to rather than show a conversation the page already does).
168
+ def chats_tray_item(key, label:, icon: nil, title: label, badge: nil, badge_url: nil, &block)
169
+ render(partial: "chats/tray/item",
170
+ locals: { key: key, label: label, title: title, icon: icon, badge: badge, badge_url: badge_url,
171
+ body: block ? capture(&block) : nil })
172
+ end
173
+
174
+ # An item's badge, hidden at zero: what its owner's stream replaces (by
175
+ # +id+) when the count moves.
176
+ def chats_tray_badge(count, id:)
177
+ render(partial: "chats/tray/badge", locals: { count: count.to_i, id: id })
107
178
  end
108
179
 
109
180
  # The data that makes ANY link to a conversation — a recent-list row, a
@@ -239,7 +310,8 @@ module Chats
239
310
  # app/views/chats/slots/_inbox_top.html.erb
240
311
  #
241
312
  # The slots: inbox_top, inbox_empty, conversation_header_actions,
242
- # composer_actions, locked_composer, message_meta, dock_panel_footer.
313
+ # composer_actions, locked_composer, message_meta, message_menu,
314
+ # dock_panel_footer.
243
315
  def chats_slot(name, **locals)
244
316
  return unless chats_slot?(name)
245
317
 
@@ -381,6 +453,28 @@ module Chats
381
453
  respond_to?(method_name) ? send(method_name) : nil
382
454
  end
383
455
 
456
+ # The tray block's items, or nil when it renders nothing. Captured as
457
+ # "inside a tray", so a chats_tray in it renders its items alone.
458
+ def chats_tray_items(&block)
459
+ return if block.nil?
460
+
461
+ outer = @chats_tray_open
462
+ @chats_tray_open = true
463
+ capture(&block).presence
464
+ ensure
465
+ @chats_tray_open = outer
466
+ end
467
+
468
+ # The tray in a corner of its own, remembered under the viewer's key (or
469
+ # "guest"): nothing without items.
470
+ def chats_tray_corner(items, launcher:)
471
+ return if items.nil?
472
+
473
+ viewer = chats_viewer
474
+ render(partial: "chats/tray/corner",
475
+ locals: { tray: items, owner: viewer ? Chats.messager_key(viewer) : "guest", launcher: launcher })
476
+ end
477
+
384
478
  # Whether this view is one of the engine's own screens.
385
479
  def chats_engine_page?
386
480
  respond_to?(:controller) && controller.is_a?(Chats::ApplicationController)
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
3
+ import { around, fitViewport, holdInert, lockScroll, releaseInert } from "chats/sheet"
3
4
 
4
5
  // chats--dock: the messaging dock (`chats_dock`) — LinkedIn / Steam style
5
6
  // floating conversation windows in the bottom-right corner.
@@ -51,6 +52,12 @@ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
51
52
  // photo, an edit, the window menu, then the window
52
53
  // (minimized), the panel (closed); on a phone, the window
53
54
  // back to the list, then the list closed
55
+ // * the tray the items a host puts in `chats_dock { … }` sit between
56
+ // the windows and the panel (chats--tray runs them). The
57
+ // windows' room is what the tray leaves (measured, so an
58
+ // item opening reflows them like a narrower screen), and
59
+ // one menu is open at a time: an item opening closes the
60
+ // panel, the panel opening closes the item
54
61
  //
55
62
  // The element is data-turbo-permanent: Turbo Drive carries it, windows and
56
63
  // all, from page to page, and may detach and reattach it, so connect()
@@ -74,7 +81,8 @@ const LIST_DELAY_MS = 300
74
81
 
75
82
  export default class extends Controller {
76
83
  static targets = [
77
- "windows", "window", "panel", "list", "badge", "signal", "announcer", "loadingShell", "failedShell", "composer"
84
+ "windows", "window", "panel", "list", "badge", "signal", "announcer", "loadingShell", "failedShell", "composer",
85
+ "tray"
78
86
  ]
79
87
  static values = {
80
88
  windows: { type: Number, default: 3 },
@@ -94,7 +102,6 @@ export default class extends Controller {
94
102
  initialize() {
95
103
  this.openers = new WeakMap()
96
104
  this.announced = {}
97
- this.inerted = []
98
105
  this.closed = this.read(this.storageKey)?.closed || {}
99
106
  }
100
107
 
@@ -104,6 +111,11 @@ export default class extends Controller {
104
111
  window.addEventListener("resize", this.resized)
105
112
  window.visualViewport?.addEventListener("resize", this.fitViewport)
106
113
  window.visualViewport?.addEventListener("scroll", this.fitViewport)
114
+ // An item opening or closing changes the tray's width, and so the room.
115
+ if (this.hasTrayTarget && window.ResizeObserver) {
116
+ this.trayObserver = new ResizeObserver(this.resized)
117
+ this.trayObserver.observe(this.trayTarget)
118
+ }
107
119
 
108
120
  // A window always names its viewer. One that doesn't match never stays:
109
121
  // nothing of one account may surface in another's page.
@@ -125,7 +137,9 @@ export default class extends Controller {
125
137
  window.removeEventListener("resize", this.resized)
126
138
  window.visualViewport?.removeEventListener("resize", this.fitViewport)
127
139
  window.visualViewport?.removeEventListener("scroll", this.fitViewport)
140
+ this.trayObserver?.disconnect()
128
141
  this.releaseSheet()
142
+
129
143
  this.stopRecovery?.()
130
144
  }
131
145
 
@@ -388,10 +402,16 @@ export default class extends Controller {
388
402
  }
389
403
 
390
404
  // What the row may take: the viewport, less the right inset, the panel
391
- // and the 8px left margin the spec keeps (the fit formula's 312px).
405
+ // and the 8px left margin the spec keeps (the fit formula's 312px), and
406
+ // the tray with its gap when it holds anything.
392
407
  get room() {
393
408
  const panel = this.panelTarget.offsetWidth || this.pixels("--chats-dock-panel-width", 288)
394
- return window.innerWidth - this.pixels("--chats-dock-inset", 16) - panel - this.gap
409
+ return window.innerWidth - this.pixels("--chats-dock-inset", 16) - panel - this.gap - this.trayWidth
410
+ }
411
+
412
+ get trayWidth() {
413
+ const width = this.hasTrayTarget ? this.trayTarget.offsetWidth : 0
414
+ return width ? width + this.gap : 0
395
415
  }
396
416
 
397
417
  get gap() {
@@ -500,6 +520,7 @@ export default class extends Controller {
500
520
  }
501
521
 
502
522
  panelToggled() {
523
+ if (this.panelTarget.open) this.closeTray()
503
524
  if (this.panelTarget.open && this.listStale) {
504
525
  this.listStale = false
505
526
  this.listTarget.reload()
@@ -511,6 +532,25 @@ export default class extends Controller {
511
532
  this.save()
512
533
  }
513
534
 
535
+ // --- The tray ----------------------------------------------------------------
536
+ //
537
+ // One menu open at a time in the corner. An item of the tray opened
538
+ // (chats--tray dispatches `chats--tray:open`): the panel closes, and on a
539
+ // phone the window in front goes back, so the item's sheet is the only one.
540
+ // Synchronous, so the dock's sheet is released before the tray's is held.
541
+ trayOpened() {
542
+ this.panelTarget.open = false
543
+ if (this.compact) this.windowTargets.forEach((frame) => frame.classList.remove(FRONT))
544
+ this.syncSheet()
545
+ }
546
+
547
+ // The panel opened: whatever item of the tray was open closes.
548
+ closeTray() {
549
+ if (!this.hasTrayTarget) return
550
+
551
+ this.trayTarget.querySelectorAll(".chats-tray__item[open]").forEach((item) => { item.open = false })
552
+ }
553
+
514
554
  // A send from one of our windows: the draft is gone, the list reorders.
515
555
  sent(event) {
516
556
  if (!event.detail?.success) return
@@ -960,49 +1000,30 @@ export default class extends Controller {
960
1000
  })
961
1001
  }
962
1002
 
963
- // Full-screen on a phone means the VISIBLE viewport: with the keyboard up
964
- // it is shorter than 100dvh, and the composer must sit on the keys.
965
- fitViewport = () => {
966
- const viewport = window.visualViewport
967
- if (!viewport) return
968
-
969
- this.element.style.setProperty("--chats-vvh", `${viewport.height}px`)
970
- this.element.style.setProperty("--chats-vv-top", `${viewport.offsetTop}px`)
971
- this.element.classList.toggle("chats-dock--keyboard", this.compact && window.innerHeight - viewport.height > 150)
972
- }
1003
+ // Full-screen on a phone means the VISIBLE viewport (chats/sheet).
1004
+ fitViewport = () => fitViewport(this.element, this.compact)
973
1005
 
974
1006
  // On a phone, an open sheet is modal: the page behind can't scroll or
975
1007
  // take the focus — and neither can the list behind a window in front of
976
- // it, until Back.
1008
+ // it, until Back. Everything on the page but the dock (chats/sheet's
1009
+ // `around`: a host may wrap the dock in a layout element) is held inert
1010
+ // by the dock for as long as its sheet is open; the tray's own sheet
1011
+ // holds its share separately.
977
1012
  syncSheet() {
978
1013
  const front = Boolean(this.compact) && this.windowTargets.some((frame) => frame.classList.contains(FRONT))
979
1014
  const open = Boolean(this.compact) && !this.element.hidden && (this.panelTarget.open || front)
980
1015
  this.panelTarget.inert = open && front
981
- document.documentElement.classList.toggle("chats-dock-sheet-open", open)
982
1016
  if (open) {
983
- if (this.inerted.length) return
984
- this.inerted = this.pageAround().filter((node) => !node.inert)
985
- this.inerted.forEach((node) => { node.inert = true })
1017
+ holdInert(this, around(this.element))
1018
+ lockScroll(this, true)
986
1019
  } else {
987
1020
  this.releaseSheet()
988
1021
  }
989
1022
  }
990
1023
 
991
- // Everything on the page but the dock: the siblings of the dock and of
992
- // each of its ancestors, up to <body>. A host may render the dock inside
993
- // a layout wrapper, and inerting the wrapper would inert the sheet too.
994
- pageAround() {
995
- const around = []
996
- for (let node = this.element; node?.parentElement && node !== document.body; node = node.parentElement) {
997
- around.push(...Array.from(node.parentElement.children).filter((sibling) => sibling !== node))
998
- }
999
- return around
1000
- }
1001
-
1002
1024
  releaseSheet() {
1003
- this.inerted?.forEach((node) => { node.inert = false })
1004
- this.inerted = []
1005
- if (this.compact !== undefined) document.documentElement.classList.remove("chats-dock-sheet-open")
1025
+ releaseInert(this)
1026
+ if (this.compact !== undefined) lockScroll(this, false)
1006
1027
  }
1007
1028
 
1008
1029
  // --- Lookups ---------------------------------------------------------------------