@nyaruka/temba-components 0.159.2 → 0.159.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.159.2",
3
+ "version": "0.159.4",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -47,6 +47,7 @@ export default {
47
47
  replace({
48
48
  preventAssignment: true,
49
49
  'process.env.NODE_ENV': JSON.stringify('development'),
50
+ '__TEMBA_DEV_SERVER__': JSON.stringify(false),
50
51
  '__TEMBA_COMPONENTS_VERSION__': JSON.stringify(TEMBA_COMPONENTS_VERSION)
51
52
  }),
52
53
 
@@ -802,6 +802,15 @@ export class Chat extends RapidElement {
802
802
  @property({ type: Boolean })
803
803
  avatars = false;
804
804
 
805
+ // identity of the contact this chat belongs to, used to render a
806
+ // name-based avatar for the contact's own incoming messages (which the
807
+ // backend does not attach a `_user` to)
808
+ @property({ type: String })
809
+ contactName: string;
810
+
811
+ @property({ type: String })
812
+ contactUuid: string;
813
+
805
814
  @property({ type: Boolean, attribute: false })
806
815
  endOfHistory = false;
807
816
 
@@ -1213,7 +1222,36 @@ export class Chat extends RapidElement {
1213
1222
  const showAvatar =
1214
1223
  this.avatars && ((isMessageType && this.agent) || !incoming);
1215
1224
 
1216
- const isSystem = !currentMsg._user?.uuid;
1225
+ // resolve the identity shown in the avatar: prefer the user attached to
1226
+ // the event (an agent or flow author), otherwise fall back to the contact
1227
+ // for their own incoming messages.
1228
+ //
1229
+ // contact fallback assumes `_user` is absent for `msg_received` (contact
1230
+ // messages carry no `_user`, so first_name/last_name aren't available and
1231
+ // getFullName falls back to `name`); the fallback only applies when there
1232
+ // is no `_user` on the event.
1233
+ const fromContact = currentMsg.type === 'msg_received' && !currentMsg._user;
1234
+ const avatarName = currentMsg._user
1235
+ ? currentMsg._user.name
1236
+ : fromContact
1237
+ ? this.contactName
1238
+ : undefined;
1239
+ const avatarUuid = currentMsg._user
1240
+ ? currentMsg._user.uuid
1241
+ : fromContact
1242
+ ? this.contactUuid
1243
+ : undefined;
1244
+
1245
+ // determine whether to fall back to the generic default (system) avatar.
1246
+ // when the event has a `_user`, preserve the original behavior exactly:
1247
+ // system iff that user has no uuid (a name-only flow author still gets the
1248
+ // default avatar). for a contact event with no `_user`, it's system only
1249
+ // when we have no contact identity at all.
1250
+ const isSystem = currentMsg._user
1251
+ ? !currentMsg._user.uuid
1252
+ : fromContact
1253
+ ? !this.contactUuid && !this.contactName
1254
+ : true;
1217
1255
 
1218
1256
  const reasonLabel = this.getReasonLabel(group.reason);
1219
1257
  const showReason = false; // reasonLabel && idx > 0;
@@ -1294,11 +1332,11 @@ export class Chat extends RapidElement {
1294
1332
  ${showAvatar
1295
1333
  ? html`<div class="avatar" style="align-self:flex-end">
1296
1334
  <temba-user
1297
- uuid=${currentMsg._user?.uuid}
1298
- name=${name}
1299
- first_name=${currentMsg._user?.first_name}
1300
- last_name=${currentMsg._user?.last_name}
1301
- avatar=${currentMsg._user?.avatar}
1335
+ uuid=${avatarUuid ?? nothing}
1336
+ name=${avatarName ?? nothing}
1337
+ first_name=${currentMsg._user?.first_name ?? nothing}
1338
+ last_name=${currentMsg._user?.last_name ?? nothing}
1339
+ avatar=${currentMsg._user?.avatar ?? nothing}
1302
1340
  ?system=${isSystem}
1303
1341
  >
1304
1342
  </temba-user>
@@ -4,6 +4,7 @@ import { property } from 'lit/decorators.js';
4
4
  import { colorHash, extractInitials } from '../utils';
5
5
 
6
6
  import { DEFAULT_AVATAR } from '../webchat/assets';
7
+ import { Icon } from '../Icons';
7
8
  import { RapidElement } from '../RapidElement';
8
9
 
9
10
  export const getFullName = (user: {
@@ -83,8 +84,14 @@ export class TembaUser extends RapidElement {
83
84
  public willUpdate(changed: PropertyValues): void {
84
85
  super.willUpdate(changed);
85
86
 
86
- if (changed.has('system') && this.system) {
87
- this.bgimage = `url('${DEFAULT_AVATAR}') center / contain no-repeat`;
87
+ // when system toggles, set the default avatar background while system, and
88
+ // clear it otherwise so a reused element doesn't keep a stale default that
89
+ // would suppress the initials/contact-icon branch. a real `avatar` below
90
+ // can still override this.
91
+ if (changed.has('system')) {
92
+ this.bgimage = this.system
93
+ ? `url('${DEFAULT_AVATAR}') center / contain no-repeat`
94
+ : null;
88
95
  }
89
96
 
90
97
  if (
@@ -136,13 +143,18 @@ export class TembaUser extends RapidElement {
136
143
  box-shadow: inset 0 0 0 3px rgba(0, 0, 0, 0.1);
137
144
  background:${this.bgimage || this.bgcolor};"
138
145
  >
139
- ${this.initials && !this.bgimage
140
- ? html` <div
141
- style="border: 0px solid red; display:flex; flex-direction: column; align-items:center;flex-grow:1"
142
- >
143
- <div style="border:0px solid blue;">${this.initials}</div>
144
- </div>`
145
- : null}
146
+ ${this.bgimage
147
+ ? null
148
+ : this.initials
149
+ ? html` <div
150
+ style="display:flex; flex-direction: column; align-items:center;flex-grow:1"
151
+ >
152
+ <div>${this.initials}</div>
153
+ </div>`
154
+ : html`<temba-icon
155
+ name="${Icon.contact}"
156
+ style="display:flex;flex-grow:1;justify-content:center;color:rgba(0,0,0,0.35)"
157
+ ></temba-icon>`}
146
158
  </div>
147
159
  ${this.showName
148
160
  ? html`<div
@@ -1129,11 +1129,16 @@ export class NodeEditor extends RapidElement {
1129
1129
  }
1130
1130
  }
1131
1131
 
1132
- // Check required fields (skip in localization mode since all fields are optional)
1132
+ // Check required fields (skip in localization mode since all fields are optional).
1133
+ // A whitespace-only string counts as empty here - otherwise it slips past this
1134
+ // check and is emitted (e.g. trimmed to "") into the definition, which the backend
1135
+ // then rejects (e.g. a dial wait with an empty phone).
1133
1136
  if (
1134
1137
  !this.isTranslating &&
1135
1138
  (fieldConfig as any).required &&
1136
- (!value || (Array.isArray(value) && value.length === 0))
1139
+ (!value ||
1140
+ (typeof value === 'string' && value.trim() === '') ||
1141
+ (Array.isArray(value) && value.length === 0))
1137
1142
  ) {
1138
1143
  errors[fieldName] = `${
1139
1144
  (fieldConfig as any).label || fieldName
@@ -1151,17 +1156,32 @@ export class NodeEditor extends RapidElement {
1151
1156
  } must be at least ${(fieldConfig as any).minLength} characters`;
1152
1157
  }
1153
1158
 
1154
- // Check maxLength for text fields
1155
- if (
1156
- typeof value === 'string' &&
1157
- (fieldConfig as any).maxLength &&
1158
- value.length > (fieldConfig as any).maxLength
1159
- ) {
1160
- errors[fieldName] = `${
1161
- (fieldConfig as any).label || fieldName
1162
- } must be no more than ${
1163
- (fieldConfig as any).maxLength
1164
- } characters`;
1159
+ // Check maxLength for text fields, as well as for the values of select/tag
1160
+ // items (e.g. result names, quick replies) which are stored as arrays of
1161
+ // option objects (or plain strings) rather than a single string. Without the
1162
+ // array/object handling, an over-long value here is emitted into the definition
1163
+ // and rejected by the backend (goflow caps result names at 64 and quick replies
1164
+ // at 1000 chars).
1165
+ const maxLength = (fieldConfig as any).maxLength;
1166
+ if (maxLength) {
1167
+ const itemLength = (item: any): number => {
1168
+ if (typeof item === 'string') return item.length;
1169
+ if (item && typeof item === 'object') {
1170
+ const text = item.value ?? item.name;
1171
+ return typeof text === 'string' ? text.length : 0;
1172
+ }
1173
+ return 0;
1174
+ };
1175
+
1176
+ const exceedsMax = Array.isArray(value)
1177
+ ? value.some((item) => itemLength(item) > maxLength)
1178
+ : itemLength(value) > maxLength;
1179
+
1180
+ if (exceedsMax) {
1181
+ errors[fieldName] = `${
1182
+ (fieldConfig as any).label || fieldName
1183
+ } must be no more than ${maxLength} characters`;
1184
+ }
1165
1185
  }
1166
1186
  });
1167
1187
  }
@@ -6,7 +6,10 @@ import { renderNamedObjects } from '../utils';
6
6
  export const add_input_labels: ActionConfig = {
7
7
  name: 'Add Input Labels',
8
8
  group: ACTION_GROUPS.save,
9
- flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
9
+ // Not allowed in background flows: goflow treats add_input_labels as an interactive
10
+ // action (messaging, messaging_offline, voice only), so offering it in a
11
+ // messaging_background flow produces a definition the backend rejects.
12
+ flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE],
10
13
  render: (_node: Node, action: AddInputLabels) => {
11
14
  return html`<div>${renderNamedObjects(action.labels, 'label')}</div>`;
12
15
  },
@@ -81,6 +81,7 @@ export const send_msg: ActionConfig = {
81
81
  searchable: true,
82
82
  placeholder: 'Add quick replies...',
83
83
  maxItems: 10,
84
+ maxLength: 1000,
84
85
  evaluated: true
85
86
  },
86
87
  template: {
@@ -21,6 +21,7 @@ export const set_run_result: ActionConfig = {
21
21
  label: 'Result Name',
22
22
  helpText: 'Select an existing result name or type a new one',
23
23
  required: true,
24
+ maxLength: 64,
24
25
  placeholder: 'Select or enter result name...',
25
26
  createArbitraryOption: (input, options) => {
26
27
  const exists = options.some(
@@ -41,6 +41,7 @@ export const split_by_ticket: NodeConfig = {
41
41
  type: 'textarea',
42
42
  label: 'Note',
43
43
  required: false,
44
+ evaluated: true,
44
45
  placeholder: 'Enter a note for the ticket (optional)',
45
46
  minHeight: 100
46
47
  }
@@ -88,6 +88,15 @@ export class Checkbox extends FieldElement {
88
88
  cursor: not-allowed;
89
89
  --icon-color: #ccc;
90
90
  }
91
+
92
+ /* While a change driven by this checkbox is in flight, the glyph
93
+ is swapped for spinning arrows to signal work is underway, and the
94
+ white checkbox backing is hidden so the circular arrows aren't
95
+ framed by a square. Clicks are ignored in handleClick so the user
96
+ can't re-fire the same toggle. */
97
+ .checkbox-container.busy .checkbox-background {
98
+ display: none;
99
+ }
91
100
  `;
92
101
  }
93
102
 
@@ -103,6 +112,12 @@ export class Checkbox extends FieldElement {
103
112
  @property({ type: Boolean })
104
113
  disabled = false;
105
114
 
115
+ // When set, the checkbox glyph is replaced by spinning arrows to indicate
116
+ // a change it drives is in flight, and clicks are ignored until the host
117
+ // clears it.
118
+ @property({ type: Boolean })
119
+ busy = false;
120
+
106
121
  @property({ type: Number })
107
122
  size = 1.2;
108
123
 
@@ -154,7 +169,7 @@ export class Checkbox extends FieldElement {
154
169
  }
155
170
 
156
171
  private handleClick(): void {
157
- if (!this.disabled) {
172
+ if (!this.disabled && !this.busy) {
158
173
  this.checked = !this.checked;
159
174
  }
160
175
  }
@@ -165,22 +180,33 @@ export class Checkbox extends FieldElement {
165
180
  }
166
181
 
167
182
  protected renderWidget(): TemplateResult {
168
- const icon = html`<temba-icon
169
- name="${this.checked
170
- ? Icon.checkbox_checked
171
- : this.partial
172
- ? Icon.checkbox_partial
173
- : Icon.checkbox}"
174
- size="${this.size}"
175
- animatechange="${this.animateChange}"
176
- ></temba-icon>`;
183
+ const icon = this.busy
184
+ ? html`<temba-icon
185
+ name="${Icon.progress_spinner}"
186
+ size="${this.size}"
187
+ spin
188
+ ></temba-icon>`
189
+ : html`<temba-icon
190
+ name="${this.checked
191
+ ? Icon.checkbox_checked
192
+ : this.partial
193
+ ? Icon.checkbox_partial
194
+ : Icon.checkbox}"
195
+ size="${this.size}"
196
+ animatechange="${this.animateChange}"
197
+ ></temba-icon>`;
177
198
 
178
199
  return html`
179
200
  <div
180
201
  class="wrapper ${this.label ? 'label' : ''}"
181
202
  @click=${this.handleClick}
182
203
  >
183
- <div class="checkbox-container ${this.disabled ? 'disabled' : ''}">
204
+ <div
205
+ class="checkbox-container ${this.disabled ? 'disabled' : ''} ${this
206
+ .busy
207
+ ? 'busy'
208
+ : ''}"
209
+ >
184
210
  <div class="checkbox-background"></div>
185
211
  ${icon}
186
212
 
@@ -46,28 +46,44 @@ export class PageHeader extends RapidElement {
46
46
  'tnum' 0;
47
47
  }
48
48
 
49
- /* Title on the left, actions + content menu on the right. */
49
+ /* One row: the title/subtitle block on the left and the
50
+ actions/content-menu on the right, vertically centered against
51
+ each other. The title block is the flexing column so the
52
+ actions hold their size. The vertical padding matches the
53
+ horizontal inset the host supplies (the list panel's 20px) so
54
+ the whole header is wrapped in even, consistent padding. */
50
55
  .header {
51
56
  display: flex;
52
- align-items: flex-start;
57
+ align-items: center;
53
58
  gap: var(--gap);
54
- padding: 20px 0 16px 0;
59
+ padding: 12px 0;
55
60
  }
56
- .titles {
61
+ /* Title + subtitle stacked tight, sharing the left column. It
62
+ flexes and clips so a long subtitle truncates against the
63
+ actions rather than pushing them off the row. */
64
+ .title-block {
57
65
  flex: 1 1 auto;
58
66
  min-width: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 1px;
59
70
  }
60
71
  .title {
61
72
  font-size: 15.5px;
62
73
  font-weight: var(--w-semibold);
63
74
  color: var(--text-1);
64
- line-height: 1.3;
75
+ line-height: 1.25;
76
+ white-space: nowrap;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
65
79
  }
66
80
  .subtitle {
67
81
  font-size: 12.5px;
68
82
  color: var(--text-3);
69
- line-height: 1.3;
70
- margin-top: 1px;
83
+ line-height: 1.25;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
71
87
  }
72
88
  .actions {
73
89
  flex: 0 0 auto;
@@ -91,7 +107,7 @@ export class PageHeader extends RapidElement {
91
107
  same way .ds * does in the styleguide — otherwise the
92
108
  border adds 2px and the button computes 2px taller. */
93
109
  box-sizing: border-box;
94
- height: 28px;
110
+ height: 26px;
95
111
  padding: 0 10px;
96
112
  border: 1px solid var(--border-strong);
97
113
  border-radius: var(--r-sm);
@@ -133,9 +149,12 @@ export class PageHeader extends RapidElement {
133
149
  .menu-toggle {
134
150
  display: inline-flex;
135
151
  align-items: center;
152
+ justify-content: center;
136
153
  cursor: pointer;
137
154
  user-select: none;
138
- padding: 6px;
155
+ height: 26px;
156
+ box-sizing: border-box;
157
+ padding: 0 5px;
139
158
  border-radius: var(--r-sm);
140
159
  color: var(--text-2);
141
160
  --icon-color: currentColor;
@@ -314,16 +333,19 @@ export class PageHeader extends RapidElement {
314
333
  }
315
334
 
316
335
  public render(): TemplateResult {
317
- const hasSubtitle =
318
- this.subtitle || this.querySelector('[slot="subtitle"]');
336
+ const slotted = this.querySelector('[slot="subtitle"]');
337
+ const hasSubtitle = this.subtitle || slotted;
338
+ // Full subtitle text for the hover tooltip — the bar truncates a
339
+ // long subtitle, so the native title surfaces the rest on hover.
340
+ const subtitleText = (this.subtitle || slotted?.textContent || '').trim();
319
341
  return html`
320
342
  <div class="header">
321
- <div class="titles">
343
+ <div class="title-block">
322
344
  <div class="title">
323
345
  <slot name="title">${this.headerTitle}</slot>
324
346
  </div>
325
347
  ${hasSubtitle
326
- ? html`<div class="subtitle">
348
+ ? html`<div class="subtitle" title=${subtitleText}>
327
349
  <slot name="subtitle">${this.subtitle}</slot>
328
350
  </div>`
329
351
  : null}
@@ -7,6 +7,9 @@ import { getUrl } from '../utils';
7
7
 
8
8
  const FIELD_PREFIX = 'field:';
9
9
 
10
+ /** Placeholder shown in any cell whose value is empty. */
11
+ const EMPTY = '--';
12
+
10
13
  /**
11
14
  * Contact CRUDL list — drop-in replacement for the rapidpro
12
15
  * `contacts/contact_list.html` table. Each row carries a contact
@@ -60,6 +63,16 @@ export class ContactList extends ContentList<Contact> {
60
63
  this.bulkActions = [
61
64
  { key: 'send', label: 'Send', icon: Icon.compose },
62
65
  { key: 'flow', label: 'Start flow', icon: Icon.flow },
66
+ // Group toggle — a dropdown of the workspace's static (manual)
67
+ // groups to add/remove the selection to/from, like the message
68
+ // list's label dropdown.
69
+ {
70
+ key: 'label',
71
+ label: 'Group',
72
+ icon: Icon.group,
73
+ labelsEndpoint: '/api/v2/groups.json?manual_only=1',
74
+ labelsKey: 'groups'
75
+ },
63
76
  { key: 'archive', label: 'Archive', icon: Icon.archive },
64
77
  { key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
65
78
  ];
@@ -117,15 +130,16 @@ export class ContactList extends ContentList<Contact> {
117
130
  }
118
131
  }
119
132
 
120
- /** Columns: name, urn, the featured fields, then last seen.
133
+ /** Columns: name, urn, the featured fields, then last seen and
134
+ * created on.
121
135
  *
122
- * Name + URN lead and Last-seen trails, with the workspace's
123
- * custom fields filling the middle. Every column sizes to its
124
- * content between min/max bounds — none are hard-fixed — so the
136
+ * Name leads, with URN, the workspace's custom fields, and the
137
+ * last-seen / created-on dates trailing it. Every column sizes to
138
+ * its content between min/max bounds — none are hard-fixed — so the
125
139
  * table stays compact and overflows into a horizontal scroll only
126
- * when the field set is genuinely wide. Name + URN are pinned to
127
- * the left edge and Last-seen to the right, so identity and
128
- * recency stay anchored while the fields scroll between them.
140
+ * when the field set is genuinely wide. Only Name is pinned to the
141
+ * left edge, so identity stays anchored while everything else
142
+ * scrolls.
129
143
  *
130
144
  * There is deliberately no group-membership column — contacts
131
145
  * routinely belong to dozens of groups, so a groups cell is
@@ -158,8 +172,7 @@ export class ContactList extends ContentList<Contact> {
158
172
  key: 'urn',
159
173
  label: 'URN',
160
174
  minWidth: '120px',
161
- maxWidth: '190px',
162
- pinned: true
175
+ maxWidth: '190px'
163
176
  },
164
177
  ...fieldColumns,
165
178
  {
@@ -168,8 +181,15 @@ export class ContactList extends ContentList<Contact> {
168
181
  sortable: true,
169
182
  minWidth: '96px',
170
183
  maxWidth: '150px',
171
- align: 'right',
172
- pinned: 'right'
184
+ align: 'right'
185
+ },
186
+ {
187
+ key: 'created_on',
188
+ label: 'Created on',
189
+ sortable: true,
190
+ minWidth: '96px',
191
+ maxWidth: '150px',
192
+ align: 'right'
173
193
  }
174
194
  ];
175
195
  }
@@ -189,11 +209,16 @@ export class ContactList extends ContentList<Contact> {
189
209
  if (column.key.startsWith(FIELD_PREFIX)) {
190
210
  const fieldKey = column.key.substring(FIELD_PREFIX.length);
191
211
  const raw = item.fields?.[fieldKey];
192
- if (raw == null || raw === '') return '';
193
- // Date/time fields render as a relative duration, matching the
194
- // Last-seen column never a raw timestamp string.
212
+ if (raw == null || raw === '') return EMPTY;
213
+ // Location values are stored as a full hierarchy path
214
+ // (e.g. "Nigeria > Yobe > Nguru > Dabule"); show only the leaf.
215
+ if (this.isLocationField(fieldKey)) {
216
+ const path = String(raw);
217
+ return html`<span title=${path}>${this.locationLeaf(path)}</span>`;
218
+ }
219
+ // Date/time fields render via the timedate format.
195
220
  if (this.isDateField(fieldKey)) {
196
- return html`<temba-date value=${raw} display="duration"></temba-date>`;
221
+ return html`<temba-date value=${raw} display="timedate"></temba-date>`;
197
222
  }
198
223
  const value = String(raw);
199
224
  return html`<span title=${value}>${value}</span>`;
@@ -201,19 +226,26 @@ export class ContactList extends ContentList<Contact> {
201
226
  switch (column.key) {
202
227
  case 'name':
203
228
  return html`<span class="contact-name" title=${item.name || ''}
204
- >${item.name || '—'}</span
229
+ >${item.name || EMPTY}</span
205
230
  >`;
206
231
  case 'urn':
207
232
  return html`<span class="contact-urn"
208
- >${this.primaryUrn(item) || ''}</span
233
+ >${this.primaryUrn(item) || EMPTY}</span
209
234
  >`;
210
235
  case 'last_seen_on':
211
236
  return item.last_seen_on
212
237
  ? html`<temba-date
213
238
  value=${item.last_seen_on}
214
- display="duration"
239
+ display="timedate"
215
240
  ></temba-date>`
216
- : '';
241
+ : EMPTY;
242
+ case 'created_on':
243
+ return item.created_on
244
+ ? html`<temba-date
245
+ value=${item.created_on}
246
+ display="timedate"
247
+ ></temba-date>`
248
+ : EMPTY;
217
249
  default:
218
250
  return super.renderCell(item, column);
219
251
  }
@@ -229,6 +261,24 @@ export class ContactList extends ContentList<Contact> {
229
261
  return type === 'datetime' || type === 'date';
230
262
  }
231
263
 
264
+ /** True when a featured field stores a location value (state /
265
+ * district / ward) — those are serialized as a full hierarchy path
266
+ * and we render only the leaf. */
267
+ private isLocationField(fieldKey: string): boolean {
268
+ const field = (this.featuredFields || []).find(
269
+ (f: any) => f.key === fieldKey
270
+ );
271
+ const type = field?.value_type;
272
+ return type === 'state' || type === 'district' || type === 'ward';
273
+ }
274
+
275
+ /** The last segment of a location hierarchy path, e.g.
276
+ * "Nigeria > Yobe > Nguru > Dabule" → "Dabule". */
277
+ private locationLeaf(path: string): string {
278
+ const parts = path.split('>');
279
+ return parts[parts.length - 1].trim();
280
+ }
281
+
232
282
  private primaryUrn(item: Contact): string {
233
283
  const i = item as any;
234
284
  if (i.urn) return i.urn;