@nyaruka/temba-components 0.162.0 → 0.164.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.162.0",
3
+ "version": "0.164.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -5,6 +5,7 @@ import { RapidElement } from '../RapidElement';
5
5
  import { CustomEventType } from '../interfaces';
6
6
  import { TicketEvent } from '../events';
7
7
  import { DEFAULT_AVATAR } from '../webchat/assets';
8
+ import { attachmentAsString } from '../utils';
8
9
 
9
10
  const BATCH_TIME_WINDOW = 60 * 60 * 1000;
10
11
  const SCROLL_FETCH_BUFFER = 200; // pixels from top
@@ -113,6 +114,13 @@ export interface MsgEvent extends ContactEvent {
113
114
  _logs_url?: string;
114
115
  }
115
116
 
117
+ // whether a message has nothing to show in its bubble (and isn't deleted)
118
+ const isEmptyMsg = (event: MsgEvent): boolean =>
119
+ !event._deleted &&
120
+ !!event.msg &&
121
+ !event.msg.text &&
122
+ (event.msg.attachments || []).length === 0;
123
+
116
124
  const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' } as any;
117
125
  const VERBOSE_FORMAT = {
118
126
  weekday: undefined,
@@ -351,13 +359,15 @@ export class Chat extends RapidElement {
351
359
  color: rgba(192, 24, 41, 0.6);
352
360
  }
353
361
 
354
- .deleted .bubble {
362
+ .deleted .bubble,
363
+ .empty .bubble {
355
364
  background: #fff;
356
365
  color: #999;
357
366
  border: 1px solid #e0e0e0;
358
367
  }
359
368
 
360
- .deleted .bubble .name {
369
+ .deleted .bubble .name,
370
+ .empty .bubble .name {
361
371
  color: #aaa;
362
372
  }
363
373
 
@@ -369,7 +379,8 @@ export class Chat extends RapidElement {
369
379
  font-size: 13px;
370
380
  }
371
381
 
372
- .message-deleted {
382
+ .message-deleted,
383
+ .message-empty {
373
384
  font-style: italic;
374
385
  margin-bottom: 0;
375
386
  line-height: 1.2;
@@ -1325,13 +1336,14 @@ export class Chat extends RapidElement {
1325
1336
  (statusClass === 'failed' || statusClass === 'errored'));
1326
1337
  const unsendableClass = hasError ? 'error' : '';
1327
1338
  const deletedClass = msgEvent._deleted ? 'deleted' : '';
1339
+ const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
1328
1340
  const latestClass = index === msgIds.length - 1 ? 'latest' : '';
1329
1341
  const eventClass = msg._rendered ? 'is-event' : '';
1330
1342
  const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
1331
1343
  const matchClass =
1332
1344
  this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
1333
1345
  return html`<div
1334
- class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
1346
+ class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
1335
1347
  data-uuid=${msg.uuid || nothing}
1336
1348
  >
1337
1349
  ${this.renderMessage(
@@ -1430,9 +1442,12 @@ export class Chat extends RapidElement {
1430
1442
  : message._deleted.user?.name || 'user'
1431
1443
  : null;
1432
1444
 
1445
+ // messages with no text and no attachments get a muted placeholder
1446
+ const isEmpty = isEmptyMsg(message);
1447
+
1433
1448
  // check if message has location attachment and text is just coordinates
1434
1449
  const hasLocationAttachment = message.msg.attachments?.some((att) =>
1435
- att.startsWith('geo:')
1450
+ attachmentAsString(att).startsWith('geo:')
1436
1451
  );
1437
1452
  const textIsCoordinates =
1438
1453
  hasLocationAttachment &&
@@ -1480,18 +1495,23 @@ export class Chat extends RapidElement {
1480
1495
  Message deleted by ${deletedByText}
1481
1496
  </div>
1482
1497
  </div>`
1483
- : message.msg.text && !textIsCoordinates
1498
+ : isEmpty
1484
1499
  ? html`<div class="bubble">
1485
1500
  ${name ? html`<div class="name">${name}</div>` : null}
1486
- <div class="message-text">${messageText}</div>
1501
+ <div class="message-empty">Empty Message</div>
1487
1502
  </div>`
1488
- : null}
1503
+ : message.msg.text && !textIsCoordinates
1504
+ ? html`<div class="bubble">
1505
+ ${name ? html`<div class="name">${name}</div>` : null}
1506
+ <div class="message-text">${messageText}</div>
1507
+ </div>`
1508
+ : null}
1489
1509
 
1490
1510
  <div class="attachments">
1491
1511
  ${(message.msg.attachments || []).map(
1492
1512
  (attachment) =>
1493
1513
  html`<temba-thumbnail
1494
- attachment="${attachment}"
1514
+ attachment="${attachmentAsString(attachment)}"
1495
1515
  ></temba-thumbnail>`
1496
1516
  )}
1497
1517
  </div>
@@ -1,59 +1,65 @@
1
1
  import { css, html, PropertyValueMap } from 'lit';
2
- import { property } from 'lit/decorators.js';
2
+ import { property, state } from 'lit/decorators.js';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import { getClasses } from '../utils';
5
- import { styleMap } from 'lit-html/directives/style-map.js';
6
5
 
7
6
  /**
8
- * This component relies on a bit of sleight of hand magic
9
- * to achieve it's effect. As such, it requires the use of
10
- * computed animation times and window.setTimeout().
7
+ * A full-screen viewer for an image attachment. `showElement` is handed the
8
+ * element that was clicked (a raw <img> or a temba-thumbnail exposing a `url`),
9
+ * and we present that image centered and contained within the viewport with a
10
+ * short fade / scale-in. Clicking anywhere dismisses it.
11
11
  */
12
12
  export class Lightbox extends RapidElement {
13
13
  static get styles() {
14
14
  return css`
15
15
  :host {
16
- z-index: 10000;
17
- position: absolute;
16
+ position: fixed;
18
17
  top: 0;
19
18
  left: 0;
19
+ z-index: 10000;
20
20
  }
21
21
 
22
- .mask {
22
+ /* a full-viewport layer that centers the image, catches the dismiss
23
+ click, and lays a light translucent mask over the page behind */
24
+ .backdrop {
25
+ position: fixed;
26
+ inset: 0;
23
27
  display: flex;
24
- opacity: 0;
28
+ align-items: center;
29
+ justify-content: center;
25
30
  background: rgba(0, 0, 0, 0.5);
26
- position: absolute;
27
- height: 100svh;
28
- width: 100svw;
31
+ opacity: 0;
29
32
  pointer-events: none;
33
+ transition: opacity var(--anim) ease;
30
34
  }
31
35
 
32
- .zoom .mask {
36
+ .backdrop.zoom {
33
37
  opacity: 1;
34
38
  pointer-events: auto;
35
39
  }
36
40
 
37
- .matte {
38
- position: absolute;
39
- transform: translate(400, 400) scale(3, 3);
40
- border-radius: 2%;
41
- overflow: hidden;
42
- box-shadow: 0 0 12px 3px rgba(0, 0, 0, 0.15);
41
+ img {
42
+ /* contain the image within the viewport at any aspect ratio, on
43
+ its own solid backing (so transparent images aren't see-through) */
44
+ box-sizing: border-box;
45
+ max-width: 90vw;
46
+ max-height: 90vh;
47
+ object-fit: contain;
48
+ background: #fff;
49
+ padding: 6px;
50
+ border-radius: calc(var(--curvature) * 1.5);
51
+ box-shadow: 0 0 24px 6px rgba(0, 0, 0, 0.4);
52
+ transform: scale(0.85);
53
+ opacity: 0;
54
+ transition:
55
+ transform var(--anim) ease,
56
+ opacity var(--anim) ease;
43
57
  }
44
58
 
45
- .download {
46
- background: rgba(0, 0, 0, 0.5);
47
- position: absolute;
48
- display:flex;
49
- align-items:center;
50
- color:#fff;
51
- padding:0.5em;
52
- border-radius:var(--curvature);
53
- background:rgba(0,0,0,0.5);
59
+ .zoom img {
60
+ transform: scale(1);
61
+ opacity: 1;
54
62
  }
55
-
56
- }
57
63
  `;
58
64
  }
59
65
 
@@ -66,77 +72,41 @@ export class Lightbox extends RapidElement {
66
72
  @property({ type: Boolean })
67
73
  zoom = false;
68
74
 
69
- @property({ type: Number })
70
- zoomPct = 0.9;
71
-
72
- private ele: HTMLElement;
73
- private left: number;
74
- private top: number;
75
- private height: number;
76
- private width: number;
77
- private scale = 1;
78
- private xTrans = 0;
79
- private yTrans = 0;
75
+ @state()
76
+ private url = '';
80
77
 
81
78
  protected updated(
82
79
  changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
83
80
  ): void {
81
+ // mount first (opacity 0 / scaled down), then flip to zoom on the next
82
+ // tick so the transition to the visible state actually animates
84
83
  if (changed.has('show') && this.show) {
85
84
  window.setTimeout(() => {
86
85
  this.zoom = true;
87
86
  }, 0);
88
87
  }
89
88
 
89
+ // once zoomed out, wait for the fade to finish before unmounting the image
90
90
  if (changed.has('zoom') && !this.zoom && this.show) {
91
91
  window.setTimeout(() => {
92
- this.show = false;
92
+ // unless a re-open during the fade-out already zoomed us back in
93
+ if (!this.zoom) {
94
+ this.show = false;
95
+ }
93
96
  }, this.animationTime);
94
97
  }
95
98
  }
96
99
 
97
100
  public showElement(ele: HTMLElement) {
98
- // size our matte according to the ele's boundaries
99
- const bounds = ele.getBoundingClientRect();
100
- this.ele = ele.cloneNode() as HTMLElement;
101
- (this.ele as any).zoom = true;
102
-
103
- this.left = bounds.left;
104
- this.top = bounds.top;
105
- this.width = bounds.width;
106
- this.height = bounds.height;
107
-
108
- this.xTrans = 0;
109
- this.yTrans = 0;
110
- this.scale = 1;
111
-
112
- let desiredWidth = this.width;
113
- let desiredHeight = this.height;
114
- let desiredScale = this.scale;
115
-
116
- const maxHeight = window.innerHeight * this.zoomPct;
117
- const maxWidth = window.innerWidth * this.zoomPct;
118
-
119
- // if the width fits, constrain by height
120
- if (this.width * (maxHeight / this.height) < maxWidth) {
121
- desiredHeight = window.innerHeight * this.zoomPct;
122
- desiredScale = desiredHeight / this.height;
123
- desiredWidth = this.width * desiredScale;
101
+ // the clicked element is either a raw <img> or a temba-thumbnail that
102
+ // exposes the attachment url; take whichever gives us an image source
103
+ this.url = (ele as HTMLImageElement).src || (ele as any).url || '';
104
+ if (this.show) {
105
+ // already mounted (e.g. re-opened mid-dismissal) — re-zoom in place
106
+ this.zoom = true;
124
107
  } else {
125
- desiredWidth = window.innerWidth * this.zoomPct;
126
- desiredScale = desiredWidth / this.width;
127
- desiredHeight = this.height * desiredScale;
108
+ this.show = true; // updated() flips zoom on the next tick
128
109
  }
129
-
130
- const xGrowth = (desiredWidth - this.width) / 2;
131
- const xDest = (window.innerWidth - desiredWidth) / 2;
132
- this.xTrans = xDest - this.left + xGrowth;
133
-
134
- const yGrowth = (desiredHeight - this.height) / 2;
135
- const yDest = (window.innerHeight - desiredHeight) / 2;
136
- this.yTrans = yDest - this.top + yGrowth;
137
-
138
- this.scale = desiredScale;
139
- this.show = true;
140
110
  }
141
111
 
142
112
  public handleClick() {
@@ -144,37 +114,15 @@ export class Lightbox extends RapidElement {
144
114
  }
145
115
 
146
116
  public render() {
147
- const styles = {
148
- transition: `transform ${this.animationTime}ms ease, box-shadow ${this.animationTime}ms ease`
149
- };
150
-
151
- if (this.show) {
152
- styles['left'] = this.left + 'px';
153
- styles['top'] = this.top + 'px';
154
- styles['width'] = this.width + 'px';
155
- }
156
-
157
- if (this.zoom) {
158
- styles['transform'] =
159
- `translate(${this.xTrans}px, ${this.yTrans}px) scale(${this.scale}, ${this.scale})`;
160
- }
161
-
162
117
  return html`
163
118
  <div
164
- class=${getClasses({
165
- container: true,
166
- show: this.show,
167
- zoom: this.zoom
168
- })}
119
+ class=${getClasses({ backdrop: true, zoom: this.zoom })}
120
+ style="--anim: ${this.animationTime}ms"
169
121
  @click=${this.handleClick}
170
122
  >
171
- <div
172
- class=${getClasses({ mask: true })}
173
- style="transition: all ${this.animationTime}ms; ease"
174
- ></div>
175
- <div class=${getClasses({ matte: true })} style=${styleMap(styles)}>
176
- ${this.show ? html`${this.ele}` : null}
177
- </div>
123
+ ${this.show && this.url
124
+ ? html`<img src=${this.url} alt="attachment" />`
125
+ : null}
178
126
  </div>
179
127
  `;
180
128
  }
@@ -182,6 +182,11 @@ export class Thumbnail extends RapidElement {
182
182
  // audio player state
183
183
  private audio: HTMLAudioElement | null = null;
184
184
 
185
+ // set when an image attachment fails to load, so we fall back to an
186
+ // icon instead of the browser's broken-image glyph
187
+ @state()
188
+ private imageError = false;
189
+
185
190
  @state()
186
191
  private audioPlaying = false;
187
192
 
@@ -264,6 +269,8 @@ export class Thumbnail extends RapidElement {
264
269
 
265
270
  // convert our attachment to a url and label
266
271
  if (changes.has('attachment')) {
272
+ // a new attachment gets a fresh shot at loading its image
273
+ this.imageError = false;
267
274
  if (this.attachment) {
268
275
  const splitIndex = this.attachment.indexOf(':');
269
276
  if (splitIndex === -1) {
@@ -337,12 +344,27 @@ export class Thumbnail extends RapidElement {
337
344
  }
338
345
  }
339
346
 
340
- public handleThumbnailClicked() {
341
- if (this.contentType === ThumbnailContentType.IMAGE && this.preview) {
342
- window.setTimeout(() => {
343
- const lightbox = document.querySelector('temba-lightbox') as Lightbox;
344
- lightbox.showElement(this);
345
- }, 100);
347
+ public handleThumbnailClicked(e?: Event) {
348
+ // a thumbnail click is a self-contained action (lightbox / open / play);
349
+ // don't let it bubble to a clickable ancestor (e.g. a list row that would
350
+ // otherwise navigate away instead of showing the attachment)
351
+ e?.stopPropagation();
352
+ if (
353
+ this.contentType === ThumbnailContentType.IMAGE &&
354
+ this.preview &&
355
+ !this.imageError
356
+ ) {
357
+ const lightbox = document.querySelector('temba-lightbox') as Lightbox;
358
+ if (lightbox) {
359
+ window.setTimeout(() => {
360
+ lightbox.showElement(this);
361
+ }, 100);
362
+ } else {
363
+ // no lightbox mounted on this page — just open the image. This must
364
+ // happen synchronously inside the click's user activation or popup
365
+ // blockers will eat it.
366
+ window.open(this.url, '_blank');
367
+ }
346
368
  } else if (this.contentType === ThumbnailContentType.LOCATION) {
347
369
  // open location in openstreetmap
348
370
  const osmUrl = `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}#map=15/${this.latitude}/${this.longitude}`;
@@ -372,12 +394,17 @@ export class Thumbnail extends RapidElement {
372
394
  : ''}"
373
395
  url=${this.url}
374
396
  >
375
- ${this.contentType === ThumbnailContentType.IMAGE && this.preview
397
+ ${this.contentType === ThumbnailContentType.IMAGE &&
398
+ this.preview &&
399
+ !this.imageError
376
400
  ? html`<div class=""><div class="download" @click=${this.handleDownload.bind(
377
401
  this
378
402
  )}><temba-icon size="1" style="color:#fff;" name="download"></temba-icon></div><img
379
403
  class="observe thumb ${this.contentType}"
380
404
  src="${this.url}"
405
+ @error=${() => {
406
+ this.imageError = true;
407
+ }}
381
408
  ></img></div>`
382
409
  : this.contentType === ThumbnailContentType.AUDIO
383
410
  ? html`<div class="audio-player">
@@ -315,6 +315,29 @@ function getTypeName(
315
315
  return 'Unknown';
316
316
  }
317
317
 
318
+ /**
319
+ * Build the display fields for a result that matched on its type name
320
+ * (e.g. "email" matching a "Send Email" action). Shows the first content
321
+ * text as an unhighlighted preview when available, otherwise highlights
322
+ * the match within the type name itself. Returns null if the type name
323
+ * doesn't match the query.
324
+ */
325
+ function makeTypeNameResult(
326
+ typeName: string,
327
+ query: string,
328
+ contentTexts: string[]
329
+ ): Pick<SearchResult, 'fullText' | 'matchStart' | 'matchLength'> | null {
330
+ const idx = typeName.toLowerCase().indexOf(query);
331
+ if (idx === -1) {
332
+ return null;
333
+ }
334
+ const preview = contentTexts.find((t) => t.trim());
335
+ if (preview) {
336
+ return { fullText: preview, matchStart: 0, matchLength: 0 };
337
+ }
338
+ return { fullText: typeName, matchStart: idx, matchLength: query.length };
339
+ }
340
+
318
341
  /**
319
342
  * Get the localized name for a category, falling back to the original name.
320
343
  */
@@ -640,11 +663,13 @@ export class FlowSearch extends LitElement {
640
663
  if (node.actions) {
641
664
  for (const action of node.actions) {
642
665
  const actionConfig = ACTION_CONFIG[action.type];
666
+ const typeName = getTypeName(actionConfig, undefined);
643
667
  const searchAction = localizeAction(
644
668
  action,
645
669
  langLocalization?.[action.uuid]
646
670
  );
647
671
  const texts = getActionSearchTexts(searchAction);
672
+ let matched = false;
648
673
  for (const text of texts) {
649
674
  const lowerText = text.toLowerCase();
650
675
  const idx = lowerText.indexOf(query);
@@ -652,15 +677,31 @@ export class FlowSearch extends LitElement {
652
677
  results.push({
653
678
  nodeUuid: node.uuid,
654
679
  action,
655
- typeName: getTypeName(actionConfig, undefined),
680
+ typeName,
656
681
  color: getColor(actionConfig, undefined),
657
682
  fullText: text,
658
683
  matchStart: idx,
659
684
  matchLength: query.length
660
685
  });
686
+ matched = true;
661
687
  break; // One match per action is enough
662
688
  }
663
689
  }
690
+
691
+ // Fall back to matching the action type name (e.g. "email"
692
+ // matches all "Send Email" actions)
693
+ if (!matched) {
694
+ const typeMatch = makeTypeNameResult(typeName, query, texts);
695
+ if (typeMatch) {
696
+ results.push({
697
+ nodeUuid: node.uuid,
698
+ action,
699
+ typeName,
700
+ color: getColor(actionConfig, undefined),
701
+ ...typeMatch
702
+ });
703
+ }
704
+ }
664
705
  }
665
706
  }
666
707
  } else {
@@ -679,6 +720,8 @@ export class FlowSearch extends LitElement {
679
720
  }
680
721
  }
681
722
 
723
+ const typeName = getTypeName(undefined, nodeConfig);
724
+ let matched = false;
682
725
  for (const text of nodeTexts) {
683
726
  const lowerText = text.toLowerCase();
684
727
  const idx = lowerText.indexOf(query);
@@ -686,15 +729,31 @@ export class FlowSearch extends LitElement {
686
729
  results.push({
687
730
  nodeUuid: node.uuid,
688
731
  action: null,
689
- typeName: getTypeName(undefined, nodeConfig),
732
+ typeName,
690
733
  color: getColor(undefined, nodeConfig),
691
734
  fullText: text,
692
735
  matchStart: idx,
693
736
  matchLength: query.length
694
737
  });
738
+ matched = true;
695
739
  break; // One match per node is enough
696
740
  }
697
741
  }
742
+
743
+ // Fall back to matching the node type name (e.g. "wait" matches
744
+ // all "Wait for Response" nodes)
745
+ if (!matched) {
746
+ const typeMatch = makeTypeNameResult(typeName, query, nodeTexts);
747
+ if (typeMatch) {
748
+ results.push({
749
+ nodeUuid: node.uuid,
750
+ action: null,
751
+ typeName,
752
+ color: getColor(undefined, nodeConfig),
753
+ ...typeMatch
754
+ });
755
+ }
756
+ }
698
757
  }
699
758
  }
700
759
 
@@ -838,6 +897,11 @@ export class FlowSearch extends LitElement {
838
897
  // Replace newlines with spaces for single-line display
839
898
  const text = result.fullText.replace(/\n/g, ' ');
840
899
 
900
+ // Type-name matches show an unhighlighted content preview
901
+ if (matchLength === 0) {
902
+ return html`${text}`;
903
+ }
904
+
841
905
  // Show leading context before the match, then the match, then trailing.
842
906
  // CSS text-overflow:ellipsis clips the trailing text, so we keep the match
843
907
  // near the start. When the match is near the end of the text, show more