@nyaruka/temba-components 0.163.0 → 0.165.0

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 (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +5266 -3031
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/Chat.ts +3 -2
  19. package/src/display/Lightbox.ts +57 -109
  20. package/src/display/ProgressBar.ts +15 -2
  21. package/src/display/Thumbnail.ts +34 -7
  22. package/src/form/Compose.ts +31 -1
  23. package/src/form/ContactSearch.ts +229 -126
  24. package/src/interfaces.ts +131 -2
  25. package/src/layout/Card.ts +336 -0
  26. package/src/layout/CardLayout.ts +425 -0
  27. package/src/layout/CardStack.ts +131 -0
  28. package/src/layout/Dialog.ts +33 -24
  29. package/src/layout/HeaderBar.ts +41 -0
  30. package/src/layout/PageHeader.ts +5 -6
  31. package/src/layout/Resizer.ts +20 -0
  32. package/src/list/BroadcastList.ts +912 -0
  33. package/src/list/CampaignList.ts +144 -0
  34. package/src/list/ContactList.ts +3 -1
  35. package/src/list/ContentList.ts +46 -10
  36. package/src/list/FieldList.ts +1057 -0
  37. package/src/list/FlowList.ts +31 -7
  38. package/src/list/MsgList.ts +18 -12
  39. package/src/list/SortableList.ts +52 -8
  40. package/src/list/TembaList.ts +8 -0
  41. package/src/list/TembaMenu.ts +5 -38
  42. package/src/list/TriggerList.ts +538 -0
  43. package/src/live/CampaignEvents.ts +1108 -0
  44. package/src/live/ContactChat.ts +7 -1
  45. package/src/live/ContactDetails.ts +20 -14
  46. package/src/live/ContactFieldEditor.ts +7 -3
  47. package/src/live/ContactFields.ts +1 -1
  48. package/src/live/ContactNameFetch.ts +5 -2
  49. package/src/live/ContactNotepad.ts +88 -2
  50. package/src/live/ContactStoreElement.ts +15 -3
  51. package/src/live/ContactTimeline.ts +28 -7
  52. package/src/locales/es.ts +4 -0
  53. package/src/locales/fr.ts +4 -0
  54. package/src/locales/pt.ts +4 -0
  55. package/src/utils.ts +19 -0
  56. package/static/svg/index.svg +1 -1
  57. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  58. package/static/svg/work/traced/star-filled.svg +1 -0
  59. package/static/svg/work/used/star-filled.svg +3 -0
  60. package/stress-test.js +3 -3
  61. package/temba-modules.ts +18 -0
  62. package/web-dev-server.config.mjs +54 -0
  63. package/web-test-runner.config.mjs +1 -1
  64. package/xliff/es.xlf +12 -0
  65. package/xliff/fr.xlf +12 -0
  66. package/xliff/pt.xlf +12 -0
@@ -0,0 +1,912 @@
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { ContentList, ContentListColumn } from './ContentList';
4
+ import { Icon } from '../Icons';
5
+ import { Broadcast, CustomEventType, ObjectReference } from '../interfaces';
6
+ import { attachmentAsString } from '../utils';
7
+
8
+ /** Placeholder shown in any cell whose value is empty. */
9
+ const EMPTY = '--';
10
+
11
+ /** Most recipient pills a row renders before folding the remainder
12
+ * into a "+N" summary whose tooltip names everything. */
13
+ const MAX_PILLS = 2;
14
+
15
+ /** Row status → status-pill kind + label. Broadcasts sit at
16
+ * `pending` until their (schedule's) fire time, move through
17
+ * `queued` / `started` while mailroom creates messages, and land on
18
+ * one of the terminal states. */
19
+ const STATUS_PILLS: { [status: string]: { kind: string; label: string } } = {
20
+ pending: { kind: 'pending', label: 'Pending' },
21
+ queued: { kind: 'pending', label: 'Sending' },
22
+ started: { kind: 'pending', label: 'Sending' },
23
+ completed: { kind: 'active', label: 'Sent' },
24
+ failed: { kind: 'error', label: 'Failed' },
25
+ interrupted: { kind: 'neutral', label: 'Interrupted' }
26
+ };
27
+
28
+ /**
29
+ * Broadcast CRUDL list — drop-in replacement for the rapidpro
30
+ * `msgs/broadcast_list.html` (sent) and `msgs/broadcast_scheduled.html`
31
+ * (upcoming) cards, selected via the `mode` attribute. Every row is
32
+ * one line: the message (text + attachment thumbnails + template /
33
+ * opt-in pills), who it goes to, and either the send column (count or
34
+ * in-flight status) or the schedule columns. In place of a broadcast
35
+ * read page, clicking a row opens a floating detail dialog — the same
36
+ * pattern as the campaign read page's event details — with the full
37
+ * message, recipients, schedule/delivery specifics, and (scheduled
38
+ * mode, permission-gated) Edit / Delete actions that fire
39
+ * `temba-selection` for the host to open its modals.
40
+ */
41
+ export class BroadcastList extends ContentList<Broadcast> {
42
+ static get styles() {
43
+ return css`
44
+ ${ContentList.styles}
45
+ /* Rows are clickable (they open the detail dialog) but carry no
46
+ href, so ContentList doesn't mark them .clickable — carry the
47
+ affordance ourselves. */
48
+ tr.row {
49
+ cursor: pointer;
50
+ }
51
+ /* The message cell holds the body text on the leading edge with
52
+ its attachment thumbnails right after it and the template /
53
+ opt-in pills pushed to the trailing edge — the msg-list
54
+ treatment. */
55
+ .msg-cell {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 12px;
59
+ }
60
+ .msg-text {
61
+ flex: 0 1 auto;
62
+ min-width: 80px;
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ white-space: nowrap;
66
+ }
67
+ .msg-attachments {
68
+ flex: 0 0 auto;
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 6px;
72
+ }
73
+ .cell-pills {
74
+ flex: 0 0 auto;
75
+ margin-left: auto;
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 6px;
79
+ }
80
+ /* Attachment thumbnails sized well under the row height — the
81
+ same small square preview the message list uses. */
82
+ .msg-thumb {
83
+ --thumb-size: 24px;
84
+ --thumb-padding: 2px;
85
+ --thumb-icon-padding: 4px;
86
+ max-height: 36px;
87
+ overflow: hidden;
88
+ }
89
+ /* Recipient pills — a capped run with a "+N" summary. Pills
90
+ never shrink (the cap bounds the row); the summary carries
91
+ the hidden names in its tooltip. */
92
+ .pills {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 4px;
96
+ min-width: 0;
97
+ max-width: 100%;
98
+ overflow: hidden;
99
+ }
100
+ .pills temba-label {
101
+ flex: 0 0 auto;
102
+ }
103
+ .num {
104
+ font-variant-numeric: tabular-nums;
105
+ color: inherit;
106
+ }
107
+ /* In-flight send progress — a compact bare meter, right-aligned
108
+ in the Messages cell. */
109
+ .send-progress {
110
+ width: 110px;
111
+ margin-left: auto;
112
+ --progress-min-height: 14px;
113
+ --progress-bar-min-height: 6px;
114
+ --progress-padding: 3px;
115
+ }
116
+ /* The detail's counterpart fills the status line's slack up to
117
+ the message count on its right. */
118
+ .detail-progress {
119
+ flex: 1;
120
+ --progress-min-height: 16px;
121
+ --progress-bar-min-height: 6px;
122
+ --progress-padding: 4px;
123
+ }
124
+ .muted {
125
+ color: var(--text-3);
126
+ }
127
+
128
+ /* ---- detail dialog — the campaign-events detail treatment:
129
+ page-like (no colored header bar), header and footer pinned
130
+ with full-bleed rules, the body scrolling between them. */
131
+ .detail {
132
+ display: flex;
133
+ flex-direction: column;
134
+ }
135
+ .detail-header {
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 12px;
139
+ padding: 1em;
140
+ border-bottom: 1px solid var(--border, #e4e7ec);
141
+ }
142
+ .detail-title {
143
+ flex: 1;
144
+ min-width: 0;
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 4px;
148
+ }
149
+ .detail-name {
150
+ font-size: 15.5px;
151
+ font-weight: var(--w-semibold, 600);
152
+ color: var(--text-1);
153
+ white-space: nowrap;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ }
157
+ .detail-when {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 6px;
161
+ font-size: 0.9em;
162
+ color: var(--text-3, #7b8593);
163
+ }
164
+ .detail-actions {
165
+ flex-shrink: 0;
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ }
170
+ /* the status pill sits in the header just left of the close */
171
+ .detail-header .status-pill {
172
+ flex-shrink: 0;
173
+ }
174
+ /* match the page header's content-menu buttons so the dialog's
175
+ actions read like page actions */
176
+ .menu-button {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: 6px;
180
+ box-sizing: border-box;
181
+ height: 26px;
182
+ padding: 0 10px;
183
+ border: 1px solid var(--border-strong, #9ca3af);
184
+ border-radius: var(--r-sm);
185
+ font-size: 12.5px;
186
+ font-weight: var(--w-regular, 400);
187
+ cursor: pointer;
188
+ user-select: none;
189
+ background: var(--surface);
190
+ color: var(--text-1);
191
+ white-space: nowrap;
192
+ }
193
+ .menu-button:hover {
194
+ background: var(--sunken);
195
+ }
196
+ .menu-button.destructive {
197
+ color: #dc2626;
198
+ }
199
+ .menu-button.destructive:hover {
200
+ border-color: #dc2626;
201
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
202
+ }
203
+ .detail-body {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 1.25em;
207
+ overflow-y: auto;
208
+ max-height: calc(100vh - 300px);
209
+ padding: 1.25em 1.75em;
210
+ }
211
+ /* the status line leads the body — schedule / delivery facts on
212
+ the left, the labeled message count on the right */
213
+ .detail-status-row {
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: space-between;
217
+ gap: 12px;
218
+ }
219
+ .detail-status {
220
+ /* take the row's slack so an in-flight progress bar can
221
+ stretch to the message count on the right */
222
+ flex: 1;
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 8px;
226
+ font-size: 0.9em;
227
+ color: var(--text-3, #7b8593);
228
+ }
229
+ .detail-count {
230
+ flex-shrink: 0;
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 0.35em;
234
+ font-size: 0.85em;
235
+ color: var(--text-3, #7b8593);
236
+ --icon-color: var(--text-3, #7b8593);
237
+ }
238
+ /* the message itself, contained with a leading section label.
239
+ Children hug their content so pills don't stretch the box. */
240
+ .detail-message {
241
+ display: flex;
242
+ flex-direction: column;
243
+ align-items: flex-start;
244
+ gap: 0.6em;
245
+ padding: 0.9em 1em 1em;
246
+ background: color-mix(
247
+ in srgb,
248
+ var(--sunken, #f1f3f5) 45%,
249
+ var(--surface, #fff)
250
+ );
251
+ border: 1px solid var(--border, #e4e7ec);
252
+ border-radius: var(--r);
253
+ line-height: 1.5;
254
+ }
255
+ .detail-message temba-expression-highlight {
256
+ align-self: stretch;
257
+ white-space: pre-wrap;
258
+ overflow-wrap: anywhere;
259
+ }
260
+ .detail-attachments {
261
+ display: flex;
262
+ flex-wrap: wrap;
263
+ gap: 8px;
264
+ }
265
+ .detail-attachments temba-thumbnail {
266
+ --thumb-size: 48px;
267
+ }
268
+ .detail-section-title {
269
+ font-size: 0.75em;
270
+ font-weight: 600;
271
+ text-transform: uppercase;
272
+ letter-spacing: 0.05em;
273
+ color: var(--text-3, #7b8593);
274
+ }
275
+ /* a section heading with a trailing fact on its right — the
276
+ recipients title with the message count across from it */
277
+ .detail-section-row {
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: space-between;
281
+ gap: 12px;
282
+ }
283
+ .detail-pills {
284
+ display: flex;
285
+ flex-wrap: wrap;
286
+ align-items: center;
287
+ gap: 6px;
288
+ }
289
+ /* exclusions read as muted footnotes under the recipient pills */
290
+ .detail-exclusions {
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 2px;
294
+ font-size: 0.85em;
295
+ color: var(--text-3, #7b8593);
296
+ }
297
+ /* the close (✕) in the header's upper right - the square
298
+ hover-wash button treatment (the pager buttons'), sitting
299
+ just outboard of the action buttons */
300
+ .detail-close {
301
+ flex-shrink: 0;
302
+ margin-left: -4px;
303
+ display: inline-flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ width: 28px;
307
+ height: 28px;
308
+ border-radius: var(--r-sm);
309
+ color: var(--text-3, #7b8593);
310
+ cursor: pointer;
311
+ user-select: none;
312
+ }
313
+ .detail-close:hover {
314
+ background: var(--sunken);
315
+ color: var(--text-1);
316
+ }
317
+ .detail-close temba-icon {
318
+ --icon-color: currentColor;
319
+ }
320
+ `;
321
+ }
322
+
323
+ /** Which broadcast list this is — `sent` (the results list) or
324
+ * `scheduled` (broadcasts waiting to fire). Drives the trailing
325
+ * columns and the detail dialog's specifics. */
326
+ @property({ type: String })
327
+ mode: 'sent' | 'scheduled' = 'sent';
328
+
329
+ /** Whether the viewer may edit scheduled broadcasts — surfaces the
330
+ * detail dialog's Edit action. Set by the host from its perms. */
331
+ @property({ type: Boolean, attribute: 'can-edit' })
332
+ canEdit = false;
333
+
334
+ /** Whether the viewer may delete scheduled broadcasts — surfaces
335
+ * the detail dialog's Delete action. */
336
+ @property({ type: Boolean, attribute: 'can-delete' })
337
+ canDelete = false;
338
+
339
+ /** The broadcast whose detail dialog is open. */
340
+ @state()
341
+ private detailBroadcast: Broadcast = null;
342
+
343
+ constructor() {
344
+ super();
345
+ this.valueKey = 'id';
346
+ this.emptyMessage = 'No broadcasts';
347
+ this.searchPlaceholder = 'Search broadcasts';
348
+ this.syncColumns();
349
+ }
350
+
351
+ protected willUpdate(changes: PropertyValues): void {
352
+ super.willUpdate(changes);
353
+ if (changes.has('mode')) {
354
+ this.syncColumns();
355
+ }
356
+ }
357
+
358
+ /** The leading columns are shared; the trailing ones swap between
359
+ * the sent list's count/date and the scheduled list's repeat/next
360
+ * fire. */
361
+ private syncColumns(): void {
362
+ const leading: ContentListColumn[] = [
363
+ { key: 'message', label: 'Message', grow: true, minWidth: '220px' },
364
+ {
365
+ key: 'recipients',
366
+ label: 'Recipients',
367
+ minWidth: '130px',
368
+ maxWidth: '320px'
369
+ }
370
+ ];
371
+ if (this.mode === 'scheduled') {
372
+ this.columns = [
373
+ ...leading,
374
+ {
375
+ key: 'schedule',
376
+ label: 'Schedule',
377
+ minWidth: '120px',
378
+ maxWidth: '280px'
379
+ },
380
+ {
381
+ key: 'next_fire',
382
+ label: 'Next Send',
383
+ sortable: true,
384
+ minWidth: '96px',
385
+ maxWidth: '170px',
386
+ align: 'right'
387
+ }
388
+ ];
389
+ } else {
390
+ this.columns = [
391
+ ...leading,
392
+ {
393
+ key: 'messages',
394
+ label: 'Messages',
395
+ minWidth: '80px',
396
+ align: 'right'
397
+ },
398
+ {
399
+ key: 'created_on',
400
+ label: 'Sent',
401
+ sortable: true,
402
+ minWidth: '96px',
403
+ maxWidth: '150px',
404
+ align: 'right'
405
+ }
406
+ ];
407
+ }
408
+ }
409
+
410
+ protected getRowIcon(_item: Broadcast): string | null {
411
+ return this.mode === 'scheduled' ? Icon.schedule : Icon.broadcast;
412
+ }
413
+
414
+ public connectedCallback(): void {
415
+ super.connectedCallback();
416
+ // In place of a broadcast read page, a row click opens the detail
417
+ // dialog. ContentList fires RowClick on the host element, so we
418
+ // can listen to ourselves rather than override its private click
419
+ // handler; the event still bubbles on to outside listeners.
420
+ this.addEventListener(
421
+ CustomEventType.RowClick,
422
+ this.handleRowClickEvent as EventListener
423
+ );
424
+ }
425
+
426
+ public disconnectedCallback(): void {
427
+ super.disconnectedCallback();
428
+ this.removeEventListener(
429
+ CustomEventType.RowClick,
430
+ this.handleRowClickEvent as EventListener
431
+ );
432
+ }
433
+
434
+ private handleRowClickEvent = (event: CustomEvent): void => {
435
+ const item = event.detail?.item as Broadcast;
436
+ if (item) {
437
+ this.detailBroadcast = item;
438
+ }
439
+ };
440
+
441
+ // closes arrive from the header ✕, our own @keyup ESC handler, and
442
+ // mask clicks (which fire temba-dialog-hidden) - our open state must
443
+ // reset on every path or the next open is a no-op change and the
444
+ // dialog never reopens
445
+ private handleDetailClosed = (): void => {
446
+ this.detailBroadcast = null;
447
+ };
448
+
449
+ // edit / delete close the detail dialog before the host opens the
450
+ // edit or delete-confirm modal in its place - modals never stack
451
+ public handleEditClicked(): void {
452
+ const broadcast = this.detailBroadcast;
453
+ this.detailBroadcast = null;
454
+ this.fireCustomEvent(CustomEventType.Selection, {
455
+ action: 'edit_broadcast',
456
+ broadcast
457
+ });
458
+ }
459
+
460
+ public handleDeleteClicked(): void {
461
+ const broadcast = this.detailBroadcast;
462
+ this.detailBroadcast = null;
463
+ this.fireCustomEvent(CustomEventType.Selection, {
464
+ action: 'delete_broadcast',
465
+ broadcast
466
+ });
467
+ }
468
+
469
+ /** Navigate a recipient pill through the SPA, suppressing the
470
+ * row's own click (which opens the detail dialog) — and closing the
471
+ * dialog when clicked from inside it, since we're leaving the page.
472
+ * Meta/ctrl-click opens a new tab and leaves the dialog alone. */
473
+ private handlePillClick(href: string, event: MouseEvent): void {
474
+ event.stopPropagation();
475
+ // Guard the JSON-driven href against open-redirect, same as the
476
+ // row-click path in ContentList.handleRowClick.
477
+ if (!href || !this.isSafeHref(href)) return;
478
+ if (event.metaKey || event.ctrlKey) {
479
+ window.open(href, '_blank');
480
+ return;
481
+ }
482
+ this.detailBroadcast = null;
483
+ this.fireCustomEvent(CustomEventType.Redirected, { url: href });
484
+ }
485
+
486
+ /** Every way a broadcast addresses contacts, flattened to pills:
487
+ * groups, contacts, a contact query, and raw URNs. Groups open the
488
+ * group's contact list, contacts their read page, and the query the
489
+ * contact list searched by it; raw URNs have no page of their own. */
490
+ private getRecipientPills(item: Broadcast): TemplateResult[] {
491
+ const pills: TemplateResult[] = [];
492
+ for (const g of item.groups || []) {
493
+ pills.push(
494
+ html`<temba-label
495
+ type="group"
496
+ icon=${Icon.group_include}
497
+ clickable
498
+ @click=${(e: MouseEvent) =>
499
+ this.handlePillClick(`/contact/group/${g.uuid}/`, e)}
500
+ >${g.name}</temba-label
501
+ >`
502
+ );
503
+ }
504
+ for (const c of item.contacts || []) {
505
+ pills.push(
506
+ html`<temba-label
507
+ type="contact"
508
+ clickable
509
+ @click=${(e: MouseEvent) =>
510
+ this.handlePillClick(`/contact/read/${c.uuid}/`, e)}
511
+ >${c.name}</temba-label
512
+ >`
513
+ );
514
+ }
515
+ if (item.query) {
516
+ pills.push(
517
+ html`<temba-label
518
+ type="neutral"
519
+ icon=${Icon.search}
520
+ clickable
521
+ @click=${(e: MouseEvent) =>
522
+ this.handlePillClick(
523
+ `/contact/?search=${encodeURIComponent(item.query)}`,
524
+ e
525
+ )}
526
+ >${item.query}</temba-label
527
+ >`
528
+ );
529
+ }
530
+ for (const urn of item.urns || []) {
531
+ pills.push(html`<temba-label type="contact">${urn}</temba-label>`);
532
+ }
533
+ return pills;
534
+ }
535
+
536
+ /** Plain-text names for the pills past the visible cap — the "+N"
537
+ * summary's tooltip. */
538
+ private getRecipientNames(item: Broadcast): string[] {
539
+ return [
540
+ ...(item.groups || []).map((g: ObjectReference) => g.name),
541
+ ...(item.contacts || []).map((c: ObjectReference) => c.name),
542
+ ...(item.query ? [item.query] : []),
543
+ ...(item.urns || [])
544
+ ];
545
+ }
546
+
547
+ private renderRecipients(item: Broadcast): TemplateResult | string {
548
+ const pills = this.getRecipientPills(item);
549
+ if (!pills.length) {
550
+ return EMPTY;
551
+ }
552
+ const visible = pills.slice(0, MAX_PILLS);
553
+ const hidden = this.getRecipientNames(item).slice(MAX_PILLS);
554
+ return html`<span class="pills">
555
+ ${visible}
556
+ ${hidden.length
557
+ ? html`<temba-label type="neutral" title=${hidden.join(', ')}
558
+ >+${hidden.length}</temba-label
559
+ >`
560
+ : null}
561
+ </span>`;
562
+ }
563
+
564
+ /** The message cell — body text with attachment thumbnails right
565
+ * after it and the template / opt-in pills pushed to the trailing
566
+ * edge. A broadcast with no text (a template or opt-in request
567
+ * send) leads with that pill instead. */
568
+ private renderMessage(item: Broadcast): TemplateResult {
569
+ const text = item.text || '';
570
+ return html`
571
+ <div class="msg-cell">
572
+ ${text ? html`<span class="msg-text">${text}</span>` : ''}
573
+ ${this.renderAttachmentThumbs(item)}${this.renderContentPills(item)}
574
+ </div>
575
+ `;
576
+ }
577
+
578
+ private renderAttachmentThumbs(item: Broadcast): TemplateResult | string {
579
+ const attachments = item.attachments || [];
580
+ if (!attachments.length) return '';
581
+ return html`
582
+ <div class="msg-attachments">
583
+ ${attachments.map(
584
+ (a) => html`
585
+ <temba-thumbnail
586
+ class="msg-thumb"
587
+ attachment=${attachmentAsString(a)}
588
+ ></temba-thumbnail>
589
+ `
590
+ )}
591
+ </div>
592
+ `;
593
+ }
594
+
595
+ /** Template / opt-in pills — what the broadcast sends beyond (or
596
+ * instead of) plain text. */
597
+ private renderContentPills(item: Broadcast): TemplateResult | string {
598
+ if (!item.template && !item.optin) return '';
599
+ return html`
600
+ <div class="cell-pills">
601
+ ${item.template
602
+ ? html`<temba-label type="label" icon=${Icon.channel_wa}
603
+ >${item.template.name}</temba-label
604
+ >`
605
+ : null}
606
+ ${item.optin
607
+ ? html`<temba-label type="label" icon=${Icon.optin}
608
+ >${item.optin.name}</temba-label
609
+ >`
610
+ : null}
611
+ </div>
612
+ `;
613
+ }
614
+
615
+ /** Whether the broadcast is mid-send with a resolved recipient
616
+ * total — the state the progress bar can meaningfully render. Until
617
+ * mailroom resolves the total (-1) the Sending pill stands in. */
618
+ private isSending(item: Broadcast): boolean {
619
+ const status = item.status || '';
620
+ return (
621
+ (status === 'queued' || status === 'started') &&
622
+ (item.progress?.total ?? -1) > 0
623
+ );
624
+ }
625
+
626
+ /** The sent list's Messages cell — the created-message count once
627
+ * the broadcast has gone out, a live progress bar while its
628
+ * messages are being created, or a status pill for the other
629
+ * states. */
630
+ private renderMessages(item: Broadcast): TemplateResult | string {
631
+ const status = item.status || '';
632
+ if (status === 'completed') {
633
+ return html`<span class="num"
634
+ >${(item.msg_count ?? 0).toLocaleString()}</span
635
+ >`;
636
+ }
637
+ if (this.isSending(item)) {
638
+ return html`<temba-progress
639
+ class="send-progress"
640
+ hidePercentage
641
+ total=${item.progress.total}
642
+ current=${item.progress.started || 0}
643
+ ></temba-progress>`;
644
+ }
645
+ const pill = STATUS_PILLS[status];
646
+ return pill ? this.renderStatusPill(pill.kind, pill.label) : EMPTY;
647
+ }
648
+
649
+ /** The scheduled list's Schedule cell — the server-rendered human
650
+ * repeat ("each week on Monday"), or "Not scheduled" once the
651
+ * schedule is exhausted or paused. */
652
+ private renderSchedule(item: Broadcast): TemplateResult | string {
653
+ if (!item.schedule?.next_fire) {
654
+ return html`<span class="muted">Not scheduled</span>`;
655
+ }
656
+ return item.schedule.display || EMPTY;
657
+ }
658
+
659
+ protected renderCell(
660
+ item: Broadcast,
661
+ column: ContentListColumn
662
+ ): TemplateResult | string {
663
+ switch (column.key) {
664
+ case 'message':
665
+ return this.renderMessage(item);
666
+ case 'recipients':
667
+ return this.renderRecipients(item);
668
+ case 'messages':
669
+ return this.renderMessages(item);
670
+ case 'schedule':
671
+ return this.renderSchedule(item);
672
+ case 'next_fire':
673
+ return item.schedule?.next_fire
674
+ ? html`<temba-date
675
+ value=${item.schedule.next_fire}
676
+ display="timedate"
677
+ ></temba-date>`
678
+ : EMPTY;
679
+ case 'created_on':
680
+ return item.created_on
681
+ ? html`<temba-date
682
+ value=${item.created_on}
683
+ display="timedate"
684
+ ></temba-date>`
685
+ : EMPTY;
686
+ default:
687
+ return super.renderCell(item, column);
688
+ }
689
+ }
690
+
691
+ /** The detail dialog's lead line — when the broadcast fires (and
692
+ * how it repeats) for a scheduled broadcast, or its status for a
693
+ * sent one. */
694
+ private renderDetailStatus(broadcast: Broadcast): TemplateResult {
695
+ if (this.mode === 'scheduled') {
696
+ return html`
697
+ <div class="detail-status">
698
+ ${broadcast.schedule?.next_fire
699
+ ? html`
700
+ ${broadcast.schedule.display
701
+ ? html`<span>${broadcast.schedule.display}</span>`
702
+ : null}
703
+ <span>starting</span>
704
+ <temba-date
705
+ value=${broadcast.schedule.next_fire}
706
+ display="datetime"
707
+ ></temba-date>
708
+ `
709
+ : html`<span>Not scheduled</span>`}
710
+ </div>
711
+ `;
712
+ }
713
+ // the status pill lives in the dialog header (see renderDetail);
714
+ // here only an in-flight send renders, as a bar filling the line
715
+ return html`
716
+ <div class="detail-status">
717
+ ${this.isSending(broadcast)
718
+ ? html`<temba-progress
719
+ class="detail-progress"
720
+ hidePercentage
721
+ total=${broadcast.progress.total}
722
+ current=${broadcast.progress.started || 0}
723
+ ></temba-progress>`
724
+ : null}
725
+ </div>
726
+ `;
727
+ }
728
+
729
+ /** The status pill in the dialog header, just left of the close —
730
+ * sent mode only, and only when the send isn't mid-flight (the
731
+ * body's progress bar carries that state instead). */
732
+ private renderHeaderStatus(broadcast: Broadcast): TemplateResult | null {
733
+ if (this.mode !== 'sent' || this.isSending(broadcast)) {
734
+ return null;
735
+ }
736
+ const pill = STATUS_PILLS[broadcast.status || ''];
737
+ return pill ? this.renderStatusPill(pill.kind, pill.label) : null;
738
+ }
739
+
740
+ /** The labeled message count, across from the Recipients section
741
+ * title — only meaningful once a sent broadcast has created
742
+ * messages. */
743
+ private renderDetailCount(broadcast: Broadcast): TemplateResult | string {
744
+ if (this.mode !== 'sent' || broadcast.msg_count == null) {
745
+ return '';
746
+ }
747
+ return html`
748
+ <div class="detail-count">
749
+ <temba-icon name=${Icon.message} size="0.9"></temba-icon>
750
+ ${broadcast.msg_count.toLocaleString()} messages
751
+ </div>
752
+ `;
753
+ }
754
+
755
+ /** Dialog detail view for a broadcast, standing in for a read page
756
+ * — the full message (text, attachments, quick replies, template /
757
+ * opt-in), everyone it addresses including exclusions, and the
758
+ * schedule or delivery specifics in the header. */
759
+ private renderDetail(): TemplateResult {
760
+ const broadcast = this.detailBroadcast;
761
+ const pills = broadcast ? this.getRecipientPills(broadcast) : [];
762
+ const showActions =
763
+ this.mode === 'scheduled' && (this.canEdit || this.canDelete);
764
+
765
+ return html`
766
+ <temba-dialog
767
+ size="xlarge"
768
+ variant="flat"
769
+ primaryButtonName=""
770
+ cancelButtonName=""
771
+ hideOnClick
772
+ ?open=${!!broadcast}
773
+ @temba-dialog-hidden=${this.handleDetailClosed}
774
+ @keyup=${(e: KeyboardEvent) => {
775
+ if (e.key === 'Escape') {
776
+ this.handleDetailClosed();
777
+ }
778
+ }}
779
+ >
780
+ ${broadcast
781
+ ? html`
782
+ <div class="detail">
783
+ <div class="detail-header">
784
+ <div class="detail-title">
785
+ <div class="detail-name">
786
+ ${this.mode === 'scheduled'
787
+ ? 'Scheduled Broadcast'
788
+ : 'Broadcast'}
789
+ </div>
790
+ ${broadcast.created_on || broadcast.created_by
791
+ ? html`<div class="detail-when">
792
+ ${broadcast.created_on
793
+ ? html`${this.mode === 'scheduled'
794
+ ? 'Created'
795
+ : 'Sent'}
796
+ <temba-date
797
+ value=${broadcast.created_on}
798
+ display="datetime"
799
+ ></temba-date>`
800
+ : null}
801
+ ${broadcast.created_by
802
+ ? html`<span>by ${broadcast.created_by}</span>`
803
+ : null}
804
+ </div>`
805
+ : null}
806
+ </div>
807
+ ${showActions
808
+ ? html`<div class="detail-actions">
809
+ ${this.canEdit
810
+ ? html`<button
811
+ class="menu-button"
812
+ @click=${this.handleEditClicked}
813
+ >
814
+ Edit
815
+ </button>`
816
+ : null}
817
+ ${this.canDelete
818
+ ? html`<button
819
+ class="menu-button destructive"
820
+ @click=${this.handleDeleteClicked}
821
+ >
822
+ Delete
823
+ </button>`
824
+ : null}
825
+ </div>`
826
+ : null}
827
+ ${this.renderHeaderStatus(broadcast)}
828
+ <div
829
+ class="detail-close"
830
+ role="button"
831
+ tabindex="0"
832
+ aria-label="Close"
833
+ @click=${this.handleDetailClosed}
834
+ @keydown=${(e: KeyboardEvent) => {
835
+ if (e.key === 'Enter' || e.key === ' ') {
836
+ e.preventDefault();
837
+ this.handleDetailClosed();
838
+ }
839
+ }}
840
+ >
841
+ <temba-icon name=${Icon.close} size="1.4"></temba-icon>
842
+ </div>
843
+ </div>
844
+
845
+ <div class="detail-body">
846
+ ${this.mode === 'scheduled' || this.isSending(broadcast)
847
+ ? html`<div class="detail-status-row">
848
+ ${this.renderDetailStatus(broadcast)}
849
+ </div>`
850
+ : null}
851
+
852
+ <div class="detail-message">
853
+ <div class="detail-section-title">Message</div>
854
+ ${broadcast.text
855
+ ? html`<temba-expression-highlight
856
+ >${broadcast.text}</temba-expression-highlight
857
+ >`
858
+ : null}
859
+ ${broadcast.attachments?.length
860
+ ? html`<div class="detail-attachments">
861
+ ${broadcast.attachments.map(
862
+ (a) => html`
863
+ <temba-thumbnail
864
+ attachment=${attachmentAsString(a)}
865
+ ></temba-thumbnail>
866
+ `
867
+ )}
868
+ </div>`
869
+ : null}
870
+ ${broadcast.quick_replies?.length
871
+ ? html`<div class="detail-pills">
872
+ ${broadcast.quick_replies.map(
873
+ (reply) =>
874
+ html`<temba-label type="neutral"
875
+ >${reply}</temba-label
876
+ >`
877
+ )}
878
+ </div>`
879
+ : null}
880
+ ${this.renderContentPills(broadcast)}
881
+ </div>
882
+
883
+ ${pills.length ||
884
+ broadcast.exclusions?.length ||
885
+ broadcast.msg_count != null
886
+ ? html`
887
+ <div class="detail-section-row">
888
+ <div class="detail-section-title">Recipients</div>
889
+ ${this.renderDetailCount(broadcast)}
890
+ </div>
891
+ <div class="detail-pills">${pills}</div>
892
+ ${broadcast.exclusions?.length
893
+ ? html`<div class="detail-exclusions">
894
+ ${broadcast.exclusions.map(
895
+ (line) => html`<div>${line}</div>`
896
+ )}
897
+ </div>`
898
+ : null}
899
+ `
900
+ : null}
901
+ </div>
902
+ </div>
903
+ `
904
+ : null}
905
+ </temba-dialog>
906
+ `;
907
+ }
908
+
909
+ public render(): TemplateResult {
910
+ return html`${super.render()}${this.renderDetail()}`;
911
+ }
912
+ }