@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
package/orca/setup.sh CHANGED
@@ -3,7 +3,7 @@
3
3
  # Orca setup hook for a temba-components worktree.
4
4
  # Symlinks shared utility files from nyaruka/utils, ensures the devcontainer
5
5
  # is built/running (recreating it if the bind mounts are stale), and installs
6
- # pnpm dependencies inside the worktree.
6
+ # bun dependencies inside the worktree.
7
7
  #
8
8
  # Usage:
9
9
  # ./orca/setup.sh # run directly
@@ -75,7 +75,7 @@ esac
75
75
  # Install dependencies inside the worktree.
76
76
  docker exec "$CONTAINER_NAME" bash -c '
77
77
  cd "/workspaces/worktrees/temba-components/'"$WORKSPACE_NAME"'"
78
- pnpm install
78
+ bun install
79
79
  '
80
80
 
81
81
  echo "Worktree '$WORKSPACE_NAME' ready for development"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.163.0",
3
+ "version": "0.165.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",
@@ -9,25 +9,25 @@
9
9
  "license": "AGPL-3.0-only",
10
10
  "repository": "https://github.com/nyaruka/temba-components/",
11
11
  "scripts": {
12
- "start": "concurrently --kill-others --names tsc,web-dev-server \"pnpm tsc:watch\" \"web-dev-server --app-index demo/index.html --node-resolve --watch --port 3010 --esbuild-target auto\"",
12
+ "start": "concurrently --kill-others --names tsc,web-dev-server \"bun run tsc:watch\" \"web-dev-server --app-index demo/index.html --node-resolve --watch --port 3010 --esbuild-target auto\"",
13
13
  "tsc:watch": "tsc --watch",
14
- "build": "rimraf dist && pnpm svg && tsc && rollup -c rollup.components.mjs ",
14
+ "build": "rimraf dist && bun run svg && tsc && rollup -c rollup.components.mjs ",
15
15
  "build-wc": "rollup -c rollup.webchat.mjs",
16
- "dev": "pnpm build && cp -R ./dist/* ../temba/rapidpro/node_modules/@nyaruka/temba-components/dist/ && cp -R ./dist/* ../floweditor/node_modules/@nyaruka/temba-components/dist/",
17
- "pre-commit": "pnpm locale:extract && pnpm locale:build && pnpm svg && git add ./src/Icons.ts ./static/svg/index.svg ./xliff ./src/locales && lint-staged",
16
+ "dev": "bun run build && cp -R ./dist/* ../temba/rapidpro/node_modules/@nyaruka/temba-components/dist/ && cp -R ./dist/* ../floweditor/node_modules/@nyaruka/temba-components/dist/",
17
+ "pre-commit": "bun run locale:extract && bun run locale:build && bun run svg && git add ./src/Icons.ts ./static/svg/index.svg ./xliff ./src/locales && lint-staged",
18
18
  "format:eslint": "eslint --ext .ts . --fix --ignore-path .gitignore --ignore-pattern \"src/locales/\"",
19
19
  "format:prettier": "prettier \"**/*.js\" \"**/*.ts\" \"!src/locales/**\" --config .prettierrc --write --ignore-path .gitignore",
20
- "format": "pnpm format:eslint && pnpm format:prettier",
20
+ "format": "bun run format:eslint && bun run format:prettier",
21
21
  "test": "wtr --node-resolve",
22
22
  "test:fast": "wtr --node-resolve --fast",
23
23
  "test:coverage": "wtr --node-resolve --coverage",
24
24
  "check-coverage": "node check-coverage.js",
25
- "validate": "pnpm format && pnpm build && pnpm test:coverage && pnpm check-coverage",
25
+ "validate": "bun run format && bun run build && bun run test:coverage && bun run check-coverage",
26
26
  "test:watch": "wtr --node-resolve --watch",
27
27
  "stress-test": "node stress-test.js",
28
28
  "svg": "rimraf static/svg/work && node svg.js --resolution=150 --output='./static/svg/index.svg' --usage='./src/Icons.ts'",
29
29
  "svg-wc": "rimraf static/svg/work && node svg.js --resolution=150 --output='./static/svg/webchat.svg' --usage='./src/webchat/index.ts'",
30
- "version": "pnpm run build && auto-changelog -p && git add CHANGELOG.md",
30
+ "version": "bun run build && auto-changelog -p && git add CHANGELOG.md",
31
31
  "locale:extract": "lit-localize extract --config localize.config.json",
32
32
  "locale:build": "echo 'Building localizations..' && lit-localize build --config localize.config.json > dist/locale.out 2>&1",
33
33
  "dev-data:sync": "node scripts/dev-data-sync.mjs copy",
@@ -112,14 +112,9 @@
112
112
  "typescript": "5.9.3",
113
113
  "yargs": "^17.7.2"
114
114
  },
