@conduction/nextcloud-vue 2.96.0 → 2.97.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 (79) hide show
  1. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  2. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
  3. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  6. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  7. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
  8. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  9. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
  10. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  11. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  12. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
  13. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  17. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  22. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
  23. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  27. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  28. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +301 -18
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
  34. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
  36. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
  38. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  39. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  40. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
  41. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
  43. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  44. package/dist/esm/composables/indexSources.js +182 -3
  45. package/dist/esm/composables/indexSources.js.map +1 -1
  46. package/dist/esm/l10n/en.json.js +7 -1
  47. package/dist/esm/l10n/en.json.js.map +1 -1
  48. package/dist/esm/l10n/nl.json.js +7 -1
  49. package/dist/esm/l10n/nl.json.js.map +1 -1
  50. package/dist/esm/library-style.js +1 -1
  51. package/dist/esm/nextcloud-vue.css +301 -177
  52. package/dist/esm/utils/queryPresets.js +13 -2
  53. package/dist/esm/utils/queryPresets.js.map +1 -1
  54. package/dist/esm/utils/scopeListLayout.js +37 -1
  55. package/dist/esm/utils/scopeListLayout.js.map +1 -1
  56. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  57. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  58. package/dist/nextcloud-vue.cjs.js +3009 -1907
  59. package/dist/nextcloud-vue.cjs.js.map +2 -2
  60. package/dist/nextcloud-vue.css +301 -177
  61. package/l10n/en.json +7 -1
  62. package/l10n/nl.json +7 -1
  63. package/package.json +1 -1
  64. package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
  65. package/src/components/CnConversationThread/CnConversationThread.md +12 -0
  66. package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
  67. package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
  68. package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
  69. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
  70. package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  71. package/src/components/CnIndexPage/CnIndexPage.vue +324 -24
  72. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
  73. package/src/components/CnIndexPage/useNamedSource.js +1 -1
  74. package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
  75. package/src/composables/indexSources.js +182 -3
  76. package/src/schemas/app-manifest-v2.schema.json +11 -4
  77. package/src/utils/queryPresets.js +13 -2
  78. package/src/utils/scopeListLayout.js +37 -1
  79. package/src/utils/validateManifestV2.compiled.js +3 -3
package/l10n/en.json CHANGED
@@ -1145,6 +1145,8 @@
1145
1145
  "Due tomorrow": "Due tomorrow",
1146
1146
  "Due in {days} days": "Due in {days} days",
1147
1147
  "Available": "Available",
1148
+ "{value} of {limit}": "{value} of {limit}",
1149
+ "{value} of {limit}, over the limit": "{value} of {limit}, over the limit",
1148
1150
  "Ready to claim": "Ready to claim",
1149
1151
  "In progress": "In progress",
1150
1152
  "Completed": "Completed",
@@ -1584,7 +1586,11 @@
1584
1586
  "{s} s": "{s} s",
1585
1587
  "About this {app} installation.": "About this {app} installation.",
1586
1588
  "{version}, up to date": "{version}, up to date",
1587
- "for an SLA": "for an SLA"
1589
+ "for an SLA": "for an SLA",
1590
+ "About {name}": "About {name}",
1591
+ "Sent message": "Sent message",
1592
+ "Received message": "Received message",
1593
+ "Loading messages…": "Loading messages…"
1588
1594
  },
