@nyaruka/temba-components 0.164.0 → 0.166.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 (55) hide show
  1. package/CHANGELOG.md +20 -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 +1983 -865
  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 +232 -18
  19. package/src/display/ProgressBar.ts +15 -2
  20. package/src/form/Compose.ts +31 -1
  21. package/src/form/ContactSearch.ts +229 -126
  22. package/src/interfaces.ts +73 -0
  23. package/src/layout/Card.ts +25 -0
  24. package/src/layout/CardLayout.ts +3 -3
  25. package/src/layout/HeaderBar.ts +4 -1
  26. package/src/list/BroadcastList.ts +912 -0
  27. package/src/list/ContentList.ts +46 -10
  28. package/src/list/FieldList.ts +1057 -0
  29. package/src/list/NotificationList.ts +131 -21
  30. package/src/list/SortableList.ts +7 -2
  31. package/src/list/TembaList.ts +19 -5
  32. package/src/list/TembaMenu.ts +47 -46
  33. package/src/live/CampaignEvents.ts +33 -19
  34. package/src/live/ContactChat.ts +190 -18
  35. package/src/live/ContactDetails.ts +20 -13
  36. package/src/live/ContactFieldEditor.ts +7 -3
  37. package/src/live/ContactStoreElement.ts +3 -1
  38. package/src/live/ContactTimeline.ts +28 -7
  39. package/src/live/Realtime.ts +133 -0
  40. package/src/live/SocketService.ts +24 -0
  41. package/src/locales/es.ts +4 -0
  42. package/src/locales/fr.ts +4 -0
  43. package/src/locales/pt.ts +4 -0
  44. package/src/store/Store.ts +12 -0
  45. package/src/utils.ts +4 -0
  46. package/static/svg/index.svg +1 -1
  47. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  48. package/static/svg/work/traced/star-filled.svg +1 -0
  49. package/static/svg/work/used/star-filled.svg +3 -0
  50. package/stress-test.js +3 -3
  51. package/temba-modules.ts +4 -0
  52. package/web-test-runner.config.mjs +1 -1
  53. package/xliff/es.xlf +12 -0
  54. package/xliff/fr.xlf +12 -0
  55. 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.164.0",
3
+ "version": "0.166.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',
@@ -11,6 +11,10 @@ const BATCH_TIME_WINDOW = 60 * 60 * 1000;
11
11
  const SCROLL_FETCH_BUFFER = 200; // pixels from top
12
12
  const MIN_FETCH_TIME = 250;
13
13
 
