chats 0.4.2 → 0.5.1
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 +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +11 -118
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +138 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_composer.html.erb +11 -1
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -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/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +23 -2
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
--chats-bubble-text: #111827;
|
|
33
33
|
--chats-radius: 1.1rem;
|
|
34
34
|
--chats-max-width: 42rem;
|
|
35
|
+
|
|
36
|
+
/* Safe areas, re-pointable by a host with its own source of truth. */
|
|
37
|
+
--chats-safe-top: env(safe-area-inset-top, 0px);
|
|
38
|
+
--chats-safe-right: env(safe-area-inset-right, 0px);
|
|
39
|
+
--chats-safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
40
|
+
--chats-safe-left: env(safe-area-inset-left, 0px);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/* --- Shell ----------------------------------------------------------------- */
|
|
@@ -127,6 +133,9 @@
|
|
|
127
133
|
flex-shrink: 0;
|
|
128
134
|
}
|
|
129
135
|
.chats-badge--floating[hidden] { display: none; }
|
|
136
|
+
/* chats_unread_badge's wrapper (its catch-up controller): no box of its own,
|
|
137
|
+
so the badge sits in the host's layout exactly as if it weren't there. */
|
|
138
|
+
.chats-unread-badge-live { display: contents; }
|
|
130
139
|
|
|
131
140
|
/* --- Empty states ------------------------------------------------------------ */
|
|
132
141
|
|
|
@@ -996,3 +1005,593 @@ turbo-frame[id^="chats_page_"] {
|
|
|
996
1005
|
font-size: 0.85rem;
|
|
997
1006
|
color: var(--chats-text-muted);
|
|
998
1007
|
}
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
/* ---------------------------------------------------------------------------
|
|
1011
|
+
0.5.0 — the messaging dock (chats_dock)
|
|
1012
|
+
---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
Windows to the left, the recent-chats panel at the right edge, flush with
|
|
1015
|
+
the bottom of the viewport. One element (`.chats-dock`, permanent across
|
|
1016
|
+
Turbo visits) rendered by `chats_dock`; the chats--dock controller owns
|
|
1017
|
+
the state classes:
|
|
1018
|
+
|
|
1019
|
+
.chats-dock[data-mode] "dock" or "sheet", set once JS runs (the
|
|
1020
|
+
layout itself comes from the media query
|
|
1021
|
+
at the end, so it holds before and without)
|
|
1022
|
+
.chats-dock__panel[open] the recent list shows (a <details>)
|
|
1023
|
+
.chats-window--minimized folded to its header
|
|
1024
|
+
.chats-window--unread something arrived the viewer hasn't seen
|
|
1025
|
+
.chats-window--front the one window a phone shows
|
|
1026
|
+
.chats-window--entering its first 200ms; --popped: opened by a message
|
|
1027
|
+
|
|
1028
|
+
Derived variables live on :where(.chats-dock), NOT :root: a custom
|
|
1029
|
+
property that references another resolves where it is declared, so a host
|
|
1030
|
+
re-pointing --chats-bg on .chats-dock would otherwise leave --chats-dock-bg
|
|
1031
|
+
frozen at the :root value. :where() keeps them at zero specificity.
|
|
1032
|
+
|
|
1033
|
+
Never a lasting transform, filter, backdrop-filter, perspective or contain
|
|
1034
|
+
on the dock or a window: each makes the window the containing block of the
|
|
1035
|
+
thread's position: fixed popup and photo preview, which would then open
|
|
1036
|
+
inside a 336px box. Entrance animations end at `transform: none`, and
|
|
1037
|
+
minimizing changes a height, not a transform. */
|
|
1038
|
+
|
|
1039
|
+
:where(.chats-dock) {
|
|
1040
|
+
--chats-dock-z: 40; /* above the page, under host modals */
|
|
1041
|
+
--chats-dock-offset: 0px; /* bottom clearance (a cookie banner…) */
|
|
1042
|
+
--chats-dock-inset: 16px; /* from the right edge */
|
|
1043
|
+
--chats-dock-gap: 8px;
|
|
1044
|
+
--chats-dock-bar-height: 48px; /* the bar, every window header */
|
|
1045
|
+
--chats-dock-panel-width: 288px;
|
|
1046
|
+
--chats-dock-panel-height: 560px;
|
|
1047
|
+
--chats-dock-window-width: 336px;
|
|
1048
|
+
--chats-dock-window-height: 480px;
|
|
1049
|
+
--chats-dock-squeezed-width: 104px; /* a window folded to make room */
|
|
1050
|
+
--chats-dock-top-clearance: 24px;
|
|
1051
|
+
--chats-dock-radius: 12px;
|
|
1052
|
+
--chats-dock-bg: var(--chats-bg);
|
|
1053
|
+
--chats-dock-header-bg: var(--chats-bg);
|
|
1054
|
+
/* Not --chats-surface: muted text on it is 4.39:1; on this, 4.63:1. */
|
|
1055
|
+
--chats-dock-hover: #f9fafb;
|
|
1056
|
+
--chats-dock-edge: rgba(17, 24, 39, 0.12);
|
|
1057
|
+
--chats-dock-shadow: 0 12px 40px rgba(17, 24, 39, 0.16), 0 2px 6px rgba(17, 24, 39, 0.08);
|
|
1058
|
+
--chats-dock-unread-bg: var(--chats-accent);
|
|
1059
|
+
--chats-dock-unread-text: var(--chats-accent-contrast);
|
|
1060
|
+
--chats-dock-focus: var(--chats-accent);
|
|
1061
|
+
/* --chats-dock-title-font: unset on purpose; titles inherit the host font. */
|
|
1062
|
+
--chats-dock-launcher-size: 56px;
|
|
1063
|
+
--chats-dock-sheet-header-height: 56px;
|
|
1064
|
+
--chats-dock-duration: 200ms;
|
|
1065
|
+
--chats-dock-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/* --- The dock ----------------------------------------------------------------- */
|
|
1069
|
+
|
|
1070
|
+
.chats-dock {
|
|
1071
|
+
position: fixed;
|
|
1072
|
+
z-index: var(--chats-dock-z);
|
|
1073
|
+
right: calc(var(--chats-dock-inset) + var(--chats-safe-right));
|
|
1074
|
+
bottom: var(--chats-dock-offset);
|
|
1075
|
+
display: flex;
|
|
1076
|
+
align-items: flex-end;
|
|
1077
|
+
gap: var(--chats-dock-gap);
|
|
1078
|
+
color: var(--chats-text);
|
|
1079
|
+
font-size: 0.875rem;
|
|
1080
|
+
line-height: 1.4;
|
|
1081
|
+
pointer-events: none; /* the gaps between surfaces never eat page clicks */
|
|
1082
|
+
}
|
|
1083
|
+
/* Fixed-size surfaces with padding: never trust the host to have a reset. */
|
|
1084
|
+
.chats-dock,
|
|
1085
|
+
.chats-dock *,
|
|
1086
|
+
.chats-dock *::before,
|
|
1087
|
+
.chats-dock *::after { box-sizing: border-box; }
|
|
1088
|
+
.chats-dock[hidden] { display: none; }
|
|
1089
|
+
.chats-dock__windows {
|
|
1090
|
+
display: flex;
|
|
1091
|
+
align-items: flex-end;
|
|
1092
|
+
gap: var(--chats-dock-gap);
|
|
1093
|
+
}
|
|
1094
|
+
.chats-window,
|
|
1095
|
+
.chats-dock__panel { pointer-events: auto; }
|
|
1096
|
+
|
|
1097
|
+
/* Inset: every surface clips (overflow: hidden), so an outside ring is cut. */
|
|
1098
|
+
.chats-dock :focus-visible {
|
|
1099
|
+
outline: 2px solid var(--chats-dock-focus);
|
|
1100
|
+
outline-offset: -2px;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/* --- Surfaces (panel and windows share one skin) -------------------------------- */
|
|
1104
|
+
|
|
1105
|
+
.chats-window,
|
|
1106
|
+
.chats-dock__panel {
|
|
1107
|
+
background: var(--chats-dock-bg);
|
|
1108
|
+
border: 1px solid var(--chats-dock-edge);
|
|
1109
|
+
border-bottom: 0;
|
|
1110
|
+
border-radius: var(--chats-dock-radius) var(--chats-dock-radius) 0 0;
|
|
1111
|
+
box-shadow: var(--chats-dock-shadow);
|
|
1112
|
+
overflow: hidden;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.chats-dock__panel {
|
|
1116
|
+
position: relative;
|
|
1117
|
+
width: var(--chats-dock-panel-width);
|
|
1118
|
+
padding-bottom: var(--chats-safe-bottom);
|
|
1119
|
+
}
|
|
1120
|
+
.chats-dock__panel[open] {
|
|
1121
|
+
height: min(var(--chats-dock-panel-height),
|
|
1122
|
+
calc(100dvh - var(--chats-dock-offset) - var(--chats-dock-top-clearance)));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
.chats-window {
|
|
1126
|
+
display: flex;
|
|
1127
|
+
flex-direction: column;
|
|
1128
|
+
flex-shrink: 0;
|
|
1129
|
+
width: var(--chats-dock-window-width);
|
|
1130
|
+
height: min(var(--chats-dock-window-height),
|
|
1131
|
+
calc(100dvh - var(--chats-dock-offset) - var(--chats-dock-top-clearance)));
|
|
1132
|
+
}
|
|
1133
|
+
/* Folded to its header (and the home-indicator strip). A height, not a
|
|
1134
|
+
transform: the thread keeps its box and its scroll position, and its
|
|
1135
|
+
zero-height scroller is how the thread controller knows not to read. */
|
|
1136
|
+
.chats-window--minimized { height: calc(var(--chats-dock-bar-height) + var(--chats-safe-bottom)); }
|
|
1137
|
+
/* Folded to make room (it was in use when another opened, and the row is
|
|
1138
|
+
full): the width the controller budgets for it, the avatar (which expands
|
|
1139
|
+
it) and ×. The name stays in the title's tooltip. */
|
|
1140
|
+
.chats-window--squeezed { width: var(--chats-dock-squeezed-width); }
|
|
1141
|
+
.chats-window--squeezed :is(.chats-window__identity, .chats-window__unread, .chats-window__menu,
|
|
1142
|
+
.chats-window__expand, .chats-window__minimize) {
|
|
1143
|
+
display: none;
|
|
1144
|
+
}
|
|
1145
|
+
/* Nothing of the thread keeps a height while folded, padding included, so
|
|
1146
|
+
the geometry says what the state says: there's nothing to read. */
|
|
1147
|
+
.chats-window--minimized .chats-thread--window .chats-thread__scroll { padding-top: 0; padding-bottom: 0; }
|
|
1148
|
+
.chats-window__card {
|
|
1149
|
+
display: flex;
|
|
1150
|
+
flex-direction: column;
|
|
1151
|
+
flex: 1;
|
|
1152
|
+
min-height: 0;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/* --- The bar (the panel's <summary>) and window headers -------------------------- */
|
|
1156
|
+
|
|
1157
|
+
.chats-dock__bar,
|
|
1158
|
+
.chats-window__header {
|
|
1159
|
+
flex: 0 0 var(--chats-dock-bar-height);
|
|
1160
|
+
height: var(--chats-dock-bar-height);
|
|
1161
|
+
display: flex;
|
|
1162
|
+
align-items: center;
|
|
1163
|
+
background: var(--chats-dock-header-bg);
|
|
1164
|
+
border-bottom: 1px solid var(--chats-dock-edge);
|
|
1165
|
+
}
|
|
1166
|
+
.chats-dock__toggle {
|
|
1167
|
+
gap: 10px;
|
|
1168
|
+
color: inherit;
|
|
1169
|
+
padding: 0 8px 0 12px;
|
|
1170
|
+
list-style: none;
|
|
1171
|
+
cursor: pointer;
|
|
1172
|
+
user-select: none;
|
|
1173
|
+
}
|
|
1174
|
+
.chats-dock__toggle::-webkit-details-marker { display: none; }
|
|
1175
|
+
.chats-dock__panel:not([open]) .chats-dock__toggle { border-bottom-color: transparent; }
|
|
1176
|
+
.chats-dock__toggle .chats-avatar { width: 32px; height: 32px; font-size: 0.8rem; }
|
|
1177
|
+
.chats-dock__icon { display: none; width: 26px; height: 26px; flex-shrink: 0; }
|
|
1178
|
+
.chats-dock__title {
|
|
1179
|
+
flex: 1;
|
|
1180
|
+
min-width: 0;
|
|
1181
|
+
font-family: var(--chats-dock-title-font);
|
|
1182
|
+
font-size: 0.9375rem;
|
|
1183
|
+
font-weight: 700;
|
|
1184
|
+
white-space: nowrap;
|
|
1185
|
+
overflow: hidden;
|
|
1186
|
+
text-overflow: ellipsis;
|
|
1187
|
+
}
|
|
1188
|
+
.chats-dock__chevron,
|
|
1189
|
+
.chats-dock__close-glyph {
|
|
1190
|
+
flex: 0 0 32px;
|
|
1191
|
+
width: 32px;
|
|
1192
|
+
height: 32px;
|
|
1193
|
+
display: inline-grid;
|
|
1194
|
+
place-items: center;
|
|
1195
|
+
border-radius: 8px;
|
|
1196
|
+
color: var(--chats-text-muted);
|
|
1197
|
+
}
|
|
1198
|
+
.chats-dock__chevron svg,
|
|
1199
|
+
.chats-dock__close-glyph svg {
|
|
1200
|
+
width: 18px;
|
|
1201
|
+
height: 18px;
|
|
1202
|
+
transition: transform var(--chats-dock-duration) var(--chats-dock-ease);
|
|
1203
|
+
}
|
|
1204
|
+
.chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
|
|
1205
|
+
.chats-dock__panel[open] .chats-dock__chevron svg { transform: rotate(180deg); }
|
|
1206
|
+
.chats-dock__close-glyph { display: none; }
|
|
1207
|
+
|
|
1208
|
+
/* Badges in the dock: one size, a hair smaller than the inbox's. */
|
|
1209
|
+
.chats-dock .chats-badge {
|
|
1210
|
+
min-width: 1.25rem;
|
|
1211
|
+
height: 1.25rem;
|
|
1212
|
+
padding: 0 0.3rem;
|
|
1213
|
+
font-size: 0.6875rem;
|
|
1214
|
+
font-variant-numeric: tabular-nums;
|
|
1215
|
+
}
|
|
1216
|
+
.chats-dock .chats-badge[hidden] { display: none; }
|
|
1217
|
+
|
|
1218
|
+
/* --- The panel body -------------------------------------------------------------- */
|
|
1219
|
+
|
|
1220
|
+
.chats-dock__body {
|
|
1221
|
+
/* Positioned, not a flex item: a <details>' content sits in a UA slot box,
|
|
1222
|
+
so flex sizing from the panel never reaches it. */
|
|
1223
|
+
position: absolute;
|
|
1224
|
+
inset: var(--chats-dock-bar-height) 0 var(--chats-safe-bottom) 0;
|
|
1225
|
+
display: flex;
|
|
1226
|
+
flex-direction: column;
|
|
1227
|
+
}
|
|
1228
|
+
.chats-dock__panel[open] .chats-dock__body {
|
|
1229
|
+
animation: chats-dock-rise var(--chats-dock-duration) var(--chats-dock-ease);
|
|
1230
|
+
}
|
|
1231
|
+
.chats-dock__search { padding: 8px 12px 4px; }
|
|
1232
|
+
.chats-dock__search .chats-search__input { padding: 0.45rem 0.8rem; font-size: 0.8125rem; }
|
|
1233
|
+
.chats-dock__results {
|
|
1234
|
+
display: block;
|
|
1235
|
+
flex: 1;
|
|
1236
|
+
min-height: 0;
|
|
1237
|
+
overflow-y: auto;
|
|
1238
|
+
overscroll-behavior: contain;
|
|
1239
|
+
}
|
|
1240
|
+
.chats-dock__list { list-style: none; margin: 0; padding: 4px 0; }
|
|
1241
|
+
.chats-dock__list .chats-row__link { padding: 9px 12px; gap: 10px; }
|
|
1242
|
+
.chats-dock__list .chats-row__link:hover { background: var(--chats-dock-hover); }
|
|
1243
|
+
.chats-dock__list .chats-avatar { width: 40px; height: 40px; font-size: 0.85rem; }
|
|
1244
|
+
.chats-dock__list .chats-row__title { font-size: 0.875rem; }
|
|
1245
|
+
.chats-dock__list .chats-row__subject { font-size: 0.6875rem; }
|
|
1246
|
+
.chats-dock__list .chats-row__preview { font-size: 0.8125rem; }
|
|
1247
|
+
.chats-dock__list .chats-row__time { font-size: 0.6875rem; }
|
|
1248
|
+
/* A conversation that is open in a window right now. */
|
|
1249
|
+
.chats-dock__list .chats-row__link[aria-current="true"] { box-shadow: inset 3px 0 0 var(--chats-accent); }
|
|
1250
|
+
.chats-row__muted { flex-shrink: 0; width: 14px; height: 14px; color: var(--chats-text-muted); }
|
|
1251
|
+
|
|
1252
|
+
.chats-dock__see-all {
|
|
1253
|
+
flex: 0 0 44px;
|
|
1254
|
+
display: flex;
|
|
1255
|
+
align-items: center;
|
|
1256
|
+
justify-content: center;
|
|
1257
|
+
border-top: 1px solid var(--chats-dock-edge);
|
|
1258
|
+
color: var(--chats-text);
|
|
1259
|
+
font-size: 0.8125rem;
|
|
1260
|
+
font-weight: 650;
|
|
1261
|
+
text-decoration: none;
|
|
1262
|
+
}
|
|
1263
|
+
.chats-dock__see-all:hover { background: var(--chats-dock-hover); text-decoration: underline; }
|
|
1264
|
+
|
|
1265
|
+
.chats-dock .chats-empty { padding: 40px 20px; }
|
|
1266
|
+
.chats-dock .chats-empty__icon { font-size: 2rem; margin-bottom: 0.5rem; }
|
|
1267
|
+
.chats-dock .chats-empty__title { font-size: 0.9375rem; }
|
|
1268
|
+
.chats-dock .chats-empty__hint { font-size: 0.8125rem; }
|
|
1269
|
+
|
|
1270
|
+
/* The very first load: four still rows (no shimmer). */
|
|
1271
|
+
.chats-dock__skeleton { display: flex; gap: 10px; align-items: center; padding: 9px 12px; }
|
|
1272
|
+
.chats-dock__skeleton::before {
|
|
1273
|
+
content: "";
|
|
1274
|
+
width: 40px;
|
|
1275
|
+
height: 40px;
|
|
1276
|
+
border-radius: 999px;
|
|
1277
|
+
background: var(--chats-surface);
|
|
1278
|
+
flex-shrink: 0;
|
|
1279
|
+
}
|
|
1280
|
+
.chats-dock__skeleton span { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
|
1281
|
+
.chats-dock__skeleton span::before,
|
|
1282
|
+
.chats-dock__skeleton span::after { content: ""; height: 10px; border-radius: 4px; background: var(--chats-surface); }
|
|
1283
|
+
.chats-dock__skeleton span::before { width: 45%; }
|
|
1284
|
+
.chats-dock__skeleton span::after { width: 80%; }
|
|
1285
|
+
|
|
1286
|
+
/* --- Windows --------------------------------------------------------------------- */
|
|
1287
|
+
|
|
1288
|
+
.chats-window__header { padding-right: 4px; position: relative; }
|
|
1289
|
+
.chats-window__title {
|
|
1290
|
+
flex: 1;
|
|
1291
|
+
min-width: 0;
|
|
1292
|
+
height: 100%;
|
|
1293
|
+
display: flex;
|
|
1294
|
+
align-items: center;
|
|
1295
|
+
gap: 8px;
|
|
1296
|
+
padding: 0 4px 0 8px;
|
|
1297
|
+
border: 0;
|
|
1298
|
+
background: none;
|
|
1299
|
+
color: inherit;
|
|
1300
|
+
font: inherit;
|
|
1301
|
+
text-align: left;
|
|
1302
|
+
cursor: pointer;
|
|
1303
|
+
}
|
|
1304
|
+
.chats-window__title .chats-avatar { width: 32px; height: 32px; font-size: 0.8rem; }
|
|
1305
|
+
.chats-window__identity { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
1306
|
+
.chats-window__name-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
|
1307
|
+
.chats-window__name {
|
|
1308
|
+
font-family: var(--chats-dock-title-font);
|
|
1309
|
+
font-size: 0.875rem;
|
|
1310
|
+
font-weight: 700;
|
|
1311
|
+
line-height: 1.25;
|
|
1312
|
+
white-space: nowrap;
|
|
1313
|
+
overflow: hidden;
|
|
1314
|
+
text-overflow: ellipsis;
|
|
1315
|
+
}
|
|
1316
|
+
.chats-window__subject {
|
|
1317
|
+
font-size: 0.75rem;
|
|
1318
|
+
line-height: 1.25;
|
|
1319
|
+
color: var(--chats-text-muted);
|
|
1320
|
+
white-space: nowrap;
|
|
1321
|
+
overflow: hidden;
|
|
1322
|
+
text-overflow: ellipsis;
|
|
1323
|
+
}
|
|
1324
|
+
.chats-window__title .chats-badge { margin-left: 2px; }
|
|
1325
|
+
.chats-window__icon {
|
|
1326
|
+
flex: 0 0 32px;
|
|
1327
|
+
width: 32px;
|
|
1328
|
+
height: 32px;
|
|
1329
|
+
display: inline-grid;
|
|
1330
|
+
place-items: center;
|
|
1331
|
+
padding: 0;
|
|
1332
|
+
border: 0;
|
|
1333
|
+
border-radius: 8px;
|
|
1334
|
+
background: none;
|
|
1335
|
+
color: var(--chats-text-muted);
|
|
1336
|
+
font: inherit;
|
|
1337
|
+
font-size: 1.1rem;
|
|
1338
|
+
line-height: 1;
|
|
1339
|
+
cursor: pointer;
|
|
1340
|
+
text-decoration: none;
|
|
1341
|
+
list-style: none;
|
|
1342
|
+
}
|
|
1343
|
+
.chats-window__icon::-webkit-details-marker { display: none; }
|
|
1344
|
+
.chats-window__icon svg { width: 18px; height: 18px; }
|
|
1345
|
+
.chats-window__icon:hover { background: var(--chats-dock-hover); color: var(--chats-text); }
|
|
1346
|
+
.chats-window__back { display: none; }
|
|
1347
|
+
/* The host's thread actions (conversation_header_actions). */
|
|
1348
|
+
.chats-window__menu { flex: 0 0 auto; }
|
|
1349
|
+
.chats-window__menu .chats-menu__panel { top: calc(100% - 4px); right: 4px; z-index: 30; }
|
|
1350
|
+
/* Touch in dock mode (a tablet): the same 48px header, fatter targets. */
|
|
1351
|
+
@media (pointer: coarse) {
|
|
1352
|
+
.chats-dock[data-mode="dock"] .chats-window__icon { flex-basis: 40px; width: 40px; height: 40px; }
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/* Focused: a 2px accent line along the top of the header. */
|
|
1356
|
+
.chats-window:focus-within .chats-window__header { box-shadow: inset 0 2px 0 var(--chats-dock-focus); }
|
|
1357
|
+
|
|
1358
|
+
/* Waiting to be seen (popped open, or minimized when something arrived): the
|
|
1359
|
+
header becomes the accent pair, the one every host already tunes. */
|
|
1360
|
+
.chats-window--unread .chats-window__header {
|
|
1361
|
+
background: var(--chats-dock-unread-bg);
|
|
1362
|
+
color: var(--chats-dock-unread-text);
|
|
1363
|
+
border-bottom-color: transparent;
|
|
1364
|
+
box-shadow: none;
|
|
1365
|
+
}
|
|
1366
|
+
.chats-window--unread :is(.chats-window__subject, .chats-window__icon, .chats-verified) { color: inherit; }
|
|
1367
|
+
.chats-window--unread .chats-window__icon:hover { background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; }
|
|
1368
|
+
.chats-window--unread .chats-window__header .chats-badge {
|
|
1369
|
+
background: var(--chats-dock-unread-text);
|
|
1370
|
+
color: var(--chats-dock-unread-bg);
|
|
1371
|
+
}
|
|
1372
|
+
.chats-window--unread .chats-window__header :focus-visible { outline-color: var(--chats-dock-unread-text); }
|
|
1373
|
+
|
|
1374
|
+
/* The thread, inside a window: the full page's body, compacted. Rooted at
|
|
1375
|
+
.chats-thread--window, never .chats (the full-page shell). */
|
|
1376
|
+
.chats-thread--window {
|
|
1377
|
+
--chats-radius: 0.95rem;
|
|
1378
|
+
flex: 1;
|
|
1379
|
+
min-height: 0;
|
|
1380
|
+
height: auto;
|
|
1381
|
+
display: flex;
|
|
1382
|
+
flex-direction: column;
|
|
1383
|
+
overflow: hidden;
|
|
1384
|
+
background: var(--chats-dock-bg);
|
|
1385
|
+
}
|
|
1386
|
+
.chats-thread--window .chats-thread__scroll { padding: 10px 12px 6px; }
|
|
1387
|
+
/* A short thread sits on the composer: the eye is at the bottom. */
|
|
1388
|
+
.chats-thread--window .chats-thread__scroll > :first-child { margin-top: auto; }
|
|
1389
|
+
.chats-thread--window .chats-day-separator,
|
|
1390
|
+
.chats-thread--window .chats-thread__unread-line { margin: 0.5rem 0; }
|
|
1391
|
+
.chats-thread--window .chats-message { max-width: 88%; }
|
|
1392
|
+
.chats-thread--window .chats-message__avatar { width: 1.6rem; height: 1.6rem; font-size: 0.65rem; }
|
|
1393
|
+
.chats-thread--window .chats-message__bubble { padding: 0.4rem 0.7rem; font-size: 0.875rem; line-height: 1.35; }
|
|
1394
|
+
.chats-thread--window .chats-message__bubble > .chats-message__action { font-size: 0.8125rem; padding: 0.45rem 0.8rem; }
|
|
1395
|
+
.chats-thread--window .chats-message__image { max-width: 11rem; max-height: 11rem; }
|
|
1396
|
+
.chats-thread--window .chats-typing { font-size: 0.75rem; padding: 0.3rem 0.25rem; }
|
|
1397
|
+
.chats-thread--window .chats-composer { padding: 8px 10px calc(8px + var(--chats-safe-bottom)); }
|
|
1398
|
+
.chats-thread--window .chats-composer__actions { margin-bottom: 6px; }
|
|
1399
|
+
.chats-thread--window :where(.chats-composer__actions) :where(a, button) { font-size: 0.75rem; padding: 0.25rem 0.7rem; }
|
|
1400
|
+
.chats-thread--window .chats-composer__bar { gap: 8px; }
|
|
1401
|
+
.chats-thread--window .chats-composer__input { font-size: 0.875rem; padding: 0.45rem 0.75rem; max-height: 7.5rem; }
|
|
1402
|
+
.chats-thread--window .chats-composer__send { width: 2.25rem; height: 2.25rem; font-size: 0.95rem; }
|
|
1403
|
+
.chats-thread--window .chats-composer--locked { padding: 12px 14px calc(12px + var(--chats-safe-bottom)); }
|
|
1404
|
+
.chats-thread--window .chats-composer__locked-notice { font-size: 0.8125rem; }
|
|
1405
|
+
|
|
1406
|
+
/* Before anybody wrote (opened from a Message button). */
|
|
1407
|
+
.chats-window__hello {
|
|
1408
|
+
margin: auto 0;
|
|
1409
|
+
padding: 24px 16px;
|
|
1410
|
+
text-align: center;
|
|
1411
|
+
color: var(--chats-text-muted);
|
|
1412
|
+
font-size: 0.8125rem;
|
|
1413
|
+
}
|
|
1414
|
+
.chats-window__hello .chats-avatar { display: flex; width: 56px; height: 56px; font-size: 1.2rem; margin: 0 auto 10px; }
|
|
1415
|
+
.chats-window__hello strong { display: block; color: var(--chats-text); font-size: 0.9375rem; margin-bottom: 2px; }
|
|
1416
|
+
.chats-thread__scroll:has(.chats-message) .chats-window__hello { display: none; }
|
|
1417
|
+
|
|
1418
|
+
/* Loading, and failure. */
|
|
1419
|
+
.chats-window__status {
|
|
1420
|
+
margin: auto;
|
|
1421
|
+
padding: 24px 16px;
|
|
1422
|
+
text-align: center;
|
|
1423
|
+
color: var(--chats-text-muted);
|
|
1424
|
+
font-size: 0.8125rem;
|
|
1425
|
+
}
|
|
1426
|
+
.chats-window__status p { margin: 0; }
|
|
1427
|
+
.chats-window__retry {
|
|
1428
|
+
margin-top: 10px;
|
|
1429
|
+
padding: 0.35rem 0.9rem;
|
|
1430
|
+
border: 1px solid var(--chats-border);
|
|
1431
|
+
border-radius: 999px;
|
|
1432
|
+
background: var(--chats-surface);
|
|
1433
|
+
color: var(--chats-text);
|
|
1434
|
+
font: inherit;
|
|
1435
|
+
font-size: 0.8125rem;
|
|
1436
|
+
font-weight: 600;
|
|
1437
|
+
cursor: pointer;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/* --- Motion ---------------------------------------------------------------------- */
|
|
1441
|
+
|
|
1442
|
+
@keyframes chats-dock-rise { from { transform: translateY(16px); opacity: 0; } }
|
|
1443
|
+
@keyframes chats-dock-sheet-up { from { transform: translateY(100%); } }
|
|
1444
|
+
@keyframes chats-dock-push { from { transform: translateX(100%); } }
|
|
1445
|
+
@keyframes chats-dock-bump { 50% { transform: scale(1.2); } }
|
|
1446
|
+
@keyframes chats-dock-flash { 50% { filter: brightness(1.18); } }
|
|
1447
|
+
|
|
1448
|
+
.chats-window--entering { animation: chats-dock-rise var(--chats-dock-duration) var(--chats-dock-ease); }
|
|
1449
|
+
.chats-window--popped .chats-window__header { animation: chats-dock-flash 600ms ease-in-out 2; }
|
|
1450
|
+
.chats-dock .chats-badge--bumped { animation: chats-dock-bump 160ms ease-out; }
|
|
1451
|
+
|
|
1452
|
+
/* --- Phones: the sheet ------------------------------------------------------------
|
|
1453
|
+
Under 700px wide or 500px tall (one window beside the panel needs 656px;
|
|
1454
|
+
a 480px window needs the height). A media query, not a class, so a phone
|
|
1455
|
+
gets this layout before the controller connects and without JavaScript —
|
|
1456
|
+
the panel is a <details>, so the closed bar is the launcher and opening it
|
|
1457
|
+
is the sheet. The controller reads the same query for its behaviour. */
|
|
1458
|
+
|
|
1459
|
+
@media (max-width: 699.98px), (max-height: 499.98px) {
|
|
1460
|
+
.chats-dock {
|
|
1461
|
+
inset: auto 0 0 auto;
|
|
1462
|
+
right: 0;
|
|
1463
|
+
display: block;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/* Closed: the round launcher, bottom-right above the home indicator. */
|
|
1467
|
+
.chats-dock__panel:not([open]) {
|
|
1468
|
+
position: fixed;
|
|
1469
|
+
right: calc(16px + var(--chats-safe-right));
|
|
1470
|
+
bottom: calc(16px + var(--chats-dock-offset) + var(--chats-safe-bottom));
|
|
1471
|
+
width: var(--chats-dock-launcher-size);
|
|
1472
|
+
height: var(--chats-dock-launcher-size);
|
|
1473
|
+
padding: 0;
|
|
1474
|
+
border: 0;
|
|
1475
|
+
border-radius: 999px;
|
|
1476
|
+
background: var(--chats-accent);
|
|
1477
|
+
color: var(--chats-accent-contrast);
|
|
1478
|
+
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.28);
|
|
1479
|
+
overflow: visible;
|
|
1480
|
+
}
|
|
1481
|
+
.chats-dock__panel:not([open]) .chats-dock__toggle {
|
|
1482
|
+
position: relative;
|
|
1483
|
+
justify-content: center;
|
|
1484
|
+
width: 100%;
|
|
1485
|
+
height: 100%;
|
|
1486
|
+
padding: 0;
|
|
1487
|
+
border: 0;
|
|
1488
|
+
border-radius: 999px;
|
|
1489
|
+
background: none;
|
|
1490
|
+
}
|
|
1491
|
+
.chats-dock__panel:not([open]) :is(.chats-avatar, .chats-dock__chevron) { display: none; }
|
|
1492
|
+
.chats-dock__panel:not([open]) .chats-dock__title {
|
|
1493
|
+
/* Still read out ("Messages, 3 unread"), just not shown. */
|
|
1494
|
+
position: absolute;
|
|
1495
|
+
width: 1px;
|
|
1496
|
+
height: 1px;
|
|
1497
|
+
overflow: hidden;
|
|
1498
|
+
clip: rect(0, 0, 0, 0);
|
|
1499
|
+
}
|
|
1500
|
+
.chats-dock__panel:not([open]) .chats-dock__icon { display: block; }
|
|
1501
|
+
.chats-dock__panel:not([open]) .chats-badge {
|
|
1502
|
+
position: absolute;
|
|
1503
|
+
top: -2px;
|
|
1504
|
+
right: -2px;
|
|
1505
|
+
/* accent-on-accent would vanish: the launcher's badge is the inverse pair */
|
|
1506
|
+
background: var(--chats-accent-contrast);
|
|
1507
|
+
color: var(--chats-accent);
|
|
1508
|
+
box-shadow: 0 0 0 2px var(--chats-accent);
|
|
1509
|
+
}
|
|
1510
|
+
.chats-dock--no-launcher .chats-dock__panel:not([open]) { display: none; }
|
|
1511
|
+
/* Anything full-screen up: the launcher would only cover it. */
|
|
1512
|
+
.chats-dock:has(.chats-window--front) .chats-dock__panel:not([open]) { display: none; }
|
|
1513
|
+
|
|
1514
|
+
/* Open: the full-screen sheet; a window sits on top of the list. */
|
|
1515
|
+
.chats-dock__panel[open],
|
|
1516
|
+
.chats-window {
|
|
1517
|
+
position: fixed;
|
|
1518
|
+
left: 0;
|
|
1519
|
+
right: 0;
|
|
1520
|
+
top: var(--chats-vv-top, 0px);
|
|
1521
|
+
width: 100%;
|
|
1522
|
+
height: var(--chats-vvh, 100dvh);
|
|
1523
|
+
border: 0;
|
|
1524
|
+
border-radius: 0;
|
|
1525
|
+
box-shadow: none;
|
|
1526
|
+
padding: var(--chats-safe-top) var(--chats-safe-right) 0 var(--chats-safe-left);
|
|
1527
|
+
}
|
|
1528
|
+
.chats-dock__panel[open] {
|
|
1529
|
+
z-index: 1;
|
|
1530
|
+
padding-bottom: var(--chats-safe-bottom);
|
|
1531
|
+
animation: chats-dock-sheet-up 260ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
1532
|
+
}
|
|
1533
|
+
.chats-dock__panel[open] .chats-dock__body { animation: none; }
|
|
1534
|
+
.chats-window { z-index: 2; }
|
|
1535
|
+
.chats-window:not(.chats-window--front) { display: none; }
|
|
1536
|
+
.chats-window--front.chats-window--entering { animation: chats-dock-push 260ms cubic-bezier(0.32, 0.72, 0, 1); }
|
|
1537
|
+
|
|
1538
|
+
.chats-dock__body {
|
|
1539
|
+
inset: calc(var(--chats-safe-top) + var(--chats-dock-sheet-header-height)) var(--chats-safe-right)
|
|
1540
|
+
var(--chats-safe-bottom) var(--chats-safe-left);
|
|
1541
|
+
}
|
|
1542
|
+
.chats-dock__panel[open] .chats-dock__bar,
|
|
1543
|
+
.chats-window__header {
|
|
1544
|
+
flex-basis: var(--chats-dock-sheet-header-height);
|
|
1545
|
+
height: var(--chats-dock-sheet-header-height);
|
|
1546
|
+
box-shadow: none;
|
|
1547
|
+
}
|
|
1548
|
+
.chats-dock__panel[open] .chats-dock__toggle { padding: 0 6px 0 16px; }
|
|
1549
|
+
.chats-dock__panel[open] .chats-dock__title { font-size: 1.125rem; font-weight: 800; }
|
|
1550
|
+
.chats-dock__panel[open] :is(.chats-dock__toggle .chats-avatar, .chats-dock__chevron) { display: none; }
|
|
1551
|
+
.chats-dock__panel[open] .chats-dock__close-glyph { display: inline-grid; flex-basis: 44px; width: 44px; height: 44px; }
|
|
1552
|
+
.chats-dock__panel[open] .chats-dock__close-glyph svg { width: 22px; height: 22px; }
|
|
1553
|
+
.chats-dock__search { padding: 8px 16px; }
|
|
1554
|
+
/* 16px or iOS Safari zooms the page on focus. */
|
|
1555
|
+
.chats-dock__search .chats-search__input { font-size: 16px; padding: 0.55rem 0.9rem; }
|
|
1556
|
+
.chats-dock__list .chats-row__link { padding: 10px 16px; gap: 12px; }
|
|
1557
|
+
.chats-dock__list .chats-avatar { width: 48px; height: 48px; }
|
|
1558
|
+
.chats-dock__list .chats-row__title { font-size: 1rem; }
|
|
1559
|
+
.chats-dock__list .chats-row__preview { font-size: 0.875rem; }
|
|
1560
|
+
.chats-dock__list .chats-row__time { font-size: 0.75rem; }
|
|
1561
|
+
.chats-dock__see-all { flex-basis: 52px; font-size: 0.9375rem; }
|
|
1562
|
+
|
|
1563
|
+
.chats-window__back { display: inline-grid; }
|
|
1564
|
+
.chats-window__header { padding: 0 4px; }
|
|
1565
|
+
.chats-window__header :is(.chats-window__minimize, .chats-window__close) { display: none; }
|
|
1566
|
+
.chats-window__icon { flex-basis: 44px; width: 44px; height: 44px; }
|
|
1567
|
+
.chats-window__icon svg { width: 22px; height: 22px; }
|
|
1568
|
+
.chats-window__title { cursor: default; padding-left: 2px; }
|
|
1569
|
+
.chats-window__title .chats-avatar { width: 36px; height: 36px; }
|
|
1570
|
+
.chats-window__name { font-size: 1rem; }
|
|
1571
|
+
/* Full screen: the conversation is in front of you, nothing to flag. */
|
|
1572
|
+
.chats-window--unread .chats-window__header {
|
|
1573
|
+
background: var(--chats-dock-header-bg);
|
|
1574
|
+
color: inherit;
|
|
1575
|
+
border-bottom-color: var(--chats-dock-edge);
|
|
1576
|
+
}
|
|
1577
|
+
.chats-thread--window .chats-thread__scroll { padding: 12px 14px 8px; }
|
|
1578
|
+
.chats-thread--window .chats-message__bubble { font-size: 1rem; padding: 0.5rem 0.8rem; }
|
|
1579
|
+
.chats-thread--window .chats-composer { padding: 8px 12px calc(8px + var(--chats-safe-bottom)); }
|
|
1580
|
+
.chats-thread--window .chats-composer__input { font-size: 16px; padding: 0.55rem 0.9rem; }
|
|
1581
|
+
.chats-thread--window .chats-composer__send { width: 2.75rem; height: 2.75rem; }
|
|
1582
|
+
/* The keyboard is up: the home-indicator strip is behind it. */
|
|
1583
|
+
.chats-dock--keyboard .chats-thread--window .chats-composer { padding-bottom: 8px; }
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
html.chats-dock-sheet-open { overflow: hidden; }
|
|
1587
|
+
|
|
1588
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1589
|
+
.chats-dock,
|
|
1590
|
+
.chats-dock *,
|
|
1591
|
+
.chats-dock *::before,
|
|
1592
|
+
.chats-dock *::after {
|
|
1593
|
+
animation: none !important;
|
|
1594
|
+
transition-duration: 0s !important;
|
|
1595
|
+
transition-delay: 0s !important;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
@@ -55,11 +55,52 @@ module Chats
|
|
|
55
55
|
Chats.config.layout
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Conversations are ALWAYS resolved through
|
|
59
|
-
#
|
|
60
|
-
#
|
|
58
|
+
# Conversations are ALWAYS resolved through relations that only hold
|
|
59
|
+
# what this viewer may open (conversation_lookup_scopes): not a
|
|
60
|
+
# participant, left, or a blocked counterpart all come back as a plain
|
|
61
|
+
# 404, the same 404 as an id that doesn't exist — existence is never
|
|
62
|
+
# leaked to outsiders. Every engine endpoint (the thread, a dock window,
|
|
63
|
+
# sending, editing, reacting, reading) looks a conversation up here.
|
|
64
|
+
#
|
|
65
|
+
# The first scope that has it wins; later scopes are asked only on a
|
|
66
|
+
# miss. Errors a scope raises are not rescued: a broken product scope
|
|
67
|
+
# must fail loudly, never turn into a quiet 404.
|
|
61
68
|
def find_conversation(id = params[:id])
|
|
62
|
-
|
|
69
|
+
conversation_lookup_scopes.each do |scope|
|
|
70
|
+
conversation = scope.find_by(id: id)
|
|
71
|
+
return conversation if conversation
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
raise ActiveRecord::RecordNotFound.new(
|
|
75
|
+
"Couldn't find Chats::Conversation with 'id'=#{id}", Chats::Conversation.name, "id", id
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
# Where find_conversation looks, in order: by default the viewer's own
|
|
82
|
+
# inbox relation (`Messager#chats`: their active seats, blocks and
|
|
83
|
+
# `config.inbox_scope` applied).
|
|
84
|
+
#
|
|
85
|
+
# THE extension point for a conversation that must open although the
|
|
86
|
+
# inbox hides it. A product gem built on chats (support_desk: a
|
|
87
|
+
# requester's own ticket, which the host's inbox_scope keeps out of the
|
|
88
|
+
# inbox) prepends a module that appends its own relation:
|
|
89
|
+
#
|
|
90
|
+
# module SupportDesk::ConversationLookup
|
|
91
|
+
# protected
|
|
92
|
+
#
|
|
93
|
+
# def conversation_lookup_scopes
|
|
94
|
+
# super + [SupportDesk::Ticket.conversations_of(chats_current_messager)]
|
|
95
|
+
# end
|
|
96
|
+
# end
|
|
97
|
+
# Chats::ApplicationController.prepend(SupportDesk::ConversationLookup)
|
|
98
|
+
#
|
|
99
|
+
# Each scope must hold only conversations this viewer may open: the
|
|
100
|
+
# union of the scopes IS the authorization. Nothing the scopes don't
|
|
101
|
+
# name becomes reachable.
|
|
102
|
+
def conversation_lookup_scopes
|
|
103
|
+
[chats_current_messager.chats]
|
|
63
104
|
end
|
|
64
105
|
end
|
|
65
106
|
end
|