chats 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. metadata +204 -0
@@ -0,0 +1,818 @@
1
+ /*
2
+ * chats — bundled default styles.
3
+ *
4
+ * Self-contained on purpose: engine views can't lean on the host's CSS
5
+ * framework (a Tailwind host never scans gem view files, so utility classes
6
+ * used here would simply not exist in its build). Semantic chats-* classes +
7
+ * plain modern CSS work in ANY host with zero build integration.
8
+ *
9
+ * Theme with CSS variables (override in any host stylesheet):
10
+ *
11
+ * :root {
12
+ * --chats-accent: #facc15; /\* brand color: own bubbles, send button *\/
13
+ * --chats-accent-contrast: #111827; /\* text on top of the accent *\/
14
+ * }
15
+ *
16
+ * Want a completely different look? `rails g chats:views` ejects the
17
+ * templates into your app — restyle them with your own framework and skip
18
+ * including this file (it's linked by the views via the chats_styles helper).
19
+ */
20
+
21
+ :root {
22
+ --chats-accent: #111827;
23
+ --chats-accent-contrast: #ffffff;
24
+ --chats-bg: #ffffff;
25
+ --chats-surface: #f3f4f6;
26
+ --chats-border: rgba(0, 0, 0, 0.08);
27
+ --chats-text: #111827;
28
+ --chats-text-muted: #6b7280;
29
+ --chats-bubble-bg: #f3f4f6;
30
+ --chats-bubble-text: #111827;
31
+ --chats-radius: 1.1rem;
32
+ --chats-max-width: 42rem;
33
+ }
34
+
35
+ /* --- Shell ----------------------------------------------------------------- */
36
+
37
+ .chats {
38
+ margin: 0 auto;
39
+ width: 100%;
40
+ max-width: var(--chats-max-width);
41
+ color: var(--chats-text);
42
+ background: var(--chats-bg);
43
+ display: flex;
44
+ flex-direction: column;
45
+ min-height: 100dvh;
46
+ }
47
+
48
+ /* --- Inbox ------------------------------------------------------------------ */
49
+
50
+ .chats-inbox__header { padding: 1rem 1.25rem 0.25rem; }
51
+ .chats-inbox__title { font-size: 1.6rem; font-weight: 800; margin: 0; }
52
+
53
+ .chats-search { padding: 0.5rem 1.25rem; }
54
+ .chats-search__input {
55
+ width: 100%;
56
+ padding: 0.6rem 0.9rem;
57
+ border-radius: 999px;
58
+ border: 1px solid var(--chats-border);
59
+ background: var(--chats-surface);
60
+ font: inherit;
61
+ outline-offset: 2px;
62
+ }
63
+
64
+ .chats-inbox__list { list-style: none; margin: 0; padding: 0.25rem 0 2rem; }
65
+
66
+ .chats-row__link {
67
+ display: flex;
68
+ gap: 0.85rem;
69
+ align-items: center;
70
+ padding: 0.8rem 1.25rem;
71
+ text-decoration: none;
72
+ color: inherit;
73
+ }
74
+ .chats-row__link:hover { background: var(--chats-surface); }
75
+ .chats-row__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
76
+ .chats-row__top, .chats-row__bottom {
77
+ display: flex;
78
+ align-items: baseline;
79
+ justify-content: space-between;
80
+ gap: 0.75rem;
81
+ }
82
+ .chats-row__title {
83
+ font-weight: 650;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ }
88
+ .chats-row__time { font-size: 0.75rem; color: var(--chats-text-muted); flex-shrink: 0; }
89
+ .chats-row__subject {
90
+ font-size: 0.75rem;
91
+ color: var(--chats-text-muted);
92
+ white-space: nowrap;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ }
96
+ .chats-row__preview {
97
+ font-size: 0.875rem;
98
+ color: var(--chats-text-muted);
99
+ white-space: nowrap;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ }
103
+ .chats-row--unread .chats-row__title,
104
+ .chats-row--unread .chats-row__preview { font-weight: 750; color: var(--chats-text); }
105
+
106
+ .chats-badge {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ min-width: 1.35rem;
111
+ height: 1.35rem;
112
+ padding: 0 0.35rem;
113
+ border-radius: 999px;
114
+ background: var(--chats-accent);
115
+ color: var(--chats-accent-contrast);
116
+ font-size: 0.72rem;
117
+ font-weight: 800;
118
+ line-height: 1;
119
+ flex-shrink: 0;
120
+ }
121
+ .chats-badge--floating[hidden] { display: none; }
122
+
123
+ /* --- Empty states ------------------------------------------------------------ */
124
+
125
+ .chats-empty { text-align: center; padding: 4rem 1.5rem; }
126
+ .chats-empty__icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
127
+ .chats-empty__title { font-weight: 750; margin: 0 0 0.35rem; }
128
+ .chats-empty__hint { color: var(--chats-text-muted); font-size: 0.9rem; margin: 0; }
129
+
130
+ /* --- Avatars ------------------------------------------------------------------ */
131
+
132
+ .chats-avatar {
133
+ width: 2.75rem;
134
+ height: 2.75rem;
135
+ border-radius: 999px;
136
+ object-fit: cover;
137
+ flex-shrink: 0;
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ }
142
+ .chats-avatar--initials {
143
+ background: var(--chats-surface);
144
+ color: var(--chats-text-muted);
145
+ font-weight: 750;
146
+ font-size: 0.95rem;
147
+ user-select: none;
148
+ }
149
+
150
+ /* --- Thread -------------------------------------------------------------------- */
151
+
152
+ .chats-thread {
153
+ height: 100dvh;
154
+ overflow: hidden;
155
+ }
156
+
157
+ .chats-thread__header {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 0.7rem;
161
+ padding: 0.65rem 1rem;
162
+ border-bottom: 1px solid var(--chats-border);
163
+ background: var(--chats-bg);
164
+ position: sticky;
165
+ top: 0;
166
+ z-index: 10;
167
+ flex: 0 0 auto;
168
+ }
169
+ .chats-thread__header .chats-avatar { width: 2.4rem; height: 2.4rem; }
170
+ .chats-thread__back {
171
+ font-size: 1.8rem;
172
+ line-height: 1;
173
+ text-decoration: none;
174
+ color: inherit;
175
+ padding: 0 0.35rem;
176
+ }
177
+ .chats-thread__identity { flex: 1; min-width: 0; }
178
+ .chats-thread__title {
179
+ font-size: 1rem;
180
+ font-weight: 750;
181
+ margin: 0;
182
+ white-space: nowrap;
183
+ overflow: hidden;
184
+ text-overflow: ellipsis;
185
+ }
186
+ .chats-thread__subject {
187
+ font-size: 0.75rem;
188
+ color: var(--chats-text-muted);
189
+ margin: 0;
190
+ white-space: nowrap;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ }
194
+
195
+ .chats-thread__scroll {
196
+ flex: 1;
197
+ min-height: 0;
198
+ overflow-y: auto;
199
+ /* Keep the composer dock out of the scroll chain: the message pane is the
200
+ only scroll container, and boundary drags should not bubble into browser /
201
+ WebView pull-to-refresh. Sources:
202
+ https://developer.mozilla.org/docs/Web/CSS/min-height#values
203
+ https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior */
204
+ overscroll-behavior-y: contain;
205
+ padding: 1rem 1rem 0.5rem;
206
+ display: flex;
207
+ flex-direction: column;
208
+ }
209
+ .chats-thread__messages { display: flex; flex-direction: column; gap: 0; }
210
+ .chats-day-separator {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 0.75rem;
214
+ width: 100%;
215
+ margin: 0.8rem 0;
216
+ color: var(--chats-text-muted);
217
+ font-size: 0.7rem;
218
+ font-weight: 700;
219
+ text-align: center;
220
+ }
221
+ .chats-day-separator::before,
222
+ .chats-day-separator::after {
223
+ content: "";
224
+ flex: 1;
225
+ height: 1px;
226
+ background: var(--chats-border);
227
+ }
228
+
229
+ /* The «new messages» divider: same shape as the day separator but in the
230
+ accent color — it marks where the unread catch-up starts on thread open
231
+ (rendered server-side before the first unread bubble; see show.html.erb). */
232
+ .chats-thread__unread-line {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 0.75rem;
236
+ width: 100%;
237
+ margin: 0.8rem 0;
238
+ color: var(--chats-text-muted);
239
+ font-size: 0.7rem;
240
+ font-weight: 700;
241
+ text-align: center;
242
+ text-transform: uppercase;
243
+ letter-spacing: 0.08em;
244
+ }
245
+ .chats-thread__unread-line::before,
246
+ .chats-thread__unread-line::after {
247
+ content: "";
248
+ flex: 1;
249
+ height: 1px;
250
+ background: currentColor;
251
+ opacity: 0.4;
252
+ }
253
+ .chats-loader {
254
+ text-align: center;
255
+ color: var(--chats-text-muted);
256
+ font-size: 0.8rem;
257
+ padding: 0.5rem;
258
+ }
259
+
260
+ /* Paginated history loads inside (nested) turbo-frames, and a custom
261
+ element's default display:block strands those bubbles in a block context —
262
+ .chats-message--own aligns with align-self, which only works when the
263
+ bubble is a direct flex item of a column. Make each page frame its own
264
+ flex column mirroring the scroller. NOT display:contents: that removes
265
+ the frame's box, and Turbo's lazy loading watches the frame with an
266
+ IntersectionObserver — no box, no intersection, no fetch ("loading…"
267
+ forever). https://developer.mozilla.org/en-US/docs/Web/CSS/display#contents */
268
+ turbo-frame[id^="chats_page_"] {
269
+ display: flex;
270
+ flex-direction: column;
271
+ width: 100%;
272
+ }
273
+
274
+ /* --- Bubbles --------------------------------------------------------------------- */
275
+
276
+ .chats-message {
277
+ display: flex;
278
+ gap: 0.5rem;
279
+ align-items: flex-end;
280
+ max-width: 85%;
281
+ margin: 0.2rem 0 0;
282
+ }
283
+ .chats-message--continuation { margin-top: 0.15rem; }
284
+ .chats-message__avatar { width: 1.9rem; height: 1.9rem; font-size: 0.7rem; }
285
+ .chats-message__content { display: flex; flex-direction: column; min-width: 0; }
286
+ .chats-message__bubble-row { display: flex; align-items: flex-end; gap: 0.5rem; }
287
+ .chats-message__sender {
288
+ font-size: 0.72rem;
289
+ font-weight: 700;
290
+ color: var(--chats-text-muted);
291
+ margin: 0 0 0.1rem 2.4rem;
292
+ }
293
+ .chats-message--continuation .chats-message__sender { display: none; }
294
+ .chats-message--followed .chats-message__avatar { visibility: hidden; }
295
+
296
+ .chats-message__bubble {
297
+ background: var(--chats-bubble-bg);
298
+ color: var(--chats-bubble-text);
299
+ border-radius: var(--chats-radius);
300
+ border-bottom-left-radius: 0.3rem;
301
+ padding: 0.5rem 0.8rem;
302
+ overflow-wrap: anywhere;
303
+ }
304
+ /* pre-wrap: the body renders as plain escaped text, so every newline the
305
+ sender typed shows literally (chat semantics — simple_format used to
306
+ collapse consecutive blank lines into one paragraph break). */
307
+ .chats-message__text {
308
+ margin: 0;
309
+ white-space: pre-wrap;
310
+ overflow-wrap: anywhere;
311
+ }
312
+
313
+ .chats-message__meta {
314
+ display: flex;
315
+ justify-content: flex-end;
316
+ align-items: baseline;
317
+ gap: 0.3rem;
318
+ margin-top: 0.2rem;
319
+ font-size: 0.62rem;
320
+ color: var(--chats-text-muted);
321
+ user-select: none;
322
+ }
323
+ .chats-message__receipt {
324
+ display: inline-flex;
325
+ align-items: baseline;
326
+ white-space: nowrap;
327
+ }
328
+ .chats-message__receipt[data-state="seen"] {
329
+ letter-spacing: -0.3em;
330
+ padding-right: 0.3em;
331
+ }
332
+ .chats-message__receipt[hidden] { display: none; }
333
+ .chats-visually-hidden {
334
+ position: absolute;
335
+ width: 1px;
336
+ height: 1px;
337
+ padding: 0;
338
+ margin: -1px;
339
+ overflow: hidden;
340
+ clip: rect(0, 0, 0, 0);
341
+ white-space: nowrap;
342
+ border: 0;
343
+ }
344
+
345
+ /* Own messages: flipped to the right, accent-colored. The chats--thread
346
+ controller adds .chats-message--own client-side (bubbles broadcast
347
+ viewer-agnostic — see _message.html.erb). */
348
+ .chats-message--own { align-self: flex-end; flex-direction: row-reverse; }
349
+ .chats-message--own .chats-message__bubble-row { flex-direction: row-reverse; }
350
+ .chats-message--own .chats-message__avatar { display: none; }
351
+ .chats-message--own .chats-message__sender { display: none; }
352
+ .chats-message--own .chats-message__bubble {
353
+ background: var(--chats-accent);
354
+ color: var(--chats-accent-contrast);
355
+ border-bottom-left-radius: var(--chats-radius);
356
+ border-bottom-right-radius: 0.3rem;
357
+ }
358
+ .chats-message--own .chats-message__meta { color: var(--chats-accent-contrast); opacity: 0.75; }
359
+ .chats-message--continuation .chats-message__bubble { border-top-left-radius: 0.35rem; }
360
+ .chats-message--followed .chats-message__bubble { border-bottom-left-radius: var(--chats-radius); }
361
+ .chats-message--own.chats-message--continuation .chats-message__bubble { border-top-right-radius: 0.35rem; }
362
+ .chats-message--own.chats-message--followed .chats-message__bubble { border-bottom-right-radius: var(--chats-radius); }
363
+
364
+ /* System + deleted */
365
+ .chats-message--system { align-self: center; max-width: 100%; }
366
+ .chats-message__system {
367
+ font-size: 0.78rem;
368
+ color: var(--chats-text-muted);
369
+ background: var(--chats-surface);
370
+ border-radius: 999px;
371
+ padding: 0.3rem 0.9rem;
372
+ text-align: center;
373
+ margin: 0.35rem auto;
374
+ }
375
+ .chats-message--deleted .chats-message__bubble { background: transparent; border: 1px dashed var(--chats-border); }
376
+ .chats-message__tombstone { color: var(--chats-text-muted); font-size: 0.85rem; }
377
+
378
+ /* --- Attachments -------------------------------------------------------------------- */
379
+
380
+ .chats-message__attachments {
381
+ display: flex;
382
+ flex-wrap: wrap;
383
+ gap: 0.35rem;
384
+ margin-top: 0.35rem;
385
+ }
386
+ .chats-message__image {
387
+ max-width: 14rem;
388
+ max-height: 14rem;
389
+ border-radius: 0.75rem;
390
+ display: block;
391
+ }
392
+ .chats-message__image-link { cursor: zoom-in; }
393
+ .chats-message__file { font-size: 0.85rem; }
394
+
395
+ .chats-attachment-preview {
396
+ position: fixed;
397
+ inset: 0;
398
+ z-index: 1000;
399
+ width: 100%;
400
+ height: 100%;
401
+ max-width: none;
402
+ max-height: none;
403
+ margin: 0;
404
+ padding: 0;
405
+ border: 0;
406
+ background: rgba(0, 0, 0, 0.82);
407
+ }
408
+ .chats-attachment-preview[hidden] { display: none; }
409
+ .chats-attachment-preview-open { overflow: hidden; }
410
+ .chats-attachment-preview__surface {
411
+ position: relative;
412
+ display: grid;
413
+ place-items: center;
414
+ width: 100%;
415
+ height: 100%;
416
+ padding: max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom));
417
+ background: rgba(0, 0, 0, 0.82);
418
+ }
419
+ .chats-attachment-preview__figure { margin: 0; max-width: 100%; max-height: 100%; }
420
+ .chats-attachment-preview__image {
421
+ display: block;
422
+ max-width: 100%;
423
+ max-height: calc(100dvh - 5rem);
424
+ object-fit: contain;
425
+ border-radius: 0.5rem;
426
+ }
427
+ .chats-attachment-preview__close {
428
+ position: absolute;
429
+ top: max(0.75rem, env(safe-area-inset-top));
430
+ right: 0.75rem;
431
+ z-index: 1;
432
+ display: inline-flex;
433
+ align-items: center;
434
+ justify-content: center;
435
+ width: 2.5rem;
436
+ height: 2.5rem;
437
+ padding: 0;
438
+ border: 0;
439
+ border-radius: 999px;
440
+ background: rgba(255, 255, 255, 0.16);
441
+ color: #ffffff;
442
+ font: inherit;
443
+ font-size: 1.75rem;
444
+ line-height: 1;
445
+ cursor: pointer;
446
+ }
447
+
448
+ /* --- Reactions ----------------------------------------------------------------------- */
449
+
450
+ .chats-message__reactions { display: flex; gap: 0.25rem; margin: 0.2rem 0 0 0.5rem; flex-wrap: wrap; }
451
+ .chats-message--own .chats-message__reactions { justify-content: flex-end; margin-right: 0.5rem; }
452
+ .chats-reaction {
453
+ border: 1px solid var(--chats-border);
454
+ background: var(--chats-bg);
455
+ border-radius: 999px;
456
+ font-size: 0.8rem;
457
+ padding: 0.1rem 0.5rem;
458
+ cursor: pointer;
459
+ display: inline-flex;
460
+ gap: 0.25rem;
461
+ align-items: center;
462
+ }
463
+ .chats-reaction__count { font-size: 0.7rem; font-weight: 700; color: var(--chats-text-muted); }
464
+
465
+ /* --- Long-press popup (Telegram-style) ------------------------------------------------ */
466
+ /*
467
+ Long-press / right-click a bubble: its clone morphs to the center over a
468
+ blurred glass backdrop, the reactions pill above, the contextual menu
469
+ below. Markup lives in conversations/show (the slots) + each bubble's
470
+ <template data-chats-message-menu> (the content); the chats--thread
471
+ controller does the FLIP transform dance.
472
+ */
473
+
474
+ .chats-message--lifted { visibility: hidden; }
475
+
476
+ /* Long-press must read as a GESTURE on touch: without this, iOS Safari
477
+ starts the selection loupe / callout mid-press and the popup never
478
+ opens cleanly. Desktop keeps normal selection; on coarse pointers the
479
+ popup menu’ Copy replaces it. (The native shells already suppress
480
+ callouts globally; this covers the mobile WEB thread.) */
481
+ @media (pointer: coarse) {
482
+ .chats-message {
483
+ user-select: none;
484
+ -webkit-user-select: none;
485
+ -webkit-touch-callout: none;
486
+ }
487
+ }
488
+
489
+ .chats-popup {
490
+ position: fixed;
491
+ inset: 0;
492
+ z-index: 60;
493
+ }
494
+ .chats-popup[hidden] { display: none; }
495
+ .chats-popup__backdrop {
496
+ position: absolute;
497
+ inset: 0;
498
+ background: rgba(15, 23, 42, 0.3);
499
+ -webkit-backdrop-filter: blur(10px);
500
+ backdrop-filter: blur(10px);
501
+ opacity: 0;
502
+ transition: opacity 180ms ease;
503
+ will-change: opacity;
504
+ }
505
+ .chats-popup--open .chats-popup__backdrop { opacity: 1; }
506
+
507
+ .chats-popup__stack {
508
+ /* Anchored to the pressed bubble's SIDE by the controller (inline
509
+ left/right) — never dragged to the horizontal center. Vertically
510
+ centered; the FLIP morph on the clone covers the y-travel. */
511
+ position: absolute;
512
+ top: 50%;
513
+ transform: translateY(-50%);
514
+ display: flex;
515
+ flex-direction: column;
516
+ align-items: flex-start;
517
+ gap: 0.6rem;
518
+ max-height: 92dvh;
519
+ }
520
+ .chats-popup__stack--own { align-items: flex-end; }
521
+ .chats-popup__slot--bubble { max-width: 100%; }
522
+ .chats-popup__bubble {
523
+ /* Long messages scroll inside the lifted clone instead of pushing the
524
+ pill/menu off-screen (Telegram does the same). */
525
+ max-height: 45dvh;
526
+ overflow-y: auto;
527
+ }
528
+ .chats-popup__bubble {
529
+ /* No background/radius/overflow of its own: the clone IS the bubble, at
530
+ its original width — wrapping it in a styled box reads as a different
531
+ element. drop-shadow (not box-shadow) hugs the bubble's real shape. */
532
+ transition: transform 260ms cubic-bezier(0.32, 0.72, 0.18, 1);
533
+ will-change: transform;
534
+ filter: drop-shadow(0 18px 40px rgba(0, 0, 0, 0.28));
535
+ }
536
+
537
+ .chats-popup__slot--reactions,
538
+ .chats-popup__slot--menu {
539
+ opacity: 0;
540
+ transform: scale(0.92);
541
+ transition: opacity 180ms ease 60ms, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1) 60ms;
542
+ }
543
+ .chats-popup--open .chats-popup__slot--reactions,
544
+ .chats-popup--open .chats-popup__slot--menu { opacity: 1; transform: scale(1); }
545
+
546
+ .chats-popup__reactions {
547
+ display: flex;
548
+ gap: 0.15rem;
549
+ background: var(--chats-bg);
550
+ border-radius: 999px;
551
+ padding: 0.3rem 0.45rem;
552
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
553
+ }
554
+ .chats-popup__reactions form { display: inline; }
555
+ .chats-popup__reaction,
556
+ .chats-reaction {
557
+ /* Emoji-FIRST family: iOS WKWebView per-glyph fallback through webfont
558
+ stacks can dead-end at LastResort (the boxed "?") for VS16/ZWJ emoji;
559
+ leading with the emoji fonts sidesteps it. Digits (reaction counts)
560
+ fall through to sans-serif — Apple Color Emoji maps none. */
561
+ font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol", sans-serif;
562
+ }
563
+ .chats-popup__reaction {
564
+ font-size: 1.35rem;
565
+ line-height: 1;
566
+ background: none;
567
+ border: none;
568
+ padding: 0.25rem;
569
+ border-radius: 999px;
570
+ cursor: pointer;
571
+ transition: transform 120ms ease;
572
+ }
573
+ .chats-popup__reaction:hover { transform: scale(1.25); }
574
+
575
+ .chats-popup__menu {
576
+ display: flex;
577
+ flex-direction: column;
578
+ min-width: 13rem;
579
+ background: var(--chats-bg);
580
+ border-radius: 1rem;
581
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
582
+ overflow: hidden;
583
+ padding: 0.25rem;
584
+ }
585
+ .chats-popup__menu form { display: contents; }
586
+ .chats-popup__item {
587
+ display: block;
588
+ width: 100%;
589
+ text-align: left;
590
+ font: inherit;
591
+ font-size: 0.9rem;
592
+ color: var(--chats-text);
593
+ background: none;
594
+ border: none;
595
+ border-radius: 0.6rem;
596
+ padding: 0.55rem 0.8rem;
597
+ cursor: pointer;
598
+ text-decoration: none;
599
+ }
600
+ .chats-popup__item:hover { background: var(--chats-surface); }
601
+ .chats-popup__item--danger { color: #dc2626; }
602
+
603
+ /* --- Seen + typing --------------------------------------------------------------------- */
604
+
605
+ .chats-seen {
606
+ align-self: flex-end;
607
+ font-size: 0.68rem;
608
+ color: var(--chats-text-muted);
609
+ margin: 0.1rem 0.5rem 0.2rem;
610
+ }
611
+ .chats-typing {
612
+ font-size: 0.8rem;
613
+ color: var(--chats-text-muted);
614
+ font-style: italic;
615
+ padding: 0.4rem 0.5rem;
616
+ }
617
+
618
+ /* --- Composer ----------------------------------------------------------------------------- */
619
+
620
+ .chats-composer {
621
+ flex: 0 0 auto;
622
+ background: var(--chats-bg);
623
+ border-top: 1px solid var(--chats-border);
624
+ padding: 0.6rem 0.9rem calc(0.6rem + env(safe-area-inset-bottom, 0px));
625
+ }
626
+ .chats-composer__bar { display: flex; align-items: flex-end; gap: 0.55rem; }
627
+ .chats-composer__input {
628
+ flex: 1;
629
+ resize: none;
630
+ border: 1px solid var(--chats-border);
631
+ background: var(--chats-surface);
632
+ border-radius: 1.25rem;
633
+ padding: 0.55rem 0.9rem;
634
+ font: inherit;
635
+ line-height: 1.35;
636
+ max-height: 10rem;
637
+ outline: none;
638
+ }
639
+ .chats-composer__input:focus { outline: none; }
640
+ .chats-composer__send {
641
+ width: 2.6rem;
642
+ height: 2.6rem;
643
+ border-radius: 999px;
644
+ border: none;
645
+ background: var(--chats-accent);
646
+ color: var(--chats-accent-contrast);
647
+ font-size: 1.05rem;
648
+ cursor: pointer;
649
+ flex-shrink: 0;
650
+ display: inline-flex;
651
+ align-items: center;
652
+ justify-content: center;
653
+ }
654
+ .chats-composer__send:hover { filter: brightness(0.92); }
655
+ .chats-composer__attach {
656
+ cursor: pointer;
657
+ font-size: 1.2rem;
658
+ padding-bottom: 0.45rem;
659
+ position: relative;
660
+ }
661
+ .chats-composer__file-count {
662
+ position: absolute;
663
+ top: -0.3rem;
664
+ right: -0.5rem;
665
+ background: var(--chats-accent);
666
+ color: var(--chats-accent-contrast);
667
+ border-radius: 999px;
668
+ font-size: 0.62rem;
669
+ font-weight: 800;
670
+ min-width: 1rem;
671
+ height: 1rem;
672
+ display: inline-flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ }
676
+ .chats-composer__error {
677
+ color: #dc2626;
678
+ font-size: 0.8rem;
679
+ margin: 0 0 0.4rem;
680
+ }
681
+
682
+ /* Selected-attachment previews (rendered by the composer controller). */
683
+ .chats-composer__previews {
684
+ display: flex;
685
+ gap: 0.5rem;
686
+ overflow-x: auto;
687
+ padding: 0.25rem 0.25rem 0.6rem;
688
+ }
689
+
690
+ /* The edit cue (Telegram's flow): a quote-style bar over the input — left
691
+ accent border, bold label, the original message trimmed to one line. */
692
+ .chats-composer__edit {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 0.6rem;
696
+ margin: 0 0.25rem 0.45rem;
697
+ padding: 0.3rem 0.6rem;
698
+ border-left: 3px solid var(--chats-accent);
699
+ background: var(--chats-surface);
700
+ border-radius: 0.45rem;
701
+ }
702
+ .chats-composer__edit-quote { min-width: 0; flex: 1; display: flex; flex-direction: column; }
703
+ .chats-composer__edit-label { font-size: 0.72rem; font-weight: 700; color: var(--chats-accent); }
704
+ .chats-composer__edit-preview {
705
+ font-size: 0.8rem;
706
+ color: var(--chats-text-muted);
707
+ white-space: nowrap;
708
+ overflow: hidden;
709
+ text-overflow: ellipsis;
710
+ }
711
+ .chats-composer__edit-cancel {
712
+ border: none;
713
+ background: none;
714
+ font-size: 1.1rem;
715
+ line-height: 1;
716
+ color: var(--chats-text-muted);
717
+ cursor: pointer;
718
+ padding: 0.2rem 0.4rem;
719
+ }
720
+
721
+ .chats-composer__previews[hidden] { display: none; }
722
+ .chats-composer__preview {
723
+ position: relative;
724
+ flex-shrink: 0;
725
+ }
726
+ .chats-composer__preview-image {
727
+ height: 4.5rem;
728
+ width: 4.5rem;
729
+ border-radius: 0.75rem;
730
+ object-fit: cover;
731
+ border: 1px solid var(--chats-border);
732
+ }
733
+ .chats-composer__preview-name {
734
+ display: inline-flex;
735
+ align-items: center;
736
+ height: 4.5rem;
737
+ max-width: 10rem;
738
+ padding: 0 0.75rem;
739
+ border-radius: 0.75rem;
740
+ border: 1px solid var(--chats-border);
741
+ background: var(--chats-surface);
742
+ font-size: 0.75rem;
743
+ overflow: hidden;
744
+ text-overflow: ellipsis;
745
+ white-space: nowrap;
746
+ }
747
+ .chats-composer__preview-remove {
748
+ position: absolute;
749
+ top: -0.4rem;
750
+ right: -0.4rem;
751
+ width: 1.3rem;
752
+ height: 1.3rem;
753
+ border-radius: 999px;
754
+ border: none;
755
+ background: var(--chats-text);
756
+ color: var(--chats-bg);
757
+ font-size: 0.7rem;
758
+ line-height: 1;
759
+ cursor: pointer;
760
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
761
+ }
762
+
763
+ /* --- Menus (⋯ and emoji picker) ----------------------------------------------------------------- */
764
+
765
+ .chats-menu { position: relative; }
766
+ .chats-menu__trigger {
767
+ cursor: pointer;
768
+ list-style: none;
769
+ padding: 0.2rem 0.5rem;
770
+ border-radius: 0.5rem;
771
+ }
772
+ .chats-menu__trigger::-webkit-details-marker,
773
+ .chats-menu__trigger:hover { background: var(--chats-surface); }
774
+ .chats-menu__panel {
775
+ position: absolute;
776
+ right: 0;
777
+ top: 100%;
778
+ background: var(--chats-bg);
779
+ border: 1px solid var(--chats-border);
780
+ border-radius: 0.75rem;
781
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
782
+ padding: 0.3rem;
783
+ z-index: 20;
784
+ min-width: 9rem;
785
+ }
786
+ .chats-menu__panel--emoji {
787
+ display: flex;
788
+ gap: 0.15rem;
789
+ min-width: 0;
790
+ left: 0;
791
+ right: auto;
792
+ bottom: 100%;
793
+ top: auto;
794
+ }
795
+ .chats-message--own .chats-menu__panel--emoji { left: auto; right: 0; }
796
+ .chats-menu__item {
797
+ display: block;
798
+ width: 100%;
799
+ text-align: left;
800
+ background: none;
801
+ border: none;
802
+ font: inherit;
803
+ font-size: 0.85rem;
804
+ padding: 0.45rem 0.7rem;
805
+ border-radius: 0.5rem;
806
+ cursor: pointer;
807
+ }
808
+ .chats-menu__item:hover { background: var(--chats-surface); }
809
+ .chats-menu__item--danger { color: #dc2626; }
810
+ .chats-emoji {
811
+ background: none;
812
+ border: none;
813
+ font-size: 1.1rem;
814
+ padding: 0.25rem;
815
+ cursor: pointer;
816
+ border-radius: 0.5rem;
817
+ }
818
+ .chats-emoji:hover { background: var(--chats-surface); }