@nyaruka/temba-components 0.167.0 → 0.168.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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
@@ -1,4 +1,5 @@
1
1
  import { html, TemplateResult } from 'lit';
2
+ import { ifDefined } from 'lit-html/directives/if-defined.js';
2
3
  import {
3
4
  AirtimeCreatedEvent,
4
5
  AirtimeTransferredEvent,
@@ -15,7 +16,6 @@ import {
15
16
  URNsChangedEvent
16
17
  } from '../events';
17
18
  import { getLanguageName } from '../languages';
18
- import { oxfordFn } from '../utils';
19
19
 
20
20
  export enum Events {
21
21
  AIRTIME_CREATED = 'airtime_created',
@@ -26,8 +26,12 @@ export enum Events {
26
26
  CALL_RECEIVED = 'call_received',
27
27
  CHAT_STARTED = 'chat_started',
28
28
  CONTACT_FIELD_CHANGED = 'contact_field_changed',
29
+ // ephemeral, updates contact state instead of rendering as history
30
+ CONTACT_FLOW_CHANGED = 'contact_flow_changed',
29
31
  CONTACT_GROUPS_CHANGED = 'contact_groups_changed',
30
32
  CONTACT_LANGUAGE_CHANGED = 'contact_language_changed',
33
+ // ephemeral, updates contact state instead of rendering as history
34
+ CONTACT_LAST_SEEN_CHANGED = 'contact_last_seen_changed',
31
35
  CONTACT_NAME_CHANGED = 'contact_name_changed',
32
36
  CONTACT_STATUS_CHANGED = 'contact_status_changed',
33
37
  CONTACT_URNS_CHANGED = 'contact_urns_changed',
@@ -67,87 +71,157 @@ export enum Events {
67
71
  * rendered inline as a plain pill. Single source of truth for the
68
72
  * "entity pill in chat history" look — inline margin keeps wrapping
69
73
  * airy, and inline style works regardless of host-page Tailwind reach.
74
+ *
75
+ * Pills carry no native `title` — hover detail (verb, actor, time)
76
+ * lives in the rich tooltip renderEvent wraps around each event.
70
77
  */
71
78
  const renderEntityPill = (
72
79
  pillType: string,
73
80
  name: string,
74
- opts: { href?: string; icon?: string } = {}
81
+ opts: {
82
+ href?: string;
83
+ icon?: string;
84
+ prefixIcon?: string;
85
+ } = {}
75
86
  ): TemplateResult => {
76
- const pill = opts.icon
77
- ? html`<temba-label
78
- icon=${opts.icon}
79
- type=${pillType}
80
- ?clickable=${!!opts.href}
81
- style="margin: 1px 2px; vertical-align: middle;"
82
- >${name}</temba-label
83
- >`
84
- : html`<temba-label
85
- type=${pillType}
86
- ?clickable=${!!opts.href}
87
- style="margin: 1px 2px; vertical-align: middle;"
88
- >${name}</temba-label
89
- >`;
87
+ // max-width lets a long name (flows especially) ellipsize inside the
88
+ // pill instead of blowing out the event line. The wrapping anchor
89
+ // needs the same cap plus min-width:0 — as a flex item it would
90
+ // otherwise refuse to shrink below the pill's full content size,
91
+ // defeating the ellipsis. --label-min-width keeps a couple of
92
+ // characters visible when a pill is squeezed here, so it never
93
+ // collapses to bare chrome (icon + ellipsis) in a tight event line.
94
+ const pill = html`<temba-label
95
+ type=${pillType}
96
+ icon=${ifDefined(opts.icon)}
97
+ prefix-icon=${ifDefined(opts.prefixIcon)}
98
+ ?clickable=${!!opts.href}
99
+ style="margin: 1px 2px; vertical-align: middle; max-width: 100%; --label-min-width: 2em;"
100
+ >${name}</temba-label
101
+ >`;
102
+ // text-decoration: none matters here - flex blockifies the label,
103
+ // and unlike the old inline layout an anchor's underline propagates
104
+ // into block-level children, underlining the pill text.
90
105
  return opts.href
91
106
  ? html`<a
92
107
  href=${opts.href}
93
108
  onclick="goto(event, this)"
94
- style="vertical-align: middle;"
109
+ style="vertical-align: middle; display: inline-flex; min-width: 0; max-width: 100%; text-decoration: none;"
95
110
  >${pill}</a
96
111
  >`
97
112
  : pill;
98
113
  };
99
114
 
100
- const groupPill = (item: any) =>
101
- renderEntityPill('group', item.name, {
102
- href: `/contact/group/${item.uuid}/`
115
+ /**
116
+ * Renders a single group addition or removal as a self-contained
117
+ * pill linking to the group, following the fixed pill convention:
118
+ * the group name on the left, the bold verb on the right — " Drivers
119
+ * | Added " / " Farmers | Removed " — with the red removal variant
120
+ * reinforcing removals (it has no default icon, so the group icon is
121
+ * set explicitly there).
122
+ */
123
+ const groupPill = (group: any, removed = false): TemplateResult =>
124
+ attributePill(group.name, removed ? 'Removed' : 'Added', {
125
+ type: removed ? 'removed' : 'group',
126
+ icon: removed ? 'group' : undefined,
127
+ href: `/contact/group/${group.uuid}/`
103
128
  });
104
129
 
105
- const flowPill = (flow: any) =>
130
+ const flowPill = (flow: any, opts: { prefixIcon?: string } = {}) =>
106
131
  renderEntityPill('flow', flow.name, {
107
- href: `/flow/editor/${flow.uuid}/`
132
+ href: `/flow/editor/${flow.uuid}/`,
133
+ ...opts
108
134
  });
109
135
 
110
- const fieldPill = (field: any) => renderEntityPill('field', field.name);
111
-
112
136
  const topicPill = (topic: any) =>
113
137
  renderEntityPill('topic', topic.name, {
114
138
  href: `/ticket/${topic.uuid}/open/`
115
139
  });
116
140
 
117
141
  /**
118
- * Renders a user as a plain text link to the "All" ticket folder
119
- * filtered by that assignee. Used for actor attribution in the
120
- * chat history (ticket assigned / opened / closed events).
121
- *
122
- * The richer avatar-chip variant is parked in git history (see
123
- * userPill) we'll bring it back if denser surfaces need it.
142
+ * Renders a ticket lifecycle change as a self-contained pill linking
143
+ * to the ticket, following the fixed pill convention: label on the
144
+ * left (a user or "Ticket") and the bold verb on the right, e.g.
145
+ * " Sally Sue | Assigned ". Uses the topic (orange) variant so
146
+ * everything ticket-related shares one color, with the agent icon
147
+ * marking these as the ticket itself rather than a topic change.
148
+ * The acting user is named (with an avatar) in the rich hover
149
+ * tooltip renderEvent attaches.
124
150
  */
125
- const userLink = (user: any): TemplateResult => {
126
- const name =
127
- user.name || [user.first_name, user.last_name].filter(Boolean).join(' ');
128
- return html`<a
129
- href="/ticket/all/open/?assignee=${user.uuid}"
130
- onclick="goto(event, this)"
131
- title=${name}
132
- >${name}</a
133
- >`;
134
- };
151
+ const ticketPill = (opts: {
152
+ uuid: string;
153
+ name?: string;
154
+ folder?: string;
155
+ verb: string;
156
+ }) =>
157
+ attributePill(opts.name || 'Ticket', opts.verb, {
158
+ type: 'topic',
159
+ icon: 'tickets',
160
+ href: `/ticket/all/${opts.folder || 'open'}/${opts.uuid}/`
161
+ });
162
+
163
+ const getUserName = (user: any): string =>
164
+ user.name || [user.first_name, user.last_name].filter(Boolean).join(' ');
165
+
166
+ // thin currentColor rule separating the attribute name from its value
167
+ // inside an attribute pill — picks up each variant's text color
168
+ const attributeDividerStyle =
169
+ 'display: inline-block; width: 1px; height: 11px; background: currentColor; opacity: 0.3; margin: 0 6px; vertical-align: -2px;';
170
+
171
+ // either half of an attribute pill — capped so a long name or value
172
+ // can't stretch the pill across the chat
173
+ const attributeSideStyle = (maxWidth = '18em') =>
174
+ `display: inline-block; max-width: ${maxWidth}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;`;
135
175
 
136
176
  /**
137
- * Renders a contact-data value (field text, name, URN, language,
138
- * status, amount, etc.) as bold inline text, truncated with an
139
- * ellipsis past a reasonable width. Pills are reserved for system-
140
- * level objects (group, flow, field, topic, …) these are just
141
- * values, so the chip chrome would over-state them. The full value
142
- * is exposed via `title` for hover inspection.
177
+ * Renders a name/value pair as a single self-contained pill: the
178
+ * name, a thin divider, then the bolded value — e.g. " Age | 43 " or
179
+ * " Update Info | Completed ". The layout is a fixed convention
180
+ * across every event pill: label on the left, value on the right,
181
+ * bold on the right. A null value renders as an italic "cleared".
182
+ * Both sides truncate the full text lives in the rich hover
183
+ * tooltip renderEvent attaches, so no native titles here.
184
+ * TemplateResult values (e.g. a temba-date) are passed through
185
+ * bolded. With `href` the pill wraps in a navigation anchor, same
186
+ * as renderEntityPill.
143
187
  */
144
- const valueText = (value: string | number) => {
145
- const str = String(value);
146
- return html`<span
147
- title=${str}
148
- style="display: inline-block; max-width: 18em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-weight: 600;"
149
- >${str}</span
188
+ const attributePill = (
189
+ name: string,
190
+ value: string | TemplateResult | null,
191
+ opts: {
192
+ type?: string;
193
+ icon?: string;
194
+ href?: string;
195
+ nameMaxWidth?: string;
196
+ } = {}
197
+ ): TemplateResult => {
198
+ const valueContent =
199
+ value === null
200
+ ? html`<span style="font-style: italic; opacity: 0.7;">cleared</span>`
201
+ : typeof value === 'string'
202
+ ? html`<span style="${attributeSideStyle()} font-weight: 600;"
203
+ >${value}</span
204
+ >`
205
+ : html`<span style="font-weight: 600;">${value}</span>`;
206
+ const nameContent = html`<span style=${attributeSideStyle(opts.nameMaxWidth)}
207
+ >${name}</span
208
+ >`;
209
+ const pill = html`<temba-label
210
+ type=${opts.type || 'neutral'}
211
+ icon=${ifDefined(opts.icon)}
212
+ ?clickable=${!!opts.href}
213
+ style="margin: 1px 2px; vertical-align: middle;"
214
+ >${nameContent}<span style=${attributeDividerStyle}></span
215
+ >${valueContent}</temba-label
150
216
  >`;
217
+ return opts.href
218
+ ? html`<a
219
+ href=${opts.href}
220
+ onclick="goto(event, this)"
221
+ style="vertical-align: middle;"
222
+ >${pill}</a
223
+ >`
224
+ : pill;
151
225
  };
152
226
 
153
227
  /**
@@ -164,56 +238,50 @@ const valueText = (value: string | number) => {
164
238
  const eventLineStyle =
165
239
  'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px; min-height: 24px;';
166
240
 
167
- const renderInfoList = (
168
- singular: string,
169
- plural: string,
170
- items: any[]
171
- ): TemplateResult => {
172
- if (items.length === 1) {
173
- return html`<div style=${eventLineStyle}>
174
- ${singular} ${groupPill(items[0])}
175
- </div>`;
176
- }
177
- if (items.length === 2) {
178
- return html`<div style=${eventLineStyle}>
179
- ${plural} ${groupPill(items[0])} and ${groupPill(items[1])}
180
- </div>`;
181
- }
182
- // No commas between pills — the flex `gap` on eventLineStyle
183
- // already provides visual separation, and a pill list reads as a
184
- // single "set" rather than a sentence.
185
- const middle = items.slice(0, -1).map((item) => groupPill(item));
186
- const last = items[items.length - 1];
187
- return html`<div style=${eventLineStyle}>
188
- ${plural} ${middle} and ${groupPill(last)}
189
- </div>`;
190
- };
191
-
192
241
  export const renderRunEvent = (event: RunEvent): TemplateResult => {
242
+ // follows the fixed pill convention: label (flow name) on the
243
+ // left, bold value (the run state) on the right — " Update Info |
244
+ // Completed " — matching how field pills read. Runs cut short
245
+ // (interrupted / expired) use the red removal variant, matching
246
+ // group removals; that variant has no default icon so the flow
247
+ // icon is set explicitly.
193
248
  let verb = 'Started';
249
+ let cutShort = false;
194
250
  if (event.type === Events.RUN_ENDED) {
195
251
  if (event.status === 'completed') {
196
252
  verb = 'Completed';
197
253
  } else if (event.status === 'expired') {
198
- verb = 'Expired from';
254
+ verb = 'Expired';
255
+ cutShort = true;
199
256
  } else {
200
257
  verb = 'Interrupted';
258
+ cutShort = true;
201
259
  }
202
260
  }
203
261
 
204
262
  return html`<div style=${eventLineStyle}>
205
- ${verb} ${flowPill(event.flow)}
263
+ ${attributePill(event.flow.name, verb, {
264
+ type: cutShort ? 'removed' : 'flow',
265
+ icon: cutShort ? 'flow' : undefined,
266
+ href: `/flow/editor/${event.flow.uuid}/`,
267
+ // flow names run long — cap them harder than other pill labels
268
+ nameMaxWidth: '160px'
269
+ })}
206
270
  </div>`;
207
271
  };
208
272
 
209
273
  export const renderChatStartedEvent = (
210
274
  event: ChatStartedEvent
211
275
  ): TemplateResult => {
212
- if (event.params) {
213
- return html`<div>Chat referral</div>`;
214
- } else {
215
- return html`<div>Chat started</div>`;
216
- }
276
+ return html`<div style=${eventLineStyle}>
277
+ ${renderEntityPill(
278
+ 'neutral',
279
+ event.params ? 'Chat referral' : 'Chat started',
280
+ {
281
+ icon: 'message'
282
+ }
283
+ )}
284
+ </div>`;
217
285
  };
218
286
 
219
287
  export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
@@ -221,35 +289,44 @@ export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
221
289
  // cleared field — backfill / reset payloads sometimes arrive as
222
290
  // `value: { text: '' }`, which would otherwise render with an
223
291
  // empty value pill.
224
- return event.value && event.value.text
225
- ? html`<div style=${eventLineStyle}>
226
- Updated ${fieldPill(event.field)} to ${valueText(event.value.text)}
227
- </div>`
228
- : html`<div style=${eventLineStyle}>
229
- Cleared ${fieldPill(event.field)}
230
- </div>`;
292
+ const value = event.value;
293
+ let display: string | TemplateResult | null = null;
294
+ if (value && value.text) {
295
+ // date values carry a typed datetime alongside the raw text — show
296
+ // those through temba-date for short, locale-aware formatting
297
+ display = value.datetime
298
+ ? html`<temba-date value=${value.datetime} display="day"></temba-date>`
299
+ : value.text;
300
+ }
301
+ return html`<div style=${eventLineStyle}>
302
+ ${attributePill(event.field.name, display, { type: 'field' })}
303
+ </div>`;
231
304
  };
232
305
 
233
306
  export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
234
- if (!event.name) {
235
- return html`<div style=${eventLineStyle}>Cleared name</div>`;
236
- }
237
307
  return html`<div style=${eventLineStyle}>
238
- Updated name to ${valueText(event.name)}
308
+ ${attributePill('Name', event.name || null, { icon: 'contact' })}
239
309
  </div>`;
240
310
  };
241
311
 
312
+ // strip the scheme and query string from a raw URN for display
313
+ // (e.g. "tel:+1234?channel=x" → "+1234"); a malformed URN without a
314
+ // scheme separator passes through as-is rather than throwing
315
+ const urnDisplayValue = (urn: string): string => {
316
+ const parts = urn.split(':');
317
+ return (parts.length > 1 ? parts[1] : urn).split('?')[0];
318
+ };
319
+
242
320
  export const renderContactURNsChanged = (
243
321
  event: URNsChangedEvent
244
322
  ): TemplateResult => {
245
- if (!event.urns || event.urns.length === 0) {
246
- return html`<div style=${eventLineStyle}>Cleared URNs</div>`;
247
- }
323
+ // URNs are replaced as a set, so the whole set shares one pill —
324
+ // the value truncates past 18em with the full list on hover
325
+ const urns = (event.urns || []).map(urnDisplayValue);
248
326
  return html`<div style=${eventLineStyle}>
249
- Updated URNs to
250
- ${oxfordFn(event.urns, (urn: string) =>
251
- valueText(urn.split(':')[1].split('?')[0])
252
- )}
327
+ ${attributePill('URNs', urns.length ? urns.join(', ') : null, {
328
+ icon: 'at-sign'
329
+ })}
253
330
  </div>`;
254
331
  };
255
332
 
@@ -273,102 +350,215 @@ export const renderTicketAction = (
273
350
  }
274
351
 
275
352
  // closed → ticket is in the closed folder now; reopened → it's
276
- // back in open. Linking the word "ticket" lets a reader jump to
277
- // wherever the ticket actually lives.
353
+ // back in open, so the pill links to wherever the ticket actually
354
+ // lives. Tickets are always closed by someone, so that user's name
355
+ // is the pill's label ("Adam Ant | Closed") rather than a generic
356
+ // "Ticket" — with the generic fallback kept for defensive safety.
278
357
  const folder = action === 'closed' ? 'closed' : 'open';
279
- const href = `/ticket/all/${folder}/${ticketUUID}/`;
280
- return event._user
281
- ? html`<div style=${eventLineStyle}>
282
- ${userLink(event._user)} ${action} a <a href=${href}>ticket</a>
283
- </div>`
284
- : html`<div style=${eventLineStyle}>
285
- A <a href=${href}>ticket</a> was ${action}
286
- </div>`;
358
+ const verb = action === 'closed' ? 'Closed' : 'Reopened';
359
+ return html`<div style=${eventLineStyle}>
360
+ ${ticketPill({
361
+ uuid: ticketUUID,
362
+ name:
363
+ action === 'closed' && event._user
364
+ ? getUserName(event._user)
365
+ : undefined,
366
+ folder,
367
+ verb
368
+ })}
369
+ </div>`;
287
370
  };
288
371
 
289
372
  export const renderTicketAssigneeChanged = (
290
373
  event: TicketEvent
291
374
  ): TemplateResult => {
292
375
  const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
293
- // Link the word "ticket" in assignee events too, so the noun is
294
- // consistently interactive across open / close / reopen / assigned
295
- // rows (the contact-history page can show events from any of the
296
- // contact's tickets, so the jump-to-ticket affordance is useful).
297
- const ticketLink = html`<a href="/ticket/all/open/${ticketUUID}/">ticket</a>`;
298
- const ticketLinkCapitalized = html`<a href="/ticket/all/open/${ticketUUID}/"
299
- >This ticket</a
300
- >`;
301
- if (event._user) {
302
- if (event.assignee) {
303
- // Self-assignment ("took the ticket") reads naturally as one
304
- // user link + verb, rather than "<user> assigned to <same user>".
305
- // Match on uuid when present, falling back to email — depending
306
- // on the API surface a user payload may carry one or the other.
307
- const sameUser =
308
- (event._user.uuid && event._user.uuid === event.assignee.uuid) ||
309
- (event._user.email && event._user.email === event.assignee.email);
310
- if (sameUser) {
311
- return html`<div style=${eventLineStyle}>
312
- ${userLink(event._user)} took this ${ticketLink}
313
- </div>`;
314
- }
315
- return html`<div style=${eventLineStyle}>
316
- ${userLink(event._user)} assigned this ${ticketLink} to
317
- ${userLink(event.assignee)}
318
- </div>`;
319
- } else {
320
- return html`<div style=${eventLineStyle}>
321
- ${userLink(event._user)} unassigned this ${ticketLink}
322
- </div>`;
323
- }
324
- } else {
325
- if (event.assignee) {
326
- return html`<div style=${eventLineStyle}>
327
- ${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
328
- </div>`;
329
- } else {
330
- return html`<div style=${eventLineStyle}>
331
- ${ticketLinkCapitalized} was unassigned
332
- </div>`;
333
- }
376
+ if (event.assignee) {
377
+ // the assignee is the pill's label who owns the ticket now is
378
+ // the fact that matters. Who did the assigning lives in the
379
+ // hover tooltip.
380
+ return html`<div style=${eventLineStyle}>
381
+ ${ticketPill({
382
+ uuid: ticketUUID,
383
+ name: getUserName(event.assignee),
384
+ verb: 'Assigned'
385
+ })}
386
+ </div>`;
334
387
  }
388
+ return html`<div style=${eventLineStyle}>
389
+ ${ticketPill({
390
+ uuid: ticketUUID,
391
+ verb: 'Unassigned'
392
+ })}
393
+ </div>`;
335
394
  };
336
395
 
337
396
  export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
338
- const ticketUUID = event.ticket.uuid;
339
- const href = `/ticket/all/open/${ticketUUID}/`;
340
- // ticket.topic is optional in events.ts guard so a payload
341
- // without one degrades to "A ticket was opened" rather than
342
- // throwing inside topicPill.
397
+ // the opened pill leads with the topic behind its topic icon, then
398
+ // the assignee behind an inline agent icon when the event carries
399
+ // one, each segment split by the standard divider before the bold
400
+ // verb e.g. " (topic) Billing | (agent) Sally Sue | Opened ".
401
+ // Without a topic the assignee (behind the label-level agent icon)
402
+ // is the whole label, and with neither it falls back to the
403
+ // generic ticket pill. ticket_opened carries its assignee inside
404
+ // the ticket envelope (unlike ticket_assignee_changed, where it's
405
+ // on the event itself).
343
406
  const topic = event.ticket.topic;
344
- const tail = topic ? html` in ${topicPill(topic)}` : null;
345
- return event._user
346
- ? html`<div style=${eventLineStyle}>
347
- ${userLink(event._user)} opened a <a href=${href}>ticket</a>${tail}
348
- </div>`
349
- : html`<div style=${eventLineStyle}>
350
- A <a href=${href}>ticket</a> was opened${tail}
351
- </div>`;
407
+ const assignee = event.ticket.assignee || event.assignee;
408
+
409
+ if (!topic && !assignee) {
410
+ return html`<div style=${eventLineStyle}>
411
+ ${ticketPill({
412
+ uuid: event.ticket.uuid,
413
+ verb: 'Opened'
414
+ })}
415
+ </div>`;
416
+ }
417
+
418
+ // an inline agent icon marking the assignee segment when the topic
419
+ // occupies the pill's leading (label-level) icon slot — aligned to
420
+ // match the divider
421
+ const inlineAgentIcon = html`<temba-icon
422
+ name="tickets"
423
+ style="display: inline-block; vertical-align: -2px; margin-right: 4px;"
424
+ ></temba-icon>`;
425
+
426
+ const pill = html`<temba-label
427
+ type="topic"
428
+ icon=${topic ? 'topic' : 'tickets'}
429
+ clickable
430
+ style="margin: 1px 2px; vertical-align: middle;"
431
+ >${topic
432
+ ? html`<span style=${attributeSideStyle()}>${topic.name}</span>`
433
+ : null}${assignee
434
+ ? html`${topic
435
+ ? html`<span style=${attributeDividerStyle}></span>`
436
+ : null}${inlineAgentIcon}<span style=${attributeSideStyle()}
437
+ >${getUserName(assignee)}</span
438
+ >`
439
+ : null}<span style=${attributeDividerStyle}></span
440
+ ><span style="font-weight: 600;">Opened</span></temba-label
441
+ >`;
442
+
443
+ return html`<div style=${eventLineStyle}>
444
+ <a
445
+ href=${`/ticket/all/open/${event.ticket.uuid}/`}
446
+ onclick="goto(event, this)"
447
+ style="vertical-align: middle;"
448
+ >${pill}</a
449
+ >
450
+ </div>`;
352
451
  };
353
452
 
354
453
  export const renderContactGroupsEvent = (
355
- event: ContactGroupsEvent
454
+ event: ContactGroupsEvent,
455
+ withTooltips = false
456
+ ): TemplateResult | null => {
457
+ // a group event can carry several groups — with tooltips on (the
458
+ // contact chat), each pill gets its own tip anchored to the hovered
459
+ // pill, so the tips wrap per-pill here rather than renderEvent
460
+ // wrapping the whole line
461
+ const wrap = (pill: TemplateResult): TemplateResult =>
462
+ withTooltips
463
+ ? html`<temba-tip
464
+ style="display: inline-block; max-width: 100%;"
465
+ position="top"
466
+ distance="4"
467
+ arrow-size="18"
468
+ interactive
469
+ .content=${renderEventTooltip(event)}
470
+ >${pill}</temba-tip
471
+ >`
472
+ : pill;
473
+
474
+ const pills = [
475
+ ...(event.groups_added || []).map((group) => wrap(groupPill(group))),
476
+ ...(event.groups_removed || []).map((group) => wrap(groupPill(group, true)))
477
+ ];
478
+ if (pills.length === 0) {
479
+ return null;
480
+ }
481
+ return html`<div style=${eventLineStyle}>${pills}</div>`;
482
+ };
483
+
484
+ /**
485
+ * Renders a run of informational events as a single collapsed
486
+ * summary pill — the unique set of icons for what's inside followed
487
+ * by the total event count, e.g. " ⌄ ⚡👤 12 ". The pill is
488
+ * stateless; the chat wires `onExpand` and swaps in the detailed
489
+ * pills when clicked.
490
+ */
491
+ export const renderEventSummary = (
492
+ events: any[],
493
+ onExpand: () => void
356
494
  ): TemplateResult => {
357
- if (event.groups_added) {
358
- return renderInfoList('Added to', 'Added to', event.groups_added);
359
- } else if (event.groups_removed) {
360
- return renderInfoList('Removed from', 'Removed from', event.groups_removed);
495
+ // one icon per kind of thing inside, deduped, in order of first
496
+ // appearance
497
+ const icons: string[] = [];
498
+ const addIcon = (icon: string) => {
499
+ if (!icons.includes(icon)) {
500
+ icons.push(icon);
501
+ }
502
+ };
503
+ for (const event of events) {
504
+ switch (event.type) {
505
+ case Events.RUN_STARTED:
506
+ case Events.RUN_ENDED:
507
+ case Events.FLOW_ENTERED:
508
+ case Events.SESSION_TRIGGERED:
509
+ addIcon('flow');
510
+ break;
511
+ case Events.CONTACT_GROUPS_CHANGED:
512
+ addIcon('group');
513
+ break;
514
+ case Events.CONTACT_FIELD_CHANGED:
515
+ case Events.CONTACT_NAME_CHANGED:
516
+ case Events.CONTACT_LANGUAGE_CHANGED:
517
+ case Events.CONTACT_STATUS_CHANGED:
518
+ case Events.CONTACT_URNS_CHANGED:
519
+ addIcon('fields');
520
+ break;
521
+ case Events.TICKET_OPENED:
522
+ case Events.TICKET_CLOSED:
523
+ case Events.TICKET_REOPENED:
524
+ case Events.TICKET_ASSIGNEE_CHANGED:
525
+ case Events.TICKET_TOPIC_CHANGED:
526
+ addIcon('tickets');
527
+ break;
528
+ default:
529
+ addIcon('event');
530
+ }
361
531
  }
532
+
533
+ return html`<temba-label
534
+ type="neutral"
535
+ icon="down"
536
+ title="Show details"
537
+ clickable
538
+ style="margin: 1px 2px; vertical-align: middle;"
539
+ @click=${onExpand}
540
+ >${icons.map(
541
+ (icon) =>
542
+ html`<temba-icon
543
+ name=${icon}
544
+ style="display: inline-block; vertical-align: -2px; margin-right: 3px;"
545
+ ></temba-icon>`
546
+ )}<span style="font-weight: 600; margin-left: 2px;"
547
+ >${events.length}</span
548
+ ></temba-label
549
+ >`;
362
550
  };
363
551
 
364
552
  export const renderAirtimeTransferredEvent = (
365
553
  event: AirtimeTransferredEvent
366
554
  ): TemplateResult => {
367
- if (parseFloat(event.amount) === 0) {
368
- return html`<div>Airtime transfer failed</div>`;
369
- }
555
+ const failed = parseFloat(event.amount) === 0;
370
556
  return html`<div style=${eventLineStyle}>
371
- Transferred ${valueText(event.amount)} ${event.currency} of airtime
557
+ ${failed
558
+ ? attributePill('Airtime', 'failed', { icon: 'airtime' })
559
+ : attributePill('Airtime', `${event.amount} ${event.currency}`, {
560
+ icon: 'airtime'
561
+ })}
372
562
  </div>`;
373
563
  };
374
564
 
@@ -376,67 +566,83 @@ export const renderAirtimeCreatedEvent = (
376
566
  event: AirtimeCreatedEvent
377
567
  ): TemplateResult => {
378
568
  const status = event._status?.status ?? 'created';
379
- const amount = html`${valueText(event.amount)} ${event.currency}`;
569
+ let value = `${event.amount} ${event.currency}`;
380
570
 
381
571
  switch (status) {
382
572
  case 'reversed':
383
- return html`<div>Airtime transfer reversed</div>`;
573
+ value = 'reversed';
574
+ break;
384
575
  case 'rejected':
385
576
  case 'cancelled':
386
577
  case 'declined':
387
- return html`<div>Airtime transfer failed</div>`;
388
- case 'completed':
389
- return html`<div style=${eventLineStyle}>
390
- Transferred ${amount} of airtime
391
- </div>`;
392
- default:
393
- return html`<div style=${eventLineStyle}>
394
- Sending ${amount} of airtime
395
- </div>`;
578
+ value = 'failed';
579
+ break;
396
580
  }
581
+
582
+ return html`<div style=${eventLineStyle}>
583
+ ${attributePill('Airtime', value, { icon: 'airtime' })}
584
+ </div>`;
397
585
  };
398
586
 
399
587
  export const renderContactLanguageChangedEvent = (
400
588
  event: ContactLanguageChangedEvent
401
589
  ): TemplateResult => {
402
- if (!event.language) {
403
- return html`<div style=${eventLineStyle}>Cleared language</div>`;
404
- }
590
+ const language = event.language ? getLanguageName(event.language) : null;
405
591
  return html`<div style=${eventLineStyle}>
406
- Language updated to ${valueText(getLanguageName(event.language))}
592
+ ${attributePill('Language', language, { icon: 'language' })}
407
593
  </div>`;
408
594
  };
409
595
 
410
596
  export const renderContactStatusChangedEvent = (
411
597
  event: ContactStatusChangedEvent
412
598
  ): TemplateResult => {
413
- if (!event.status) {
414
- return html`<div style=${eventLineStyle}>Cleared status</div>`;
415
- }
416
599
  return html`<div style=${eventLineStyle}>
417
- Status updated to ${valueText(event.status)}
600
+ ${attributePill('Status', event.status || null)}
418
601
  </div>`;
419
602
  };
420
603
 
421
- export const renderCallEvent = (event: CallEvent): TemplateResult => {
604
+ export const renderCallEvent = (event: CallEvent): TemplateResult | null => {
605
+ let label: string = null;
606
+ let icon: string = null;
422
607
  if (event.type === Events.CALL_CREATED) {
423
- return html`<div>Call started</div>`;
608
+ label = 'Call started';
609
+ icon = 'call';
424
610
  } else if (event.type === Events.CALL_MISSED) {
425
- return html`<div>Call missed</div>`;
611
+ label = 'Call missed';
612
+ icon = 'missed_call';
426
613
  } else if (event.type === Events.CALL_RECEIVED) {
427
- return html`<div>Call answered</div>`;
614
+ label = 'Call answered';
615
+ icon = 'incoming_call';
428
616
  }
617
+ if (!label) {
618
+ return null;
619
+ }
620
+ return html`<div style=${eventLineStyle}>
621
+ ${renderEntityPill('neutral', label, { icon })}
622
+ </div>`;
429
623
  };
430
624
 
431
625
  export const renderOptInEvent = (event: OptInEvent): TemplateResult => {
626
+ // opt-out gets the red removal treatment to mirror group removal;
627
+ // the hover tooltip and icon carry the verb in all three cases
432
628
  if (event.type === Events.OPTIN_REQUESTED) {
433
- return html`<div>
434
- Requested opt-in for <strong>${event.optin.name}</strong>
629
+ return html`<div style=${eventLineStyle}>
630
+ ${renderEntityPill('neutral', event.optin.name, {
631
+ icon: 'optin_requested'
632
+ })}
435
633
  </div>`;
436
634
  } else if (event.type === Events.OPTIN_STARTED) {
437
- return html`<div>Opted in to <strong>${event.optin.name}</strong></div>`;
635
+ return html`<div style=${eventLineStyle}>
636
+ ${renderEntityPill('neutral', event.optin.name, {
637
+ icon: 'optin'
638
+ })}
639
+ </div>`;
438
640
  } else if (event.type === Events.OPTIN_STOPPED) {
439
- return html`<div>Opted out of <strong>${event.optin.name}</strong></div>`;
641
+ return html`<div style=${eventLineStyle}>
642
+ ${renderEntityPill('removed', event.optin.name, {
643
+ icon: 'optout'
644
+ })}
645
+ </div>`;
440
646
  }
441
647
  };
442
648
 
@@ -469,85 +675,65 @@ export const renderDiagnosticEvent = (
469
675
  return null;
470
676
  };
471
677
 
472
- export const renderRunResultChanged = (
473
- event: any,
474
- isSimulation: boolean = false
475
- ): TemplateResult | null => {
476
- const val = String(event.value);
477
- const MAX_LEN = isSimulation ? 30 : 100;
478
-
479
- if (val.length > MAX_LEN) {
480
- const displayVal = val.substring(0, MAX_LEN) + '...';
481
- return html`<div>
482
- Set result <strong>${event.name}</strong> to "<span
483
- title="${val}"
484
- style="cursor: help; border-bottom: 1px dotted #999;"
485
- >${displayVal}</span
486
- >"
487
- </div>`;
488
- }
489
- return html`<div>Set result <strong>${event.name}</strong> to "${val}"</div>`;
678
+ export const renderRunResultChanged = (event: any): TemplateResult | null => {
679
+ // attributePill truncates long values; the hover tooltip carries
680
+ // the full text
681
+ return html`<div style=${eventLineStyle}>
682
+ ${attributePill(event.name, String(event.value))}
683
+ </div>`;
490
684
  };
491
685
 
492
686
  export const renderInputLabelsAdded = (event: any): TemplateResult | null => {
493
687
  const labels = event.labels || [];
494
- if (labels.length > 0) {
495
- const labelList = labels.map((l: any) => l.name);
496
- if (labelList.length === 1) {
497
- return html`<div>
498
- Message labeled with <strong>${labelList[0]}</strong>
499
- </div>`;
500
- } else {
501
- const last = labelList.pop();
502
- return html`<div>
503
- Message labeled with
504
- ${labelList.map(
505
- (name: string, index: number) =>
506
- html`<strong>${name}</strong>${index < labelList.length - 1
507
- ? ', '
508
- : ''}`
509
- )}
510
- and <strong>${last}</strong>
511
- </div>`;
512
- }
688
+ if (labels.length === 0) {
689
+ return null;
513
690
  }
514
- return null;
691
+ return html`<div style=${eventLineStyle}>
692
+ ${labels.map((l: any) => renderEntityPill('label', l.name))}
693
+ </div>`;
515
694
  };
516
695
 
517
696
  export const renderEmailSent = (event: any): TemplateResult | null => {
518
697
  const recipients = event.to || event.addresses || [];
519
- const subject = event.subject;
520
- if (recipients.length > 0) {
521
- const recipientList = recipients.join(', ');
522
- return html`<div>
523
- Sent email to <strong>${recipientList}</strong> with subject "${subject}"
524
- </div>`;
698
+ if (recipients.length === 0) {
699
+ return null;
525
700
  }
526
- return null;
701
+ return html`<div style=${eventLineStyle}>
702
+ ${renderEntityPill('neutral', recipients.join(', '), {
703
+ icon: 'email'
704
+ })}
705
+ </div>`;
527
706
  };
528
707
 
529
708
  export const renderBroadcastCreated = (event: any): TemplateResult | null => {
530
- const translations = event.translations;
531
- const baseLanguage = event.base_language;
532
- if (translations && translations[baseLanguage]) {
533
- return html`<div>
534
- Sent broadcast: "${translations[baseLanguage].text}"
535
- </div>`;
536
- }
537
- return html`<div>Sent broadcast</div>`;
709
+ const text = event.translations?.[event.base_language]?.text;
710
+ const maxLen = 50;
711
+ const display =
712
+ text && text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
713
+ return html`<div style=${eventLineStyle}>
714
+ ${renderEntityPill('neutral', display || 'Broadcast', {
715
+ icon: 'broadcast'
716
+ })}
717
+ </div>`;
538
718
  };
539
719
 
540
720
  export const renderSessionTriggered = (event: any): TemplateResult | null => {
541
721
  const flow = event.flow;
542
722
  if (flow) {
543
- return html`<div>Started somebody else in ${flowPill(flow)}</div>`;
723
+ return html`<div style=${eventLineStyle}>
724
+ ${flowPill(flow, {
725
+ prefixIcon: 'contact'
726
+ })}
727
+ </div>`;
544
728
  }
545
729
  return null;
546
730
  };
547
731
 
548
732
  export const renderResthookCalled = (event: any): TemplateResult | null => {
549
- return html`<div>
550
- Triggered flow event <strong>${event.resthook}</strong>
733
+ return html`<div style=${eventLineStyle}>
734
+ ${renderEntityPill('neutral', event.resthook, {
735
+ icon: 'resthooks'
736
+ })}
551
737
  </div>`;
552
738
  };
553
739
 
@@ -557,15 +743,210 @@ export const renderWebhookCalled = (event: any): TemplateResult | null => {
557
743
  event.url && event.url.length > maxLen
558
744
  ? event.url.slice(0, maxLen) + '...'
559
745
  : event.url;
560
- return html`<div>Called <strong>${displayUrl}</strong></div>`;
746
+ return html`<div style=${eventLineStyle}>
747
+ ${renderEntityPill('neutral', displayUrl, {
748
+ icon: 'webhook'
749
+ })}
750
+ </div>`;
561
751
  };
562
752
 
563
753
  export const renderServiceCalled = (event: any): TemplateResult | null => {
564
- const service = event.service;
565
- if (service === 'classifier') {
566
- return html`<div>Called classifier</div>`;
754
+ return html`<div style=${eventLineStyle}>
755
+ ${renderEntityPill('neutral', event.service, {
756
+ icon: 'service'
757
+ })}
758
+ </div>`;
759
+ };
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // Rich hover tooltips
763
+ //
764
+ // Every inline event pill in the contact chat gets a temba-tip that
765
+ // only adds what the pill can't already show: the full text of a
766
+ // value the pill truncates (or the nicely formatted date and time
767
+ // behind a date field's short day), the acting user (avatar on the
768
+ // left, name beside the timestamp) and the detailed timestamp. A
769
+ // pill with nothing else to add still shows the timestamp alone.
770
+ // This replaces the native `title` attributes the pills used to
771
+ // carry.
772
+ // ---------------------------------------------------------------------------
773
+
774
+ const tooltipAvatar = (user: any): TemplateResult =>
775
+ html`<temba-user
776
+ name=${ifDefined(user.name)}
777
+ first_name=${ifDefined(user.first_name)}
778
+ last_name=${ifDefined(user.last_name)}
779
+ email=${ifDefined(user.email)}
780
+ avatar=${ifDefined(user.avatar)}
781
+ ></temba-user>`;
782
+
783
+ // pill sides ellipsize at 18em — roughly this many characters at the
784
+ // pill font size. Values within the cap are already fully visible in
785
+ // the pill, so the tooltip only repeats ones longer than this.
786
+ const PILL_VALUE_CHARS = 32;
787
+
788
+ const fullValue = (
789
+ value: string | undefined,
790
+ visibleChars = PILL_VALUE_CHARS
791
+ ): string[] => (value && value.length > visibleChars ? [value] : []);
792
+
793
+ /**
794
+ * The detail lines an event's tooltip adds beyond the pill itself —
795
+ * just the values the pill truncates. Most events add nothing (their
796
+ * pill already says it all); returns null for events that don't
797
+ * render as pills at all.
798
+ */
799
+ const getEventTooltipLines = (
800
+ event: any
801
+ ): (string | TemplateResult)[] | null => {
802
+ switch (event.type) {
803
+ case Events.CONTACT_FIELD_CHANGED: {
804
+ const value = event.value;
805
+ // date fields render as a short day in the pill — the tooltip
806
+ // carries the full date and time, formatted rather than a raw
807
+ // iso string
808
+ if (value?.datetime) {
809
+ return [
810
+ html`<temba-date
811
+ value=${value.datetime}
812
+ display="datetime"
813
+ ></temba-date>`
814
+ ];
815
+ }
816
+ return fullValue(value?.text);
817
+ }
818
+ case Events.CONTACT_NAME_CHANGED:
819
+ return fullValue(event.name);
820
+ case Events.CONTACT_URNS_CHANGED: {
821
+ const urns = (event.urns || []).map(urnDisplayValue);
822
+ return fullValue(urns.join(', '));
823
+ }
824
+ case Events.RUN_RESULT_CHANGED:
825
+ return fullValue(String(event.value));
826
+ case Events.EMAIL_CREATED:
827
+ case Events.EMAIL_SENT: {
828
+ const recipients = event.to || event.addresses || [];
829
+ // the subject is never in the pill; recipients only repeat when
830
+ // the pill truncates them
831
+ return [
832
+ ...fullValue(recipients.join(', ')),
833
+ ...(event.subject ? [event.subject] : [])
834
+ ];
835
+ }
836
+ // these two pills cut their value at 50 characters themselves
837
+ case Events.BROADCAST_CREATED:
838
+ return fullValue(event.translations?.[event.base_language]?.text, 50);
839
+ case Events.WEBHOOK_CALLED:
840
+ return fullValue(event.url, 50);
841
+ // diagnostics render as banners and notes as chat bubbles — no
842
+ // pill, so no pill tooltip
843
+ case Events.ERROR:
844
+ case Events.FAILURE:
845
+ case Events.WARNING:
846
+ case Events.TICKET_NOTE_ADDED:
847
+ return null;
848
+ default:
849
+ return [];
567
850
  }
568
- return html`<div>Called <strong>${service}</strong></div>`;
851
+ };
852
+
853
+ // a detail line in the tooltip: short values stay on one line (the
854
+ // tooltip hugs them), while runaway ones (a paragraph-sized field
855
+ // value, a long URL) wrap for a few lines within the container's cap
856
+ // and then ellipsize
857
+ const tooltipLineStyle =
858
+ 'white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden;';
859
+
860
+ /**
861
+ * Lays out tooltip content: the acting user's avatar on the left
862
+ * (when there is one) with a hairline rule, then the detail column —
863
+ * truncated-value lines set apart on a tinted, padded block, then a
864
+ * metadata line with the user's name (slightly heavier) and the
865
+ * detailed timestamp separated by a middle dot. When the metadata is
866
+ * all there is, the name and time stack on two lines instead to
867
+ * balance against the avatar. Without a user it's just the time.
868
+ */
869
+ const renderTooltipContent = (
870
+ event: any,
871
+ lines: (string | TemplateResult)[]
872
+ ): TemplateResult | null => {
873
+ const user = event._user;
874
+ const createdOn = event.created_on;
875
+ const iso = !createdOn
876
+ ? null
877
+ : typeof createdOn === 'string'
878
+ ? createdOn
879
+ : createdOn.toISOString();
880
+
881
+ if (!user && lines.length === 0 && !iso) {
882
+ return null;
883
+ }
884
+
885
+ // the hairline rule gets equal breathing room from the flex gap and
886
+ // stretches to the row height. The avatar column is pinned to the
887
+ // circle's 26px width because temba-user carries a trailing margin
888
+ // (meant for an inline name) that would otherwise push the rule
889
+ // off-balance.
890
+ return html`<div
891
+ style="display: flex; align-items: center; gap: 8px; text-align: left; font-size: 12px; line-height: 1.45; padding: 4px 2px; max-width: 340px;"
892
+ >
893
+ ${user
894
+ ? html`<div style="flex-shrink: 0; width: 26px;">
895
+ ${tooltipAvatar(user)}
896
+ </div>
897
+ <div
898
+ style="align-self: stretch; width: 1px; background: rgba(0, 0, 0, 0.08); flex-shrink: 0;"
899
+ ></div>`
900
+ : null}
901
+ <div style="min-width: 0;">
902
+ ${lines.length > 0
903
+ ? html`<div
904
+ style="background: #f3f3f3; border-radius: 4px; padding: 4px 8px; margin-bottom: 4px;"
905
+ >
906
+ ${lines.map(
907
+ (line) => html`<div style=${tooltipLineStyle}>${line}</div>`
908
+ )}
909
+ </div>`
910
+ : null}
911
+ ${lines.length === 0 && user && iso
912
+ ? html`<div style="font-size: 11px;">
913
+ <div style="font-weight: 500;">${getUserName(user)}</div>
914
+ <div><temba-date value=${iso} display="datetime"></temba-date></div>
915
+ </div>`
916
+ : user || iso
917
+ ? html`<div
918
+ style="font-size: 11px; display: flex; align-items: center; gap: 5px;"
919
+ >
920
+ ${user
921
+ ? html`<div style="font-weight: 500;">
922
+ ${getUserName(user)}
923
+ </div>`
924
+ : null}
925
+ ${user && iso
926
+ ? html`<div style="font-size: 16px; line-height: 1;">·</div>`
927
+ : null}
928
+ ${iso
929
+ ? html`<temba-date
930
+ value=${iso}
931
+ display="datetime"
932
+ ></temba-date>`
933
+ : null}
934
+ </div>`
935
+ : null}
936
+ </div>
937
+ </div>`;
938
+ };
939
+
940
+ /**
941
+ * Builds the rich tooltip content for an event pill. Returns null for
942
+ * events that don't render as pills.
943
+ */
944
+ export const renderEventTooltip = (event: any): TemplateResult | null => {
945
+ const lines = getEventTooltipLines(event);
946
+ if (lines === null) {
947
+ return null;
948
+ }
949
+ return renderTooltipContent(event, lines);
569
950
  };
570
951
 
571
952
  /**
@@ -604,7 +985,12 @@ export const renderEvent = (
604
985
  content = renderUpdateEvent(event as UpdateFieldEvent);
605
986
  break;
606
987
  case Events.CONTACT_GROUPS_CHANGED:
607
- content = renderContactGroupsEvent(event as ContactGroupsEvent);
988
+ // in the chat, tooltips attach per group pill (each speaks only
989
+ // to its own group), so the whole-line wrap below is skipped
990
+ content = renderContactGroupsEvent(
991
+ event as ContactGroupsEvent,
992
+ !isSimulation
993
+ );
608
994
  break;
609
995
  case Events.CONTACT_LANGUAGE_CHANGED:
610
996
  content = renderContactLanguageChangedEvent(
@@ -626,7 +1012,7 @@ export const renderEvent = (
626
1012
  content = renderInputLabelsAdded(event);
627
1013
  break;
628
1014
  case Events.RUN_RESULT_CHANGED:
629
- content = renderRunResultChanged(event, isSimulation);
1015
+ content = renderRunResultChanged(event);
630
1016
  break;
631
1017
  case Events.OPTIN_REQUESTED:
632
1018
  case Events.OPTIN_STARTED:
@@ -675,11 +1061,11 @@ export const renderEvent = (
675
1061
  case Events.TICKET_TOPIC_CHANGED: {
676
1062
  // event.topic is optional — guard so a payload without one
677
1063
  // degrades cleanly instead of throwing inside topicPill.
1064
+ // The topic pill (icon + topic color) is self-contained enough
1065
+ // to stand alone without a "Topic changed to" sentence.
678
1066
  const newTopic = (event as TicketEvent).topic;
679
1067
  content = newTopic
680
- ? html`<div style=${eventLineStyle}>
681
- Topic changed to ${topicPill(newTopic)}
682
- </div>`
1068
+ ? html`<div style=${eventLineStyle}>${topicPill(newTopic)}</div>`
683
1069
  : null;
684
1070
  break;
685
1071
  }
@@ -693,6 +1079,30 @@ export const renderEvent = (
693
1079
 
694
1080
  // wrap in a div with appropriate font size
695
1081
  const fontSize = isSimulation ? '11px' : '14px';
1082
+
1083
+ // in the contact chat, hovering an event pill pops our own rich
1084
+ // tooltip (verbose detail, avatars, detailed time). The simulator
1085
+ // keeps its click-to-inspect event details instead, and group
1086
+ // events carry per-pill tips (see renderContactGroupsEvent). width:
1087
+ // fit-content keeps the hover area hugging the pills rather than
1088
+ // spanning the row.
1089
+ const tooltip =
1090
+ isSimulation || event.type === Events.CONTACT_GROUPS_CHANGED
1091
+ ? null
1092
+ : renderEventTooltip(event);
1093
+ if (tooltip) {
1094
+ return html`<div style="font-size: ${fontSize}">
1095
+ <temba-tip
1096
+ style="display: block; width: fit-content; max-width: 100%; margin: 0 auto;"
1097
+ position="top"
1098
+ distance="4"
1099
+ arrow-size="18"
1100
+ interactive
1101
+ .content=${tooltip}
1102
+ >${content}</temba-tip
1103
+ >
1104
+ </div>`;
1105
+ }
696
1106
  return html`<div style="font-size: ${fontSize}">${content}</div>`;
697
1107
  };
698
1108