1589
1595
  "plurals": {
1590
1596
  "{count} selected": [
package/l10n/nl.json CHANGED
@@ -1171,6 +1171,8 @@
1171
1171
  "Due tomorrow": "Morgen",
1172
1172
  "Due in {days} days": "Over {days} dagen",
1173
1173
  "Available": "Beschikbaar",
1174
+ "{value} of {limit}": "{value} van {limit}",
1175
+ "{value} of {limit}, over the limit": "{value} van {limit}, boven de limiet",
1174
1176
  "Ready to claim": "Klaar om op te pakken",
1175
1177
  "In progress": "In behandeling",
1176
1178
  "Completed": "Afgerond",
@@ -1770,7 +1772,11 @@
1770
1772
  "{s} s": "{s} s",
1771
1773
  "About this {app} installation.": "Over deze {app}-installatie.",
1772
1774
  "{version}, up to date": "{version}, bijgewerkt",
1773
- "for an SLA": "voor een SLA"
1775
+ "for an SLA": "voor een SLA",
1776
+ "About {name}": "Over {name}",
1777
+ "Sent message": "Verstuurd bericht",
1778
+ "Received message": "Ontvangen bericht",
1779
+ "Loading messages…": "Berichten laden…"
1774
1780
  },
1775
1781
  "plurals": {
1776
1782
  "{count} selected": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.96.0",
3
+ "version": "2.97.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -24,6 +24,24 @@
24
24
  </span>
25
25
  </template>
26
26
 
27
+ <!-- Built-in "actions" widget: row actions as inline buttons ("Aan zaak
28
+ koppelen", "Nieuwe zaak"). The page resolves the buttons
29
+ (widgetProps.items) and runs a click (widgetProps.run). -->
30
+ <template v-else-if="widget === 'actions'">
31
+ <span class="cn-cell-renderer__actions" data-testid="cn-cell-actions">
32
+ <NcButton
33
+ v-for="item in actionItems"
34
+ :key="item.key"
35
+ :variant="item.variant"
36
+ size="small"
37
+ :disabled="item.disabled"
38
+ :data-testid="`cn-cell-action-${item.key}`"
39
+ @click.stop="runAction(item)">
40
+ {{ item.label }}
41
+ </NcButton>
42
+ </span>
43
+ </template>
44
+
27
45
  <!-- Built-in "badge" widget: renders the value as a status pill. An enum
28
46
  value shows its label (x-enum-labels, translated) unless a formatter
29
47
  shapes it; the colour stays keyed on the raw value. -->
@@ -350,7 +368,7 @@
350
368
 
351
369
  <script>
352
370
  import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
353
- import { NcAvatar, NcCheckboxRadioSwitch, NcDateTime } from '@nextcloud/vue'
371
+ import { NcAvatar, NcButton, NcCheckboxRadioSwitch, NcDateTime } from '@nextcloud/vue'
354
372
  import CheckBold from 'vue-material-design-icons/CheckBold.vue'
355
373
  import CnFkResolveCell from '../CnFkResolveCell/CnFkResolveCell.vue'
356
374
  import CnIcon from '../CnIcon/CnIcon.vue'
@@ -398,6 +416,7 @@ export default {
398
416
  CnGroupNameCell,
399
417
  CheckBold,
400
418
  NcAvatar,
419
+ NcButton,
401
420
  NcDateTime,
402
421
  },
403
422
 
@@ -586,6 +605,24 @@ export default {
586
605
  },
587
606
 
588
607
  computed: {
608
+ /**
609
+ * The `actions` widget's buttons for this row: the page's resolved
610
+ * `widgetProps.items` (a list, or a function of the row returning
611
+ * one), each `{ key, label, variant?, disabled? }`.
612
+ *
613
+ * @return {Array<{key: string, label: string, variant: string, disabled: boolean}>}
614
+ * @spec openspec/changes/table-actions-column/specs/index-page/spec.md#requirement-a-column-can-show-row-actions-as-buttons
615
+ */
616
+ actionItems() {
617
+ const raw = typeof this.widgetProps?.items === 'function' ? this.widgetProps.items(this.row) : this.widgetProps?.items
618
+ const items = Array.isArray(raw) ? raw : []
619
+ return items.map((item) => ({
620
+ ...item,
621
+ variant: item.variant || 'secondary',
622
+ disabled: item.disabled === true,
623
+ }))
624
+ },
625
+
589
626
  /**
590
627
  * Whether the "switch" widget is on: the pending state while a toggle
591
628
  * is in flight, else `widgetProps.checked(row)`, else the value.
@@ -1419,6 +1456,19 @@ export default {
1419
1456
  },
1420
1457
 
1421
1458
  methods: {
1459
+ /**
1460
+ * Run an `actions` widget button for this row through the page.
1461
+ *
1462
+ * @param {{key: string}} item The button.
1463
+ * @return {void}
1464
+ * @spec openspec/changes/table-actions-column/specs/index-page/spec.md#requirement-a-column-can-show-row-actions-as-buttons
1465
+ */
1466
+ runAction(item) {
1467
+ if (typeof this.widgetProps?.run === 'function') {
1468
+ this.widgetProps.run(item.key, this.row)
1469
+ }
1470
+ },
1471
+
1422
1472
  /**
1423
1473
  * The "switch" widget was flipped: show the new state and hand it to
1424
1474
  * `widgetProps.onToggle(row, on)`. A rejection puts the switch back.
@@ -1555,6 +1605,12 @@ export default {
1555
1605
  </script>
1556
1606
 
1557
1607
  <style scoped>
1608
+ .cn-cell-renderer__actions {
1609
+ display: inline-flex;
1610
+ flex-wrap: wrap;
1611
+ gap: 8px;
1612
+ }
1613
+
1558
1614
  .cn-cell-renderer__icon-label {
1559
1615
  display: inline-flex;
1560
1616
  align-items: center;
@@ -49,3 +49,15 @@ Posting to an endpoint. The reply goes out as `{ message, ...payload }`. `title-
49
49
  </template>
50
50
  </CnConversationThread>
51
51
  ```
52
+
53
+ While the messages load, `loading` makes the thread say so (in a status region) instead of showing the empty text. A
54
+ message's `channel` is shown after its time:
55
+
56
+ ```vue
57
+ <CnConversationThread
58
+ title="Contact"
59
+ :loading="false"
60
+ :messages="[
61
+ { id: 1, author: 'Sanne de Vries', time: '2026-10-04T10:52:00', side: 'them', channel: 'Message box', text: 'Thank you.' },
62
+ ]" />
63
+ ```
@@ -17,7 +17,13 @@
17
17
  {{ title }}
18
18
  </component>
19
19
 
20
- <p v-if="rows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-empty">
20
+ <p v-if="loading && rows.length === 0"
21
+ class="cn-conversation-thread__empty"
22
+ role="status"
23
+ data-testid="cn-conversation-loading">
24
+ {{ loadingText }}
25
+ </p>
26
+ <p v-else-if="rows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-empty">
21
27
  {{ emptyText }}
22
28
  </p>
23
29
 
@@ -32,8 +38,10 @@
32
38
  <span class="cn-conversation-thread__avatar" aria-hidden="true">{{ row.initials }}</span>
33
39
  <div class="cn-conversation-thread__bubble">
34
40
  <span class="cn-conversation-thread__meta">
41
+ <span class="hidden-visually">{{ row.directionLabel }}</span>
35
42
  <span class="cn-conversation-thread__author">{{ row.author }}</span>
36
43
  <time v-if="row.timeLabel" :datetime="row.datetime || null">{{ row.timeLabel }}</time>
44
+ <span v-if="row.channel" class="cn-conversation-thread__channel" data-testid="cn-conversation-channel">{{ row.channel }}</span>
37
45
  </span>
38
46
  <span class="cn-conversation-thread__text">{{ row.text }}</span>
39
47
  </div>
@@ -123,9 +131,10 @@ export default {
123
131
 
124
132
  props: {
125
133
  /**
126
- * The messages, oldest first: `{ id?, author?, time?, text, side? }`.
134
+ * The messages, oldest first: `{ id?, author?, time?, text, side?, channel? }`.
127
135
  * `side` is `us` or `them` (default `them`). `time` is an ISO
128
- * date, or any text to show as is.
136
+ * date, or any text to show as is. `channel` is shown after the time
137
+ * ("E-mail", "Phone note").
129
138
  */
130
139
  messages: {
131
140
  type: Array,
@@ -189,6 +198,12 @@ export default {
189
198
  default: () => ({}),
190
199
  },
191
200
 
201
+ /** Whether the messages are still loading; with none yet the thread says so. */
202
+ loading: {
203
+ type: Boolean,
204
+ default: false,
205
+ },
206
+
192
207
  /** Shown when there are no messages yet. */
193
208
  emptyText: {
194
209
  type: String,
@@ -221,7 +236,7 @@ export default {
221
236
  /**
222
237
  * The messages, with author, side, time and initials resolved.
223
238
  *
224
- * @return {Array<{key: string, author: string, initials: string, side: string, text: string, timeLabel: string, datetime: string}>} The rows.
239
+ * @return {Array<{key: string, author: string, initials: string, side: string, text: string, timeLabel: string, datetime: string, channel: string, directionLabel: string}>} The rows.
225
240
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-conversation-thread
226
241
  */
227
242
  rows() {
@@ -241,6 +256,8 @@ export default {
241
256
  text: message.text === undefined || message.text === null ? '' : String(message.text),
242
257
  timeLabel: time.label,
243
258
  datetime: time.datetime,
259
+ channel: typeof message.channel === 'string' ? message.channel : '',
260
+ directionLabel: side === 'us' ? t('nextcloud-vue', 'Sent message') : t('nextcloud-vue', 'Received message'),
244
261
  }
245
262
  })
246
263
  },
@@ -264,6 +281,11 @@ export default {
264
281
  fallbackLabel() {
265
282
  return t('nextcloud-vue', 'Conversation')
266
283
  },
284
+
285
+ /** @return {string} What the thread says while its messages load. */
286
+ loadingText() {
287
+ return t('nextcloud-vue', 'Loading messages…')
288
+ },
267
289
  },
268
290
 
269
291
  methods: {
@@ -455,4 +477,8 @@ export default {
455
477
  gap: calc(2.5 * var(--default-grid-baseline));
456
478
  justify-content: flex-end;
457
479
  }
480
+
481
+ .cn-conversation-thread__channel {
482
+ color: var(--color-text-maxcontrast);
483
+ }
458
484
  </style>
@@ -7,29 +7,44 @@
7
7
  <div class="cn-case-widget-card">
8
8
  <CnConversationThread
9
9
  :messages="messages"
10
- :title="content.title || ''"
10
+ :title="tr(content.title || '')"
11
11
  :allowReply="content.allowReply !== false"
12
- :replyLabel="content.replyLabel || undefined"
13
- :placeholder="content.placeholder || ''"
14
- :sendLabel="content.sendLabel || undefined"
15
- :endpoint="endpoint"
16
- :emptyText="content.emptyText || undefined"
12
+ :replyLabel="content.replyLabel ? tr(content.replyLabel) : undefined"
13
+ :placeholder="tr(content.placeholder || '')"
14
+ :sendLabel="content.sendLabel ? tr(content.sendLabel) : undefined"
15
+ :endpoint="replyAction ? '' : endpoint"
16
+ :loading="loading"
17
+ :emptyText="content.emptyText ? tr(content.emptyText) : undefined"
18
+ @send="onSend"
17
19
  @sent="onSent" />
18
20
  </div>
19
21
  </template>
20
22
 
21
23
  <script>
22
24
  import CnConversationThread from './CnConversationThread.vue'
25
+ import { dispatchAction } from '../../utils/actionsDispatcher.js'
26
+ import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
23
27
  import { readPath } from '../../utils/readPath.js'
28
+ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
24
29
 
25
30
  /**
26
31
  * CnConversationWidget: the `conversation` detail widget type.
27
32
  *
28
- * Renders CnConversationThread from a list on the bound record:
29
- * `content: { field, endpoint?, authorField?, timeField?, textField?, sideField?, usValue? }`.
30
- * `@objectId` in `endpoint` is replaced by the record's id. A reply the
31
- * endpoint accepts is shown at once, until the record is read again.
32
- * Resolved by its registry key, and exported for an app that renders it itself.
33
+ * Renders CnConversationThread from the messages of a record, oldest first.
34
+ * The messages are a list on the bound record (`field`), or the objects of a
35
+ * register list (`source: { register, schema, filter?, limit? }`, the filter
36
+ * token-resolved like a record-list's: `{ "case": "@objectId" }`).
37
+ *
38
+ * Mapping keys name where each message keeps its parts: `textField` (alias
39
+ * `bodyField`), `timeField` (alias `timestampField`), `authorField` (alias
40
+ * `senderField`), `sideField` (alias `directionField`) with `usValue` (alias
41
+ * `outValue`) for the messages the organisation sent, and `channelField` with
42
+ * an optional `channelMap` from stored value to label ("email" to "E-mail").
43
+ *
44
+ * The reply box posts to `endpoint` (`@objectId` filled in), or runs
45
+ * `replyAction`, an action descriptor the app decides the meaning of: it is
46
+ * dispatched with `args: [{ text, objectId }]` and `props.text`. After a send
47
+ * the source is read again.
33
48
  */
34
49
  export default {
35
50
  name: 'CnConversationWidget',
@@ -38,8 +53,19 @@ export default {
38
53
  CnConversationThread,
39
54
  },
40
55
 
56
+ inject: {
57
+ cnTranslate: { default: () => (key) => key },
58
+ /** Pre-bound action dispatcher of the page, for `replyAction`. */
59
+ cnDispatchAction: { default: null },
60
+ },
61
+
41
62
  props: {
42
- /** The widget's content. `field` is the dot-path to the messages on the record. */
63
+ /**
64
+ * The widget's content: `field` (the dot-path to the messages on the
65
+ * record) or `source`, the mapping keys, `endpoint` or `replyAction`,
66
+ * and the texts (`title`, `replyLabel`, `placeholder`, `sendLabel`,
67
+ * `emptyText`, translated).
68
+ */
43
69
  content: {
44
70
  type: Object,
45
71
  default: () => ({}),
@@ -62,29 +88,63 @@ export default {
62
88
  return {
63
89
  /** Replies sent from this widget that the record does not hold yet. */
64
90
  sentHere: [],
91
+ /** The messages read from `source`. */
92
+ listed: [],
93
+ /** Whether `source` is being read. */
94
+ loading: false,
65
95
  }
66
96
  },
67
97
 
68
98
  computed: {
99
+ /** @return {object|null} The register list the messages come from, or null. */
100
+ source() {
101
+ const source = this.content?.source
102
+ return source && typeof source === 'object' && source.register && source.schema ? source : null
103
+ },
104
+
105
+ /** @return {object|null} The reply action, or null. */
106
+ replyAction() {
107
+ const action = this.content?.replyAction
108
+ return action && typeof action === 'object' ? action : null
109
+ },
110
+
69
111
  /**
70
- * The record's messages in the thread's shape, plus the replies sent here.
112
+ * The messages in the thread's shape, oldest first, plus the replies
113
+ * sent here.
71
114
  *
72
115
  * @return {Array<object>} The messages.
73
116
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-conversation-thread
117
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-conversation-reads-its-messages-from-a-list-and-maps-their-parts
74
118
  */
75
119
  messages() {
76
- const c = this.content
77
- const value = readPath(this.objectData || {}, c.field || 'messages')
78
- const usValue = String(c.usValue === undefined ? 'us' : c.usValue)
79
- const stored = (Array.isArray(value) ? value : [])
120
+ const c = this.content || {}
121
+ const raw = this.source ? this.listed : readPath(this.objectData || {}, c.field || 'messages')
122
+ const usValue = String(c.outValue ?? c.usValue ?? 'us')
123
+ const channelMap = c.channelMap && typeof c.channelMap === 'object' ? c.channelMap : {}
124
+ const stored = (Array.isArray(raw) ? raw : [])
80
125
  .filter((message) => message && typeof message === 'object')
81
- .map((message) => ({
82
- id: message.id,
83
- author: readPath(message, c.authorField || 'author'),
84
- time: readPath(message, c.timeField || 'time'),
85
- text: readPath(message, c.textField || 'text'),
86
- side: String(readPath(message, c.sideField || 'side')) === usValue ? 'us' : 'them',
87
- }))
126
+ .map((message, index) => {
127
+ const channel = c.channelField ? readPath(message, c.channelField) : undefined
128
+ const channelKey = channel === undefined || channel === null ? '' : String(channel)
129
+ return {
130
+ id: message.id ?? `message-${index}`,
131
+ author: readPath(message, c.senderField || c.authorField || 'author'),
132
+ time: readPath(message, c.timestampField || c.timeField || 'time'),
133
+ text: readPath(message, c.bodyField || c.textField || 'text'),
134
+ side: String(readPath(message, c.directionField || c.sideField || 'side')) === usValue ? 'us' : 'them',
135
+ channel: channelKey === '' ? '' : this.tr(Object.hasOwn(channelMap, channelKey) ? channelMap[channelKey] : channelKey),
136
+ order: index,
137
+ }
138
+ })
139
+ const at = (message) => {
140
+ const ms = Date.parse(message.time)
141
+ return Number.isNaN(ms) ? null : ms
142
+ }
143
+ stored.sort((a, b) => {
144
+ const ta = at(a)
145
+ const tb = at(b)
146
+ return (ta === null || tb === null || ta === tb) ? a.order - b.order : ta - tb
147
+ })
88
148
  return [...stored, ...this.sentHere]
89
149
  },
90
150
 
@@ -101,6 +161,11 @@ export default {
101
161
  }
102
162
  return url.replace(/@objectId/g, encodeURIComponent(String(this.objectId ?? '')))
103
163
  },
164
+
165
+ /** @return {object} The token context for `source.filter` and `replyAction`. */
166
+ tokenCtx() {
167
+ return { objectId: this.objectId, object: this.objectData || {} }
168
+ },
104
169
  },
105
170
 
106
171
  watch: {
@@ -108,9 +173,87 @@ export default {
108
173
  objectData() {
109
174
  this.sentHere = []
110
175
  },
176
+
177
+ objectId: {
178
+ immediate: true,
179
+ handler() {
180
+ this.loadSource()
181
+ },
182
+ },
111
183
  },
112
184
 
113
185
  methods: {
186
+ /**
187
+ * Translate through the app's translate.
188
+ *
189
+ * @param {string} key The source string.
190
+ * @return {string}
191
+ */
192
+ tr(key) {
193
+ return key && typeof this.cnTranslate === 'function' ? this.cnTranslate(key) : (key || '')
194
+ },
195
+
196
+ /**
197
+ * Read the messages from `source`. A filter whose token does not
198
+ * resolve sends no request; a failed read leaves the thread empty.
199
+ *
200
+ * @return {Promise<void>}
201
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-conversation-reads-its-messages-from-a-list-and-maps-their-parts
202
+ */
203
+ async loadSource() {
204
+ if (!this.source) {
205
+ return
206
+ }
207
+ const raw = this.source.filter || {}
208
+ const filter = resolveDeepTokens(raw, this.tokenCtx)
209
+ const emptied = Object.keys(raw).some((key) => typeof raw[key] === 'string' && raw[key].startsWith('@') && (filter[key] === '' || filter[key] === null || filter[key] === undefined))
210
+ if (emptied || hasUnresolvedDeepTokens(filter)) {
211
+ this.listed = []
212
+ return
213
+ }
214
+ this.loading = true
215
+ try {
216
+ this.listed = await fetchReferenceObjects({ register: this.source.register, schema: this.source.schema, filter, limit: this.source.limit || 200 })
217
+ this.sentHere = []
218
+ } catch {
219
+ this.listed = []
220
+ } finally {
221
+ this.loading = false
222
+ }
223
+ },
224
+
225
+ /**
226
+ * Run `replyAction` with the reply, when there is one; the endpoint
227
+ * path is the thread's own.
228
+ *
229
+ * @param {string} text The reply.
230
+ * @return {Promise<void>}
231
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-reply-can-run-an-app-action
232
+ */
233
+ async onSend(text) {
234
+ if (!this.replyAction) {
235
+ return
236
+ }
237
+ const resolved = resolveDeepTokens(this.replyAction, this.tokenCtx)
238
+ const action = { ...resolved, args: [{ text, objectId: this.objectId }], props: { ...(resolved.props || {}), text } }
239
+ const result = typeof this.cnDispatchAction === 'function'
240
+ ? this.cnDispatchAction(action)
241
+ : dispatchAction(action, { router: this.$router || null })
242
+ try {
243
+ const outcome = await result
244
+ if (outcome === false) {
245
+ return
246
+ }
247
+ } catch {
248
+ return
249
+ }
250
+ if (this.source) {
251
+ await this.loadSource()
252
+ return
253
+ }
254
+ this.onSent({ text })
255
+ },
256
+
114
257
  /**
115
258
  * Show an accepted reply at once, without waiting for the record.
116
259
  *