115
- "pnpm": {
116
- "overrides": {
117
- "trim": "^0.0.3",
118
- "prismjs": "^1.23.0",
119
- "@open-wc/testing-helpers": "3.0.0",
120
- "@types/minimatch": "5.1.2",
121
- "@types/chai": "4.3.20"
122
- }
123
- },
124
- "packageManager": "pnpm@9.15.4"
115
+ "overrides": {
116
+ "@open-wc/testing-helpers": "3.0.0",
117
+ "@types/minimatch": "5.1.2",
118
+ "@types/chai": "4.3.20"
119
+ }
125
120
  }
@@ -117,7 +117,7 @@ function showStatus() {
117
117
 
118
118
  if (!fs.existsSync(DEV_DATA_DIR)) {
119
119
  console.log('❌ No development data directory found');
120
- console.log('💡 Run the dev server to initialize, or use "pnpm dev-data:reset"');
120
+ console.log('💡 Run the dev server to initialize, or use "bun run dev-data:reset"');
121
121
  return;
122
122
  }
123
123
 
@@ -175,8 +175,8 @@ switch (command) {
175
175
  console.log(' reset/wipe - Wipe dev data and restore defaults');
176
176
  console.log(' status - Show current status');
177
177
  console.log('');
178
- console.log('Usage: pnpm dev-data:copy');
179
- console.log(' pnpm dev-data:reset');
180
- console.log(' pnpm dev-data:status');
178
+ console.log('Usage: bun run dev-data:copy');
179
+ console.log(' bun run dev-data:reset');
180
+ console.log(' bun run dev-data:status');
181
181
  process.exit(1);
182
182
  }
package/src/Icons.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-duplicate-enum-values */
2
- // for cache busting we dynamically generate a fingerprint, use pnpm svg to update
3
- export const SVG_FINGERPRINT = '45268f1573f1e352c7d62232863725bb';
2
+ // for cache busting we dynamically generate a fingerprint, use bun run svg to update
3
+ export const SVG_FINGERPRINT = '27198f759c371df944f8ec5de0a29dd6';
4
4
 
5
5
  // only icons below are included in the sprite sheet
6
6
  export enum Icon {
@@ -132,6 +132,7 @@ export enum Icon {
132
132
  progress_spinner = 'refresh-cw-04',
133
133
  refresh = 'refresh-cw-05',
134
134
  featured = 'star-01',
135
+ featured_filled = 'star-filled',
135
136
  quick_replies = 'dotpoints-01',
136
137
  recording = 'microphone-01',
137
138
  resend = 'refresh-cw-05',
@@ -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
@@ -1446,7 +1447,7 @@ export class Chat extends RapidElement {
1446
1447
 
1447
1448
  // check if message has location attachment and text is just coordinates
1448
1449
  const hasLocationAttachment = message.msg.attachments?.some((att) =>
1449
- att.startsWith('geo:')
1450
+ attachmentAsString(att).startsWith('geo:')
1450
1451
  );
1451
1452
  const textIsCoordinates =
1452
1453
  hasLocationAttachment &&
@@ -1510,7 +1511,7 @@ export class Chat extends RapidElement {
1510
1511
  ${(message.msg.attachments || []).map(
1511
1512
  (attachment) =>
1512
1513
  html`<temba-thumbnail
1513
- attachment="${attachment}"
1514
+ attachment="${attachmentAsString(attachment)}"
1514
1515
  ></temba-thumbnail>`
1515
1516
  )}
1516
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
  }
@@ -108,6 +108,12 @@ export class ProgressBar extends RapidElement {
108
108
  transition: flex-basis 2s;
109
109
  }
110
110
 
111
+ /* At 0% an animated meter keeps a sliver of bar so its moving
112
+ stripes show the work is underway rather than a dead track. */
113
+ .meter.zero > span {
114
+ min-width: 1.25em;
115
+ }
116
+
111
117
  .meter .incomplete {
112
118
  flex-grow: 1;
113
119
  }
@@ -163,6 +169,11 @@ export class ProgressBar extends RapidElement {
163
169
  @property({ type: Boolean })
164
170
  showPercentage = false;
165
171
 
172
+ /** Suppresses the trailing percentage / countdown box outright —
173
+ * for compact placements where the meter alone is the display. */
174
+ @property({ type: Boolean })
175
+ hidePercentage = false;
176
+
166
177
  @property({ type: String })
167
178
  message: string;
168
179
 
@@ -194,7 +205,8 @@ export class ProgressBar extends RapidElement {
194
205
  const meterClasses = [
195
206
  'meter',
196
207
  this.done ? 'done' : '',
197
- this.animated ? '' : 'static'
208
+ this.animated ? '' : 'static',
209
+ this.animated && !this.done && this.pct === 0 ? 'zero' : ''
198
210
  ]
199
211
  .filter(Boolean)
200
212
  .join(' ');
@@ -208,7 +220,8 @@ export class ProgressBar extends RapidElement {
208
220
  <div class="incomplete"></div>
209
221
  </div>
210
222
 
211
- ${this.showPercentage || this.showEstimatedCompletion
223
+ ${(this.showPercentage || this.showEstimatedCompletion) &&
224
+ !this.hidePercentage
212
225
  ? html`<div class="etc">
213
226
  <div>
214
227
  ${this.estimatedCompletionDate &&
@@ -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">
@@ -406,11 +406,25 @@ export class Compose extends FieldElement {
406
406
  if (!this.hasAttribute('tabindex')) {
407
407
  this.setAttribute('tabindex', '-1');
408
408
  }
409
+ // both phases: capture claims a send-Enter before the rich editor's
410
+ // own keydown inserts a newline (which would flash before the send
411
+ // clears it); bubble keeps Enter-to-send working from anywhere else
412
+ // in the compose after inner widgets have had their shot at it
413
+ this.addEventListener(
414
+ 'keydown',
415
+ this.handleHostKeyDown as EventListener,
416
+ true
417
+ );
409
418
  this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
410
419
  }
411
420
 
412
421
  disconnectedCallback() {
413
422
  super.disconnectedCallback();
423
+ this.removeEventListener(
424
+ 'keydown',
425
+ this.handleHostKeyDown as EventListener,
426
+ true
427
+ );
414
428
  this.removeEventListener(
415
429
  'keydown',
416
430
  this.handleHostKeyDown as EventListener
@@ -449,10 +463,23 @@ export class Compose extends FieldElement {
449
463
  }
450
464
 
451
465
  if (evt.key === 'Enter' && !evt.shiftKey) {
466
+ // an Enter confirming an IME composition belongs to the editor
467
+ if (evt.isComposing || evt.keyCode === 229) {
468
+ return;
469
+ }
452
470
  if (this.showShortcuts) {
453
471
  return;
454
472
  }
455
473
  const editor = this.getMessageEditor();
474
+ // during capture only editor presses are claimed — anything else
475
+ // (quick replies, attachments) waits for the bubble so the widget
476
+ // it targets keeps first claim on the event
477
+ if (
478
+ evt.eventPhase === Event.CAPTURING_PHASE &&
479
+ !(editor && evt.composedPath().includes(editor))
480
+ ) {
481
+ return;
482
+ }
456
483
  if (editor) {
457
484
  const richEdit = editor.getRichEditor();
458
485
  if (richEdit && richEdit.hasVisibleOptions()) {
@@ -460,7 +487,10 @@ export class Compose extends FieldElement {
460
487
  }
461
488
  }
462
489
  evt.preventDefault();
463
- evt.stopPropagation();
490
+ // immediate: the handler is registered for both phases, and an
491
+ // at-target event would otherwise reach the second registration
492
+ // and send twice
493
+ evt.stopImmediatePropagation();
464
494
  this.triggerSend();
465
495
  }
466
496
  };