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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1323
|
-
.chats-
|
|
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 {
|
|
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
|
|
99
|
-
# opens the sheet from its own
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
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
|
|
964
|
-
|
|
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
|
-
|
|
984
|
-
this
|
|
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
|
|
1004
|
-
this.
|
|
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 ---------------------------------------------------------------------
|