14
+ // how long a typing indicator lives without a fresh pulse - stop events can
15
+ // be missed (they're excluded from history recovery) so indicators decay
16
+ const TYPING_TIMEOUT = 10 * 1000;
17
+
14
18
  const getUnsendableReasonMessage = (reason: string): string => {
15
19
  switch (reason) {
16
20
  case 'no_route':
@@ -80,6 +84,7 @@ export interface Msg {
80
84
  urn: string;
81
85
  direction: string;
82
86
  type: string;
87
+ external_id?: string;
83
88
  attachments: string[];
84
89
  unsendable_reason?:
85
90
  | 'no_route'
@@ -114,6 +119,28 @@ export interface MsgEvent extends ContactEvent {
114
119
  _logs_url?: string;
115
120
  }
116
121
 
122
+ export interface TypingEvent extends ContactEvent {
123
+ direction?: string;
124
+ }
125
+
126
+ // typing indicators group and side with real messages: a user typing
127
+ // (outgoing) behaves like a reply, a contact typing (incoming) like a
128
+ // received message
129
+ const effectiveType = (event: ContactEvent): string => {
130
+ if (event.type === 'typing_started') {
131
+ return (event as TypingEvent).direction === 'incoming'
132
+ ? 'msg_received'
133
+ : 'msg_created';
134
+ }
135
+ return event.type;
136
+ };
137
+
138
+ // identifies who a typing indicator belongs to - the user when stamped,
139
+ // otherwise the contact
140
+ const getTypingKey = (event: TypingEvent): string => {
141
+ return event._user?.uuid || 'contact';
142
+ };
143
+
117
144
  // whether a message has nothing to show in its bubble (and isn't deleted)
118
145
  const isEmptyMsg = (event: MsgEvent): boolean =>
119
146
  !event._deleted &&
@@ -371,6 +398,43 @@ export class Chat extends RapidElement {
371
398
  color: #aaa;
372
399
  }
373
400
 
401
+ .typing-dots {
402
+ display: flex;
403
+ align-items: center;
404
+ gap: 4px;
405
+ padding: 4px 0;
406
+ }
407
+
408
+ .typing-dots span {
409
+ width: 7px;
410
+ height: 7px;
411
+ border-radius: 50%;
412
+ background: currentColor;
413
+ opacity: 0.4;
414
+ animation: typing-bounce 1.2s ease-in-out infinite;
415
+ }
416
+
417
+ .typing-dots span:nth-child(2) {
418
+ animation-delay: 0.15s;
419
+ }
420
+
421
+ .typing-dots span:nth-child(3) {
422
+ animation-delay: 0.3s;
423
+ }
424
+
425
+ @keyframes typing-bounce {
426
+ 0%,
427
+ 60%,
428
+ 100% {
429
+ transform: translateY(0);
430
+ opacity: 0.4;
431
+ }
432
+ 30% {
433
+ transform: translateY(-3px);
434
+ opacity: 1;
435
+ }
436
+ }
437
+
374
438
  .message-text {
375
439
  white-space: pre-wrap;
376
440
  margin-bottom: 0;
@@ -849,10 +913,24 @@ export class Chat extends RapidElement {
849
913
  private msgMap = new Map<string, ContactEvent>();
850
914
  private metadataCache = new Map<string, ContactEvent>();
851
915
 
916
+ // ephemeral typing indicators by author key - they live in msgMap and
917
+ // messageGroups like real messages (so they group and render normally)
918
+ // but are added and removed through setTyping / clearTyping
919
+ private typingEvents = new Map<string, TypingEvent>();
920
+ private typingTimeouts = new Map<string, number>();
921
+
852
922
  // bumped on reset so deferred addMessages work from before a reset is
853
923
  // discarded rather than re-merged into the fresh view
854
924
  private resetGeneration = 0;
855
925
 
926
+ public disconnectedCallback(): void {
927
+ super.disconnectedCallback();
928
+ // clear pending typing decay timers so no callback fires on a detached
929
+ // element (typingEvents/msgMap are left intact for a possible reconnect)
930
+ this.typingTimeouts.forEach((timeout) => window.clearTimeout(timeout));
931
+ this.typingTimeouts.clear();
932
+ }
933
+
856
934
  public firstUpdated(
857
935
  changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
858
936
  ): void {
@@ -911,9 +989,21 @@ export class Chat extends RapidElement {
911
989
  const isScrolledAway =
912
990
  scrollableHeight > 0 && Math.abs(ele.scrollTop) > 50;
913
991
 
992
+ // a real message from someone typing replaces their indicator, and
993
+ // any remaining indicators re-float below the appended messages
994
+ if (append) {
995
+ this.clearTypingForAuthors(
996
+ newMessages.map((uuid) => this.msgMap.get(uuid))
997
+ );
998
+ }
999
+
914
1000
  const grouped = this.groupMessages(newMessages);
915
1001
  this.insertGroups(grouped, append);
916
1002
 
1003
+ if (append) {
1004
+ this.refloatTyping();
1005
+ }
1006
+
917
1007
  // show notification if new messages are appended and user is scrolled away from bottom
918
1008
  // but not during search (searchHighlight is set)
919
1009
  if (
@@ -986,6 +1076,115 @@ export class Chat extends RapidElement {
986
1076
  return isNew;
987
1077
  }
988
1078
 
1079
+ /**
1080
+ * Shows a typing indicator for the event's author, appended to the
1081
+ * newest messages so it groups with them. Repeat pulses from the same
1082
+ * author just refresh the decay timer; without fresh pulses or an
1083
+ * explicit clearTyping the indicator decays on its own.
1084
+ */
1085
+ public setTyping(event: TypingEvent) {
1086
+ const key = getTypingKey(event);
1087
+ const shown = this.typingEvents.get(key);
1088
+ if (shown) {
1089
+ // keep the indicator's timestamp fresh so a long-lived one doesn't
1090
+ // drift across the grouping time window and split into its own group
1091
+ shown.created_on = event.created_on;
1092
+ } else {
1093
+ this.typingEvents.set(key, event);
1094
+ this.addMessage(event);
1095
+ this.insertGroups(this.groupMessages([event.uuid]), true);
1096
+ }
1097
+
1098
+ const existing = this.typingTimeouts.get(key);
1099
+ if (existing) {
1100
+ window.clearTimeout(existing);
1101
+ }
1102
+ this.typingTimeouts.set(
1103
+ key,
1104
+ window.setTimeout(() => this.removeTyping(key), TYPING_TIMEOUT)
1105
+ );
1106
+ }
1107
+
1108
+ /**
1109
+ * Clears the typing indicator for the event's author, or all indicators
1110
+ * when no event is given.
1111
+ */
1112
+ public clearTyping(event?: TypingEvent) {
1113
+ if (event) {
1114
+ this.removeTyping(getTypingKey(event));
1115
+ } else {
1116
+ for (const key of [...this.typingEvents.keys()]) {
1117
+ this.removeTyping(key);
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ private removeTyping(key: string) {
1123
+ const timeout = this.typingTimeouts.get(key);
1124
+ if (timeout) {
1125
+ window.clearTimeout(timeout);
1126
+ this.typingTimeouts.delete(key);
1127
+ }
1128
+
1129
+ const event = this.typingEvents.get(key);
1130
+ if (event) {
1131
+ this.typingEvents.delete(key);
1132
+ this.removeEvent(event.uuid);
1133
+ }
1134
+ }
1135
+
1136
+ private removeEvent(uuid: string) {
1137
+ this.msgMap.delete(uuid);
1138
+ for (let i = 0; i < this.messageGroups.length; i++) {
1139
+ const group = this.messageGroups[i];
1140
+ const idx = group.messages.indexOf(uuid);
1141
+ if (idx >= 0) {
1142
+ group.messages.splice(idx, 1);
1143
+ if (group.messages.length === 0) {
1144
+ this.messageGroups.splice(i, 1);
1145
+ }
1146
+ break;
1147
+ }
1148
+ }
1149
+ this.requestUpdate('messageGroups');
1150
+ }
1151
+
1152
+ // an author's real message replaces their typing indicator
1153
+ private clearTypingForAuthors(events: ContactEvent[]) {
1154
+ for (const event of events) {
1155
+ if (event.type === 'typing_started') {
1156
+ continue;
1157
+ }
1158
+ const key = event._user?.uuid
1159
+ ? event._user.uuid
1160
+ : event.type === 'msg_received'
1161
+ ? 'contact'
1162
+ : null;
1163
+ if (key && this.typingEvents.has(key)) {
1164
+ this.removeTyping(key);
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ // re-appends active typing indicators so they always sit below the
1170
+ // newest messages
1171
+ private refloatTyping() {
1172
+ if (this.typingEvents.size === 0) {
1173
+ return;
1174
+ }
1175
+ const events = [...this.typingEvents.values()];
1176
+ for (const event of events) {
1177
+ this.removeEvent(event.uuid);
1178
+ }
1179
+ for (const event of events) {
1180
+ this.addMessage(event);
1181
+ }
1182
+ this.insertGroups(
1183
+ this.groupMessages(events.map((event) => event.uuid)),
1184
+ true
1185
+ );
1186
+ }
1187
+
989
1188
  public messageExists(msg: ContactEvent): boolean {
990
1189
  return this.msgMap.has(msg.uuid);
991
1190
  }
@@ -1002,18 +1201,19 @@ export class Chat extends RapidElement {
1002
1201
  // for type equivalence, treat all non-message types as the same.
1003
1202
  // Notes are treated like messages so they group with other notes
1004
1203
  // only when type + author match (and split from non-note events).
1204
+ const type1 = effectiveType(msg1);
1205
+ const type2 = effectiveType(msg2);
1005
1206
  const isMsg1 =
1006
- msg1.type === 'msg_created' ||
1007
- msg1.type === 'msg_received' ||
1008
- msg1.type === 'ivr_created' ||
1009
- msg1.type === 'ticket_note_added';
1207
+ type1 === 'msg_created' ||
1208
+ type1 === 'msg_received' ||
1209
+ type1 === 'ivr_created' ||
1210
+ type1 === 'ticket_note_added';
1010
1211
  const isMsg2 =
1011
- msg2.type === 'msg_created' ||
1012
- msg2.type === 'msg_received' ||
1013
- msg2.type === 'ivr_created' ||
1014
- msg2.type === 'ticket_note_added';
1015
- const typeMatch =
1016
- isMsg1 && isMsg2 ? msg1.type === msg2.type : isMsg1 === isMsg2;
1212
+ type2 === 'msg_created' ||
1213
+ type2 === 'msg_received' ||
1214
+ type2 === 'ivr_created' ||
1215
+ type2 === 'ticket_note_added';
1216
+ const typeMatch = isMsg1 && isMsg2 ? type1 === type2 : isMsg1 === isMsg2;
1017
1217
 
1018
1218
  // check time first - if BATCH_TIME_WINDOW has passed since last time_elapsed reason
1019
1219
  // compare the current message (msg1) against the last break point to detect
@@ -1233,17 +1433,18 @@ export class Chat extends RapidElement {
1233
1433
  const mostRecentId = msgIds[msgIds.length - 1];
1234
1434
  const currentMsg = this.msgMap.get(mostRecentId);
1235
1435
 
1436
+ const currentType = effectiveType(currentMsg);
1236
1437
  const incoming = this.agent
1237
- ? currentMsg.type !== 'msg_received'
1238
- : currentMsg.type === 'msg_received';
1438
+ ? currentType !== 'msg_received'
1439
+ : currentType === 'msg_received';
1239
1440
 
1240
1441
  const name = currentMsg._user?.name;
1241
1442
 
1242
1443
  const isMessageType =
1243
- currentMsg.type === 'msg_received' ||
1244
- currentMsg.type === 'msg_created' ||
1245
- currentMsg.type === 'ivr_created' ||
1246
- currentMsg.type === 'ticket_note_added';
1444
+ currentType === 'msg_received' ||
1445
+ currentType === 'msg_created' ||
1446
+ currentType === 'ivr_created' ||
1447
+ currentType === 'ticket_note_added';
1247
1448
  const showAvatar =
1248
1449
  this.avatars && ((isMessageType && this.agent) || !incoming);
1249
1450
 
@@ -1255,7 +1456,7 @@ export class Chat extends RapidElement {
1255
1456
  // messages carry no `_user`, so first_name/last_name aren't available and
1256
1457
  // getFullName falls back to `name`); the fallback only applies when there
1257
1458
  // is no `_user` on the event.
1258
- const fromContact = currentMsg.type === 'msg_received' && !currentMsg._user;
1459
+ const fromContact = currentType === 'msg_received' && !currentMsg._user;
1259
1460
  const avatarName = currentMsg._user
1260
1461
  ? currentMsg._user.name
1261
1462
  : fromContact
@@ -1340,10 +1541,11 @@ export class Chat extends RapidElement {
1340
1541
  const latestClass = index === msgIds.length - 1 ? 'latest' : '';
1341
1542
  const eventClass = msg._rendered ? 'is-event' : '';
1342
1543
  const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
1544
+ const typingClass = msg.type === 'typing_started' ? 'typing' : '';
1343
1545
  const matchClass =
1344
1546
  this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
1345
1547
  return html`<div
1346
- class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
1548
+ class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${typingClass} ${matchClass}"
1347
1549
  data-uuid=${msg.uuid || nothing}
1348
1550
  >
1349
1551
  ${this.renderMessage(
@@ -1412,6 +1614,15 @@ export class Chat extends RapidElement {
1412
1614
  return this.renderNote(event as TicketEvent, name, isLast);
1413
1615
  }
1414
1616
 
1617
+ if (event.type === 'typing_started') {
1618
+ return html`<div class="bubble-wrap">
1619
+ <div class="bubble">
1620
+ ${name ? html`<div class="name">${name}</div>` : null}
1621
+ <div class="typing-dots"><span></span><span></span><span></span></div>
1622
+ </div>
1623
+ </div>`;
1624
+ }
1625
+
1415
1626
  const message = event as MsgEvent;
1416
1627
 
1417
1628
  // safety check: if msg doesn't exist, return nothing
@@ -1521,6 +1732,9 @@ export class Chat extends RapidElement {
1521
1732
 
1522
1733
  public reset() {
1523
1734
  this.resetGeneration++;
1735
+ this.typingTimeouts.forEach((timeout) => window.clearTimeout(timeout));
1736
+ this.typingTimeouts.clear();
1737
+ this.typingEvents.clear();
1524
1738
  this.msgMap.clear();
1525
1739
  this.metadataCache.clear();
1526
1740
  this.messageGroups = [];
@@ -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 &&
@@ -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
  };